feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Expanded Logs / Formatting change

This commit is contained in:
Dave
2025-11-12 17:35:07 -05:00
parent 90f653c0b7
commit 577c3bec04
2 changed files with 180 additions and 57 deletions

View File

@@ -4,8 +4,11 @@ import dayjs from "../../utils/day";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({});
const mapStateToProps = createStructuredSelector({
isDarkMode: selectDarkMode
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
@@ -14,9 +17,26 @@ const mapDispatchToProps = (dispatch) => ({
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
export function DmsLogEvents({ logs, detailsOpen, detailsNonce }) {
export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colorizeJson = false }) {
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;
@@ -29,65 +49,74 @@ export function DmsLogEvents({ logs, detailsOpen, detailsNonce }) {
// Respond to global toggle button
useEffect(() => {
if (detailsNonce == null) return; // prop optional for compatibility
if (detailsNonce == null) return;
const len = (logs || []).length;
if (detailsOpen) {
setOpenSet(new Set(Array.from({ length: len }, (_, i) => i))); // expand all
} else {
setOpenSet(new Set()); // collapse all
}
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);
const hasMeta = !isEmpty(meta);
const isOpen = openSet.has(idx);
return {
key: idx,
color: logLevelColor(level),
children: (
<Space direction="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary */}
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
<Tag color={logLevelColor(level)}>{level}</Tag>
<Divider type="vertical" />
<span>{dayjs(timestamp).format("MM/DD/YYYY HH:mm:ss")}</span>
<Divider type="vertical" />
<span>{message}</span>
{hasMeta && (
<>
<Divider type="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 on a new line */}
{!isEmpty(meta) && (
{/* Row 2: details body (only when open) */}
{hasMeta && isOpen && (
<div style={{ marginLeft: 6 }}>
<details
open={openSet.has(idx)}
onToggle={(e) => {
const isOpen = e.currentTarget.open;
setOpenSet((prev) => {
const next = new Set(prev);
if (isOpen) next.add(idx);
else next.delete(idx);
return next;
});
}}
>
<summary>Details</summary>
<pre style={{ margin: "6px 0 0", maxWidth: 720, overflowX: "auto" }}>{safeStringify(meta, 2)}</pre>
</details>
<JsonBlock isDarkMode={isDarkMode} data={meta} colorize={colorizeJson} />
</div>
)}
</Space>
)
};
}),
[logs, openSet]
[logs, openSet, colorizeJson]
);
return <Timeline pending reverse items={items} />;
}
/** Accepts both legacy shape and new "normalized" shape */
function normalizeLog(input) {
/**
* 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 ?? "";
@@ -95,9 +124,14 @@ function normalizeLog(input) {
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 };
}
};
function logLevelColor(level) {
/**
* Map log level to tag color.
* @param level
* @returns {string}
*/
const logLevelColor = (level) => {
switch ((level || "").toUpperCase()) {
case "DEBUG":
return "orange";
@@ -111,19 +145,83 @@ function logLevelColor(level) {
default:
return "default";
}
}
};
function isEmpty(v) {
/**
* 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;
}
};
function safeStringify(obj, spaces = 2) {
/**
* 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>`;
}
);
};