feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration - Expanded Logs / Formatting change
This commit is contained in:
@@ -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, "&").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>`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col md={24} lg={14}>
|
||||
<DmsPostForm socket={activeSocket} job={data?.jobs_by_pk} logsRef={logsRef} />
|
||||
</Col>
|
||||
@@ -384,12 +396,20 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
onRrCashierFinished={handleRrCashierFinished}
|
||||
bodyshop={bodyshop}
|
||||
/>
|
||||
|
||||
<Col span={24}>
|
||||
<div ref={logsRef}>
|
||||
<Card
|
||||
title={t("jobs.labels.dms.logs")}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Switch
|
||||
checked={colorizeJson}
|
||||
onChange={setColorizeJson}
|
||||
checkedChildren="Color JSON"
|
||||
unCheckedChildren="Plain JSON"
|
||||
/>
|
||||
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
|
||||
<Select
|
||||
placeholder="Log Level"
|
||||
value={logLevel}
|
||||
@@ -407,7 +427,6 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
<Select.Option key="WARN">WARN</Select.Option>
|
||||
<Select.Option key="ERROR">ERROR</Select.Option>
|
||||
</Select>
|
||||
<Button onClick={toggleDetailsAll}>{detailsOpen ? "Collapse All" : "Expand All"}</Button>
|
||||
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -425,7 +444,13 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<DmsLogEvents logs={logs} detailsOpen={detailsOpen} detailsNonce={detailsNonce} />
|
||||
<DmsLogEvents
|
||||
socket={socket}
|
||||
logs={logs}
|
||||
detailsOpen={detailsOpen}
|
||||
detailsNonce={detailsNonce}
|
||||
colorizeJson={colorizeJson}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
Reference in New Issue
Block a user