Add diagnostics logging (RAM), UI viewer, and toggles

This commit is contained in:
Aaron
2025-12-13 11:16:57 -05:00
parent 28acb94a6f
commit 48be7a1c61
6 changed files with 386 additions and 9 deletions

View File

@@ -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",
});

167
pikit-web/assets/diaglog.js Normal file
View 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,
};
}

View File

@@ -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=20251213g";
import { initReleaseUI } from "./releases.js?v=20251213h";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213h";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
@@ -97,6 +98,14 @@ 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 TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
@@ -495,6 +504,7 @@ function main() {
showBusy,
hideBusy,
confirmAction,
logUi,
});
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
toastPosSelect?.addEventListener("change", () => {
const val = toastPosSelect.value;

View File

@@ -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");
@@ -236,6 +236,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
releaseCheckBtn?.addEventListener("click", async () => {
try {
logRelease("Checking for updates…");
logUi("Update check requested");
await checkRelease();
await loadReleaseStatus(true);
const state = window.__lastReleaseState || {};
@@ -254,6 +255,7 @@ 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 =
@@ -287,6 +289,7 @@ 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…");

View File

@@ -522,6 +522,36 @@
</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">
<button class="accordion-toggle danger-btn" data-target="acc-reset">
Factory reset
@@ -726,7 +756,7 @@
</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>
</body>
</html>