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 <cbontempo@hashicorp.com>
This commit is contained in:
Angel Garbarino 2022-05-02 19:37:09 -06:00 committed by GitHub
parent bef350c916
commit 33de0a0a49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 469 additions and 385 deletions

View File

@ -13,23 +13,25 @@ import { inject as service } from '@ember/service';
* @chartLegend={{this.chartLegend}} * @chartLegend={{this.chartLegend}}
* @totalUsageCounts={{this.totalUsageCounts}} * @totalUsageCounts={{this.totalUsageCounts}}
* @newUsageCounts={{this.newUsageCounts}} * @newUsageCounts={{this.newUsageCounts}}
* @totalClientsData={{this.totalClientsData}} * @totalClientAttribution={{this.totalClientAttribution}}
* @newClientsData={{this.newClientsData}} * @newClientAttribution={{this.newClientAttribution}}
* @selectedNamespace={{this.selectedNamespace}} * @selectedNamespace={{this.selectedNamespace}}
* @startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}} * @startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}}
* @isDateRange={{this.isDateRange}} * @isDateRange={{this.isDateRange}}
* @isCurrentMonth={{false}}
* @timestamp={{this.responseTimestamp}} * @timestamp={{this.responseTimestamp}}
* /> * />
* ``` * ```
* @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked * @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} totalUsageCounts - object with total client counts for chart tooltip text
* @param {object} newUsageCounts - object with new 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} totalClientAttribution - 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} 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} selectedNamespace - namespace selected from filter bar
* @param {string} startTimeDisplay - string that displays as start date for CSV modal * @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 {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 * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
*/ */
@ -38,14 +40,15 @@ export default class Attribution extends Component {
@service downloadCsv; @service downloadCsv;
get hasCsvData() { get hasCsvData() {
return this.args.totalClientsData ? this.args.totalClientsData.length > 0 : false; return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
} }
get isDateRange() { get isDateRange() {
return this.args.isDateRange; return this.args.isDateRange;
} }
get isSingleNamespace() { get isSingleNamespace() {
if (!this.args.totalClientsData) { if (!this.args.totalClientAttribution) {
return 'no data'; return 'no data';
} }
// if a namespace is selected, then we're viewing top 10 auth methods (mounts) // 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 // 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() { get barChartTotalClients() {
return this.args.totalClientsData?.slice(0, 10); return this.args.totalClientAttribution?.slice(0, 10);
} }
get barChartNewClients() { get barChartNewClients() {
return this.args.newClientsData?.slice(0, 10); return this.args.newClientAttribution?.slice(0, 10);
} }
get topClientCounts() { get topClientCounts() {
// get top namespace or auth method // 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() { 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 = [], let csvData = [],
graphData = this.args.totalClientsData,
csvHeader = [ csvHeader = [
'Namespace path', 'Namespace path',
'Authentication method', 'Authentication method',
@ -114,24 +134,41 @@ export default class Attribution extends Component {
'Non-entity clients', 'Non-entity clients',
]; ];
// each array will be a row in the csv file if (newAttribution) {
if (this.isSingleNamespace) { csvHeader = [...csvHeader, 'Total new clients, New entity clients, New non-entity clients'];
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]);
});
}
});
} }
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); 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'); return csvData.map((d) => d.join()).join('\n');
} }
@ -145,7 +182,8 @@ export default class Attribution extends Component {
// ACTIONS // ACTIONS
@action @action
exportChartData(filename, contents) { exportChartData(filename) {
let contents = this.generateCsvData();
this.downloadCsv.download(filename, contents); this.downloadCsv.download(filename, contents);
this.showCSVDownloadModal = false; this.showCSVDownloadModal = false;
} }

View File

@ -9,7 +9,7 @@ export default class Current extends Component {
{ key: 'non_entity_clients', label: 'non-entity clients' }, { key: 'non_entity_clients', label: 'non-entity clients' },
]; ];
@tracked selectedNamespace = null; @tracked selectedNamespace = null;
@tracked namespaceArray = this.byNamespaceTotalClients.map((namespace) => { @tracked namespaceArray = this.byNamespace.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] }; 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)); let findUpgrade = versionHistory.find((versionData) => versionData.id.match(version));
if (findUpgrade) relevantUpgrades.push(findUpgrade); 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 // array of upgrade data objects for noteworthy upgrades
return relevantUpgrades; return relevantUpgrades;
} }
// Response total client count data by namespace for current/partial month // Response client count data by namespace for current/partial month
get byNamespaceTotalClients() { get byNamespace() {
return this.args.model.monthly?.byNamespaceTotalClients || []; return this.args.model.monthly?.byNamespace || [];
}
// Response new client count data by namespace for current/partial month
get byNamespaceNewClients() {
return this.args.model.monthly?.byNamespaceNewClients || [];
} }
get isGatheringData() { get isGatheringData() {
// return true if tracking IS enabled but no data collected yet // return true if tracking IS enabled but no data collected yet
return ( return this.args.model.config?.enabled === 'On' && this.byNamespace.length === 0;
this.args.model.config?.enabled === 'On' &&
this.byNamespaceTotalClients.length === 0 &&
this.byNamespaceNewClients.length === 0
);
} }
get hasAttributionData() { get hasAttributionData() {
@ -67,33 +48,19 @@ export default class Current extends Component {
if (this.selectedNamespace) { if (this.selectedNamespace) {
return this.authMethodOptions.length > 0; 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 namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod; const auth = this.selectedAuthMethod;
if (!namespace && !auth) { if (!namespace && !auth) {
return this.byNamespaceTotalClients; return this.byNamespace;
} }
if (!auth) { if (!auth) {
return this.byNamespaceTotalClients.find((ns) => ns.label === namespace); return this.byNamespace.find((ns) => ns.label === namespace);
} }
return this.byNamespaceTotalClients return this.byNamespace
.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
.find((ns) => ns.label === namespace) .find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth); .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.'; 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')) { 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 combined explanation if spans multiple upgrades
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 ' 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 // top level TOTAL client counts for current/partial month
get totalUsageCounts() { get totalUsageCounts() {
return this.selectedNamespace ? this.filteredTotalData : this.args.model.monthly?.total; return this.selectedNamespace ? this.filteredCurrentData : this.args.model.monthly?.total;
} }
get newUsageCounts() { // total client attribution data for horizontal bar chart in attribution component
return this.selectedNamespace ? this.filteredNewData : this.args.model.monthly?.new; get totalClientAttribution() {
}
// total client data for horizontal bar chart in attribution component
get totalClientsData() {
if (this.selectedNamespace) { if (this.selectedNamespace) {
return this.filteredTotalData?.mounts || null; return this.filteredCurrentData?.mounts || null;
} else { } else {
return this.byNamespaceTotalClients; return this.byNamespace;
}
}
// new client data for horizontal bar chart in attribution component
get newClientsData() {
if (this.selectedNamespace) {
return this.filteredNewData?.mounts || null;
} else {
return this.byNamespaceNewClients;
} }
} }
@ -183,7 +137,7 @@ export default class Current extends Component {
this.selectedAuthMethod = null; this.selectedAuthMethod = null;
} else { } else {
// Side effect: set auth namespaces // Side effect: set auth namespaces
const mounts = this.filteredTotalData.mounts?.map((mount) => ({ const mounts = this.filteredCurrentData.mounts?.map((mount) => ({
id: mount.label, id: mount.label,
name: mount.label, name: mount.label,
})); }));

View File

@ -6,6 +6,7 @@ import { isSameMonth, isAfter, isBefore } from 'date-fns';
import getStorage from 'vault/lib/token-storage'; import getStorage from 'vault/lib/token-storage';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters'; import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { dateFormat } from 'core/helpers/date-format'; import { dateFormat } from 'core/helpers/date-format';
import { parseAPITimestamp } from 'core/utils/date-formatters';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date'; const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
@ -85,8 +86,8 @@ export default class History extends Component {
get isDateRange() { get isDateRange() {
return !isSameMonth( return !isSameMonth(
new Date(this.getActivityResponse.startTime), parseAPITimestamp(this.getActivityResponse.startTime),
new Date(this.getActivityResponse.endTime) parseAPITimestamp(this.getActivityResponse.endTime)
); );
} }
@ -104,15 +105,6 @@ export default class History extends Component {
if (findUpgrade) relevantUpgrades.push(findUpgrade); 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 // array of upgrade data objects for noteworthy upgrades
return relevantUpgrades; 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.'; 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')) { 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 combined explanation if spans multiple upgrades
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 ' 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() { get startTimeDisplay() {
@ -193,12 +185,20 @@ export default class History extends Component {
return this.queriedActivityResponse || this.args.model.activity; 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() { get hasAttributionData() {
if (this.selectedAuthMethod) return false; if (this.selectedAuthMethod) return false;
if (this.selectedNamespace) { if (this.selectedNamespace) {
return this.authMethodOptions.length > 0; 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 // 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; 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 // total client data for horizontal bar chart in attribution component
get totalClientsData() { get totalClientAttribution() {
if (this.selectedNamespace) { if (this.selectedNamespace) {
return this.filteredActivity?.mounts || null; return this.filteredActivity?.mounts || null;
} else { } 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() { get responseTimestamp() {
return this.getActivityResponse.responseTimestamp; return this.getActivityResponse.responseTimestamp;
} }
get byMonthTotalClients() {
return this.getActivityResponse?.byMonth;
}
get byMonthNewClients() {
return this.byMonthTotalClients.map((m) => m.new_clients);
}
get filteredActivity() { get filteredActivity() {
const namespace = this.selectedNamespace; const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod; const auth = this.selectedAuthMethod;
@ -241,6 +253,21 @@ export default class History extends Component {
.mounts?.find((mount) => mount.label === auth); .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 @action
async handleClientActivityQuery(month, year, dateType) { async handleClientActivityQuery(month, year, dateType) {
this.isEditStartMonthOpen = false; this.isEditStartMonthOpen = false;

View File

@ -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} 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 {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 // SIZING CONSTANTS
@ -36,12 +39,20 @@ export default class HorizontalBarChart extends Component {
return this.args.labelKey || 'label'; return this.args.labelKey || 'label';
} }
get xKey() {
return this.args.xKey || 'clients';
}
get chartLegend() { get chartLegend() {
return this.args.chartLegend; return this.args.chartLegend;
} }
get topNamespace() { 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() { @action removeTooltip() {
@ -49,17 +60,17 @@ export default class HorizontalBarChart extends Component {
} }
@action @action
renderChart(element, args) { renderChart(element, [chartData]) {
// chart legend tells stackFunction how to stack/organize data // chart legend tells stackFunction how to stack/organize data
// creates an array of data for each key name // creates an array of data for each key name
// each array contains coordinates for each data bar // each array contains coordinates for each data bar
let stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); let stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
let dataset = args[0]; let dataset = chartData;
let stackedData = stackFunction(dataset); let stackedData = stackFunction(dataset);
let labelKey = this.labelKey; let labelKey = this.labelKey;
let xKey = this.xKey;
let xScale = scaleLinear() 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 .range([0, 75]); // 25% reserved for margins
let yScale = scaleBand() let yScale = scaleBand()
@ -162,13 +173,15 @@ export default class HorizontalBarChart extends Component {
// MOUSE EVENTS FOR DATA BARS // MOUSE EVENTS FOR DATA BARS
actionBars actionBars
.on('mouseover', (data) => { .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.tooltipTarget = hoveredElement;
this.isLabel = false; 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: of total client counts:
${formatTooltipNumber(data.entity_clients)} entity clients, ${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); select(hoveredElement).style('opacity', 1);
@ -190,11 +203,11 @@ export default class HorizontalBarChart extends Component {
// MOUSE EVENTS FOR Y-AXIS LABELS // MOUSE EVENTS FOR Y-AXIS LABELS
labelActionBar labelActionBar
.on('mouseover', (data) => { .on('mouseover', (data) => {
if (data.label.length >= CHAR_LIMIT) { if (data[labelKey].length >= CHAR_LIMIT) {
let hoveredElement = labelActionBar.filter((bar) => bar.label === data.label).node(); let hoveredElement = labelActionBar.filter((bar) => bar[labelKey] === data[labelKey]).node();
this.tooltipTarget = hoveredElement; this.tooltipTarget = hoveredElement;
this.isLabel = true; this.isLabel = true;
this.tooltipText = data.label; this.tooltipText = data[labelKey];
} else { } else {
this.tooltipTarget = null; this.tooltipTarget = null;
} }
@ -234,13 +247,13 @@ export default class HorizontalBarChart extends Component {
.data(dataset) .data(dataset)
.enter() .enter()
.append('text') .append('text')
.text((d) => d.clients) .text((d) => d[xKey])
.attr('fill', '#000') .attr('fill', '#000')
.attr('class', 'total-value') .attr('class', 'total-value')
.style('font-size', '.8rem') .style('font-size', '.8rem')
.attr('text-anchor', 'start') .attr('text-anchor', 'start')
.attr('alignment-baseline', 'middle') .attr('alignment-baseline', 'middle')
.attr('x', (chartData) => `${xScale(chartData.clients)}%`) .attr('x', (chartData) => `${xScale(chartData[xKey])}%`)
.attr('y', (chartData) => yScale(chartData.label)); .attr('y', (chartData) => yScale(chartData[labelKey]));
} }
} }

View File

@ -47,11 +47,11 @@ export default class LineChart extends Component {
} }
@action @action
renderChart(element, args) { renderChart(element, [chartData]) {
const dataset = args[0]; const dataset = chartData;
const upgradeData = []; const upgradeData = [];
if (args[1]) { if (this.args.upgradeData) {
args[1].forEach((versionData) => this.args.upgradeData.forEach((versionData) =>
upgradeData.push({ month: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'), ...versionData }) upgradeData.push({ month: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'), ...versionData })
); );
} }
@ -59,6 +59,9 @@ export default class LineChart extends Component {
const chartSvg = select(element); const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions 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 // DEFINE AXES SCALES
const yScale = scaleLinear() const yScale = scaleLinear()
.domain([0, max(filteredData.map((d) => d[this.yKey]))]) .domain([0, max(filteredData.map((d) => d[this.yKey]))])
@ -154,10 +157,11 @@ export default class LineChart extends Component {
// MOUSE EVENT FOR TOOLTIP // MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => { 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.tooltipMonth = formatChartDate(data[this.xKey]);
this.tooltipTotal = data[this.yKey] + ' total clients'; 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 = ''; this.tooltipUpgradeText = '';
let upgradeInfo = findUpgradeData(data); let upgradeInfo = findUpgradeData(data);
if (upgradeInfo) { if (upgradeInfo) {

View File

@ -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: { object example: {
month: '1/22', month: '1/22',
entity_clients: 23, entity_clients: 23,
@ -32,7 +32,6 @@ import { mean } from 'd3-array';
namespaces: [], 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 {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 {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 * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response

View File

@ -48,8 +48,8 @@ export default class VerticalBarChart extends Component {
} }
@action @action
registerListener(element, args) { renderChart(element, [chartData]) {
const dataset = args[0]; 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 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 stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
const stackedData = stackFunction(filteredData); 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 .range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels
.paddingInner(0.85); .paddingInner(0.85);
// clear out DOM before appending anything
chartSvg.selectAll('g').remove().exit().data(stackedData).enter();
const dataBars = chartSvg const dataBars = chartSvg
.selectAll('g') .selectAll('g')
.data(stackedData) .data(stackedData)

View File

@ -2,7 +2,5 @@ import Model, { attr } from '@ember-data/model';
export default class MonthlyModel extends Model { export default class MonthlyModel extends Model {
@attr('string') responseTimestamp; @attr('string') responseTimestamp;
@attr('object') total; // total clients during the current/partial month @attr('object') total; // total clients during the current/partial month
@attr('object') new; // total NEW clients during the current/partial @attr('array') byNamespace;
@attr('array') byNamespaceTotalClients;
@attr('array') byNamespaceNewClients;
} }

View File

@ -1,90 +1,8 @@
import ApplicationSerializer from '../application'; import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns'; 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 { 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) { normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.id === 'no-data') { if (payload.id === 'no-data') {
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
@ -93,9 +11,9 @@ export default class ActivitySerializer extends ApplicationSerializer {
let transformedPayload = { let transformedPayload = {
...payload, ...payload,
response_timestamp, response_timestamp,
by_namespace: this.formatByNamespace(payload.data.by_namespace), by_namespace: formatByNamespace(payload.data.by_namespace),
by_month: this.formatByMonths(payload.data.months), by_month: formatByMonths(payload.data.months),
total: this.homogenizeClientNaming(payload.data.total), total: homogenizeClientNaming(payload.data.total),
formatted_end_time: parseRFC3339(payload.data.end_time), formatted_end_time: parseRFC3339(payload.data.end_time),
formatted_start_time: parseRFC3339(payload.data.start_time), formatted_start_time: parseRFC3339(payload.data.start_time),
}; };

View File

@ -1,60 +1,8 @@
import ApplicationSerializer from '../application'; import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns'; import { formatISO } from 'date-fns';
import { formatByNamespace, homogenizeClientNaming } from 'core/utils/client-count-utils';
export default class MonthlySerializer extends ApplicationSerializer { 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) { normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.id === 'no-data') { if (payload.id === 'no-data') {
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
@ -62,23 +10,12 @@ export default class MonthlySerializer extends ApplicationSerializer {
let response_timestamp = formatISO(new Date()); let response_timestamp = formatISO(new Date());
// TODO CMB: the following is assumed, need to confirm // 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 // 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 = { let transformedPayload = {
...payload, ...payload,
response_timestamp, response_timestamp,
by_namespace_total_clients: this.formatByNamespace(payload.data.by_namespace), by_namespace: formatByNamespace(payload.data.by_namespace),
by_namespace_new_clients,
// nest within 'total' object to mimic /activity response shape // nest within 'total' object to mimic /activity response shape
total: this.homogenizeClientNaming(payload.data), total: homogenizeClientNaming(payload.data),
new: new_clients,
}; };
delete payload.data.by_namespace; delete payload.data.by_namespace;
delete payload.data.months; delete payload.data.months;

View File

@ -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) }}
<div <div
class={{concat "chart-wrapper" (if @isDateRange " single-chart-grid") (unless @isDateRange " dual-chart-grid")}} class={{concat "chart-wrapper" (if (or @isCurrentMonth @isDateRange) " single-chart-grid" " dual-chart-grid")}}
data-test-clients-attribution data-test-clients-attribution
> >
<div class="chart-header has-header-link has-bottom-margin-m"> <div class="chart-header has-header-link has-bottom-margin-m">
@ -23,12 +23,12 @@
</div> </div>
{{#if this.barChartTotalClients}} {{#if this.barChartTotalClients}}
{{#if (eq @isDateRange true)}} {{#if (or @isDateRange @isCurrentMonth)}}
<div class="chart-container-wide" data-test-chart-container="total-clients"> <div class="chart-container-wide" data-test-chart-container="total-clients">
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}} @dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}} @chartLegend={{@chartLegend}}
@totalUsageCounts={{@totalUsageCounts}} @totalCounts={{@totalUsageCounts}}
/> />
</div> </div>
<div class="chart-subTitle"> <div class="chart-subTitle">
@ -51,7 +51,7 @@
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.barChartNewClients}} @dataset={{this.barChartNewClients}}
@chartLegend={{@chartLegend}} @chartLegend={{@chartLegend}}
@totalUsageCounts={{@newUsageCounts}} @totalCounts={{@newUsageCounts}}
@noDataMessage={{"There are no new clients for this namespace during this time period."}} @noDataMessage={{"There are no new clients for this namespace during this time period."}}
/> />
</div> </div>
@ -62,7 +62,7 @@
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}} @dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}} @chartLegend={{@chartLegend}}
@totalUsageCounts={{@totalUsageCounts}} @totalCounts={{@totalUsageCounts}}
/> />
</div> </div>
{{/if}} {{/if}}
@ -93,18 +93,15 @@
> >
<section class="modal-card-body"> <section class="modal-card-body">
<p class="has-bottom-margin-s"> <p class="has-bottom-margin-s">
This export will include the namespace path, authentication method path, and the associated clients, unique entities, This export will include the namespace path, authentication method path, and the associated total, entity, and
and non-entity tokens for the below date range. non-entity clients for the below
{{if @isCurrentMonth "month" "date range"}}.
</p> </p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if @endTimeDisplay " RANGE"}}</p> <p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if @endTimeDisplay " RANGE"}}</p>
<p class="has-bottom-margin-s">{{@startTimeDisplay}} {{if @endTimeDisplay "-"}} {{@endTimeDisplay}}</p> <p class="has-bottom-margin-s">{{@startTimeDisplay}} {{if @endTimeDisplay "-"}} {{@endTimeDisplay}}</p>
</section> </section>
<footer class="modal-card-foot modal-card-foot-outlined"> <footer class="modal-card-foot modal-card-foot-outlined">
<button <button type="button" class="button is-primary" {{on "click" (fn this.exportChartData this.getCsvFileName)}}>
type="button"
class="button is-primary"
{{on "click" (fn this.exportChartData this.getCsvFileName this.getCsvData)}}
>
Export Export
</button> </button>
<button type="button" class="button is-secondary" onclick={{action (mut this.showCSVDownloadModal) false}}> <button type="button" class="button is-secondary" onclick={{action (mut this.showCSVDownloadModal) false}}>

View File

@ -73,10 +73,10 @@
@chartLegend={{this.chartLegend}} @chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}} @totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newUsageCounts}} @newUsageCounts={{this.newUsageCounts}}
@totalClientsData={{this.totalClientsData}} @totalClientAttribution={{this.totalClientAttribution}}
@newClientsData={{this.newClientsData}}
@selectedNamespace={{this.selectedNamespace}} @selectedNamespace={{this.selectedNamespace}}
@startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}} @startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}}
@isCurrentMonth={{true}}
@isDateRange={{false}} @isDateRange={{false}}
@timestamp={{this.responseTimestamp}} @timestamp={{this.responseTimestamp}}
/> />

View File

@ -133,6 +133,7 @@
{{#if this.byMonthTotalClients}} {{#if this.byMonthTotalClients}}
<Clients::RunningTotal <Clients::RunningTotal
@chartLegend={{this.chartLegend}} @chartLegend={{this.chartLegend}}
@selectedNamespace={{this.selectedNamespace}}
@lineChartData={{this.byMonthTotalClients}} @lineChartData={{this.byMonthTotalClients}}
@barChartData={{this.byMonthNewClients}} @barChartData={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}} @runningTotals={{this.totalUsageCounts}}
@ -143,8 +144,10 @@
{{#if this.hasAttributionData}} {{#if this.hasAttributionData}}
<Clients::Attribution <Clients::Attribution
@chartLegend={{this.chartLegend}} @chartLegend={{this.chartLegend}}
@totalClientsData={{this.totalClientsData}}
@totalUsageCounts={{this.totalUsageCounts}} @totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newUsageCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{this.selectedNamespace}} @selectedNamespace={{this.selectedNamespace}}
@startTimeDisplay={{this.startTimeDisplay}} @startTimeDisplay={{this.startTimeDisplay}}
@endTimeDisplay={{this.endTimeDisplay}} @endTimeDisplay={{this.endTimeDisplay}}

View File

@ -2,8 +2,8 @@
data-test-line-chart data-test-line-chart
class="chart has-grid" class="chart has-grid"
{{on "mouseleave" this.removeTooltip}} {{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset @upgradeData}} {{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset @upgradeData}} {{did-update this.renderChart @dataset}}
> >
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 988 B

View File

@ -2,7 +2,7 @@
data-test-vertical-bar-chart data-test-vertical-bar-chart
class="chart has-grid" class="chart has-grid"
{{on "mouseleave" this.removeTooltip}} {{on "mouseleave" this.removeTooltip}}
{{did-insert this.registerListener @dataset}} {{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset}} {{did-update this.renderChart @dataset}}
> >
</svg> </svg>

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 857 B

View File

@ -0,0 +1,89 @@
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc } from 'date-fns';
export const flattenDataset = (object) => {
if (Object.keys(object).includes('counts') && object.counts) {
let flattenedObject = {};
Object.keys(object['counts']).forEach((key) => (flattenedObject[key] = object['counts'][key]));
return homogenizeClientNaming(flattenedObject);
}
return object;
};
export const sortMonthsByTimestamp = (monthsArray) => {
// backend is working on a fix to sort months by date
// right now months are ordered in descending client count number
const sortedPayload = [...monthsArray];
return sortedPayload.sort((a, b) =>
compareAsc(parseAPITimestamp(a.timestamp), parseAPITimestamp(b.timestamp))
);
};
export const formatByMonths = (monthsArray) => {
if (!Array.isArray(monthsArray)) return monthsArray;
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload.map((m) => {
if (Object.keys(m).includes('counts')) {
let totalClients = flattenDataset(m);
let newClients = m.new_clients ? flattenDataset(m.new_clients) : {};
return {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
...totalClients,
namespaces: formatByNamespace(m.namespaces),
new_clients: {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
...newClients,
namespaces: formatByNamespace(m.new_clients?.namespaces) || [],
},
};
}
});
};
export const formatByNamespace = (namespaceArray) => {
if (!Array.isArray(namespaceArray)) return 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 = 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'],
...flattenDataset(mount),
};
});
}
return {
label,
...flattenedNs,
};
});
};
// In 1.10 'distinct_entities' changed to 'entity_clients' and
// 'non_entity_tokens' to 'non_entity_clients'
export const 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;
};

View File

@ -15,7 +15,7 @@ export const ARRAY_OF_MONTHS = [
'December', 'December',
]; ];
// convert RFC3339 timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format // convert API timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp, style) => { export const parseAPITimestamp = (timestamp, style) => {
if (!timestamp) return; if (!timestamp) return;
let date = parseISO(timestamp.split('T')[0]); let date = parseISO(timestamp.split('T')[0]);

View File

@ -1,15 +1,128 @@
import { import { formatISO, isAfter, isBefore, sub, isSameMonth, startOfMonth } from 'date-fns';
differenceInCalendarMonths,
formatRFC3339,
formatISO,
isAfter,
isBefore,
sub,
isSameMonth,
startOfMonth,
} from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters'; import { parseAPITimestamp } from 'core/utils/date-formatters';
const MOCK_MONTHLY_DATA = [ const MOCK_MONTHLY_DATA = [
{
timestamp: '2021-05-01T00:00:00Z',
counts: {
distinct_entities: 0,
entity_clients: 25,
non_entity_tokens: 0,
non_entity_clients: 20,
clients: 50,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 0,
entity_clients: 13,
non_entity_tokens: 0,
non_entity_clients: 7,
clients: 20,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 8,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 8,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 7,
clients: 7,
},
},
],
},
{
namespace_id: 's07UR',
namespace_path: 'ns1/',
counts: {
distinct_entities: 0,
entity_clients: 5,
non_entity_tokens: 0,
non_entity_clients: 5,
clients: 10,
},
mounts: [
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 5,
clients: 5,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 5,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 5,
},
},
],
},
],
new_clients: {
counts: {
distinct_entities: 0,
entity_clients: 3,
non_entity_tokens: 0,
non_entity_clients: 2,
clients: 5,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 0,
entity_clients: 3,
non_entity_tokens: 0,
non_entity_clients: 2,
clients: 5,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 3,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 3,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 2,
clients: 2,
},
},
],
},
],
},
},
{ {
timestamp: '2021-10-01T00:00:00Z', timestamp: '2021-10-01T00:00:00Z',
counts: { counts: {
@ -661,14 +774,7 @@ const handleMockQuery = (queryStartTimestamp, queryEndTimestamp, monthlyData) =>
const endDateByMonth = parseAPITimestamp(monthlyData[0].timestamp); const endDateByMonth = parseAPITimestamp(monthlyData[0].timestamp);
let transformedMonthlyArray = [...monthlyData]; let transformedMonthlyArray = [...monthlyData];
if (isBefore(queryStartDate, startDateByMonth)) { if (isBefore(queryStartDate, startDateByMonth)) {
// no data for months before (upgraded to 1.10 during billing period) return transformedMonthlyArray;
let i = 0;
do {
i++;
let timestamp = formatRFC3339(sub(startDateByMonth, { months: i }));
// TODO CMB update this when we confirm what combined data looks like
transformedMonthlyArray.push({ timestamp });
} while (i < differenceInCalendarMonths(startDateByMonth, queryStartDate));
} }
if (isAfter(queryStartDate, startDateByMonth)) { if (isAfter(queryStartDate, startDateByMonth)) {
let index = monthlyData.findIndex((e) => isSameMonth(queryStartDate, parseAPITimestamp(e.timestamp))); let index = monthlyData.findIndex((e) => isSameMonth(queryStartDate, parseAPITimestamp(e.timestamp)));
@ -976,6 +1082,14 @@ export default function (server) {
non_entity_clients: 15, non_entity_clients: 15,
}, },
}, },
{
mount_path: 'auth_userpass_3158c012',
counts: {
clients: 2,
entity_clients: 2,
non_entity_clients: 0,
},
},
], ],
}, },
], ],

View File

@ -89,21 +89,12 @@ module('Acceptance | clients current', function (hooks) {
.dom('[data-test-stat-text="non-entity-clients"] .stat-value') .dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText(non_entity_clients.toString()); .hasText(non_entity_clients.toString());
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area'); assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
assert assert
.dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]') .dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]')
.exists('Shows totals attribution bar chart'); .exists('Shows totals attribution bar chart');
assert
// TODO CMB - this assertion should be updated so the response includes new client counts
// TODO then move somewhere to assert empty state shows when filtering a namespace with no new clients
.dom('[data-test-chart-container="new-clients"] [data-test-empty-state-subtext]')
.includesText(
'There are no new clients for this namespace during this time period.',
'Shows empty state if no new client counts'
);
// check chart displays correct elements and values // check chart displays correct elements and values
for (const key in CHART_ELEMENTS) { for (const key in CHART_ELEMENTS) {
let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10; let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10;
@ -128,21 +119,16 @@ module('Acceptance | clients current', function (hooks) {
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
await settled(); await settled();
await waitUntil(() => find('[data-test-horizontal-bar-chart]'));
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('15'); assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('15');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('5'); assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('5');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10'); assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10');
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
assert assert
.dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]') .dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]')
.exists('Still shows totals attribution bar chart'); .exists('Still shows totals attribution bar chart');
assert assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
.dom('[data-test-chart-container="total-clients"] .chart-description') assert.dom('[data-test-chart-container="total-clients"]').exists();
.includesText('The total clients used by the auth method for this month.');
assert
.dom('[data-test-chart-container="new-clients"] .chart-description')
.includesText('The new clients used by the auth method for this month.');
// check chart displays correct elements and values // check chart displays correct elements and values
for (const key in CHART_ELEMENTS) { for (const key in CHART_ELEMENTS) {
@ -179,8 +165,8 @@ module('Acceptance | clients current', function (hooks) {
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('5'); assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('5');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10'); assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10');
await settled(); await settled();
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
assert.dom(SELECTORS.attributionBlock).exists('Still shows attribution block'); assert.dom(SELECTORS.attributionBlock).exists('Still shows attribution block');
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
@ -193,11 +179,9 @@ module('Acceptance | clients current', function (hooks) {
assert assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value') .dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText(non_entity_clients.toString()); .hasText(non_entity_clients.toString());
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
assert assert.dom('[data-test-chart-container="new-clients"] [data-test-empty-state-subtext]').doesNotExist();
.dom('[data-test-chart-container="new-clients"] [data-test-empty-state-subtext]')
.includesText('There are no new clients', 'Shows empty state if no new client counts');
}); });
test('filters correctly on current with no auth mounts', async function (assert) { test('filters correctly on current with no auth mounts', async function (assert) {
@ -224,14 +208,12 @@ module('Acceptance | clients current', function (hooks) {
.dom('[data-test-stat-text="non-entity-clients"] .stat-value') .dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText(non_entity_clients.toString()); .hasText(non_entity_clients.toString());
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area'); assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
assert assert
.dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]') .dom('[data-test-chart-container="total-clients"] [data-test-horizontal-bar-chart]')
.exists('Shows totals attribution bar chart'); .exists('Shows totals attribution bar chart');
assert assert.dom('[data-test-chart-container="total-clients"]').exists();
.dom('[data-test-chart-container="total-clients"] .chart-description')
.includesText('The total clients in the namespace for this month.');
// Filter by namespace // Filter by namespace
await clickTrigger(); await clickTrigger();
@ -250,8 +232,8 @@ module('Acceptance | clients current', function (hooks) {
assert assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value') .dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText(non_entity_clients.toString()); .hasText(non_entity_clients.toString());
assert.dom('[data-test-chart-container="new-clients"] .chart-title').includesText('New clients'); assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="total-clients"] .chart-title').includesText('Total clients'); assert.dom('[data-test-chart-container="total-clients"]').exists();
}); });
test('shows correct empty state when config off but no read on config', async function (assert) { test('shows correct empty state when config off but no read on config', async function (assert) {

View File

@ -144,6 +144,15 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart'); assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top namespace'); assert.dom('[data-test-top-attribution]').includesText('Top namespace');
// TODO CMB - add assertion so double charts show for single historical month
// TODO and check for empty state there
// assert
// .dom('[data-test-chart-container="new-clients"] [data-test-empty-state-subtext]')
// .includesText(
// 'There are no new clients for this namespace during this time period.',
// 'Shows empty state if no new client counts'
// );
// check chart displays correct elements and values // check chart displays correct elements and values
for (const key in CHART_ELEMENTS) { for (const key in CHART_ELEMENTS) {
let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10; let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10;

View File

@ -17,7 +17,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
{ label: 'non-entity clients', key: 'non_entity_clients' }, { label: 'non-entity clients', key: 'non_entity_clients' },
]); ]);
this.set('totalUsageCounts', { clients: 15, entity_clients: 10, non_entity_clients: 5 }); this.set('totalUsageCounts', { clients: 15, entity_clients: 10, non_entity_clients: 5 });
this.set('totalClientsData', [ this.set('totalClientAttribution', [
{ label: 'second', clients: 10, entity_clients: 7, non_entity_clients: 3 }, { label: 'second', clients: 10, entity_clients: 7, non_entity_clients: 3 },
{ label: 'first', clients: 5, entity_clients: 3, non_entity_clients: 2 }, { label: 'first', clients: 5, entity_clients: 3, non_entity_clients: 2 },
]); ]);
@ -46,7 +46,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Attribution <Clients::Attribution
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalClientsData={{totalClientsData}} @totalClientAttribution={{totalClientAttribution}}
@totalUsageCounts={{totalUsageCounts}} @totalUsageCounts={{totalUsageCounts}}
@timestamp={{timestamp}} @timestamp={{timestamp}}
@selectedNamespace={{selectedNamespace}} @selectedNamespace={{selectedNamespace}}
@ -77,7 +77,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Attribution <Clients::Attribution
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalClientsData={{totalClientsData}} @totalClientAttribution={{totalClientAttribution}}
@totalUsageCounts={{totalUsageCounts}} @totalUsageCounts={{totalUsageCounts}}
@timestamp={{timestamp}} @timestamp={{timestamp}}
@selectedNamespace={{selectedNamespace}} @selectedNamespace={{selectedNamespace}}
@ -130,7 +130,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Attribution <Clients::Attribution
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalClientsData={{namespaceMountsData}} @totalClientAttribution={{namespaceMountsData}}
@totalUsageCounts={{totalUsageCounts}} @totalUsageCounts={{totalUsageCounts}}
@timestamp={{timestamp}} @timestamp={{timestamp}}
@selectedNamespace={{selectedNamespace}} @selectedNamespace={{selectedNamespace}}
@ -160,7 +160,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<Clients::Attribution <Clients::Attribution
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalClientsData={{namespaceMountsData}} @totalClientAttribution={{namespaceMountsData}}
@timestamp={{timestamp}} @timestamp={{timestamp}}
@startTimeDisplay={{"January 2022"}} @startTimeDisplay={{"January 2022"}}
@endTimeDisplay={{"February 2022"}} @endTimeDisplay={{"February 2022"}}

View File

@ -18,14 +18,14 @@ module('Integration | Component | clients/horizontal-bar-chart', function (hooks
{ label: 'second', clients: 3, entity_clients: 1, non_entity_clients: 2 }, { label: 'second', clients: 3, entity_clients: 1, non_entity_clients: 2 },
{ label: 'first', clients: 2, entity_clients: 1, non_entity_clients: 1 }, { label: 'first', clients: 2, entity_clients: 1, non_entity_clients: 1 },
]; ];
this.set('totalUsageCounts', totalObject); this.set('totalCounts', totalObject);
this.set('totalClientsData', dataArray); this.set('totalClientAttribution', dataArray);
await render(hbs` await render(hbs`
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.totalClientsData}} @dataset={{this.totalClientAttribution}}
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalUsageCounts={{totalUsageCounts}} @totalCounts={{totalCounts}}
/>`); />`);
assert.dom('[data-test-horizontal-bar-chart]').exists(); assert.dom('[data-test-horizontal-bar-chart]').exists();
@ -43,7 +43,6 @@ module('Integration | Component | clients/horizontal-bar-chart', function (hooks
textTotals.forEach((label, index) => { textTotals.forEach((label, index) => {
assert.dom(label).hasText(`${dataArray[index].clients}`, 'total value renders correct number'); assert.dom(label).hasText(`${dataArray[index].clients}`, 'total value renders correct number');
}); });
for (let [i, bar] of actionBars.entries()) { for (let [i, bar] of actionBars.entries()) {
let percent = Math.round((dataArray[i].clients / totalObject.clients) * 100); let percent = Math.round((dataArray[i].clients / totalObject.clients) * 100);
await triggerEvent(bar, 'mouseover'); await triggerEvent(bar, 'mouseover');
@ -58,14 +57,14 @@ module('Integration | Component | clients/horizontal-bar-chart', function (hooks
{ label: 'second', clients: 5929093, entity_clients: 1391896, non_entity_clients: 4537100 }, { label: 'second', clients: 5929093, entity_clients: 1391896, non_entity_clients: 4537100 },
{ label: 'first', clients: 300, entity_clients: 101, non_entity_clients: 296 }, { label: 'first', clients: 300, entity_clients: 101, non_entity_clients: 296 },
]; ];
this.set('totalUsageCounts', totalObject); this.set('totalCounts', totalObject);
this.set('totalClientsData', dataArray); this.set('totalClientAttribution', dataArray);
await render(hbs` await render(hbs`
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.totalClientsData}} @dataset={{this.totalClientAttribution}}
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
@totalUsageCounts={{totalUsageCounts}} @totalCounts={{totalCounts}}
/>`); />`);
assert.dom('[data-test-horizontal-bar-chart]').exists(); assert.dom('[data-test-horizontal-bar-chart]').exists();