Compare commits
10 Commits
v0.1.0-dev
...
v0.1.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c182eb179d | ||
|
|
650175913e | ||
|
|
5ee183d607 | ||
|
|
48be7a1c61 | ||
|
|
28acb94a6f | ||
|
|
2c60ba981b | ||
|
|
92e4ce88df | ||
|
|
c1eb7d0765 | ||
|
|
c66f7d78a0 | ||
|
|
c20ea57da6 |
142
pikit-api.py
142
pikit-api.py
@@ -8,6 +8,8 @@ import urllib.parse
|
||||
import fcntl
|
||||
from functools import partial
|
||||
import json as jsonlib
|
||||
import io
|
||||
from collections import deque
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 4000
|
||||
@@ -49,6 +51,99 @@ API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
|
||||
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
||||
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
|
||||
|
||||
# Diagnostics logging (RAM-only)
|
||||
DIAG_STATE_FILE = pathlib.Path("/dev/shm/pikit-diag.state") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.state")
|
||||
DIAG_LOG_FILE = pathlib.Path("/dev/shm/pikit-diag.log") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.log")
|
||||
DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM
|
||||
DIAG_MAX_ENTRY_CHARS = 2048
|
||||
DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug
|
||||
_diag_state = None
|
||||
|
||||
|
||||
def _load_diag_state():
|
||||
global _diag_state
|
||||
if _diag_state is not None:
|
||||
return _diag_state
|
||||
try:
|
||||
if DIAG_STATE_FILE.exists():
|
||||
_diag_state = json.loads(DIAG_STATE_FILE.read_text())
|
||||
return _diag_state
|
||||
except Exception:
|
||||
pass
|
||||
_diag_state = DIAG_DEFAULT_STATE.copy()
|
||||
return _diag_state
|
||||
|
||||
|
||||
def _save_diag_state(enabled=None, level=None):
|
||||
state = _load_diag_state()
|
||||
if enabled is not None:
|
||||
state["enabled"] = bool(enabled)
|
||||
if level in ("normal", "debug"):
|
||||
state["level"] = level
|
||||
try:
|
||||
DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DIAG_STATE_FILE.write_text(json.dumps(state))
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
|
||||
def diag_log(level: str, message: str, meta: dict | None = None):
|
||||
"""
|
||||
Append a diagnostic log line to RAM-backed file.
|
||||
Skips when disabled or when debug level is off.
|
||||
"""
|
||||
state = _load_diag_state()
|
||||
if not state.get("enabled"):
|
||||
return
|
||||
if level == "debug" and state.get("level") != "debug":
|
||||
return
|
||||
try:
|
||||
ts = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
entry = {"ts": ts, "level": level, "msg": message}
|
||||
if meta:
|
||||
entry["meta"] = meta
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
if len(line) > DIAG_MAX_ENTRY_CHARS:
|
||||
entry.pop("meta", None)
|
||||
entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + "…"
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
line_bytes = (line + "\n").encode()
|
||||
DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with DIAG_LOG_FILE.open("ab") as f:
|
||||
f.write(line_bytes)
|
||||
# Trim file if above cap
|
||||
if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES:
|
||||
with DIAG_LOG_FILE.open("rb") as f:
|
||||
f.seek(-DIAG_MAX_BYTES, io.SEEK_END)
|
||||
tail = f.read()
|
||||
# drop partial first line to keep JSON lines clean
|
||||
if b"\n" in tail:
|
||||
tail = tail.split(b"\n", 1)[1]
|
||||
with DIAG_LOG_FILE.open("wb") as f:
|
||||
f.write(tail)
|
||||
except Exception:
|
||||
# Never break caller
|
||||
pass
|
||||
|
||||
|
||||
def diag_read(limit=500):
|
||||
"""Return latest log entries (dicts), newest first."""
|
||||
if not DIAG_LOG_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = DIAG_LOG_FILE.read_bytes()
|
||||
except Exception:
|
||||
return []
|
||||
lines = data.splitlines()[-limit:]
|
||||
out = []
|
||||
for line in lines:
|
||||
try:
|
||||
out.append(json.loads(line.decode("utf-8", errors="ignore")))
|
||||
except Exception:
|
||||
continue
|
||||
return out[::-1]
|
||||
|
||||
|
||||
def ensure_dir(path: pathlib.Path):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -80,12 +175,17 @@ def normalize_path(path: str | None) -> str:
|
||||
|
||||
|
||||
def dbg(msg):
|
||||
if not DEBUG_FLAG:
|
||||
return
|
||||
# Legacy debug file logging (when /boot/pikit-debug exists)
|
||||
if DEBUG_FLAG:
|
||||
API_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.datetime.utcnow().isoformat()
|
||||
with API_LOG.open("a") as f:
|
||||
f.write(f"[{ts}] {msg}\n")
|
||||
# Mirror into diagnostics if enabled
|
||||
try:
|
||||
diag_log("debug", msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_ssh_password_auth(allow: bool):
|
||||
@@ -677,6 +777,7 @@ def check_for_update():
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"})
|
||||
state["in_progress"] = True
|
||||
state["progress"] = "Checking for updates…"
|
||||
save_update_state(state)
|
||||
@@ -696,10 +797,12 @@ def check_for_update():
|
||||
else:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Up to date"
|
||||
diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)})
|
||||
except Exception as e:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = f"Could not reach update server: {e}"
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
diag_log("error", "Update check failed", {"error": str(e)})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
@@ -729,6 +832,7 @@ def apply_update_stub():
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Starting update…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
|
||||
|
||||
try:
|
||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||
@@ -763,6 +867,7 @@ def apply_update_stub():
|
||||
state["progress"] = "Downloading release…"
|
||||
save_update_state(state)
|
||||
download_file(bundle_url, bundle_path)
|
||||
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
|
||||
|
||||
# Verify hash if provided
|
||||
expected_hash = None
|
||||
@@ -774,6 +879,7 @@ def apply_update_stub():
|
||||
got = sha256_file(bundle_path)
|
||||
if got.lower() != expected_hash.lower():
|
||||
raise RuntimeError("Bundle hash mismatch")
|
||||
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
|
||||
|
||||
state["progress"] = "Staging files…"
|
||||
save_update_state(state)
|
||||
@@ -804,14 +910,17 @@ def apply_update_stub():
|
||||
state["message"] = "Update installed"
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update applied", {"version": str(latest)})
|
||||
except urllib.error.HTTPError as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"No release available ({e.code})"
|
||||
diag_log("error", "Update apply HTTP error", {"code": e.code})
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Update failed: {e}"
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
diag_log("error", "Update apply failed", {"error": str(e)})
|
||||
# Attempt rollback if backup exists
|
||||
backup = choose_rollback_backup()
|
||||
if backup:
|
||||
@@ -820,9 +929,11 @@ def apply_update_stub():
|
||||
state["current_version"] = read_current_version()
|
||||
state["message"] += f" (rolled back to backup {backup.name})"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Rollback after failed update", {"backup": backup.name})
|
||||
except Exception as re:
|
||||
state["message"] += f" (rollback failed: {re})"
|
||||
save_update_state(state)
|
||||
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
@@ -844,6 +955,7 @@ def rollback_update_stub():
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Rolling back…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Rollback started")
|
||||
backup = choose_rollback_backup()
|
||||
if not backup:
|
||||
state["status"] = "error"
|
||||
@@ -861,9 +973,11 @@ def rollback_update_stub():
|
||||
ver = get_backup_version(backup)
|
||||
suffix = f" (version {ver})" if ver else ""
|
||||
state["message"] = f"Rolled back to backup {backup.name}{suffix}"
|
||||
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Rollback failed: {e}"
|
||||
diag_log("error", "Rollback failed", {"error": str(e)})
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
@@ -1090,6 +1204,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._send(200, {"text": text})
|
||||
except Exception as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
elif self.path.startswith("/api/diag/log"):
|
||||
entries = diag_read()
|
||||
state = _load_diag_state()
|
||||
return self._send(200, {"entries": entries, "state": state})
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
@@ -1100,6 +1218,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if payload.get("confirm") == "YES":
|
||||
self._send(200, {"message": "Resetting and rebooting..."})
|
||||
dbg("Factory reset triggered via API")
|
||||
diag_log("info", "Factory reset requested")
|
||||
factory_reset()
|
||||
else:
|
||||
self._send(400, {"error": "type YES to confirm"})
|
||||
@@ -1109,14 +1228,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
set_auto_updates(enable)
|
||||
dbg(f"Auto updates set to {enable}")
|
||||
state = auto_updates_state()
|
||||
diag_log("info", "Auto updates toggled", {"enabled": enable})
|
||||
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
|
||||
if self.path.startswith("/api/updates/config"):
|
||||
try:
|
||||
cfg = set_updates_config(payload or {})
|
||||
dbg(f"Update settings applied: {cfg}")
|
||||
diag_log("info", "Update settings saved", cfg)
|
||||
return self._send(200, cfg)
|
||||
except Exception as e:
|
||||
dbg(f"Failed to apply updates config: {e}")
|
||||
diag_log("error", "Update settings save failed", {"error": str(e)})
|
||||
return self._send(500, {"error": str(e)})
|
||||
if self.path.startswith("/api/update/check"):
|
||||
state = check_for_update()
|
||||
@@ -1140,6 +1262,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state = load_update_state()
|
||||
state["auto_check"] = bool(payload.get("enable"))
|
||||
save_update_state(state)
|
||||
diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]})
|
||||
return self._send(200, state)
|
||||
if self.path.startswith("/api/update/channel"):
|
||||
chan = payload.get("channel", "dev")
|
||||
@@ -1148,7 +1271,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state = load_update_state()
|
||||
state["channel"] = chan
|
||||
save_update_state(state)
|
||||
diag_log("info", "Release channel set", {"channel": chan})
|
||||
return self._send(200, state)
|
||||
if self.path.startswith("/api/diag/log/level"):
|
||||
state = _save_diag_state(payload.get("enabled"), payload.get("level"))
|
||||
diag_log("info", "Diag level updated", state)
|
||||
return self._send(200, {"state": state})
|
||||
if self.path.startswith("/api/diag/log/clear"):
|
||||
try:
|
||||
DIAG_LOG_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
diag_log("info", "Diag log cleared")
|
||||
return self._send(200, {"cleared": True, "state": _load_diag_state()})
|
||||
if self.path.startswith("/api/services/add"):
|
||||
name = payload.get("name")
|
||||
port = int(payload.get("port", 0))
|
||||
@@ -1182,6 +1317,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
allow_port_lan(port)
|
||||
except FirewallToolMissing as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme})
|
||||
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
|
||||
if self.path.startswith("/api/services/remove"):
|
||||
port = int(payload.get("port", 0))
|
||||
@@ -1195,6 +1331,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except FirewallToolMissing as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
save_services(services)
|
||||
diag_log("info", "Service removed", {"port": port})
|
||||
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
|
||||
if self.path.startswith("/api/services/update"):
|
||||
port = int(payload.get("port", 0))
|
||||
@@ -1272,6 +1409,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not updated:
|
||||
return self._send(404, {"error": "service not found"})
|
||||
save_services(services)
|
||||
diag_log("info", "Service updated", {"port": target_port, "name": new_name or None, "scheme": scheme})
|
||||
return self._send(200, {"services": services, "message": "Service updated"})
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
|
||||
@@ -117,3 +117,15 @@ export const removeService = ({ port }) =>
|
||||
method: "POST",
|
||||
body: JSON.stringify({ port }),
|
||||
});
|
||||
|
||||
// Diagnostics
|
||||
export const getDiagLog = () => api("/api/diag/log");
|
||||
export const setDiagLevel = ({ enabled, level }) =>
|
||||
api("/api/diag/log/level", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled, level }),
|
||||
});
|
||||
export const clearDiagLog = () =>
|
||||
api("/api/diag/log/clear", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
218
pikit-web/assets/diaglog.js
Normal file
218
pikit-web/assets/diaglog.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// Diagnostic logging (frontend side)
|
||||
// Maintains a client-side ring buffer, fetches server logs, and wires UI controls.
|
||||
|
||||
import { getDiagLog, setDiagLevel, clearDiagLog } from "./api.js";
|
||||
|
||||
const UI_MAX = 500;
|
||||
const uiBuffer = [];
|
||||
let uiEnabled = false;
|
||||
let uiLevel = "normal";
|
||||
let clickListenerAttached = false;
|
||||
let loading = false;
|
||||
|
||||
function appendUi(level, msg, meta = null) {
|
||||
if (!uiEnabled) return;
|
||||
if (level === "debug" && uiLevel !== "debug") return;
|
||||
const ts = new Date().toISOString();
|
||||
const entry = { ts, level, msg, meta, source: "ui" };
|
||||
uiBuffer.unshift(entry);
|
||||
if (uiBuffer.length > UI_MAX) uiBuffer.length = UI_MAX;
|
||||
}
|
||||
|
||||
function attachClickTracker() {
|
||||
if (clickListenerAttached) return;
|
||||
clickListenerAttached = true;
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!uiEnabled || uiLevel !== "debug") return;
|
||||
const el = e.target.closest("button,input,select,textarea,label");
|
||||
if (!el) return;
|
||||
const label =
|
||||
el.getAttribute("aria-label") ||
|
||||
el.getAttribute("title") ||
|
||||
el.textContent?.trim()?.slice(0, 60) ||
|
||||
el.id ||
|
||||
el.tagName.toLowerCase();
|
||||
appendUi("debug", `UI click: ${label || el.tagName}`, {
|
||||
id: el.id || null,
|
||||
type: el.tagName.toLowerCase(),
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function logUi(msg, level = "info", meta) {
|
||||
appendUi(level, msg, meta);
|
||||
}
|
||||
|
||||
export async function initDiagUI({ elements, toast }) {
|
||||
const {
|
||||
enableToggle,
|
||||
debugToggle,
|
||||
refreshBtn,
|
||||
clearBtn,
|
||||
copyBtn,
|
||||
downloadBtn,
|
||||
logBox,
|
||||
statusEl,
|
||||
logButton,
|
||||
modal,
|
||||
modalClose,
|
||||
} = elements;
|
||||
|
||||
const setBusy = (on) => {
|
||||
loading = on;
|
||||
[refreshBtn, clearBtn, copyBtn, downloadBtn, enableToggle, debugToggle].forEach((el) => {
|
||||
if (el) el.disabled = !!on;
|
||||
});
|
||||
};
|
||||
|
||||
async function syncState() {
|
||||
const data = await getDiagLog();
|
||||
const state = data.state || {};
|
||||
uiEnabled = !!state.enabled;
|
||||
uiLevel = state.level || "normal";
|
||||
if (enableToggle) enableToggle.checked = uiEnabled;
|
||||
if (debugToggle) debugToggle.checked = uiLevel === "debug";
|
||||
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||
if (modal && !uiEnabled) modal.classList.add("hidden");
|
||||
return data.entries || [];
|
||||
}
|
||||
|
||||
function render(entries) {
|
||||
if (!logBox) return;
|
||||
const merged = [
|
||||
...(entries || []).map((e) => ({ ...e, source: "api" })),
|
||||
...uiBuffer,
|
||||
].sort((a, b) => (a.ts < b.ts ? 1 : -1));
|
||||
logBox.textContent = merged
|
||||
.map((e) => `${new Date(e.ts).toLocaleTimeString()} [${e.source || "api"} ${e.level}] ${e.msg}`)
|
||||
.join("\n");
|
||||
if (statusEl) statusEl.textContent = `${merged.length} entries`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (loading) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const entries = await syncState();
|
||||
render(entries);
|
||||
toast?.("Diagnostics refreshed", "success");
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to load diagnostics", "error");
|
||||
// retry once if failed
|
||||
try {
|
||||
const entries = await syncState();
|
||||
render(entries);
|
||||
toast?.("Diagnostics refreshed (after retry)", "success");
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
enableToggle?.addEventListener("change", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
uiEnabled = enableToggle.checked;
|
||||
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||
appendUi("info", `Diagnostics ${uiEnabled ? "enabled" : "disabled"}`);
|
||||
if (uiEnabled) attachClickTracker();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to save diagnostics setting", "error");
|
||||
enableToggle.checked = !enableToggle.checked;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
debugToggle?.addEventListener("change", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
uiLevel = debugToggle.checked ? "debug" : "normal";
|
||||
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||
appendUi("info", `Diagnostics level set to ${uiLevel}`);
|
||||
if (uiEnabled) attachClickTracker();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to save level", "error");
|
||||
debugToggle.checked = uiLevel === "debug";
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", refresh);
|
||||
|
||||
clearBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await clearDiagLog();
|
||||
uiBuffer.length = 0;
|
||||
appendUi("info", "Cleared diagnostics");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to clear log", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
const text = logBox?.textContent || "";
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text || "No log entries.");
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text || "No log entries.";
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
toast?.("Diagnostics copied", "success");
|
||||
} catch (e) {
|
||||
toast?.("Copy failed", "error");
|
||||
}
|
||||
});
|
||||
|
||||
downloadBtn?.addEventListener("click", () => {
|
||||
try {
|
||||
const blob = new Blob([logBox?.textContent || "No log entries."], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "pikit-diagnostics.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast?.("Download failed", "error");
|
||||
}
|
||||
});
|
||||
|
||||
// initial load
|
||||
attachClickTracker();
|
||||
await refresh();
|
||||
|
||||
logButton?.addEventListener("click", () => {
|
||||
if (!uiEnabled) return;
|
||||
modal?.classList.remove("hidden");
|
||||
});
|
||||
modalClose?.addEventListener("click", () => modal?.classList.add("hidden"));
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) e.stopPropagation(); // prevent accidental close
|
||||
});
|
||||
|
||||
return {
|
||||
logUi,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { placeholderStatus, renderStats } from "./status.js";
|
||||
import { initServiceControls, renderServices } from "./services.js";
|
||||
import { initSettings } from "./settings.js";
|
||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||
import { initReleaseUI } from "./releases.js?v=20251213f";
|
||||
import { initReleaseUI } from "./releases.js?v=20251213h";
|
||||
import { initDiagUI, logUi } from "./diaglog.js?v=20251213i";
|
||||
|
||||
const servicesGrid = document.getElementById("servicesGrid");
|
||||
const heroStats = document.getElementById("heroStats");
|
||||
@@ -97,6 +98,18 @@ const changelogModal = document.getElementById("changelogModal");
|
||||
const changelogTitle = document.getElementById("changelogTitle");
|
||||
const changelogBody = document.getElementById("changelogBody");
|
||||
const changelogClose = document.getElementById("changelogClose");
|
||||
const diagEnableToggle = document.getElementById("diagEnableToggle");
|
||||
const diagDebugToggle = document.getElementById("diagDebugToggle");
|
||||
const diagRefreshBtn = document.getElementById("diagRefreshBtn");
|
||||
const diagClearBtn = document.getElementById("diagClearBtn");
|
||||
const diagCopyBtn = document.getElementById("diagCopyBtn");
|
||||
const diagDownloadBtn = document.getElementById("diagDownloadBtn");
|
||||
const diagLogBox = document.getElementById("diagLogBox");
|
||||
const diagStatus = document.getElementById("diagStatus");
|
||||
const diagLogBtn = document.getElementById("diagLogBtn");
|
||||
const diagModal = document.getElementById("diagModal");
|
||||
const diagClose = document.getElementById("diagClose");
|
||||
const diagStatusModal = document.getElementById("diagStatusModal");
|
||||
|
||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||
@@ -495,6 +508,7 @@ function main() {
|
||||
showBusy,
|
||||
hideBusy,
|
||||
confirmAction,
|
||||
logUi,
|
||||
});
|
||||
loadToastSettings();
|
||||
|
||||
@@ -558,6 +572,26 @@ function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Diagnostics
|
||||
initDiagUI({
|
||||
elements: {
|
||||
enableToggle: diagEnableToggle,
|
||||
debugToggle: diagDebugToggle,
|
||||
refreshBtn: diagRefreshBtn,
|
||||
clearBtn: diagClearBtn,
|
||||
copyBtn: diagCopyBtn,
|
||||
downloadBtn: diagDownloadBtn,
|
||||
logBox: diagLogBox,
|
||||
statusEl: diagStatusModal || diagStatus,
|
||||
logButton: diagLogBtn,
|
||||
modal: diagModal,
|
||||
modalClose: diagClose,
|
||||
},
|
||||
toast: showToast,
|
||||
}).catch((e) => {
|
||||
console.error("Diag init failed", e);
|
||||
});
|
||||
|
||||
// Toast controls
|
||||
toastPosSelect?.addEventListener("change", () => {
|
||||
const val = toastPosSelect.value;
|
||||
|
||||
@@ -15,7 +15,7 @@ function shorten(text, max = 90) {
|
||||
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 releaseBtn = document.getElementById("releaseBtn");
|
||||
const releaseModal = document.getElementById("releaseModal");
|
||||
@@ -45,15 +45,19 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
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}`;
|
||||
if (releaseLogLines[0] === line) return;
|
||||
releaseLogLines.unshift(line);
|
||||
releaseLogLines = releaseLogLines.slice(0, 120);
|
||||
if (releaseLog) {
|
||||
@@ -84,12 +88,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
const msg = shorten(message, 80) || "";
|
||||
releaseFlagTop.title = msg || "Pi-Kit release status";
|
||||
if (releaseStatusMsg) {
|
||||
releaseStatusMsg.textContent =
|
||||
status === "update_available"
|
||||
? msg || "Update available"
|
||||
: status === "up_to_date"
|
||||
? msg || "Up to date"
|
||||
: msg || status;
|
||||
releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : "";
|
||||
releaseStatusMsg.classList.remove("error");
|
||||
}
|
||||
if (releaseLogStatus) {
|
||||
releaseLogStatus.textContent =
|
||||
@@ -177,10 +177,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
} 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");
|
||||
}
|
||||
// surface via toast/log only; avoid inline red flashes
|
||||
showToast("Failed to load release status", "error");
|
||||
logRelease("Error: failed to load release status");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,11 +199,20 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
releaseBusyActive = false;
|
||||
hideBusy();
|
||||
if (releaseProgress) releaseProgress.textContent = "";
|
||||
if (state.status === "up_to_date") {
|
||||
// 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"}`);
|
||||
}
|
||||
}
|
||||
@@ -218,13 +226,17 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
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) releaseModal.classList.add("hidden");
|
||||
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 || {};
|
||||
@@ -242,6 +254,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
|
||||
releaseApplyBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
lastReleaseToastKey = null;
|
||||
logUi("Update apply requested");
|
||||
const state = window.__lastReleaseState || {};
|
||||
const { current_version, latest_version } = state;
|
||||
const sameVersion =
|
||||
@@ -274,6 +288,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
|
||||
releaseRollbackBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
lastReleaseToastKey = null;
|
||||
logUi("Rollback requested");
|
||||
releaseBusyActive = true;
|
||||
showBusy("Rolling back…", "Restoring previous backup.");
|
||||
logRelease("Starting rollback…");
|
||||
|
||||
@@ -422,18 +422,13 @@ body {
|
||||
min-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#diagModal pre.log-box {
|
||||
max-height: 60vh;
|
||||
min-height: 300px;
|
||||
}
|
||||
#releaseProgress {
|
||||
display: none;
|
||||
}
|
||||
.status-msg {
|
||||
display: none;
|
||||
}
|
||||
.status-msg.error {
|
||||
display: block;
|
||||
}
|
||||
#releaseStatusMsg {
|
||||
display: block;
|
||||
}
|
||||
.updates-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -115,14 +115,15 @@ export function initUpdateSettings({
|
||||
|
||||
function showMessage(text, isError = false) {
|
||||
if (!msgEl) return;
|
||||
msgEl.textContent = text || "";
|
||||
msgEl.classList.toggle("error", isError);
|
||||
|
||||
if (text) {
|
||||
if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success");
|
||||
setTimeout(() => (msgEl.textContent = ""), 2500);
|
||||
// Only surface inline text for errors; successes go to toast only.
|
||||
if (isError) {
|
||||
msgEl.textContent = text || "Something went wrong";
|
||||
msgEl.classList.add("error");
|
||||
} else {
|
||||
msgEl.textContent = "";
|
||||
msgEl.classList.remove("error");
|
||||
}
|
||||
|
||||
if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success");
|
||||
}
|
||||
|
||||
function currentConfigFromForm() {
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
>
|
||||
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
||||
</button>
|
||||
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
|
||||
Log
|
||||
</button>
|
||||
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
||||
Update
|
||||
</button>
|
||||
@@ -102,6 +105,29 @@
|
||||
</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">Refresh</button>
|
||||
<button id="diagClearBtn" class="ghost">Clear</button>
|
||||
<button id="diagCopyBtn" class="ghost">Copy</button>
|
||||
<button id="diagDownloadBtn" class="ghost">Download</button>
|
||||
<span id="diagStatusModal" class="hint quiet"></span>
|
||||
</div>
|
||||
<pre id="diagLogBox" class="log-box" aria-live="polite"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="releaseModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
@@ -522,6 +548,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button class="accordion-toggle" data-target="acc-diag">
|
||||
Diagnostics
|
||||
</button>
|
||||
<div class="accordion-body" id="acc-diag">
|
||||
<p class="hint">
|
||||
Temporary, RAM-only logs for debugging. Toggle on, choose debug for extra detail. Logs reset on reboot or clear. Use the Log button in the top bar (visible when diagnostics is enabled) to view, copy, download, or clear entries.
|
||||
</p>
|
||||
<div class="control-actions split-row">
|
||||
<label class="checkbox-row inline tight">
|
||||
<input type="checkbox" id="diagEnableToggle" />
|
||||
<span>Enable diagnostics</span>
|
||||
</label>
|
||||
<label class="checkbox-row inline tight">
|
||||
<input type="checkbox" id="diagDebugToggle" />
|
||||
<span>Debug detail (includes UI clicks)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<span id="diagStatus" class="hint quiet"></span>
|
||||
</div>
|
||||
<p class="hint quiet">Stored in RAM (max ~1MB server, ~500 entries client). No data written to disk.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
||||
Factory reset
|
||||
@@ -726,7 +777,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="assets/main.js?v=20251213f"></script>
|
||||
<script type="module" src="assets/main.js?v=20251213i"></script>
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user