235 lines
7.2 KiB
JavaScript
235 lines
7.2 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";
|
|
|
|
const mapStateToProps = createStructuredSelector({
|
|
isDarkMode: selectDarkMode
|
|
});
|
|
|
|
const mapDispatchToProps = () => ({});
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
|
|
|
|
export function DmsLogEvents({
|
|
logs,
|
|
detailsOpen,
|
|
detailsNonce,
|
|
isDarkMode,
|
|
colorizeJson = false,
|
|
showDetails = true
|
|
}) {
|
|
const [openSet, setOpenSet] = useState(() => new Set());
|
|
|
|
// Inject JSON highlight styles once (only when colorize is enabled)
|
|
useEffect(() => {
|
|
if (!colorizeJson) return;
|
|
if (typeof document === "undefined") return;
|
|
if (document.getElementById("json-highlight-styles")) return;
|
|
const style = document.createElement("style");
|
|
style.id = "json-highlight-styles";
|
|
style.textContent = `
|
|
.json-key { color: #fa8c16; }
|
|
.json-string { color: #52c41a; }
|
|
.json-number { color: #722ed1; }
|
|
.json-boolean { color: #1890ff; }
|
|
.json-null { color: #faad14; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}, [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);
|
|
|
|
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" }}
|
|
>
|
|
{isOpen ? "Hide details" : "Details"}
|
|
</a>
|
|
</>
|
|
)}
|
|
</Space>
|
|
|
|
{/* Row 2: details body (only when open) */}
|
|
{hasMeta && isOpen && (
|
|
<div style={{ marginLeft: 6 }}>
|
|
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
|
|
</div>
|
|
)}
|
|
</Space>
|
|
)
|
|
};
|
|
}),
|
|
[logs, openSet, colorizeJson, isDarkMode, showDetails]
|
|
);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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>;
|
|
};
|
|
|
|
/**
|
|
* 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>`;
|
|
}
|
|
);
|
|
};
|