diff --git a/client/src/components/dms-log-events/dms-log-events.component.jsx b/client/src/components/dms-log-events/dms-log-events.component.jsx index fef6c5e91..34f6f7988 100644 --- a/client/src/components/dms-log-events/dms-log-events.component.jsx +++ b/client/src/components/dms-log-events/dms-log-events.component.jsx @@ -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: ( - {/* Row 1: summary */} + {/* 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" }} + > + {isOpen ? "Hide details" : "Details"} + + + )} - {/* Row 2: details on a new line */} - {!isEmpty(meta) && ( + {/* Row 2: details body (only when open) */} + {hasMeta && isOpen && (
-
{ - const isOpen = e.currentTarget.open; - setOpenSet((prev) => { - const next = new Set(prev); - if (isOpen) next.add(idx); - else next.delete(idx); - return next; - }); - }} - > - Details -
{safeStringify(meta, 2)}
-
+
)}
) }; }), - [logs, openSet] + [logs, openSet, colorizeJson] ); return ; } -/** 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
;
+  }
+  return 
{jsonText}
; +}; + +/** + * 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}`; + } + ); +}; diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index ccb7629b3..80c6a5e68 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -1,29 +1,35 @@ -import { useQuery } from "@apollo/client"; -import { Button, Card, Col, Result, Row, Select, Space } from "antd"; -import queryString from "query-string"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { connect } from "react-redux"; import { Link, useLocation, useNavigate } from "react-router-dom"; +import { connect } from "react-redux"; +import { useTranslation } from "react-i18next"; import { createStructuredSelector } from "reselect"; import SocketIO from "socket.io-client"; -import AlertComponent from "../../components/alert/alert.component"; -import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component"; -import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component"; -import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component"; -import DmsPostForm from "../../components/dms-post-form/dms-post-form.component"; -import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component"; -import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component"; +import queryString from "query-string"; +import { useQuery } from "@apollo/client"; +import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd"; +import { useSplitTreatments } from "@splitsoftware/splitio-react"; + import { auth } from "../../firebase/firebase.utils"; + +import { useSocket } from "../../contexts/SocketIO/useSocket.js"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; + import { QUERY_JOB_EXPORT_DMS } from "../../graphql/jobs.queries"; -import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; + import { selectBodyshop } from "../../redux/user/user.selectors"; +import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; + import InstanceRenderManager from "../../utils/instanceRenderMgr"; import AuditTrailMapping from "../../utils/AuditTrailMappings"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; -import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import { determineDmsType } from "../../utils/determineDmsType"; +import { OwnerNameDisplayFunction } from "../../components/owner-name-display/owner-name-display.component"; + +import AlertComponent from "../../components/alert/alert.component"; +import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component"; +import DmsPostForm from "../../components/dms-post-form/dms-post-form.component"; +import DmsLogEvents from "../../components/dms-log-events/dms-log-events.component"; +import DmsCustomerSelector from "../../components/dms-customer-selector/dms-customer-selector.component"; +import DmsAllocationsSummary from "../../components/dms-allocations-summary/dms-allocations-summary.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -50,15 +56,19 @@ export const socket = SocketIO(import.meta.env.PROD ? import.meta.env.VITE_APP_A export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) { const { t } = useTranslation(); const dms = determineDmsType(bodyshop); - const [logLevel, setLogLevel] = useState(dms === "pbs" ? "INFO" : "DEBUG"); + const history = useNavigate(); - const [logs, setLogs] = useState([]); const search = queryString.parse(useLocation().search); + + const [logLevel, setLogLevel] = useState(dms === "pbs" ? "INFO" : "DEBUG"); + const [logs, setLogs] = useState([]); const [detailsOpen, setDetailsOpen] = useState(false); // false => button shows "Expand All" const [detailsNonce, setDetailsNonce] = useState(0); // forces child to react to toggles + const [colorizeJson, setColorizeJson] = useState(false); // default: OFF const { jobId } = search; const notification = useNotification(); + const { treatments: { Fortellis } } = useSplitTreatments({ @@ -66,9 +76,9 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse names: ["Fortellis"], splitKey: bodyshop.imexshopid }); + // New unified wss socket (Fortellis, RR) const { socket: wsssocket } = useSocket(); - const activeSocket = useMemo(() => { return dms === "rr" || (dms === "cdk" && Fortellis.treatment === "on") ? wsssocket : socket; }, [dms, Fortellis.treatment, wsssocket, socket]); @@ -162,6 +172,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse if (dms === "rr") { // set log level on connect and immediately wsssocket.emit("set-log-level", logLevel); + const handleConnect = () => wsssocket.emit("set-log-level", logLevel); const handleReconnect = () => setLogs((prev) => [ @@ -371,6 +382,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse jobId={jobId} /> + @@ -384,12 +396,20 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse onRrCashierFinished={handleRrCashierFinished} bodyshop={bodyshop} /> +
+ + -