import { Divider, Space, Tag, Timeline } from "antd"; import { useEffect, useMemo, useState } from "react"; import dayjs from "../../utils/day"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectDarkMode } from "../../redux/application/application.selectors.js"; import { useTranslation } from "react-i18next"; const mapStateToProps = createStructuredSelector({ isDarkMode: selectDarkMode }); const mapDispatchToProps = () => ({}); export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents); export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colorizeJson = false, showDetails = true, allowXmlPayload = true }) { const { t } = useTranslation(); const [openSet, setOpenSet] = useState(() => new Set()); const [copiedKey, setCopiedKey] = useState(null); // Inject JSON highlight styles once (only when colorize is enabled) useEffect(() => { if (!colorizeJson) return; if (typeof document === "undefined") return; let style = document.getElementById("json-highlight-styles"); if (!style) { style = document.createElement("style"); style.id = "json-highlight-styles"; document.head.appendChild(style); } style.textContent = ` .json-key { color: #fa8c16; } .json-string { color: #52c41a; } .json-number { color: #722ed1; } .json-boolean { color: #1890ff; } .json-null { color: #faad14; } .xml-tag { color: #1677ff; } .xml-attr { color: #d46b08; } .xml-value { color: #389e0d; } .xml-decl { color: #7c3aed; } .xml-comment { color: #8c8c8c; } `; }, [colorizeJson]); // Trim openSet if logs shrink useEffect(() => { const len = (logs || []).length; setOpenSet((prev) => { const next = new Set(); for (let i = 0; i < len; i++) if (prev.has(i)) next.add(i); return next; }); }, [logs?.length]); // Respond to global toggle button useEffect(() => { if (detailsNonce == null) return; const len = (logs || []).length; setOpenSet(detailsOpen ? new Set(Array.from({ length: len }, (_, i) => i)) : new Set()); }, [detailsNonce, detailsOpen, logs?.length]); const items = useMemo( () => (logs || []).map((raw, idx) => { const { level, message, timestamp, meta } = normalizeLog(raw); // Only treat meta as "present" when we are allowed to show details const hasMeta = !isEmpty(meta) && showDetails; const isOpen = hasMeta && openSet.has(idx); const xml = hasMeta && allowXmlPayload ? extractXmlFromMeta(meta) : { request: null, response: null }; const hasRequestXml = !!xml.request; const hasResponseXml = !!xml.response; const copyPayload = hasMeta ? getCopyPayload(meta) : null; const copyPayloadKey = `copy-${idx}`; const copyReqKey = `copy-req-${idx}`; const copyResKey = `copy-res-${idx}`; return { key: idx, color: logLevelColor(level), content: ( {/* Row 1: summary + inline "Details" toggle */} {level} {dayjs(timestamp).format("MM/DD/YYYY HH:mm:ss")} {message} {hasMeta && ( <> setOpenSet((prev) => { const next = new Set(prev); if (isOpen) next.delete(idx); else next.add(idx); return next; }) } style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} > {isOpen ? t("dms.labels.hide_details") : t("dms.labels.details")} handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)} style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} > {copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")} {hasRequestXml && ( <> handleCopyAction(copyReqKey, xml.request, setCopiedKey)} style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} > {copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")} )} {hasResponseXml && ( <> handleCopyAction(copyResKey, xml.response, setCopiedKey)} style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }} > {copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")} )} )} {/* Row 2: details body (only when open) */} {hasMeta && isOpen && (
{hasRequestXml && ( )} {hasResponseXml && ( )}
)}
) }; }), [logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t] ); return ; } /** * Normalize various log input formats into a standard structure. * @param input * @returns {{level: string, message: *|string, timestamp: Date, meta: *}} */ const normalizeLog = (input) => { const n = input?.normalized || input || {}; const level = (n.level || input?.level || "INFO").toString().toUpperCase(); const message = n.message ?? input?.message ?? ""; const meta = input?.meta != null ? input.meta : n.meta != null ? n.meta : undefined; const tsRaw = input?.timestamp ?? n.timestamp ?? input?.ts ?? Date.now(); const timestamp = typeof tsRaw === "number" ? new Date(tsRaw) : new Date(tsRaw); return { level, message, timestamp, meta }; }; /** * Map log level to tag color. * @param level * @returns {string} */ const logLevelColor = (level) => { switch ((level || "").toUpperCase()) { case "SILLY": return "purple"; case "DEBUG": return "orange"; case "INFO": return "blue"; case "WARN": case "WARNING": return "yellow"; case "ERROR": return "red"; default: return "default"; } }; /** * Check if a value is "empty" (null/undefined, empty array, or empty object). * @param v */ const isEmpty = (v) => { if (v == null) return true; if (Array.isArray(v)) return v.length === 0; if (typeof v === "object") return Object.keys(v).length === 0; return false; }; /** * Safely stringify an object to JSON, falling back to String() on failure. * @param obj * @param spaces * @returns {string} */ const safeStringify = (obj, spaces = 2) => { try { return JSON.stringify(obj, null, spaces); } catch { return String(obj); } }; /** * Get request/response XML from various Reynolds log meta shapes. * @param meta * @returns {{request: string|null, response: string|null}} */ const extractXmlFromMeta = (meta) => { const request = firstString(meta?.requestXml) || firstString(meta?.xml?.request) || firstString(meta?.response?.xml?.request) || firstString(meta?.response?.requestXml); const response = firstString(meta?.responseXml) || firstString(meta?.xml?.response) || firstString(meta?.response?.xml?.response); return { request, response }; }; /** * Return the value to copy when clicking the "Copy" action. * @param meta * @returns {*} */ const getCopyPayload = (meta) => { if (meta?.payload != null) return meta.payload; return meta; }; /** * Remove bulky XML fields from object shown in JSON block (XML is rendered separately). * @param meta * @returns {*} */ const removeXmlFromMeta = (meta) => { if (meta == null || typeof meta !== "object") return meta; const cloned = safeClone(meta); if (cloned == null || typeof cloned !== "object") return meta; if (typeof cloned.requestXml === "string") delete cloned.requestXml; if (typeof cloned.responseXml === "string") delete cloned.responseXml; if (cloned.xml && typeof cloned.xml === "object") { if (typeof cloned.xml.request === "string") delete cloned.xml.request; if (typeof cloned.xml.response === "string") delete cloned.xml.response; if (isEmpty(cloned.xml)) delete cloned.xml; } if (cloned.response?.xml && typeof cloned.response.xml === "object") { if (typeof cloned.response.xml.request === "string") delete cloned.response.xml.request; if (typeof cloned.response.xml.response === "string") delete cloned.response.xml.response; if (isEmpty(cloned.response.xml)) delete cloned.response.xml; } return cloned; }; /** * Safe deep clone for plain JSON structures. * @param value * @returns {*} */ const safeClone = (value) => { try { return JSON.parse(JSON.stringify(value)); } catch { return value; } }; /** * First non-empty string helper. * @param value * @returns {string|null} */ const firstString = (value) => { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed ? trimmed : null; }; /** * Copy arbitrary text/object to clipboard. * @param key * @param value * @param setCopied * @returns {Promise} */ const handleCopyAction = async (key, value, setCopied) => { const text = typeof value === "string" ? value : safeStringify(value, 2); if (!text) return; const copied = await copyTextToClipboard(text); if (!copied) return; setCopied(key); setTimeout(() => { setCopied((prev) => (prev === key ? null : prev)); }, 1200); }; /** * Clipboard helper (modern async Clipboard API). * @param text * @returns {Promise} */ const copyTextToClipboard = async (text) => { if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { return false; } try { await navigator.clipboard.writeText(text); return true; } catch { return false; } }; /** * JSON display block with optional syntax highlighting. * @param data * @param colorize * @param isDarkMode * @returns {JSX.Element} * @constructor */ const JsonBlock = ({ data, colorize, isDarkMode }) => { const jsonText = safeStringify(data, 2); const preStyle = { margin: "6px 0 0", maxWidth: 720, overflowX: "auto", fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12, lineHeight: 1.45, padding: 8, borderRadius: 6, background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)", border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)", color: isDarkMode ? "var(--card-text-fallback)" : "#141414" }; if (colorize) { const html = syntaxHighlight(jsonText); return
;
  }
  return 
{jsonText}
; }; /** * XML display block with normalized indentation. * @param title * @param xmlText * @param isDarkMode * @returns {JSX.Element} * @constructor */ const XmlBlock = ({ title, xmlText, isDarkMode, colorize = false }) => { const base = { margin: "8px 0 0", maxWidth: 720, overflowX: "auto", fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12, lineHeight: 1.45, padding: 8, borderRadius: 6, background: isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)", border: isDarkMode ? "1px solid rgba(255,255,255,0.12)" : "1px solid rgba(0,0,0,0.08)", color: isDarkMode ? "var(--card-text-fallback)" : "#141414", whiteSpace: "pre" }; return (
{title}
{colorize ? (
      ) : (
        
{formatXml(xmlText)}
)}
); }; /** * Basic XML pretty-printer. * @param xml * @returns {string} */ const formatXml = (xml) => { if (typeof xml !== "string") return ""; const normalized = xml.replace(/\r\n/g, "\n").replace(/>\s*\n<").trim(); const lines = normalized.split("\n"); let indent = 0; const out = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; if (/^<\/[^>]+>/.test(line)) indent = Math.max(indent - 1, 0); out.push(`${" ".repeat(indent)}${line}`); const opens = (line.match(/<[^/!?][^>]*>/g) || []).length; const closes = (line.match(/<\/[^>]+>/g) || []).length; const selfClosing = (line.match(/<[^>]+\/>/g) || []).length; const declaration = /^<\?xml/.test(line) ? 1 : 0; indent += opens - closes - selfClosing - declaration; if (indent < 0) indent = 0; } return out.join("\n"); }; /** * Syntax highlight pretty-printed XML text for HTML display. * @param xmlText * @returns {string} */ const highlightXml = (xmlText) => { const esc = String(xmlText || "") .replace(/&/g, "&") .replace(//g, ">"); const lines = esc.split("\n"); return lines .map((line) => { let out = line; out = out.replace(/(<!--[\s\S]*?-->)/g, '$1'); out = out.replace(/(<\?xml[\s\S]*?\?>)/g, '$1'); out = out.replace(/(<\/?)([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?>)/g, (_m, open, tag, attrs, close) => { const coloredAttrs = attrs.replace( /([A-Za-z_][\w:.-]*)(=)("[^"]*"|'[^']*'|"[\s\S]*?"|'[\s\S]*?')/g, '$1$2$3' ); return `${open}${tag}${coloredAttrs}${close}`; }); return out; }) .join("\n"); }; /** * Syntax highlight JSON text for HTML display. * @param jsonText * @returns {*} */ const syntaxHighlight = (jsonText) => { const esc = jsonText.replace(/&/g, "&").replace(//g, ">"); return esc.replace( /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => { let cls = "json-number"; if (match.startsWith('"')) { cls = match.endsWith(":") ? "json-key" : "json-string"; } else if (match === "true" || match === "false") { cls = "json-boolean"; } else if (match === "null") { cls = "json-null"; } return `${match}`; } ); };