515 lines
16 KiB
JavaScript
515 lines
16 KiB
JavaScript
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: (
|
|
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
|
|
{/* Row 1: summary + inline "Details" toggle */}
|
|
<Space wrap align="start">
|
|
<Tag color={logLevelColor(level)}>{level}</Tag>
|
|
<Divider orientation="vertical" />
|
|
<span>{dayjs(timestamp).format("MM/DD/YYYY HH:mm:ss")}</span>
|
|
<Divider orientation="vertical" />
|
|
<span>{message}</span>
|
|
{hasMeta && (
|
|
<>
|
|
<Divider orientation="vertical" />
|
|
<a
|
|
role="button"
|
|
aria-expanded={isOpen}
|
|
onClick={() =>
|
|
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")}
|
|
</a>
|
|
<Divider orientation="vertical" />
|
|
<a
|
|
role="button"
|
|
onClick={() => handleCopyAction(copyPayloadKey, copyPayload, setCopiedKey)}
|
|
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
|
>
|
|
{copiedKey === copyPayloadKey ? t("dms.labels.copied") : t("dms.labels.copy")}
|
|
</a>
|
|
{hasRequestXml && (
|
|
<>
|
|
<Divider orientation="vertical" />
|
|
<a
|
|
role="button"
|
|
onClick={() => handleCopyAction(copyReqKey, xml.request, setCopiedKey)}
|
|
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
|
>
|
|
{copiedKey === copyReqKey ? t("dms.labels.copied") : t("dms.labels.copy_request")}
|
|
</a>
|
|
</>
|
|
)}
|
|
{hasResponseXml && (
|
|
<>
|
|
<Divider orientation="vertical" />
|
|
<a
|
|
role="button"
|
|
onClick={() => handleCopyAction(copyResKey, xml.response, setCopiedKey)}
|
|
style={{ cursor: "pointer", userSelect: "none", fontSize: 11 }}
|
|
>
|
|
{copiedKey === copyResKey ? t("dms.labels.copied") : t("dms.labels.copy_response")}
|
|
</a>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Space>
|
|
|
|
{/* Row 2: details body (only when open) */}
|
|
{hasMeta && isOpen && (
|
|
<div style={{ marginLeft: 6 }}>
|
|
<JsonBlock isDarkMode={isDarkMode} data={removeXmlFromMeta(meta)} colorize={colorizeJson} />
|
|
{hasRequestXml && (
|
|
<XmlBlock
|
|
isDarkMode={isDarkMode}
|
|
title={t("dms.labels.request_xml")}
|
|
xmlText={xml.request}
|
|
colorize={colorizeJson}
|
|
/>
|
|
)}
|
|
{hasResponseXml && (
|
|
<XmlBlock
|
|
isDarkMode={isDarkMode}
|
|
title={t("dms.labels.response_xml")}
|
|
xmlText={xml.response}
|
|
colorize={colorizeJson}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Space>
|
|
)
|
|
};
|
|
}),
|
|
[logs, openSet, colorizeJson, copiedKey, isDarkMode, showDetails, allowXmlPayload, t]
|
|
);
|
|
|
|
return <Timeline reverse items={items} />;
|
|
}
|
|
|
|
/**
|
|
* 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<void>}
|
|
*/
|
|
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<boolean>}
|
|
*/
|
|
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 <pre style={preStyle} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
}
|
|
return <pre style={preStyle}>{jsonText}</pre>;
|
|
};
|
|
|
|
/**
|
|
* 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 (
|
|
<div style={{ marginTop: 6 }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600 }}>{title}</div>
|
|
{colorize ? (
|
|
<pre style={base} dangerouslySetInnerHTML={{ __html: highlightXml(formatXml(xmlText)) }} />
|
|
) : (
|
|
<pre style={base}>{formatXml(xmlText)}</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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*</g, ">\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, "<")
|
|
.replace(/>/g, ">");
|
|
const lines = esc.split("\n");
|
|
|
|
return lines
|
|
.map((line) => {
|
|
let out = line;
|
|
|
|
out = out.replace(/(<!--[\s\S]*?-->)/g, '<span class="xml-comment">$1</span>');
|
|
out = out.replace(/(<\?xml[\s\S]*?\?>)/g, '<span class="xml-decl">$1</span>');
|
|
|
|
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,
|
|
'<span class="xml-attr">$1</span>$2<span class="xml-value">$3</span>'
|
|
);
|
|
return `${open}<span class="xml-tag">${tag}</span>${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, "<").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 `<span class="${cls}">${match}</span>`;
|
|
}
|
|
);
|
|
};
|