/* Eno.Health Console — collapsible right-side API inspector. */ (function () { const emptyArray = []; const NetworkLog = (function () { let entries = []; let subscribers = []; let seq = 0; function publish() { subscribers.forEach((fn) => fn(entries)); } function emit(event) { const entry = { id: "req_" + String(++seq).padStart(4, "0"), ts: Date.now(), method: "GET", status: 0, durationMs: 0, kind: "http", path: "", request: {}, response: null, ...event, }; entries = [entry, ...entries].slice(0, 300); publish(); return entry; } function subscribe(fn) { subscribers.push(fn); fn(entries); return () => { subscribers = subscribers.filter((item) => item !== fn); }; } function getAll() { return entries; } function clear() { entries = []; publish(); } return { emit, subscribe, getAll, clear }; })(); window.EnoNet = NetworkLog; window.enoLog = (event) => NetworkLog.emit(event || {}); let lastKafkaStreamErrorAt = 0; function parseMaybeJSON(value) { if (!value || typeof value !== "string") return value || null; try { return JSON.parse(value); } catch { return value; } } function requestPath(input) { const raw = typeof input === "string" ? input : input && input.url ? input.url : ""; try { const url = new URL(raw, window.location.href); return url.origin === window.location.origin ? url.pathname + url.search : url.href; } catch { return raw || "unknown"; } } function requestUrl(input) { const raw = typeof input === "string" ? input : input && input.url ? input.url : ""; try { return new URL(raw, window.location.href).href; } catch { return raw || ""; } } function requestMethod(input, init) { return String((init && init.method) || (input && input.method) || "GET").toUpperCase(); } function compactHeaders(headers) { if (!headers) return {}; if (typeof Headers !== "undefined" && headers instanceof Headers) return Object.fromEntries(headers.entries()); if (Array.isArray(headers)) return Object.fromEntries(headers); return { ...headers }; } function compactBody(body) { if (!body) return {}; if (typeof FormData !== "undefined" && body instanceof FormData) { const out = {}; body.forEach((value, key) => { out[key] = value && value.name ? { file: value.name, type: value.type || "", size: value.size || 0 } : value; }); return out; } if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return Object.fromEntries(body.entries()); if (typeof Blob !== "undefined" && body instanceof Blob) return { blob: { type: body.type || "", size: body.size || 0 } }; return parseMaybeJSON(body) || {}; } function installFetchLogger() { if (window.__enoFetchLoggerInstalled || typeof window.fetch !== "function") return; const nativeFetch = window.fetch.bind(window); window.__enoFetchLoggerInstalled = true; window.__enoNativeFetch = nativeFetch; // Patch fetch once so future screens are logged even if they bypass EnoAdminAPI. window.fetch = async function enoLoggedFetch(input, init) { const started = performance.now(); const method = requestMethod(input, init); const path = requestPath(input); const url = requestUrl(input); const headers = compactHeaders((init && init.headers) || (input && input.headers)); const request = compactBody(init && init.body); try { const response = await nativeFetch(input, init); let body = {}; try { body = parseMaybeJSON(await response.clone().text()) || {}; } catch (err) { body = { error: err.message || "Response body could not be read" }; } NetworkLog.emit({ method, path, url, status: response.status, durationMs: Math.round(performance.now() - started), headers, request, response: body, }); return response; } catch (err) { NetworkLog.emit({ method, path, url, status: 0, durationMs: Math.round(performance.now() - started), headers, request, response: { error: err.message || "Network request failed" }, }); throw err; } }; } installFetchLogger(); function debugStreamURL() { const api = window.EnoAdminAPI; if (api && api.adminKafkaDebugEventsURL) return api.adminKafkaDebugEventsURL(); const base = api && api.config ? api.config().baseUrl : "/api"; return String(base || "/api").replace(/\/+$/, "") + "/v1/admin/debug/kafka-events"; } function jsonString(value) { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function copyText(value) { const text = String(value == null ? "" : value); if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text); const el = document.createElement("textarea"); el.value = text; el.setAttribute("readonly", "readonly"); el.style.position = "fixed"; el.style.left = "-9999px"; document.body.appendChild(el); el.select(); document.execCommand("copy"); document.body.removeChild(el); return Promise.resolve(); } function shellQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'"; } function hasBody(value) { if (value == null) return false; if (typeof value === "string") return value.length > 0; return typeof value === "object" ? Object.keys(value).length > 0 : true; } function curlForEntry(entry) { const lines = ["curl -X " + shellQuote(entry.method || "GET") + " " + shellQuote(entry.url || (entry.path && entry.path.startsWith("/") ? window.location.origin + entry.path : entry.path || ""))]; Object.entries(entry.headers || {}).forEach(([key, value]) => { lines.push(" -H " + shellQuote(key + ": " + value)); }); if (hasBody(entry.request)) { lines.push(" --data-raw " + shellQuote(typeof entry.request === "string" ? entry.request : jsonString(entry.request))); } return lines.join(" \\\n"); } function CopyIconButton({ title, icon = "copy", value, onCopied }) { const [done, setDone] = useState(false); const click = (event) => { event.stopPropagation(); copyText(value).then(() => { setDone(true); onCopied && onCopied(); setTimeout(() => setDone(false), 1200); }).catch(() => setDone(false)); }; return h("button", { className: "net-copy" + (done ? " copied" : ""), title, onClick: click }, h(Icon, { name: done ? "check" : icon, size: 13 })); } function HighlightedJSON({ value }) { const source = jsonString(value); const tokens = []; const re = /("(\\.|[^"\\])*"\s*:)|("(\\.|[^"\\])*")|(\b(true|false|null)\b)|(-?\d+\.?\d*(e[+-]?\d+)?)/gi; let last = 0; let match; let idx = 0; while ((match = re.exec(source)) !== null) { if (match.index > last) tokens.push(h("span", { key: idx++ }, source.slice(last, match.index))); let cls = "j-num"; if (match[1]) cls = "j-key"; else if (match[3]) cls = "j-str"; else if (match[5]) cls = "j-bool"; tokens.push(h("span", { key: idx++, className: cls }, match[0])); last = match.index + match[0].length; } if (last < source.length) tokens.push(h("span", { key: idx++ }, source.slice(last))); return h("pre", { className: "dbg-json" }, tokens); } function statusClass(status) { const code = Number(status || 0); if (code >= 500) return "st-5xx"; if (code >= 400) return "st-4xx"; if (code >= 300) return "st-3xx"; if (code >= 200) return "st-2xx"; return "st-pending"; } function methodClass(method) { return "mt-" + String(method || "get").toLowerCase(); } function kafkaStatusClass(status) { const value = String(status || "").toLowerCase(); if (value === "published" || value === "sent" || value === "delivered") return "kst-ok"; if (value === "pending") return "kst-wait"; if (value === "failed" || value === "dead_letter") return "kst-bad"; return "kst-neutral"; } function agoShort(ts) { const seconds = Math.round((Date.now() - ts) / 1000); if (seconds < 5) return "now"; if (seconds < 60) return seconds + "s"; if (seconds < 3600) return Math.floor(seconds / 60) + "m"; return Math.floor(seconds / 3600) + "h"; } function NetRow({ entry, open, onToggle }) { if (entry.kind === "kafka") return h(KafkaRow, { entry, open, onToggle }); return h("div", { className: "net-row" + (open ? " open" : "") }, h("button", { className: "net-row-head", onClick: onToggle }, h("span", { className: "net-m " + methodClass(entry.method) }, entry.method), h("span", { className: "net-path", title: entry.path }, entry.path), h("span", { className: "net-st " + statusClass(entry.status) }, entry.status || "ERR"), h("span", { className: "net-age" }, agoShort(entry.ts))), open && h("div", { className: "net-row-body" }, h("div", { className: "net-meta" }, h("span", { className: "net-meta-line" }, h("span", { className: "net-meta-k" }, "Endpoint "), h("button", { className: "net-endpoint mono", title: "Copy endpoint", onClick: (event) => { event.stopPropagation(); copyText(entry.path); } }, entry.method, " ", entry.path), h(CopyIconButton, { title: "Copy endpoint", value: entry.path }), h(CopyIconButton, { title: "Copy as curl", icon: "curl", value: curlForEntry(entry) })), h("span", null, h("span", { className: "net-meta-k" }, "Status "), h("span", { className: "mono " + statusClass(entry.status).replace("st-", "stx-") }, entry.status || "ERR"), " · ", entry.durationMs || 0, "ms")), h("div", { className: "net-block-head" }, h("div", { className: "net-block-label" }, "Request"), h(CopyIconButton, { title: "Copy request JSON", value: jsonString(entry.request || {}) })), h(HighlightedJSON, { value: entry.request || {} }), h("div", { className: "net-block-head" }, h("div", { className: "net-block-label" }, "Response"), h(CopyIconButton, { title: "Copy response JSON", value: jsonString(entry.response == null ? {} : entry.response) })), h(HighlightedJSON, { value: entry.response == null ? {} : entry.response }))); } function KafkaRow({ entry, open, onToggle }) { const envelope = { topic: entry.topic, event_type: entry.eventType, event_id: entry.eventId, sequence_id: entry.sequenceId, status: entry.kafkaStatus, attempts: entry.attempts, key_hash: entry.keyHash, headers: entry.headers || {}, payload: entry.payload || {}, created_at: entry.createdAt, published_at: entry.publishedAt || null, redacted: true, }; return h("div", { className: "net-row kafka-row" + (open ? " open" : "") }, h("button", { className: "net-row-head", onClick: onToggle }, h("span", { className: "net-m mt-kafka" }, "KAFKA"), h("span", { className: "net-path", title: entry.topic }, entry.topic || "Kafka event"), h("span", { className: "net-st net-kstatus " + kafkaStatusClass(entry.kafkaStatus) }, entry.kafkaStatus || "event"), h("span", { className: "net-age" }, agoShort(entry.ts))), open && h("div", { className: "net-row-body" }, h("div", { className: "net-meta" }, h("span", { className: "net-meta-line" }, h("span", { className: "net-meta-k" }, "Topic "), h("button", { className: "net-endpoint mono", title: "Copy topic", onClick: (event) => { event.stopPropagation(); copyText(entry.topic || ""); } }, entry.topic || "unknown"), h(CopyIconButton, { title: "Copy topic", value: entry.topic || "" }), h(CopyIconButton, { title: "Copy Kafka envelope", value: jsonString(envelope) })), h("span", null, h("span", { className: "net-meta-k" }, "Event "), h("span", { className: "mono" }, entry.eventType || "unknown"), " · key hash ", h("span", { className: "mono" }, entry.keyHash || "n/a")), h("span", null, h("span", { className: "net-meta-k" }, "Status "), h("span", { className: "mono " + kafkaStatusClass(entry.kafkaStatus) }, entry.kafkaStatus || "event"), " · attempts ", entry.attempts || 0)), h("div", { className: "net-block-head" }, h("div", { className: "net-block-label" }, "Headers"), h(CopyIconButton, { title: "Copy Kafka headers JSON", value: jsonString(entry.headers || {}) })), h(HighlightedJSON, { value: entry.headers || {} }), h("div", { className: "net-block-head" }, h("div", { className: "net-block-label" }, "Payload summary"), h(CopyIconButton, { title: "Copy Kafka payload summary JSON", value: jsonString(entry.payload || {}) })), h(HighlightedJSON, { value: entry.payload || {} }), entry.redactedNote && h("div", { className: "net-redacted-note" }, entry.redactedNote))); } function DebugPanel() { const [open, setOpen] = useState(() => { try { return localStorage.getItem("eno_dbg") === "1"; } catch { return false; } }); const [pinned, setPinned] = useState(() => { try { return localStorage.getItem("eno_dbg_pin") === "1"; } catch { return false; } }); const [mode, setMode] = useState(() => { try { return localStorage.getItem("eno_dbg_mode") || "api"; } catch { return "api"; } }); const [entries, setEntries] = useState(NetworkLog.getAll()); const [expanded, setExpanded] = useState(null); const [, setTick] = useState(0); const apiEntries = entries.filter((entry) => entry.kind !== "kafka"); const kafkaEntries = entries.filter((entry) => entry.kind === "kafka"); const shownEntries = mode === "events" ? kafkaEntries : apiEntries; const emptyCopy = mode === "events" ? "No Kafka events yet. Trigger an ingestion flow or wait for the outbox stream." : "No API calls yet. Navigate the console to log request and response JSON."; useEffect(() => NetworkLog.subscribe(setEntries), []); useEffect(() => { try { localStorage.setItem("eno_dbg", open ? "1" : "0"); } catch {} }, [open]); useEffect(() => { try { localStorage.setItem("eno_dbg_pin", pinned ? "1" : "0"); } catch {} }, [pinned]); useEffect(() => { try { localStorage.setItem("eno_dbg_mode", mode); } catch {} setExpanded(null); }, [mode]); useEffect(() => { document.body.classList.toggle("dbg-pinned", pinned && open); return () => document.body.classList.remove("dbg-pinned"); }, [pinned, open]); useEffect(() => { if (!open || pinned) return undefined; const closeOutside = (event) => { if (event.target.closest && (event.target.closest(".dbg-panel") || event.target.closest(".dbg-rail"))) return; setOpen(false); }; document.addEventListener("pointerdown", closeOutside, true); return () => document.removeEventListener("pointerdown", closeOutside, true); }, [open, pinned]); useEffect(() => { const handler = (event) => { if (event.altKey && (event.key === "d" || event.key === "D" || event.key === "∂")) { event.preventDefault(); setOpen((value) => !value); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); useEffect(() => { if (!open) return undefined; const timer = setInterval(() => setTick((value) => value + 1), 15000); return () => clearInterval(timer); }, [open]); useEffect(() => { if (typeof EventSource === "undefined") return undefined; const source = new EventSource(debugStreamURL()); source.addEventListener("kafka.event", (event) => { let data = {}; try { data = JSON.parse(event.data || "{}"); } catch { data = { payload: { parse_error: "Kafka debug event could not be decoded" } }; } NetworkLog.emit({ kind: "kafka", method: "KAFKA", topic: data.topic || "", path: data.topic || "Kafka event", eventType: data.event_type || "", eventId: data.event_id || "", sequenceId: data.sequence_id || Number(event.lastEventId || 0), kafkaStatus: data.status || "", attempts: data.attempts || 0, keyHash: data.key_hash || "", headers: data.headers || {}, payload: data.payload || {}, createdAt: data.created_at || "", publishedAt: data.published_at || "", redactedNote: data.redacted_note || "", status: data.status || "event", }); }); source.addEventListener("stream.error", (event) => { let data = {}; try { data = JSON.parse(event.data || "{}"); } catch {} NetworkLog.emit({ kind: "kafka", method: "KAFKA", path: "Kafka debug stream", topic: "Kafka debug stream", kafkaStatus: "failed", status: "failed", payload: data, redactedNote: "The Kafka debug stream reported an error.", }); }); source.onerror = () => { const now = Date.now(); if (now - lastKafkaStreamErrorAt < 10000) return; lastKafkaStreamErrorAt = now; NetworkLog.emit({ kind: "kafka", method: "KAFKA", path: "Kafka debug stream", topic: "Kafka debug stream", kafkaStatus: "pending", status: "pending", payload: { state: "reconnecting" }, redactedNote: "Kafka debug stream is reconnecting.", }); }; return () => source.close(); }, []); return h(React.Fragment, null, h("button", { className: "dbg-rail" + (open ? " hidden" : ""), title: "Open API inspector (Alt+D)", onClick: () => setOpen(true) }, h(Icon, { name: "code", size: 16 }), h("span", { className: "dbg-rail-label" }, "Network"), entries.length > 0 && h("span", { className: "dbg-rail-badge" }, entries.length)), h("aside", { className: "dbg-panel" + (open ? " open" : "") + (pinned ? " pinned" : "") }, h("div", { className: "dbg-head" }, h("div", { className: "dbg-head-l" }, h(Icon, { name: "code", size: 16 }), h("span", { className: "dbg-title" }, "Debug"), h("span", { className: "dbg-badge" }, entries.length, " events")), h("div", { className: "dbg-head-r" }, h("button", { className: "dbg-ibtn" + (pinned ? " on" : ""), title: pinned ? "Unpin panel" : "Pin panel", onClick: () => setPinned((value) => !value) }, h(Icon, { name: "pin", size: 15 })), h("button", { className: "dbg-ibtn", title: "Clear log", onClick: () => { NetworkLog.clear(); setExpanded(null); } }, h(Icon, { name: "trash", size: 15 })), h("button", { className: "dbg-ibtn", title: "Collapse", onClick: () => setOpen(false) }, h(Icon, { name: "chevronR", size: 17 })))), h("div", { className: "dbg-switch" }, h("button", { className: "dbg-switch-btn" + (mode === "api" ? " on" : ""), onClick: () => setMode("api") }, h(Icon, { name: "code", size: 13 }), h("span", null, "API"), h("span", { className: "dbg-switch-count" }, apiEntries.length)), h("button", { className: "dbg-switch-btn" + (mode === "events" ? " on" : ""), onClick: () => setMode("events") }, h(Icon, { name: "activity", size: 13 }), h("span", null, "Events"), h("span", { className: "dbg-switch-count" }, kafkaEntries.length))), h("div", { className: "net-colhead" }, h("span", null, "Kind"), h("span", { style: { flex: 1 } }, mode === "events" ? "Topic" : "Endpoint"), h("span", null, "Status"), h("span", null, "Age")), h("div", { className: "dbg-scroll" }, (shownEntries || emptyArray).length === 0 ? h("div", { className: "net-empty" }, emptyCopy) : shownEntries.map((entry) => h(NetRow, { key: entry.id, entry, open: expanded === entry.id, onToggle: () => setExpanded((value) => (value === entry.id ? null : entry.id)), })), h("div", { className: "dbg-foot" }, "Toggle with ", h("kbd", null, "Alt"), h("kbd", null, "D"), " · HTTP + redacted Kafka")))); } window.DebugPanel = DebugPanel; })();