brobada / CPQ Performance Stats

/* eslint-disable no-undef */
/* eslint-disable no-inner-declarations */
// ==UserScript==
// @name        CPQ Performance Stats
// @version     0.230
// @description CPQ Config Model Performance Stats
// @author      Obada Kadri
// @license     MIT
// @match       *://*.bigmachines.com/commerce/display_company_profile.jsp*
// @require     https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js
// @require     https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js
// @require     https://cdn.jsdelivr.net/chartist.js/latest/chartist.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.8.0/js/bootstrap-datepicker.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data.min.js
// @grant       GM_addStyle
// ==/UserScript==

// Defaults and presets
var minUsagePerDay = 3; // The minimum number of referenceObject hits per day to consider on the chart
var queryTypes = ["configure", "update"];
var currentCallId = 0;

var MODELS = [
	// HW/SW
	{
		name: "Hardware : HW/SW Part Search",
		id: "allEquipment:hardwareSoftware:pTOSoftwareServices:hWSWPartSearch"
	},
	{
		name: "Hardware : Hardware Services",
		id: "allEquipment:hardwareSoftware:professionalServices:hardwareServices"
	},
	{
		name: "Hardware : Micros Part Search (RGBU)",
		id: "allEquipment:hardwareSoftware:microsParts:microsPartSearch"
	},
	{
		name: "Hardware : Micros Part Search (HGBU)",
		id: "allEquipment:hardwareSoftware:microsParts:microsPartSearch_HWSW"
	},
	{
		name: "Hardware : Hardware Services",
		id: "allEquipment:hardwareSoftware:professionalServices:hardwareServices"
	},
	{
		name: "Software : Software Part Search",
		id: "allEquipment:software:softwarePartSearch:softwarePartSearchModel"
	},
	{
		name: "Software : Support Only and Subscription Offerings",
		id: "allEquipment:software:softwarePartSearch:supportOnly"
	},
	{
		name: "Software : Software Services",
		id: "allEquipment:hardwareSoftware:professionalServices:softwareServices"
	},
	{
		name: "License Subscriptions : Tech License Subscriptions",
		id: "allEquipment:licenseSubscription:licenseSubscription:techLicenseSubscription"
	},
];

// Don't run on login page
var notLoginPage = !document.querySelector("#login-form .login-links");

if (notLoginPage) {
	var DAILY_USAGE_CHART = "daily-usage-chart";
	var DAILY_SPEED_CHART = "daily-speed-chart";

	$("head").append(
		"<link rel=\"stylesheet\" href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css\">"
	);
	$("head").append(
		"<link rel=\"stylesheet\" href=\"//cdn.jsdelivr.net/chartist.js/latest/chartist.min.css\">"
	);
	$("head").append(
		"<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.8.0/css/bootstrap-datepicker.min.css\">"
	);

	/**
	 * Calculates the average of a given number array, handles non numbers
	 * @param arr
	 */
	function average(arr) {
		var sum = 0;
		var count = 0;
		arr.forEach(i => {
			if (typeof i == "number" && !isNaN(i)) {
				sum += i;
				count++;
			}
		});
		return {
			sum: sum,
			count: count,
			average: count ? Math.round(sum / count) : 0
		};
	}

	/**
	 * Returns an object with dates as keys, and each value is the sum, count, and average of that date
	 */
	function getChartDailyStats(dataItems) {
		var dailyStats = {};
		var dataKeys = [];
		dataItems.forEach(function(item) {
			var itemDate = item.eventDate ? moment(item.eventDate).tz("America/Los_Angeles").format("YYYY-MM-DD") : "";
			if (itemDate) {
				if (!dailyStats[itemDate]) {
					dataKeys.push(itemDate);
					dailyStats[itemDate] = {
						serverTimes: []
					};
				}
				dailyStats[itemDate].serverTimes.push(item.serverTime);
			}
		});
		dataKeys.forEach(function(key) {
			if (dailyStats[key].serverTimes.length >= minUsagePerDay) {
				// Ignore stats for days where there are low number of visits
				dailyStats[key].averageServerTime = average(
					dailyStats[key].serverTimes
				);
			} else {
				delete dailyStats[key];
			}
		});
		console.info(dailyStats);
		return dailyStats;
	}

	/**
	 * Draws the Chartist bar chart for the given data on the div of the given id
	 * @param {*} chartId
	 * @param {*} dailyStats
	 */
	function drawChartist(chartId, dailyStats) {
		dailyStats = dailyStats || {};
		var labels = [];
		var dataSeries = [];

		var keys = Object.keys(dailyStats);
		keys.sort();
		for (var i = 0; i < keys.length; ++i) {
			labels.push(keys[i].substr(5));
			if (chartId == DAILY_USAGE_CHART) {
				dataSeries.push(dailyStats[keys[i]].averageServerTime.count);
			} else if (chartId == DAILY_SPEED_CHART) {
				dataSeries.push(
					(dailyStats[keys[i]].averageServerTime.average / 1000).toFixed(2)
				);
			}
		}

		var data = {
			labels: labels,
			series: [dataSeries]
		};

		var options = {
			height: "300px"
		};

		// eslint-disable-next-line no-undef
		new Chartist.Bar("#" + chartId, data, options);
	}

	/**
	 * Calls the CPQ API URL(s) and returns the performance logs of the given referenceObject for the period given
	 * @param {*} referenceObject
	 * @param {*} dateFrom
	 * @param {*} dateTo
	 * @param {*} url
	 * @param {*} allData
	 * @param {*} deferred
	 */
	function getStatsData(
		referenceObject,
		callId,
		dateFrom,
		dateTo,
		url,
		allData,
		deferred
	) {
		deferred = deferred || new $.Deferred();

		if (callId === currentCallId) {
			if (!url) {
				// It looks like MongoDB 'lte' is not working as expected. So I am adding a day to the dateTo and using 'lt' instead
				const dateToPlusOne = moment(dateTo, "YYYY-MM-DD").add(1, "days").toISOString();
				const dateFromISO = moment(dateFrom, "YYYY-MM-DD").toISOString();
				const queryTypesStr = queryTypes.map(qt => `{"component":{$eq:"${qt}"}}`).join(",");
				const byModelUrl = `/rest/v6/performanceLogs?q={$and:[{$or:[${queryTypesStr}]},{"referenceObject":{$like:"${referenceObject}"}},{"eventDate":{$gte:"${dateFromISO}"}},{"eventDate":{$lt:"${dateToPlusOne}"}}]}&orderby=eventDate:desc&totalResults=true&offset=0`;
				const byTagUrl = `/rest/v6/performanceLogs?q={$and:[{"component":{$like:"${referenceObject}"}},{"eventDate":{$gte:"${dateFromISO}"}},{"eventDate":{$lt:"${dateToPlusOne}"}}]}&orderby=eventDate:desc&totalResults=true&offset=0`;
				const searchBy = $("input[name=searchBy]:checked").val();
				url = searchBy == "by-referenceObject" ? byModelUrl : byTagUrl;
			}

			allData = allData || [];

			$.get(url, function(data) {
				if (data.hasMore && callId === currentCallId) {
					var nextLink = data.links.find(link => link.rel === "next").href;
					var totalResults = data.totalResults;
					allData = allData.concat(data.items);
					if ($("#selected-reference-object-stats .progress-bar").length) {
						// Update progress bar if exists
						$("#selected-reference-object-stats .progress-bar").css(
							"width",
							`${(100 * allData.length) / totalResults}%`
						);
						$("#selected-reference-object-stats .progress-bar").text(
							`${allData.length} / ${totalResults} Records`
						);
					} else {
						// Append the progress bar
						$("#selected-reference-object-stats").html(`
						<div class="progress">
							<div class="progress-bar bg-danger" style="width: ${(100 * allData.length) /
								totalResults}%">${allData.length} / ${totalResults} Records</div>
						</div>
						`);
					}

					getStatsData(
						referenceObject,
						callId,
						dateFrom,
						dateTo,
						nextLink,
						allData,
						deferred
					);
				} else {
					deferred.resolve({
						data: allData.concat(data.items),
						callId: callId
					});
				}
			}).fail(function() {
				deferred.reject();
			});
		} else {
			deferred.reject();
		}

		return deferred.promise();
	}

	/**
	 * When the referenceObject is selected from the dropdown menu, this function populates the initial API URL and renders the results
	 * @param {*} referenceObject
	 */
	function setSelectedModel(referenceObjectId) {
		currentCallId++;
		var referenceObject = MODELS.find(function(referenceObject) {
			return referenceObject.id == referenceObjectId;
		});
		if (!referenceObject) {
			return {};
		}
		var dateFrom =
			$("#dateFrom").val() || new Date().toISOString().split("T")[0];
		var dateTo = $("#dateTo").val() || new Date().toISOString().split("T")[0];

		// Set the results header
		$("#selected-reference-object-header").text(
			`${referenceObject.name} Performance Stats:`
		);
		// Set the performance stats results
		$("#reference-object-charts").addClass("d-none");
		$("#selected-reference-object-stats").html("Loading...");

		getStatsData(referenceObject.id, currentCallId, dateFrom, dateTo)
			.then(function(items) {
				if (items.callId === currentCallId) {
					console.info(items);
					var serverTime = average(items.data.map(i => parseInt(i.serverTime)));
					$("#reference-object-charts").removeClass("d-none");
					var dailyData = getChartDailyStats(items.data);
					drawChartist(DAILY_USAGE_CHART, dailyData);
					drawChartist(DAILY_SPEED_CHART, dailyData);

					// Set the performance stats results
					$("#selected-reference-object-stats").html(`
				Average Server Time for the entire period: <strong>${(
		serverTime.average / 1000
	).toFixed(2)} seconds</strong> (${serverTime.count} records)
				`);
				}
			})
			.fail(function(error) {
				$("#selected-reference-object-stats").html(`Error: ${error}`);
			});
	}

	/**
	 * When the tag is set, this function populates the initial API URL and renders the results
	 * @param {*} tag
	 */
	function setTag(tag) {
		currentCallId++;
		if (!tag) {
			return {};
		}
		var dateFrom =
			$("#dateFrom").val() || new Date().toISOString().split("T")[0];
		var dateTo = $("#dateTo").val() || new Date().toISOString().split("T")[0];

		// Set the results header
		$("#selected-reference-object-header").text(
			`Tag "${tag}" Performance Stats:`
		);
		// Set the performance stats results
		$("#reference-object-charts").addClass("d-none");
		$("#selected-reference-object-stats").html("Loading...");

		getStatsData(tag, currentCallId, dateFrom, dateTo)
			.then(function(items) {
				if (items.callId === currentCallId) {
					console.info(items);
					var serverTime = average(items.data.map(i => parseInt(i.serverTime)));
					$("#reference-object-charts").removeClass("d-none");
					var dailyData = getChartDailyStats(items.data);
					drawChartist(DAILY_USAGE_CHART, dailyData);
					drawChartist(DAILY_SPEED_CHART, dailyData);

					// Set the performance stats results
					$("#selected-reference-object-stats").html(`
				Average Server Time for the entire period: <strong>${(
		serverTime.average / 1000
	).toFixed(2)} seconds</strong> (${serverTime.count} records)
				`);
				}
			})
			.fail(function(error) {
				$("#selected-reference-object-stats").html(`Error: ${error}`);
			});
	}

	/**
	 * Appends the main button and modal to the CPQ body
	 */
	function showStats() {
		"use strict";
		var referenceObjectOptions = MODELS.map(function(referenceObject) {
			return `<option value="${
				referenceObject.id
			}">${referenceObject.name}</option>`;
		}).join("\n");
		var modalHtml = `
	<!-- Modal -->
	<div class="modal fade" id="performanceModal" tabindex="-1" role="dialog" aria-labelledby="performanceModalLabel">
	<div class="modal-dialog modal-xl" role="document">
		<div class="modal-content d-none">
			<div class="modal-header">
				<h5 class="modal-title" id="exampleModalLabel">Model Performance Stats</h5>
				<button type="button" class="close" data-dismiss="modal" aria-label="Close">
					<span aria-hidden="true">&times;</span>
				</button>
			</div>
			<div class="modal-body">
				<form>
					<div class="container form-group">
						<div class="row">
							<div class="col-sm" align="center">
								<h5>From</h5>
								<div id="datepickerFrom"></div>
								<input type="hidden" id="dateFrom">
							</div>
							<div class="col-sm" align="center">
								<h5>To</h5>
								<div id="datepickerTo"></div>
								<input type="hidden" id="dateTo">
							</div>
						</div>
					</div>
					<div class="form-group">
						<div id="search-by-mode" class="btn-group btn-group-toggle justify-content-end" data-toggle="buttons">
							<label class="btn btn-secondary active" data-value="by-referenceObject">
								<input type="radio" name="searchBy" value="by-referenceObject" autocomplete="off" checked>By Model
							</label>
							<label class="btn btn-secondary" data-value="by-tag">
								<input type="radio" name="searchBy" value="by-tag" autocomplete="off">By LogTime Tag
							</label>
						</div>
					</div>
					<div id="by-common" class="form-group">
						<label for="min-num">Minimum Number to consider per day</label>
						<input type="text" id="min-num" value="${minUsagePerDay}" class="form-control" placeholder="If you choose 3, then any day with less than 3 entries, will not be displayed" />
					</div>
					<div id="by-referenceObject" class="search-by form-group">
						<div id="query-type">
							<label for="query-type">Query Types</label><br />
							<div class="form-check form-check-inline">
								<input class="form-check-input" type="checkbox" name="query-type[]" id="query-type-1" value="configure" checked="true">
								<label class="form-check-label" for="query-type-1">New Config</label>
							</div>
							<div class="form-check form-check-inline">
								<input class="form-check-input" type="checkbox" name="query-type[]" id="query-type-2" value="update" checked="true">
								<label class="form-check-label" for="query-type-2">Update</label>
							</div>
							<div class="form-check form-check-inline">
								<input class="form-check-input" type="checkbox" name="query-type[]" id="query-type-3" value="addToTxn">
								<label class="form-check-label" for="query-type-3">Add to Quote</label>
							</div>
						</div>
						<div id="model-select">
							<label for="reference-object-select">Model</label>
							<select id="reference-object-select" class="form-control">
								<option value=""></option>
								${referenceObjectOptions}
							</select>
						</div>
					</div>
					<div id="by-tag" class="form-group search-by d-none">
						<label for="tag">Tag</label>
						<input type="text" id="tag" class="form-control" placeholder="Exact tag passed in the logtime() function" />
					</div>
				</form>

				<div class="part-data part-data-active" id="config-reference-object-parts-data">
						<h3 class="header" id="selected-reference-object-header"></h3>
						<div id="selected-reference-object-stats"></div>
						<div id="reference-object-charts" class="d-none">
							<strong>Daily Usage (Number of referenceObject loads / updates)</strong>
							<div id="${DAILY_USAGE_CHART}"></div>
							<strong>Daily Average Speed (In seconds)</strong>
							<div id="${DAILY_SPEED_CHART}"></div>
							<p class="text-muted">Not showing days with referenceObject hits less than <span id="min-num-info">${minUsagePerDay}</span></p>
						</div>
				</div>
			</div>
			<div class="modal-footer">
				<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
			</div>
	</div>
	</div>
	</div>
	`;

		var okToolBar = document.querySelector("ul.ok-tool-bar");
		if (!okToolBar) {
			okToolBar = document.createElement("ul");
			okToolBar.className = "ok-tool-bar";
			$("body").append(okToolBar);
		}

		var showPerfStatsBtn = document.createElement("li");
		var text = document.createTextNode("Show Performance Stats");
		showPerfStatsBtn.appendChild(text);
		showPerfStatsBtn.id = "btn-show-stats";
		showPerfStatsBtn.className = "show-stats";
		showPerfStatsBtn.dataset.toggle = "modal";
		showPerfStatsBtn.dataset.target = "#performanceModal";

		//--- Add nodes to page
		okToolBar.append(showPerfStatsBtn);
		$("body").append(modalHtml);

		$("#btn-show-stats").on("click", function() {
			$(".modal-content").removeClass("d-none");
		});

		$("#search-by-mode *").on("click change", function() {
			var value = $(this).data("value");
			$(".search-by").addClass("d-none");
			$("#" + value).removeClass("d-none");
		});

		$("#reference-object-select").change(function() {
			if (
				$("#dateFrom").val() &&
				$("#dateTo").val() &&
				$("#reference-object-select").val()
			) {
				setSelectedModel($("#reference-object-select").val());
			}
		});

		$("input[name='query-type[]']").change(function(e) {
			queryTypes = $.map($("input[name='query-type[]']:checked"), e => $(e).val());

            // Handle mutual exclusivity
            if (e.currentTarget.value === "addToTxn") {
                $('#query-type-1').attr('checked', false);
                $('#query-type-2').attr('checked', false);
                queryTypes = ["addToTxn"];
            } else {
                $('#query-type-3').attr('checked', false);
                const atqIndex = queryTypes.indexOf("addToTxn");
                if (atqIndex >= 0) {
                    queryTypes.splice(atqIndex, 1);
                }
            }

            if (queryTypes.length === 1) {
				$("input[name='query-type[]']:checked").attr("disabled", true);
				$("input[name='query-type[]']:not(:checked)").attr("disabled", false);
			} else {
				$("input[name='query-type[]']").removeAttr("disabled");
			}
			if (
				$("#dateFrom").val() &&
				$("#dateTo").val() &&
				$("#reference-object-select").val()
			) {
				setSelectedModel($("#reference-object-select").val());
			}
		});

		$("#tag").change(function() {
			var wto;
			clearTimeout(wto);
			wto = setTimeout(function() {
				if ($("#dateFrom").val() && $("#dateTo").val() && $("#tag").val()) {
					setTag($("#tag").val());
				}
			}, 500);
		});

		$("#min-num").change(function() {
			minUsagePerDay = 	parseInt($("#min-num").val());
			$("#min-num-info").text(minUsagePerDay);
			var wto;
			clearTimeout(wto);
			wto = setTimeout(function() {
				var searchBy = $("input[name=searchBy]:checked").val();
				if ($("#dateFrom").val() && $("#dateTo").val()) {
					if (
						searchBy == "by-referenceObject" &&
						$("#reference-object-select").val()
					) {
						setSelectedModel($("#reference-object-select").val());
					} else if (searchBy == "by-tag" && $("#tag").val()) {
						setTag($("#tag").val());
					}
				}
			}, 500);
		});

		// Date inputs config
		$("#datepickerFrom").datepicker({
			format: "yyyy-mm-dd",
			endDate: "0d"
		});
		$("#datepickerFrom").on("changeDate", function() {
			var dateFrom = $("#datepickerFrom").datepicker("getFormattedDate");
			$("#dateFrom").val(dateFrom);
			var searchBy = $("input[name=searchBy]:checked").val();
			if ($("#dateFrom").val() && $("#dateTo").val()) {
				if (
					searchBy == "by-referenceObject" &&
					$("#reference-object-select").val()
				) {
					setSelectedModel($("#reference-object-select").val());
				} else if (searchBy == "by-tag" && $("#tag").val()) {
					setTag($("#tag").val());
				}
			}
		});
		$("#datepickerTo").datepicker({
			format: "yyyy-mm-dd",
			endDate: "0d"
		});
		$("#datepickerTo").on("changeDate", function() {
			var dateTo = $("#datepickerTo").datepicker("getFormattedDate");
			$("#dateTo").val(dateTo);
			var searchBy = $("input[name=searchBy]:checked").val();
			if ($("#dateFrom").val() && $("#dateTo").val()) {
				if (
					searchBy == "by-referenceObject" &&
					$("#reference-object-select").val()
				) {
					setSelectedModel($("#reference-object-select").val());
				} else if (searchBy == "by-tag" && $("#tag").val()) {
					setTag($("#tag").val());
				}
			}
		});
	}

	showStats();

	// Styling Stuff
	var bm_css_src = `
	ul.ok-tool-bar {
		position: fixed;
		bottom: 0;
		right: 10%;
		margin: 0;
	}
	ul.ok-tool-bar li {
		display: inline-block;
		padding: 3px 6px;
		margin: 0 3px;
		font-size: 11px;
		text-align: center;
		white-space: nowrap;
		vertical-align: middle;
		-ms-touch-action: manipulation;
		cursor: pointer;
		-webkit-user-select: none;
		-moz-user-select: none;
		-ms-user-select: none;
		user-select: none;
		background-image: none;
		border: 1px solid transparent;
		border-radius: 5px 5px 0 0;
	}
	li.show-stats {
		color: #fff;
		background-color: #2647a0;
		border-color: #2647a0;
	}

	h3.header {
		font-size: 1.5em;
		margin: .5em 0 1em 0;
	}

	.ct-label.ct-horizontal {
		transform: rotate(-90deg);
		padding: 5px 0;
		white-space: nowrap;
		width: 1em !important;
		margin: 5px auto;
	}

	.modal-xl {
		width: 80%;
		max-width:1200px;
	}

	body {
		margin: auto;
	}

	#performanceModal .modal-xl {
		margin: 0;
		width: 100%;
		max-width:100%;
	}
	`;
	// eslint-disable-next-line no-undef
	GM_addStyle(bm_css_src);
}