UI/filter monthly graphs (#15279)

* alphabetize utils

* add util to add namespace key

* finish filtering

* add fake data for filtering

* address comments

* add empty state for no new client counts, when filtered by namespace

* fix mirage clients linting

* re-add namespaces to month object

* clean up filtering

* add tests and refactor accordingly

* fix tooltip bug and chart new month client chart not rendering

* filter out undefined

* optional method chaining

* add filter and fix ticks for line chart

* fix axes domains

* fix average calculation
This commit is contained in:
claire bontempo 2022-05-09 12:16:32 -07:00 committed by GitHub
parent 45efa37c4a
commit 999d243544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1189 additions and 103 deletions

View File

@ -122,6 +122,7 @@ export default class Attribution extends Component {
...otherColumns,
];
}
generateCsvData() {
const totalAttribution = this.args.totalClientAttribution;
const newAttribution = this.barChartNewClients ? this.args.newClientAttribution : null;

View File

@ -185,12 +185,19 @@ export default class History extends Component {
return this.queriedActivityResponse || this.args.model.activity;
}
get byMonthTotalClients() {
return this.getActivityResponse?.byMonth;
get byMonthActivityData() {
if (this.selectedNamespace) {
return this.filteredActivityByMonth;
} else {
return this.getActivityResponse?.byMonth;
}
}
get byMonthNewClients() {
return this.byMonthTotalClients.map((m) => m.new_clients);
if (this.byMonthActivityData) {
return this.byMonthActivityData?.map((m) => m.new_clients);
}
return null;
}
get hasAttributionData() {
@ -201,37 +208,34 @@ export default class History extends Component {
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
}
// top level TOTAL client counts for given date range
// (object) top level TOTAL client counts for given date range
get totalUsageCounts() {
return this.selectedNamespace ? this.filteredActivity : this.getActivityResponse.total;
return this.selectedNamespace ? this.filteredActivityByNamespace : this.getActivityResponse.total;
}
get newUsageCounts() {
return this.selectedNamespace
? this.filteredNewClientAttribution
: this.byMonthTotalClients[0]?.new_clients;
// (object) single month new client data with total counts + array of namespace breakdown
get newClientCounts() {
return this.isDateRange ? null : this.byMonthActivityData[0]?.new_clients;
}
// total client data for horizontal bar chart in attribution component
get totalClientAttribution() {
if (this.selectedNamespace) {
return this.filteredActivity?.mounts || null;
return this.filteredActivityByNamespace?.mounts || null;
} else {
return this.getActivityResponse?.byNamespace;
return this.getActivityResponse?.byNamespace || null;
}
}
// 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
// new client attribution only available in a single, historical month (not a date range)
if (this.isDateRange) return null;
if (this.selectedNamespace) {
return this.filteredNewClientAttribution?.mounts || null;
return this.newClientCounts?.mounts || null;
} else {
return this.byMonthTotalClients[0]?.new_clients.namespaces || null;
return this.newClientCounts?.namespaces || null;
}
}
@ -239,7 +243,8 @@ export default class History extends Component {
return this.getActivityResponse.responseTimestamp;
}
get filteredActivity() {
// FILTERS
get filteredActivityByNamespace() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
@ -253,19 +258,22 @@ export default class History extends Component {
.mounts?.find((mount) => mount.label === auth);
}
get filteredNewClientAttribution() {
get filteredActivityByMonth() {
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);
if (!namespace && !auth) {
return this.getActivityResponse?.byMonth;
}
const namespaceData = this.getActivityResponse?.byMonth
.map((m) => m.namespaces_by_key[namespace])
.filter((d) => d !== undefined);
if (!auth) {
return namespaceData.length === 0 ? null : namespaceData;
}
const mountData = namespaceData
.map((namespace) => namespace.mounts_by_key[auth])
.filter((d) => d !== undefined);
return mountData.length === 0 ? null : mountData;
}
@action
@ -346,7 +354,7 @@ export default class History extends Component {
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivity.mounts?.map((mount) => ({
const mounts = this.filteredActivityByNamespace.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));

View File

@ -56,6 +56,8 @@ export default class LineChart extends Component {
);
}
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
const dataMax = max(filteredData.map((d) => d[this.yKey]));
const domainMax = Math.ceil(dataMax / 10) * 10; // we want to round UP to the nearest tens place ex. dataMax = 102, domainMax = 110
const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
@ -63,15 +65,8 @@ export default class LineChart extends Component {
chartSvg.selectAll('g').remove().exit().data(filteredData).enter();
// DEFINE AXES SCALES
const yScale = scaleLinear()
.domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([0, 100])
.nice();
const yAxisScale = scaleLinear()
.domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([SVG_DIMENSIONS.height, 0])
.nice();
const yScale = scaleLinear().domain([0, domainMax]).range([0, 100]).nice();
const yAxisScale = scaleLinear().domain([0, domainMax]).range([SVG_DIMENSIONS.height, 0]).nice();
// use full dataset (instead of filteredData) so x-axis spans months with and without data
const xScale = scalePoint()
@ -158,10 +153,9 @@ export default class LineChart extends Component {
// MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => {
// 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 = (new_clients ? new_clients[this.yKey] : '0') + ' new clients';
this.tooltipNew = (data?.new_clients[this.yKey] || '0') + ' new clients';
this.tooltipUpgradeText = '';
let upgradeInfo = findUpgradeData(data);
if (upgradeInfo) {

View File

@ -1,5 +1,5 @@
import Component from '@glimmer/component';
import { mean } from 'd3-array';
import { calculateAverageClients } from 'vault/utils/chart-helpers';
/**
* @module MonthlyUsage
@ -10,7 +10,7 @@ import { mean } from 'd3-array';
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@timestamp={{this.responseTimestamp}}
@verticalBarChartData={{this.byMonthTotalClients}}
@verticalBarChartData={{this.byMonthActivityData}}
/>
* ```
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
@ -34,12 +34,15 @@ import { mean } from 'd3-array';
*/
export default class MonthlyUsage extends Component {
get averageTotalClients() {
let average = mean(this.args.verticalBarChartData?.map((d) => d.clients));
return Math.round(average) || null;
return calculateAverageClients(this.args.verticalBarChartData, 'clients') || '0';
}
get averageNewClients() {
let average = mean(this.args.verticalBarChartData?.map((d) => d.new_clients.clients));
return Math.round(average) || null;
return (
calculateAverageClients(
this.args.verticalBarChartData.map((d) => d.new_clients),
'clients'
) || '0'
);
}
}

View File

@ -1,5 +1,5 @@
import Component from '@glimmer/component';
import { mean } from 'd3-array';
import { calculateAverageClients } from 'vault/utils/chart-helpers';
/**
* @module RunningTotal
@ -11,6 +11,7 @@ import { mean } from 'd3-array';
* ```js
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedNamespace={{this.selectedNamespace}}
@barChartData={{this.byMonthNewClients}}
@lineChartData={{this.byMonth}}
@runningTotals={{this.runningTotals}}
@ -18,7 +19,9 @@ import { mean } from 'd3-array';
/>
* ```
* @param {array} chartData - array of objects from /activity response
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} selectedAuthMethod - string of auth method label for empty state message in bar chart
* @param {array} barChartData - array of objects from /activity response, from the 'months' key
object example: {
month: '1/22',
entity_clients: 23,
@ -32,24 +35,38 @@ import { mean } from 'd3-array';
namespaces: [],
},
};
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {array} lineChartData - array of objects from /activity response, from the 'months' key
* @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 {object} upgradeData - object containing version upgrade data e.g.: {id: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
*
*/
export default class RunningTotal extends Component {
get entityClientData() {
return {
runningTotal: this.args.runningTotals.entity_clients,
averageNewClients: Math.round(mean(this.args.barChartData?.map((d) => d.entity_clients))),
averageNewClients: calculateAverageClients(this.args.barChartData, 'entity_clients') || '0',
};
}
get nonEntityClientData() {
return {
runningTotal: this.args.runningTotals.non_entity_clients,
averageNewClients: Math.round(mean(this.args.barChartData?.map((d) => d.non_entity_clients))),
averageNewClients: calculateAverageClients(this.args.barChartData, 'non_entity_clients') || '0',
};
}
get hasRunningTotalClients() {
return (
typeof this.entityClientData.runningTotal === 'number' ||
typeof this.nonEntityClientData.runningTotal === 'number'
);
}
get hasAverageNewClients() {
return (
typeof this.entityClientData.averageNewClients === 'number' ||
typeof this.nonEntityClientData.averageNewClients === 'number'
);
}
}

View File

@ -54,13 +54,13 @@ export default class VerticalBarChart extends Component {
const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
const stackedData = stackFunction(filteredData);
const chartSvg = select(element);
const dataMax = max(filteredData.map((d) => d[this.yKey]));
const domainMax = Math.ceil(dataMax / 10) * 10; // we want to round UP to the nearest tens place ex. dataMax = 102, domainMax = 110
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// DEFINE DATA BAR SCALES
const yScale = scaleLinear()
.domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([0, 100])
.nice();
const yScale = scaleLinear().domain([0, domainMax]).range([0, 100]).nice();
const xScale = scaleBand()
.domain(dataset.map((d) => d[this.xKey]))

View File

@ -101,10 +101,13 @@
}
.chart-empty-state {
height: 100%;
width: 100%;
place-self: center stretch;
grid-row-end: span 3;
grid-column-end: span 3;
max-width: none;
padding-right: 20px;
padding-left: 20px;
> div {
box-shadow: none !important;
}

View File

@ -82,7 +82,6 @@
{{/if}}
</div>
</div>
{{! MODAL FOR CSV DOWNLOAD }}
<Modal
@title="Export attribution data"

View File

@ -127,14 +127,14 @@
<LayoutLoading />
{{else}}
{{#if this.totalUsageCounts}}
{{#unless this.byMonthTotalClients}}
{{#unless this.byMonthActivityData}}
<Clients::UsageStats @title="Total usage" @totalUsageCounts={{this.totalUsageCounts}} />
{{/unless}}
{{#if this.byMonthTotalClients}}
{{#if this.byMonthActivityData}}
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedNamespace={{this.selectedNamespace}}
@lineChartData={{this.byMonthTotalClients}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@lineChartData={{this.byMonthActivityData}}
@barChartData={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@ -145,7 +145,7 @@
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{this.selectedNamespace}}
@ -155,10 +155,10 @@
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{#if this.byMonthTotalClients}}
{{#if this.byMonthActivityData}}
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@verticalBarChartData={{this.byMonthTotalClients}}
@verticalBarChartData={{this.byMonthActivityData}}
@timestamp={{this.responseTimestamp}}
/>
{{/if}}

View File

@ -1,3 +1,4 @@
{{! TODO Add conditional, show charts if data available, otherwise show stat text boxes }}
<div class="chart-wrapper stacked-charts">
<div class="single-chart-grid">
<div class="chart-header has-bottom-margin-xl">
@ -37,8 +38,17 @@
</div>
<div class="single-chart-grid">
<div class="chart-container-wide">
<Clients::VerticalBarChart @dataset={{@barChartData}} @chartLegend={{@chartLegend}} />
<div class={{concat (unless this.hasAverageNewClients "chart-empty-state ") "chart-container-wide"}}>
<Clients::VerticalBarChart
@dataset={{if this.hasAverageNewClients @barChartData false}}
@chartLegend={{@chartLegend}}
@noDataTitle="No new clients"
@noDataMessage={{concat
"There are no new clients for this "
(if @selectedAuthMethod "auth method" "namespace")
" in this date range"
}}
/>
</div>
<div class="chart-subTitle">
@ -74,4 +84,5 @@
<span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span>
</div>
</div>
</div>

View File

@ -1,11 +1,17 @@
<svg
data-test-vertical-bar-chart
class="chart has-grid"
{{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset}}
>
</svg>
{{#if @dataset}}
<svg
data-test-vertical-bar-chart
class="chart has-grid"
{{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset}}
>
</svg>
{{else}}
<div class="chart-empty-state">
<EmptyState @title={{@noDataTitle}} @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
</div>
{{/if}}
{{! TOOLTIP }}

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,5 @@
import { format } from 'd3-format';
import { mean } from 'd3-array';
// COLOR THEME:
export const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#1563FF'];
@ -25,3 +26,10 @@ export function formatTooltipNumber(value) {
// formats a number according to the locale
return new Intl.NumberFormat().format(value);
}
export function calculateAverageClients(dataset, objectKey) {
// dataset is an array of objects (consumed by the chart components)
// objectKey is the key of the integer we want to calculate, ex: 'entity_clients', 'non_entity_clients', 'clients'
let getIntegers = dataset.map((d) => (d[objectKey] ? d[objectKey] : 0)); // if undefined no data, so return 0
return getIntegers.length !== 0 ? Math.round(mean(getIntegers)) : null;
}

View File

@ -1,38 +1,24 @@
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) => {
const month = parseAPITimestamp(m.timestamp, 'M/yy');
let totalClientsByNamespace = formatByNamespace(m.namespaces);
let newClientsByNamespace = formatByNamespace(m.new_clients?.namespaces);
if (Object.keys(m).includes('counts')) {
let totalClients = flattenDataset(m);
let newClients = m.new_clients ? flattenDataset(m.new_clients) : {};
let totalCounts = flattenDataset(m);
let newCounts = m.new_clients ? flattenDataset(m.new_clients) : {};
return {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
...totalClients,
month,
...totalCounts,
namespaces: formatByNamespace(m.namespaces),
namespaces_by_key: namespaceArrayToObject(totalClientsByNamespace, newClientsByNamespace, month),
new_clients: {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
...newClients,
month,
...newCounts,
namespaces: formatByNamespace(m.new_clients?.namespaces) || [],
},
};
@ -87,3 +73,175 @@ export const homogenizeClientNaming = (object) => {
}
return object;
};
export const flattenDataset = (object) => {
// TODO CMB revisit when backend has finished ticket VAULT-6035
if (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 namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month) => {
// all 'new_client' data resides within a separate key of each month (see data structure below)
// FIRST: iterate and nest respective 'new_clients' data within each namespace and mount object
// note: this is happening within the month object
const nestNewClientsWithinNamespace = totalClientsByNamespace.map((ns) => {
let newNamespaceCounts = newClientsByNamespace?.find((n) => n.label === ns.label);
if (newNamespaceCounts) {
let { label, clients, entity_clients, non_entity_clients } = newNamespaceCounts;
let newClientsByMount = [...newNamespaceCounts?.mounts];
let nestNewClientsWithinMounts = ns.mounts.map((mount) => {
let new_clients = newClientsByMount?.find((m) => m.label === mount.label) || {};
return {
...mount,
new_clients,
};
});
return {
...ns,
new_clients: {
label,
clients,
entity_clients,
non_entity_clients,
},
mounts: [...nestNewClientsWithinMounts],
};
}
return {
...ns,
new_clients: {},
};
});
// SECOND: create a new object (namespace_by_key) in which each namespace label is a key
let namespaces_by_key = {};
nestNewClientsWithinNamespace.forEach((namespaceObject) => {
// THIRD: make another object within the namespace where each mount label is a key
let mounts_by_key = {};
namespaceObject.mounts.forEach((mountObject) => {
mounts_by_key[mountObject.label] = {
month,
...mountObject,
new_clients: { month, ...mountObject.new_clients },
};
});
let { label, clients, entity_clients, non_entity_clients, new_clients } = namespaceObject;
namespaces_by_key[label] = {
month,
clients,
entity_clients,
non_entity_clients,
new_clients: { month, ...new_clients },
mounts_by_key,
};
});
return namespaces_by_key;
// structure of object returned
// namespace_by_key: {
// "namespace_label": {
// month: "3/22",
// clients: 32,
// entity_clients: 16,
// non_entity_clients: 16,
// new_clients: {
// month: "3/22",
// clients: 5,
// entity_clients: 2,
// non_entity_clients: 3,
// },
// mounts_by_key: {
// "mount_label": {
// month: "3/22",
// clients: 3,
// entity_clients: 2,
// non_entity_clients: 1,
// new_clients: {
// month: "3/22",
// clients: 5,
// entity_clients: 2,
// non_entity_clients: 3,
// },
// },
// },
// },
// };
};
// API RESPONSE STRUCTURE:
// data: {
// ** by_namespace organized in descending order of client count number **
// by_namespace: [
// {
// namespace_id: '96OwG',
// namespace_path: 'test-ns/',
// counts: {},
// mounts: [{ mount_path: 'path-1', counts: {} }],
// },
// ],
// ** months organized in ascending order of timestamps, oldest to most recent
// months: [
// {
// timestamp: '2022-03-01T00:00:00Z',
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// new_clients: {
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// },
// },
// {
// timestamp: '2022-04-01T00:00:00Z',
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// new_clients: {
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// },
// },
// ],
// start_time: 'start timestamp string',
// end_time: 'end timestamp string',
// total: { clients: 300, non_entity_clients: 100, entity_clients: 400} ,
// }

View File

@ -9,6 +9,7 @@ import {
startOfMonth,
} from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
const MOCK_MONTHLY_DATA = [
{
timestamp: '2021-05-01T00:00:00Z',
@ -16,7 +17,7 @@ const MOCK_MONTHLY_DATA = [
distinct_entities: 0,
entity_clients: 25,
non_entity_tokens: 0,
non_entity_clients: 20,
non_entity_clients: 25,
clients: 50,
},
namespaces: [
@ -985,6 +986,26 @@ export default function (server) {
clients: 11212,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 50,
non_entity_tokens: 0,
non_entity_clients: 23,
clients: 73,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 25,
non_entity_tokens: 0,
non_entity_clients: 15,
clients: 40,
},
},
],
},
],

View File

@ -0,0 +1,857 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import {
flattenDataset,
formatByMonths,
formatByNamespace,
homogenizeClientNaming,
sortMonthsByTimestamp,
namespaceArrayToObject,
} from 'core/utils/client-count-utils';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import isBefore from 'date-fns/isBefore';
import isAfter from 'date-fns/isAfter';
// import { setupMirage } from 'ember-cli-mirage/test-support';
// import ENV from 'vault/config/environment';
// import { formatRFC3339 } from 'date-fns';
module('Integration | Util | client count utils', function (hooks) {
setupTest(hooks);
// setupMirage(hooks);
// TODO: wire up to stubbed API/mirage?
// hooks.before(function () {
// ENV['ember-cli-mirage'].handler = 'clients';
// });
// hooks.after(function () {
// ENV['ember-cli-mirage'].handler = null;
// });
/* MONTHS array contains: (update when backend work done on months )
- one month with only old client naming
*/
const MONTHS = [
{
timestamp: '2021-05-01T00:00:00Z',
counts: {
distinct_entities: 25,
non_entity_tokens: 25,
clients: 50,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 13,
non_entity_tokens: 7,
clients: 20,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 8,
non_entity_tokens: 0,
clients: 8,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
non_entity_tokens: 7,
clients: 7,
},
},
],
},
{
namespace_id: 's07UR',
namespace_path: 'ns1/',
counts: {
distinct_entities: 5,
non_entity_tokens: 5,
clients: 10,
},
mounts: [
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
non_entity_tokens: 5,
clients: 5,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 5,
non_entity_tokens: 0,
clients: 5,
},
},
],
},
],
new_clients: {
counts: {
distinct_entities: 3,
non_entity_tokens: 2,
clients: 5,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 3,
non_entity_tokens: 2,
clients: 5,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 3,
non_entity_tokens: 0,
clients: 3,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
non_entity_tokens: 2,
clients: 2,
},
},
],
},
],
},
},
{
timestamp: '2021-10-01T00:00:00Z',
counts: {
distinct_entities: 20,
entity_clients: 20,
non_entity_tokens: 20,
non_entity_clients: 20,
clients: 40,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 8,
entity_clients: 8,
non_entity_tokens: 7,
non_entity_clients: 7,
clients: 15,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 8,
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: 7,
non_entity_clients: 7,
clients: 7,
},
},
],
},
{
namespace_id: 's07UR',
namespace_path: 'ns1/',
counts: {
distinct_entities: 5,
entity_clients: 5,
non_entity_tokens: 5,
non_entity_clients: 5,
clients: 10,
},
mounts: [
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 5,
non_entity_clients: 5,
clients: 5,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 5,
entity_clients: 5,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 5,
},
},
],
},
],
new_clients: {
counts: {
distinct_entities: 3,
entity_clients: 3,
non_entity_tokens: 2,
non_entity_clients: 2,
clients: 5,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 3,
entity_clients: 3,
non_entity_tokens: 2,
non_entity_clients: 2,
clients: 5,
},
mounts: [
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 3,
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: 2,
non_entity_clients: 2,
clients: 2,
},
},
],
},
],
},
},
{
timestamp: '2021-09-01T00:00:00Z',
counts: {
distinct_entities: 0,
entity_clients: 17,
non_entity_tokens: 0,
non_entity_clients: 18,
clients: 35,
},
namespaces: [
{
namespace_id: 'oImjk',
namespace_path: 'ns2/',
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,
},
},
],
},
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 0,
entity_clients: 2,
non_entity_tokens: 0,
non_entity_clients: 3,
clients: 5,
},
mounts: [
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 3,
clients: 3,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 2,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 2,
},
},
],
},
{
namespace_id: 's07UR',
namespace_path: 'ns1/',
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,
},
},
],
},
],
new_clients: {
counts: {
distinct_entities: 0,
entity_clients: 10,
non_entity_tokens: 0,
non_entity_clients: 10,
clients: 20,
},
namespaces: [
{
namespace_id: 'oImjk',
namespace_path: 'ns2/',
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,
},
},
],
},
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 0,
entity_clients: 2,
non_entity_tokens: 0,
non_entity_clients: 3,
clients: 5,
},
mounts: [
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 3,
clients: 3,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 2,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 2,
},
},
],
},
{
namespace_id: 's07UR',
namespace_path: 'ns1/',
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,
},
},
],
},
],
},
},
];
const BY_NAMESPACE = [
{
namespace_id: '96OwG',
namespace_path: 'test-ns/',
counts: {
distinct_entities: 18290,
entity_clients: 18290,
non_entity_tokens: 18738,
non_entity_clients: 18738,
clients: 37028,
},
mounts: [
{
mount_path: 'path-1',
counts: {
distinct_entities: 6403,
entity_clients: 6403,
non_entity_tokens: 6300,
non_entity_clients: 6300,
clients: 12703,
},
},
{
mount_path: 'path-2',
counts: {
distinct_entities: 5699,
entity_clients: 5699,
non_entity_tokens: 6777,
non_entity_clients: 6777,
clients: 12476,
},
},
{
mount_path: 'path-3',
counts: {
distinct_entities: 6188,
entity_clients: 6188,
non_entity_tokens: 5661,
non_entity_clients: 5661,
clients: 11849,
},
},
],
},
{
namespace_id: 'root',
namespace_path: '',
counts: {
distinct_entities: 19099,
entity_clients: 19099,
non_entity_tokens: 17781,
non_entity_clients: 17781,
clients: 36880,
},
mounts: [
{
mount_path: 'path-3',
counts: {
distinct_entities: 6863,
entity_clients: 6863,
non_entity_tokens: 6801,
non_entity_clients: 6801,
clients: 13664,
},
},
{
mount_path: 'path-2',
counts: {
distinct_entities: 6047,
entity_clients: 6047,
non_entity_tokens: 5957,
non_entity_clients: 5957,
clients: 12004,
},
},
{
mount_path: 'path-1',
counts: {
distinct_entities: 6189,
entity_clients: 6189,
non_entity_tokens: 5023,
non_entity_clients: 5023,
clients: 11212,
},
},
{
mount_path: 'auth/up2/',
counts: {
distinct_entities: 0,
entity_clients: 50,
non_entity_tokens: 0,
non_entity_clients: 23,
clients: 73,
},
},
{
mount_path: 'auth/up1/',
counts: {
distinct_entities: 0,
entity_clients: 25,
non_entity_tokens: 0,
non_entity_clients: 15,
clients: 40,
},
},
],
},
];
const SOME_OBJECT = { foo: 'bar' };
test('formatByMonths: formats the months array', async function (assert) {
assert.expect(101);
const keyNameAssertions = (object, objectName) => {
const objectKeys = Object.keys(object);
assert.false(objectKeys.includes('counts'), `${objectName} doesn't include 'counts' key`);
assert.true(objectKeys.includes('clients'), `${objectName} includes 'clients' key`);
assert.true(objectKeys.includes('entity_clients'), `${objectName} includes 'entity_clients' key`);
assert.true(
objectKeys.includes('non_entity_clients'),
`${objectName} includes 'non_entity_clients' key`
);
};
const assertClientCounts = (object, originalObject) => {
let newObjectKeys = ['clients', 'entity_clients', 'non_entity_clients'];
let originalKeys = Object.keys(originalObject.counts).includes('entity_clients')
? newObjectKeys
: ['clients', 'distinct_entities', 'non_entity_tokens'];
newObjectKeys.forEach((key, i) => {
assert.equal(
object[key],
originalObject.counts[originalKeys[i]],
`${object.month} ${key} equal original counts`
);
});
};
const formattedMonths = formatByMonths(MONTHS);
assert.notEqual(formattedMonths, MONTHS, 'does not modify original array');
formattedMonths.forEach((month) => {
const originalMonth = MONTHS.find((m) => month.month === parseAPITimestamp(m.timestamp, 'M/yy'));
// if originalMonth is found (not undefined) then the formatted month has an accurate, parsed timestamp
assert.ok(originalMonth, `month has parsed timestamp of ${month.month}`);
assert.ok(month.namespaces_by_key, `month includes 'namespaces_by_key' key`);
keyNameAssertions(month, 'formatted month');
assertClientCounts(month, originalMonth);
assert.ok(month.new_clients.month, 'new clients key has a month key');
keyNameAssertions(month.new_clients, 'formatted month new_clients');
assertClientCounts(month.new_clients, originalMonth.new_clients);
month.namespaces.forEach((namespace) => keyNameAssertions(namespace, 'namespace within month'));
month.new_clients.namespaces.forEach((namespace) =>
keyNameAssertions(namespace, 'new client namespaces within month')
);
});
assert.equal(formatByMonths(SOME_OBJECT), SOME_OBJECT, 'it returns if arg is not an array');
});
test('formatByNamespace: formats namespace arrays with and without mounts', async function (assert) {
assert.expect(102);
const keyNameAssertions = (object, objectName) => {
const objectKeys = Object.keys(object);
assert.false(objectKeys.includes('counts'), `${objectName} doesn't include 'counts' key`);
assert.true(objectKeys.includes('label'), `${objectName} includes 'label' key`);
assert.true(objectKeys.includes('clients'), `${objectName} includes 'clients' key`);
assert.true(objectKeys.includes('entity_clients'), `${objectName} includes 'entity_clients' key`);
assert.true(
objectKeys.includes('non_entity_clients'),
`${objectName} includes 'non_entity_clients' key`
);
};
const keyValueAssertions = (object, pathName, originalObject) => {
const keysToAssert = ['clients', 'entity_clients', 'non_entity_clients'];
assert.equal(object.label, originalObject[pathName], `${pathName} matches label`);
keysToAssert.forEach((key) => {
assert.equal(object[key], originalObject.counts[key], `number of ${key} equal original`);
});
};
const formattedNamespaces = formatByNamespace(BY_NAMESPACE);
assert.notEqual(formattedNamespaces, MONTHS, 'does not modify original array');
formattedNamespaces.forEach((namespace) => {
let origNamespace = BY_NAMESPACE.find((ns) => ns.namespace_path === namespace.label);
keyNameAssertions(namespace, 'formatted namespace');
keyValueAssertions(namespace, 'namespace_path', origNamespace);
namespace.mounts.forEach((mount) => {
let origMount = origNamespace.mounts.find((m) => m.mount_path === mount.label);
keyNameAssertions(mount, 'formatted mount');
keyValueAssertions(mount, 'mount_path', origMount);
});
});
const nsWithoutMounts = {
namespace_id: '96OwG',
namespace_path: 'no-mounts-ns/',
counts: {
distinct_entities: 18290,
entity_clients: 18290,
non_entity_tokens: 18738,
non_entity_clients: 18738,
clients: 37028,
},
mounts: [],
};
let formattedNsWithoutMounts = formatByNamespace([nsWithoutMounts])[0];
keyNameAssertions(formattedNsWithoutMounts, 'namespace without mounts');
keyValueAssertions(formattedNsWithoutMounts, 'namespace_path', nsWithoutMounts);
assert.equal(formattedNsWithoutMounts.mounts.length, 0, 'formatted namespace has no mounts');
assert.equal(formatByNamespace(SOME_OBJECT), SOME_OBJECT, 'it returns if arg is not an array');
});
test('homogenizeClientNaming: homogenizes key names when both old and new keys exist, or just old key names', async function (assert) {
assert.expect(168);
const keyNameAssertions = (object, objectName) => {
const objectKeys = Object.keys(object);
assert.false(
objectKeys.includes('distinct_entities'),
`${objectName} doesn't include 'distinct_entities' key`
);
assert.false(
objectKeys.includes('non_entity_tokens'),
`${objectName} doesn't include 'non_entity_tokens' key`
);
assert.true(objectKeys.includes('entity_clients'), `${objectName} includes 'entity_clients' key`);
assert.true(
objectKeys.includes('non_entity_clients'),
`${objectName} includes 'non_entity_clients' key`
);
};
let transformedMonths = [...MONTHS];
transformedMonths.forEach((month) => {
month.counts = homogenizeClientNaming(month.counts);
keyNameAssertions(month.counts, 'month counts');
month.new_clients.counts = homogenizeClientNaming(month.new_clients.counts);
keyNameAssertions(month.new_clients.counts, 'month new counts');
month.namespaces.forEach((ns) => {
ns.counts = homogenizeClientNaming(ns.counts);
keyNameAssertions(ns.counts, 'namespace counts');
ns.mounts.forEach((mount) => {
mount.counts = homogenizeClientNaming(mount.counts);
keyNameAssertions(mount.counts, 'mount counts');
});
});
month.new_clients.namespaces.forEach((ns) => {
ns.counts = homogenizeClientNaming(ns.counts);
keyNameAssertions(ns.counts, 'namespace new counts');
ns.mounts.forEach((mount) => {
mount.counts = homogenizeClientNaming(mount.counts);
keyNameAssertions(mount.counts, 'mount new counts');
});
});
});
});
test('flattenDataset: removes the counts key and flattens the dataset', async function (assert) {
assert.expect(18);
const flattenedNamespace = flattenDataset(BY_NAMESPACE[0]);
const flattenedMount = flattenDataset(BY_NAMESPACE[0].mounts[0]);
const flattenedMonth = flattenDataset(MONTHS[0]);
const flattenedNewMonthClients = flattenDataset(MONTHS[0].new_clients);
const objectNullCounts = { counts: null, foo: 'bar' };
const keyNameAssertions = (object, objectName) => {
const objectKeys = Object.keys(object);
assert.false(objectKeys.includes('counts'), `${objectName} doesn't include 'counts' key`);
assert.true(objectKeys.includes('clients'), `${objectName} includes 'clients' key`);
assert.true(objectKeys.includes('entity_clients'), `${objectName} includes 'entity_clients' key`);
assert.true(
objectKeys.includes('non_entity_clients'),
`${objectName} includes 'non_entity_clients' key`
);
};
keyNameAssertions(flattenedNamespace, 'namespace object');
keyNameAssertions(flattenedMount, 'mount object');
keyNameAssertions(flattenedMonth, 'month object');
keyNameAssertions(flattenedNewMonthClients, 'month new_clients object');
assert.equal(
flattenDataset(SOME_OBJECT),
SOME_OBJECT,
"it returns original object if counts key doesn't exist"
);
assert.equal(
flattenDataset(objectNullCounts),
objectNullCounts,
'it returns original object if counts are null'
);
});
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
assert.expect(3);
const sortedMonths = sortMonthsByTimestamp(MONTHS);
assert.ok(
isBefore(parseAPITimestamp(sortedMonths[0].timestamp), parseAPITimestamp(sortedMonths[1].timestamp)),
'first timestamp date is earlier than second'
);
assert.ok(
isAfter(parseAPITimestamp(sortedMonths[2].timestamp), parseAPITimestamp(sortedMonths[1].timestamp)),
'third timestamp date is later second'
);
assert.notEqual(sortedMonths, MONTHS, 'it does not modify original array');
});
test('namespaceArrayToObject: transforms data without modifying original', async function (assert) {
assert.expect(29);
const assertClientCounts = (object, originalObject) => {
let valuesToCheck = ['clients', 'entity_clients', 'non_entity_clients'];
valuesToCheck.forEach((key) => {
assert.equal(object[key], originalObject[key], `${key} equal original counts`);
});
};
const totalClientsByNamespace = formatByNamespace(MONTHS[1].namespaces);
const newClientsByNamespace = formatByNamespace(MONTHS[1].new_clients.namespaces);
const byNamespaceKeyObject = namespaceArrayToObject(
totalClientsByNamespace,
newClientsByNamespace,
'10/21'
);
assert.propEqual(
totalClientsByNamespace,
formatByNamespace(MONTHS[1].namespaces),
'it does not modify original array'
);
assert.propEqual(
newClientsByNamespace,
formatByNamespace(MONTHS[1].new_clients.namespaces),
'it does not modify original array'
);
let namespaceKeys = Object.keys(byNamespaceKeyObject);
namespaceKeys.forEach((nsKey) => {
const newNsObject = byNamespaceKeyObject[nsKey];
let originalNsData = totalClientsByNamespace.find((ns) => ns.label === nsKey);
assertClientCounts(newNsObject, originalNsData);
let mountKeys = Object.keys(newNsObject.mounts_by_key);
mountKeys.forEach((mKey) => {
let mountData = originalNsData.mounts.find((m) => m.label === mKey);
assertClientCounts(newNsObject.mounts_by_key[mKey], mountData);
});
});
namespaceKeys.forEach((nsKey) => {
const newNsObject = byNamespaceKeyObject[nsKey];
let originalNsData = newClientsByNamespace.find((ns) => ns.label === nsKey);
if (!originalNsData) return;
assertClientCounts(newNsObject.new_clients, originalNsData);
let mountKeys = Object.keys(newNsObject.mounts_by_key);
mountKeys.forEach((mKey) => {
let mountData = originalNsData.mounts.find((m) => m.label === mKey);
assertClientCounts(newNsObject.mounts_by_key[mKey].new_clients, mountData);
});
});
});
});