From 33de0a0a493572656e030ed91feeebc76e25bafa Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Mon, 2 May 2022 19:37:09 -0600 Subject: [PATCH] CSV Export include monthly data (#15169) * setup * add new clients to attribution * refactor serializers, move to util folder * cleanup export csv generator * fix isDateRange getter * remove new chart from partial/current month * fix export modal text * update version history text * update variable naming, remove new client data from current/partial month * add filtering by namespace to month over month charts * remove filtering for namespace by month, need to change serializer * add checks * update horizontal bar chart test * update tests * cleanup * address comments * fix flakey test * add new counts to export Co-authored-by: Claire Bontempo --- ui/app/components/clients/attribution.js | 98 ++++++++---- ui/app/components/clients/current.js | 84 +++------- ui/app/components/clients/history.js | 75 ++++++--- .../clients/horizontal-bar-chart.js | 41 +++-- ui/app/components/clients/line-chart.js | 16 +- ui/app/components/clients/running-total.js | 3 +- .../components/clients/vertical-bar-chart.js | 7 +- ui/app/models/clients/monthly.js | 4 +- ui/app/serializers/clients/activity.js | 92 +---------- ui/app/serializers/clients/monthly.js | 69 +------- .../components/clients/attribution.hbs | 23 ++- .../templates/components/clients/current.hbs | 4 +- .../templates/components/clients/history.hbs | 5 +- .../components/clients/line-chart.hbs | 4 +- .../components/clients/vertical-bar-chart.hbs | 2 +- ui/lib/core/addon/utils/client-count-utils.js | 89 +++++++++++ ui/lib/core/addon/utils/date-formatters.js | 2 +- ui/mirage/handlers/clients.js | 150 +++++++++++++++--- ui/tests/acceptance/client-current-test.js | 50 ++---- ui/tests/acceptance/client-history-test.js | 9 ++ .../components/clients/attribution-test.js | 10 +- .../clients/horizontal-bar-chart-test.js | 17 +- 22 files changed, 469 insertions(+), 385 deletions(-) create mode 100644 ui/lib/core/addon/utils/client-count-utils.js diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index a0513f44f..85036d20e 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -13,23 +13,25 @@ import { inject as service } from '@ember/service'; * @chartLegend={{this.chartLegend}} * @totalUsageCounts={{this.totalUsageCounts}} * @newUsageCounts={{this.newUsageCounts}} - * @totalClientsData={{this.totalClientsData}} - * @newClientsData={{this.newClientsData}} + * @totalClientAttribution={{this.totalClientAttribution}} + * @newClientAttribution={{this.newClientAttribution}} * @selectedNamespace={{this.selectedNamespace}} * @startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}} * @isDateRange={{this.isDateRange}} + * @isCurrentMonth={{false}} * @timestamp={{this.responseTimestamp}} * /> * ``` * @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked * @param {object} totalUsageCounts - object with total client counts for chart tooltip text * @param {object} newUsageCounts - object with new client counts for chart tooltip text - * @param {array} totalClientsData - array of objects containing a label and breakdown of client counts for total clients - * @param {array} newClientsData - array of objects containing a label and breakdown of client counts for new clients + * @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients + * @param {array} newClientAttribution - array of objects containing a label and breakdown of client counts for new clients * @param {string} selectedNamespace - namespace selected from filter bar * @param {string} startTimeDisplay - string that displays as start date for CSV modal * @param {string} endTimeDisplay - string that displays as end date for CSV modal - * @param {boolean} isDateRange - getter calculated in parent to relay if dataset is a date range or single month + * @param {boolean} isDateRange - getter calculated in parent to relay if dataset is a date range or single month and display text accordingly + * @param {boolean} isCurrentMonth - boolean to determine if rendered in current month tab or not * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response */ @@ -38,14 +40,15 @@ export default class Attribution extends Component { @service downloadCsv; get hasCsvData() { - return this.args.totalClientsData ? this.args.totalClientsData.length > 0 : false; + return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false; } + get isDateRange() { return this.args.isDateRange; } get isSingleNamespace() { - if (!this.args.totalClientsData) { + if (!this.args.totalClientAttribution) { return 'no data'; } // if a namespace is selected, then we're viewing top 10 auth methods (mounts) @@ -53,18 +56,17 @@ export default class Attribution extends Component { } // truncate data before sending to chart component - // move truncating to serializer when we add separate request to fetch and export ALL namespace data get barChartTotalClients() { - return this.args.totalClientsData?.slice(0, 10); + return this.args.totalClientAttribution?.slice(0, 10); } get barChartNewClients() { - return this.args.newClientsData?.slice(0, 10); + return this.args.newClientAttribution?.slice(0, 10); } get topClientCounts() { // get top namespace or auth method - return this.args.totalClientsData ? this.args.totalClientsData[0] : null; + return this.args.totalClientAttribution ? this.args.totalClientAttribution[0] : null; } get attributionBreakdown() { @@ -103,9 +105,27 @@ export default class Attribution extends Component { } } - get getCsvData() { + destructureCountsToArray(object) { + // destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, clients: 191} + // to get integers for CSV file + let { clients, entity_clients, non_entity_clients } = object; + return [clients, entity_clients, non_entity_clients]; + } + + constructCsvRow(namespaceColumn, mountColumn = null, totalColumns, newColumns = null) { + // if namespaceColumn is a string, then we're at mount level attribution, otherwise it is an object + // if constructing a namespace row, mountColumn=null so the column is blank, otherwise it is an object + let otherColumns = newColumns ? [...totalColumns, ...newColumns] : [...totalColumns]; + return [ + `${typeof namespaceColumn === 'string' ? namespaceColumn : namespaceColumn.label}`, + `${mountColumn ? mountColumn.label : ''}`, + ...otherColumns, + ]; + } + generateCsvData() { + const totalAttribution = this.args.totalClientAttribution; + const newAttribution = this.barChartNewClients ? this.args.newClientAttribution : null; let csvData = [], - graphData = this.args.totalClientsData, csvHeader = [ 'Namespace path', 'Authentication method', @@ -114,24 +134,41 @@ export default class Attribution extends Component { 'Non-entity clients', ]; - // each array will be a row in the csv file - if (this.isSingleNamespace) { - graphData.forEach((mount) => { - csvData.push(['', mount.label, mount.clients, mount.entity_clients, mount.non_entity_clients]); - }); - csvData.forEach((d) => (d[0] = this.args.selectedNamespace)); - } else { - graphData.forEach((ns) => { - csvData.push([ns.label, '', ns.clients, ns.entity_clients, ns.non_entity_clients]); - if (ns.mounts) { - ns.mounts.forEach((m) => { - csvData.push([ns.label, m.label, m.clients, m.entity_clients, m.non_entity_clients]); - }); - } - }); + if (newAttribution) { + csvHeader = [...csvHeader, 'Total new clients, New entity clients, New non-entity clients']; } + + totalAttribution.forEach((totalClientsObject) => { + let namespace = this.isSingleNamespace ? this.args.selectedNamespace : totalClientsObject; + let mount = this.isSingleNamespace ? totalClientsObject : null; + + // find new client data for namespace/mount object we're iterating over + let newClientsObject = newAttribution + ? newAttribution.find((d) => d.label === totalClientsObject.label) + : null; + + let totalClients = this.destructureCountsToArray(totalClientsObject); + let newClients = newClientsObject ? this.destructureCountsToArray(newClientsObject) : null; + + csvData.push(this.constructCsvRow(namespace, mount, totalClients, newClients)); + // constructCsvRow returns an array that corresponds to a row in the csv file: + // ['ns label', 'mount label', total client #, entity #, non-entity #, ...new client #'s] + + // only iterate through mounts if NOT viewing a single namespace + if (!this.isSingleNamespace && namespace.mounts) { + namespace.mounts.forEach((mount) => { + let newMountData = newAttribution + ? newClientsObject?.mounts.find((m) => m.label === mount.label) + : null; + let mountTotalClients = this.destructureCountsToArray(mount); + let mountNewClients = newMountData ? this.destructureCountsToArray(newMountData) : null; + csvData.push(this.constructCsvRow(namespace, mount, mountTotalClients, mountNewClients)); + }); + } + }); + csvData.unshift(csvHeader); - // make each nested array a comma separated string, join each array in csvData with line break (\n) + // make each nested array a comma separated string, join each array "row" in csvData with line break (\n) return csvData.map((d) => d.join()).join('\n'); } @@ -145,7 +182,8 @@ export default class Attribution extends Component { // ACTIONS @action - exportChartData(filename, contents) { + exportChartData(filename) { + let contents = this.generateCsvData(); this.downloadCsv.download(filename, contents); this.showCSVDownloadModal = false; } diff --git a/ui/app/components/clients/current.js b/ui/app/components/clients/current.js index 4d7962d5b..2e36a8893 100644 --- a/ui/app/components/clients/current.js +++ b/ui/app/components/clients/current.js @@ -9,7 +9,7 @@ export default class Current extends Component { { key: 'non_entity_clients', label: 'non-entity clients' }, ]; @tracked selectedNamespace = null; - @tracked namespaceArray = this.byNamespaceTotalClients.map((namespace) => { + @tracked namespaceArray = this.byNamespace.map((namespace) => { return { name: namespace['label'], id: namespace['label'] }; }); @@ -29,37 +29,18 @@ export default class Current extends Component { let findUpgrade = versionHistory.find((versionData) => versionData.id.match(version)); if (findUpgrade) relevantUpgrades.push(findUpgrade); }); - - // if no history for 1.9 or 1.10, customer skipped these releases so get first stored upgrade - // TODO account for customer STARTING on 1.11 - if (relevantUpgrades.length === 0) { - relevantUpgrades.push({ - id: versionHistory[0].id, - previousVersion: versionHistory[0].previousVersion, - timestampInstalled: versionHistory[0].timestampInstalled, - }); - } // array of upgrade data objects for noteworthy upgrades return relevantUpgrades; } - // Response total client count data by namespace for current/partial month - get byNamespaceTotalClients() { - return this.args.model.monthly?.byNamespaceTotalClients || []; - } - - // Response new client count data by namespace for current/partial month - get byNamespaceNewClients() { - return this.args.model.monthly?.byNamespaceNewClients || []; + // Response client count data by namespace for current/partial month + get byNamespace() { + return this.args.model.monthly?.byNamespace || []; } get isGatheringData() { // return true if tracking IS enabled but no data collected yet - return ( - this.args.model.config?.enabled === 'On' && - this.byNamespaceTotalClients.length === 0 && - this.byNamespaceNewClients.length === 0 - ); + return this.args.model.config?.enabled === 'On' && this.byNamespace.length === 0; } get hasAttributionData() { @@ -67,33 +48,19 @@ export default class Current extends Component { if (this.selectedNamespace) { return this.authMethodOptions.length > 0; } - return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData; + return this.totalUsageCounts.clients !== 0 && !!this.totalClientAttribution; } - get filteredTotalData() { + get filteredCurrentData() { const namespace = this.selectedNamespace; const auth = this.selectedAuthMethod; if (!namespace && !auth) { - return this.byNamespaceTotalClients; + return this.byNamespace; } if (!auth) { - return this.byNamespaceTotalClients.find((ns) => ns.label === namespace); + return this.byNamespace.find((ns) => ns.label === namespace); } - return this.byNamespaceTotalClients - .find((ns) => ns.label === namespace) - .mounts?.find((mount) => mount.label === auth); - } - - get filteredNewData() { - const namespace = this.selectedNamespace; - const auth = this.selectedAuthMethod; - if (!namespace && !auth) { - return this.byNamespaceNewClients; - } - if (!auth) { - return this.byNamespaceNewClients.find((ns) => ns.label === namespace); - } - return this.byNamespaceNewClients + return this.byNamespace .find((ns) => ns.label === namespace) .mounts?.find((mount) => mount.label === auth); } @@ -134,37 +101,24 @@ export default class Current extends Component { return ' How we count clients changed in 1.9, so keep that in mind when looking at the data below.'; } if (version.match('1.10')) { - return ' We added new client breakdowns starting in 1.10, so keep that in mind when looking at the data below.'; + return ' We added mount level attribution starting in 1.10, so keep that in mind when looking at the data below.'; } } - // return combined explanation if spans multiple upgrades, or customer skipped 1.9 and 1.10 - return ' How we count clients changed in 1.9 and we added new client breakdowns starting in 1.10. Keep this in mind when looking at the data below.'; + // return combined explanation if spans multiple upgrades + return ' How we count clients changed in 1.9 and we added mount level attribution starting in 1.10. Keep this in mind when looking at the data below.'; } // top level TOTAL client counts for current/partial month get totalUsageCounts() { - return this.selectedNamespace ? this.filteredTotalData : this.args.model.monthly?.total; + return this.selectedNamespace ? this.filteredCurrentData : this.args.model.monthly?.total; } - get newUsageCounts() { - return this.selectedNamespace ? this.filteredNewData : this.args.model.monthly?.new; - } - - // total client data for horizontal bar chart in attribution component - get totalClientsData() { + // total client attribution data for horizontal bar chart in attribution component + get totalClientAttribution() { if (this.selectedNamespace) { - return this.filteredTotalData?.mounts || null; + return this.filteredCurrentData?.mounts || null; } else { - return this.byNamespaceTotalClients; - } - } - - // new client data for horizontal bar chart in attribution component - get newClientsData() { - if (this.selectedNamespace) { - return this.filteredNewData?.mounts || null; - } else { - return this.byNamespaceNewClients; + return this.byNamespace; } } @@ -183,7 +137,7 @@ export default class Current extends Component { this.selectedAuthMethod = null; } else { // Side effect: set auth namespaces - const mounts = this.filteredTotalData.mounts?.map((mount) => ({ + const mounts = this.filteredCurrentData.mounts?.map((mount) => ({ id: mount.label, name: mount.label, })); diff --git a/ui/app/components/clients/history.js b/ui/app/components/clients/history.js index 13c13329a..e86756c8f 100644 --- a/ui/app/components/clients/history.js +++ b/ui/app/components/clients/history.js @@ -6,6 +6,7 @@ import { isSameMonth, isAfter, isBefore } from 'date-fns'; import getStorage from 'vault/lib/token-storage'; import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters'; import { dateFormat } from 'core/helpers/date-format'; +import { parseAPITimestamp } from 'core/utils/date-formatters'; const INPUTTED_START_DATE = 'vault:ui-inputted-start-date'; @@ -85,8 +86,8 @@ export default class History extends Component { get isDateRange() { return !isSameMonth( - new Date(this.getActivityResponse.startTime), - new Date(this.getActivityResponse.endTime) + parseAPITimestamp(this.getActivityResponse.startTime), + parseAPITimestamp(this.getActivityResponse.endTime) ); } @@ -104,15 +105,6 @@ export default class History extends Component { if (findUpgrade) relevantUpgrades.push(findUpgrade); }); - // if no history for 1.9 or 1.10, customer skipped these releases so get first stored upgrade - // TODO account for customer STARTING on 1.11 - if (relevantUpgrades.length === 0) { - relevantUpgrades.push({ - id: versionHistory[0].id, - previousVersion: versionHistory[0].previousVersion, - timestampInstalled: versionHistory[0].timestampInstalled, - }); - } // array of upgrade data objects for noteworthy upgrades return relevantUpgrades; } @@ -161,11 +153,11 @@ export default class History extends Component { return ' How we count clients changed in 1.9, so keep that in mind when looking at the data below.'; } if (version.match('1.10')) { - return ' We added monthly breakdowns starting in 1.10, so keep that in mind when looking at the data below.'; + return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data below.'; } } - // return combined explanation if spans multiple upgrades, or customer skipped 1.9 and 1.10 - return ' How we count clients changed in 1.9 and we added monthly breakdowns starting in 1.10. Keep this in mind when looking at the data below.'; + // return combined explanation if spans multiple upgrades + return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data below.'; } get startTimeDisplay() { @@ -193,12 +185,20 @@ export default class History extends Component { return this.queriedActivityResponse || this.args.model.activity; } + get byMonthTotalClients() { + return this.getActivityResponse?.byMonth; + } + + get byMonthNewClients() { + return this.byMonthTotalClients.map((m) => m.new_clients); + } + get hasAttributionData() { if (this.selectedAuthMethod) return false; if (this.selectedNamespace) { return this.authMethodOptions.length > 0; } - return !!this.totalClientsData && this.totalUsageCounts && this.totalUsageCounts.clients !== 0; + return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0; } // top level TOTAL client counts for given date range @@ -206,8 +206,14 @@ export default class History extends Component { return this.selectedNamespace ? this.filteredActivity : this.getActivityResponse.total; } + get newUsageCounts() { + return this.selectedNamespace + ? this.filteredNewClientAttribution + : this.byMonthTotalClients[0]?.new_clients; + } + // total client data for horizontal bar chart in attribution component - get totalClientsData() { + get totalClientAttribution() { if (this.selectedNamespace) { return this.filteredActivity?.mounts || null; } else { @@ -215,18 +221,24 @@ export default class History extends Component { } } + // new client data for horizontal bar chart + get newClientAttribution() { + // new client attribution only available in a single, historical month + if (this.isDateRange) { + return null; + } + // only a single month is returned from the api + if (this.selectedNamespace) { + return this.filteredNewClientAttribution?.mounts || null; + } else { + return this.byMonthTotalClients[0]?.new_clients.namespaces || null; + } + } + get responseTimestamp() { return this.getActivityResponse.responseTimestamp; } - get byMonthTotalClients() { - return this.getActivityResponse?.byMonth; - } - - get byMonthNewClients() { - return this.byMonthTotalClients.map((m) => m.new_clients); - } - get filteredActivity() { const namespace = this.selectedNamespace; const auth = this.selectedAuthMethod; @@ -241,6 +253,21 @@ export default class History extends Component { .mounts?.find((mount) => mount.label === auth); } + get filteredNewClientAttribution() { + const namespace = this.selectedNamespace; + const auth = this.selectedAuthMethod; + // new client data is only available by month + const newClientsData = this.byMonthTotalClients[0]?.new_clients; + if (!newClientsData) return null; + if (this.isDateRange) return null; + if (!namespace && !auth) return newClientsData; + + const foundNamespace = newClientsData.namespaces.find((ns) => ns.label === namespace); + if (!foundNamespace) return null; + if (!auth) return foundNamespace; + return foundNamespace.mounts?.find((mount) => mount.label === auth); + } + @action async handleClientActivityQuery(month, year, dateType) { this.isEditStartMonthOpen = false; diff --git a/ui/app/components/clients/horizontal-bar-chart.js b/ui/app/components/clients/horizontal-bar-chart.js index 377649b18..e59f0d428 100644 --- a/ui/app/components/clients/horizontal-bar-chart.js +++ b/ui/app/components/clients/horizontal-bar-chart.js @@ -19,6 +19,9 @@ import { tracked } from '@glimmer/tracking'; * ``` * @param {array} dataset - dataset for the chart, must be an array of flattened objects * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked + * @param {string} labelKey - string of key name for label value in chart data + * @param {string} xKey - string of key name for x value in chart data + * @param {object} totalCounts - object to calculate percentage for tooltip */ // SIZING CONSTANTS @@ -36,12 +39,20 @@ export default class HorizontalBarChart extends Component { return this.args.labelKey || 'label'; } + get xKey() { + return this.args.xKey || 'clients'; + } + get chartLegend() { return this.args.chartLegend; } get topNamespace() { - return this.args.dataset[maxIndex(this.args.dataset, (d) => d.clients)]; + return this.args.dataset[maxIndex(this.args.dataset, (d) => d[this.xKey])]; + } + + get total() { + return this.args.totalCounts[this.xKey] || null; } @action removeTooltip() { @@ -49,17 +60,17 @@ export default class HorizontalBarChart extends Component { } @action - renderChart(element, args) { + renderChart(element, [chartData]) { // chart legend tells stackFunction how to stack/organize data // creates an array of data for each key name // each array contains coordinates for each data bar let stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); - let dataset = args[0]; + let dataset = chartData; let stackedData = stackFunction(dataset); let labelKey = this.labelKey; - + let xKey = this.xKey; let xScale = scaleLinear() - .domain([0, max(dataset.map((d) => d.clients))]) + .domain([0, max(dataset.map((d) => d[xKey]))]) .range([0, 75]); // 25% reserved for margins let yScale = scaleBand() @@ -162,13 +173,15 @@ export default class HorizontalBarChart extends Component { // MOUSE EVENTS FOR DATA BARS actionBars .on('mouseover', (data) => { - let hoveredElement = actionBars.filter((bar) => bar.label === data.label).node(); + let hoveredElement = actionBars.filter((bar) => bar[labelKey] === data[labelKey]).node(); this.tooltipTarget = hoveredElement; this.isLabel = false; - this.tooltipText = `${Math.round((data.clients * 100) / this.args.totalUsageCounts.clients)}% + this.tooltipText = this.total + ? `${Math.round((data[xKey] * 100) / this.total)}% of total client counts: ${formatTooltipNumber(data.entity_clients)} entity clients, - ${formatTooltipNumber(data.non_entity_clients)} non-entity clients.`; + ${formatTooltipNumber(data.non_entity_clients)} non-entity clients.` + : ''; select(hoveredElement).style('opacity', 1); @@ -190,11 +203,11 @@ export default class HorizontalBarChart extends Component { // MOUSE EVENTS FOR Y-AXIS LABELS labelActionBar .on('mouseover', (data) => { - if (data.label.length >= CHAR_LIMIT) { - let hoveredElement = labelActionBar.filter((bar) => bar.label === data.label).node(); + if (data[labelKey].length >= CHAR_LIMIT) { + let hoveredElement = labelActionBar.filter((bar) => bar[labelKey] === data[labelKey]).node(); this.tooltipTarget = hoveredElement; this.isLabel = true; - this.tooltipText = data.label; + this.tooltipText = data[labelKey]; } else { this.tooltipTarget = null; } @@ -234,13 +247,13 @@ export default class HorizontalBarChart extends Component { .data(dataset) .enter() .append('text') - .text((d) => d.clients) + .text((d) => d[xKey]) .attr('fill', '#000') .attr('class', 'total-value') .style('font-size', '.8rem') .attr('text-anchor', 'start') .attr('alignment-baseline', 'middle') - .attr('x', (chartData) => `${xScale(chartData.clients)}%`) - .attr('y', (chartData) => yScale(chartData.label)); + .attr('x', (chartData) => `${xScale(chartData[xKey])}%`) + .attr('y', (chartData) => yScale(chartData[labelKey])); } } diff --git a/ui/app/components/clients/line-chart.js b/ui/app/components/clients/line-chart.js index 9184d4a06..03114c5a3 100644 --- a/ui/app/components/clients/line-chart.js +++ b/ui/app/components/clients/line-chart.js @@ -47,11 +47,11 @@ export default class LineChart extends Component { } @action - renderChart(element, args) { - const dataset = args[0]; + renderChart(element, [chartData]) { + const dataset = chartData; const upgradeData = []; - if (args[1]) { - args[1].forEach((versionData) => + if (this.args.upgradeData) { + this.args.upgradeData.forEach((versionData) => upgradeData.push({ month: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'), ...versionData }) ); } @@ -59,6 +59,9 @@ export default class LineChart extends Component { const chartSvg = select(element); chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions + // clear out DOM before appending anything + chartSvg.selectAll('g').remove().exit().data(filteredData).enter(); + // DEFINE AXES SCALES const yScale = scaleLinear() .domain([0, max(filteredData.map((d) => d[this.yKey]))]) @@ -154,10 +157,11 @@ export default class LineChart extends Component { // MOUSE EVENT FOR TOOLTIP hoverCircles.on('mouseover', (data) => { - // TODO: how to genericize this? + // TODO: how to generalize this? + let { new_clients } = data || null; this.tooltipMonth = formatChartDate(data[this.xKey]); this.tooltipTotal = data[this.yKey] + ' total clients'; - this.tooltipNew = data?.new_clients[this.yKey] + ' new clients'; + this.tooltipNew = (new_clients ? new_clients[this.yKey] : '0') + ' new clients'; this.tooltipUpgradeText = ''; let upgradeInfo = findUpgradeData(data); if (upgradeInfo) { diff --git a/ui/app/components/clients/running-total.js b/ui/app/components/clients/running-total.js index 4651a5757..591bc8d98 100644 --- a/ui/app/components/clients/running-total.js +++ b/ui/app/components/clients/running-total.js @@ -18,7 +18,7 @@ import { mean } from 'd3-array'; /> * ``` - * @param {array} lineChartData - array of objects + * @param {array} chartData - array of objects from /activity response object example: { month: '1/22', entity_clients: 23, @@ -32,7 +32,6 @@ import { mean } from 'd3-array'; namespaces: [], }, }; - * @param {array} barChartData - array of objects, object example: { month: '1/22', entity_clients: 11, non_entity_clients: 36, total: 47, namespaces: [] }; * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked * @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 } * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response diff --git a/ui/app/components/clients/vertical-bar-chart.js b/ui/app/components/clients/vertical-bar-chart.js index 42a8de1de..378844f93 100644 --- a/ui/app/components/clients/vertical-bar-chart.js +++ b/ui/app/components/clients/vertical-bar-chart.js @@ -48,8 +48,8 @@ export default class VerticalBarChart extends Component { } @action - registerListener(element, args) { - const dataset = args[0]; + renderChart(element, [chartData]) { + const dataset = chartData; const filteredData = dataset.filter((e) => Object.keys(e).includes('clients')); // months with data will contain a 'clients' key (otherwise only a timestamp) const stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); const stackedData = stackFunction(filteredData); @@ -67,6 +67,9 @@ export default class VerticalBarChart extends Component { .range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels .paddingInner(0.85); + // clear out DOM before appending anything + chartSvg.selectAll('g').remove().exit().data(stackedData).enter(); + const dataBars = chartSvg .selectAll('g') .data(stackedData) diff --git a/ui/app/models/clients/monthly.js b/ui/app/models/clients/monthly.js index 40b382768..2ec3859b2 100644 --- a/ui/app/models/clients/monthly.js +++ b/ui/app/models/clients/monthly.js @@ -2,7 +2,5 @@ import Model, { attr } from '@ember-data/model'; export default class MonthlyModel extends Model { @attr('string') responseTimestamp; @attr('object') total; // total clients during the current/partial month - @attr('object') new; // total NEW clients during the current/partial - @attr('array') byNamespaceTotalClients; - @attr('array') byNamespaceNewClients; + @attr('array') byNamespace; } diff --git a/ui/app/serializers/clients/activity.js b/ui/app/serializers/clients/activity.js index 05e8f319a..a05dba9cc 100644 --- a/ui/app/serializers/clients/activity.js +++ b/ui/app/serializers/clients/activity.js @@ -1,90 +1,8 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; -import { parseAPITimestamp, parseRFC3339 } from 'core/utils/date-formatters'; +import { parseRFC3339 } from 'core/utils/date-formatters'; +import { formatByMonths, formatByNamespace, homogenizeClientNaming } from 'core/utils/client-count-utils'; export default class ActivitySerializer extends ApplicationSerializer { - flattenDataset(object) { - let flattenedObject = {}; - Object.keys(object['counts']).forEach((key) => (flattenedObject[key] = object['counts'][key])); - return this.homogenizeClientNaming(flattenedObject); - } - - formatByNamespace(namespaceArray) { - return namespaceArray?.map((ns) => { - // 'namespace_path' is an empty string for root - if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root'; - let label = ns['namespace_path']; - let flattenedNs = this.flattenDataset(ns); - // if no mounts, mounts will be an empty array - flattenedNs.mounts = []; - if (ns?.mounts && ns.mounts.length > 0) { - flattenedNs.mounts = ns.mounts.map((mount) => { - return { - label: mount['mount_path'], - ...this.flattenDataset(mount), - }; - }); - } - return { - label, - ...flattenedNs, - }; - }); - } - - formatByMonths(monthsArray) { - const sortedPayload = [...monthsArray]; - // months are always returned from the API: [mostRecent...oldestMonth] - sortedPayload.reverse(); - return sortedPayload.map((m) => { - if (Object.keys(m).includes('counts')) { - let totalClients = this.flattenDataset(m); - let newClients = this.flattenDataset(m.new_clients); - return { - month: parseAPITimestamp(m.timestamp, 'M/yy'), - ...totalClients, - namespaces: this.formatByNamespace(m.namespaces), - new_clients: { - month: parseAPITimestamp(m.timestamp, 'M/yy'), - ...newClients, - namespaces: this.formatByNamespace(m.new_clients.namespaces), - }, - }; - } - // TODO CMB below is an assumption, need to test - // if no monthly data (no counts key), month object will just contain a timestamp - return { - month: parseAPITimestamp(m.timestamp, 'M/yy'), - new_clients: { - month: parseAPITimestamp(m.timestamp, 'M/yy'), - }, - }; - }); - } - - // In 1.10 'distinct_entities' changed to 'entity_clients' and - // 'non_entity_tokens' to 'non_entity_clients' - homogenizeClientNaming(object) { - // if new key names exist, only return those key/value pairs - if (Object.keys(object).includes('entity_clients')) { - let { clients, entity_clients, non_entity_clients } = object; - return { - clients, - entity_clients, - non_entity_clients, - }; - } - // if object only has outdated key names, update naming - if (Object.keys(object).includes('distinct_entities')) { - let { clients, distinct_entities, non_entity_tokens } = object; - return { - clients, - entity_clients: distinct_entities, - non_entity_clients: non_entity_tokens, - }; - } - return object; - } - normalizeResponse(store, primaryModelClass, payload, id, requestType) { if (payload.id === 'no-data') { return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); @@ -93,9 +11,9 @@ export default class ActivitySerializer extends ApplicationSerializer { let transformedPayload = { ...payload, response_timestamp, - by_namespace: this.formatByNamespace(payload.data.by_namespace), - by_month: this.formatByMonths(payload.data.months), - total: this.homogenizeClientNaming(payload.data.total), + by_namespace: formatByNamespace(payload.data.by_namespace), + by_month: formatByMonths(payload.data.months), + total: homogenizeClientNaming(payload.data.total), formatted_end_time: parseRFC3339(payload.data.end_time), formatted_start_time: parseRFC3339(payload.data.start_time), }; diff --git a/ui/app/serializers/clients/monthly.js b/ui/app/serializers/clients/monthly.js index 607ed1e30..90a285956 100644 --- a/ui/app/serializers/clients/monthly.js +++ b/ui/app/serializers/clients/monthly.js @@ -1,60 +1,8 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; +import { formatByNamespace, homogenizeClientNaming } from 'core/utils/client-count-utils'; export default class MonthlySerializer extends ApplicationSerializer { - flattenDataset(object) { - let flattenedObject = {}; - Object.keys(object['counts']).forEach((key) => (flattenedObject[key] = object['counts'][key])); - return this.homogenizeClientNaming(flattenedObject); - } - - formatByNamespace(namespaceArray) { - return namespaceArray?.map((ns) => { - // 'namespace_path' is an empty string for root - if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root'; - let label = ns['namespace_path']; - let flattenedNs = this.flattenDataset(ns); - // if no mounts, mounts will be an empty array - flattenedNs.mounts = []; - if (ns?.mounts && ns.mounts.length > 0) { - flattenedNs.mounts = ns.mounts.map((mount) => { - return { - label: mount['mount_path'], - ...this.flattenDataset(mount), - }; - }); - } - return { - label, - ...flattenedNs, - }; - }); - } - - // In 1.10 'distinct_entities' changed to 'entity_clients' and - // 'non_entity_tokens' to 'non_entity_clients' - homogenizeClientNaming(object) { - // if new key names exist, only return those key/value pairs - if (Object.keys(object).includes('entity_clients')) { - let { clients, entity_clients, non_entity_clients } = object; - return { - clients, - entity_clients, - non_entity_clients, - }; - } - // if object only has outdated key names, update naming - if (Object.keys(object).includes('distinct_entities')) { - let { clients, distinct_entities, non_entity_tokens } = object; - return { - clients, - entity_clients: distinct_entities, - non_entity_clients: non_entity_tokens, - }; - } - return object; - } - normalizeResponse(store, primaryModelClass, payload, id, requestType) { if (payload.id === 'no-data') { return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); @@ -62,23 +10,12 @@ export default class MonthlySerializer extends ApplicationSerializer { let response_timestamp = formatISO(new Date()); // TODO CMB: the following is assumed, need to confirm // the months array will always include a single object: a timestamp of the current month and new/total count data, if available - let newClientsData = payload.data.months[0]?.new_clients || null; - let by_namespace_new_clients, new_clients; - if (newClientsData) { - by_namespace_new_clients = this.formatByNamespace(newClientsData.namespaces); - new_clients = this.homogenizeClientNaming(newClientsData.counts); - } else { - by_namespace_new_clients = []; - new_clients = []; - } let transformedPayload = { ...payload, response_timestamp, - by_namespace_total_clients: this.formatByNamespace(payload.data.by_namespace), - by_namespace_new_clients, + by_namespace: formatByNamespace(payload.data.by_namespace), // nest within 'total' object to mimic /activity response shape - total: this.homogenizeClientNaming(payload.data), - new: new_clients, + total: homogenizeClientNaming(payload.data), }; delete payload.data.by_namespace; delete payload.data.months; diff --git a/ui/app/templates/components/clients/attribution.hbs b/ui/app/templates/components/clients/attribution.hbs index 1dacdc622..3865c06b0 100644 --- a/ui/app/templates/components/clients/attribution.hbs +++ b/ui/app/templates/components/clients/attribution.hbs @@ -1,6 +1,6 @@ -{{! show single chart if data is from a range, show two charts if from a single month}} +{{! show single horizontal bar chart unless data is from a single, historical month (isDateRange = false) }}
{{#if this.barChartTotalClients}} - {{#if (eq @isDateRange true)}} + {{#if (or @isDateRange @isCurrentMonth)}}
@@ -51,7 +51,7 @@
@@ -62,7 +62,7 @@
{{/if}} @@ -93,18 +93,15 @@ >