From 787ebaebd3e18433e6a43baa3a97fbb40df6d97f Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Fri, 15 Apr 2022 12:06:10 -0700 Subject: [PATCH] UI/Add double attribution chart to current (#15035) * update /monthly endpoint * change object key names to match API * update serializers * add optional no data mesage for horizontal chart * add split chart option for attribution component * wire up filtering namespaces and auth methods * update clients current tests * update todos and address comments * fix attribution test --- ui/app/components/clients/attribution.js | 34 +++-- ui/app/components/clients/current.js | 62 +++++++-- ui/app/components/clients/line-chart.js | 2 +- .../components/clients/vertical-bar-chart.js | 2 +- ui/app/models/clients/monthly.js | 5 +- ui/app/serializers/clients/activity.js | 11 +- ui/app/serializers/clients/monthly.js | 52 ++++++-- .../components/clients/attribution.hbs | 67 +++++++--- .../templates/components/clients/current.hbs | 4 +- .../clients/horizontal-bar-chart.hbs | 23 ++-- ui/mirage/handlers/clients.js | 123 ++++++++++++++++++ ui/tests/acceptance/client-current-test.js | 56 ++++++-- ui/tests/helpers/clients.js | 11 +- .../components/clients/attribution-test.js | 41 +++++- 14 files changed, 398 insertions(+), 95 deletions(-) diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index b8d0c93f4..a0513f44f 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -11,21 +11,24 @@ import { inject as service } from '@ember/service'; * ```js * * ``` * @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked - * @param {array} totalClientsData - array of objects containing a label and breakdown of total, entity and non-entity clients * @param {object} totalUsageCounts - object with total client counts for chart tooltip text + * @param {object} newUsageCounts - object with new client counts for chart tooltip text + * @param {array} totalClientsData - array of objects containing a label and breakdown of client counts for total clients + * @param {array} newClientsData - array of objects containing a label and breakdown of client counts for new clients * @param {string} selectedNamespace - namespace selected from filter bar - * @param {string} startTimeDisplay - start date for CSV modal - * @param {string} endTimeDisplay - end 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 {boolean} isDateRange - getter calculated in parent to relay if dataset is a date range or single month * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response */ @@ -34,6 +37,9 @@ export default class Attribution extends Component { @tracked showCSVDownloadModal = false; @service downloadCsv; + get hasCsvData() { + return this.args.totalClientsData ? this.args.totalClientsData.length > 0 : false; + } get isDateRange() { return this.args.isDateRange; } @@ -52,6 +58,10 @@ export default class Attribution extends Component { return this.args.totalClientsData?.slice(0, 10); } + get barChartNewClients() { + return this.args.newClientsData?.slice(0, 10); + } + get topClientCounts() { // get top namespace or auth method return this.args.totalClientsData ? this.args.totalClientsData[0] : null; @@ -69,8 +79,9 @@ export default class Attribution extends Component { return { description: 'This data shows the top ten authentication methods by client count within this namespace, and can be used to understand where clients are originating. Authentication methods are organized by path.', - newCopy: `The new clients used by the auth method for this ${dateText}. This aids in understanding which auth methods create and use new clients - ${dateText === 'date range' ? ' over time.' : '.'}`, + newCopy: `The new clients used by the auth method for this ${dateText}. This aids in understanding which auth methods create and use new clients${ + dateText === 'date range' ? ' over time.' : '.' + }`, totalCopy: `The total clients used by the auth method for this ${dateText}. This number is useful for identifying overall usage volume. `, }; case false: @@ -78,8 +89,9 @@ export default class Attribution extends Component { description: 'This data shows the top ten namespaces by client count and can be used to understand where clients are originating. Namespaces are identified by path. To see all namespaces, export this data.', newCopy: `The new clients in the namespace for this ${dateText}. - This aids in understanding which namespaces create and use new clients - ${dateText === 'date range' ? ' over time.' : '.'}`, + This aids in understanding which namespaces create and use new clients${ + dateText === 'date range' ? ' over time.' : '.' + }`, totalCopy: `The total clients in the namespace for this ${dateText}. This number is useful for identifying overall usage volume.`, }; case 'no data': @@ -95,7 +107,7 @@ export default class Attribution extends Component { let csvData = [], graphData = this.args.totalClientsData, csvHeader = [ - `Namespace path`, + 'Namespace path', 'Authentication method', 'Total clients', 'Entity clients', diff --git a/ui/app/components/clients/current.js b/ui/app/components/clients/current.js index 83eab09ed..77d6835e6 100644 --- a/ui/app/components/clients/current.js +++ b/ui/app/components/clients/current.js @@ -11,21 +11,30 @@ export default class Current extends Component { @tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp @tracked selectedNamespace = null; - @tracked namespaceArray = this.byNamespaceCurrent.map((namespace) => { + @tracked namespaceArray = this.byNamespaceTotalClients.map((namespace) => { return { name: namespace['label'], id: namespace['label'] }; }); @tracked selectedAuthMethod = null; @tracked authMethodOptions = []; - // Response client count data by namespace for current/partial month - get byNamespaceCurrent() { - return this.args.model.monthly?.byNamespace || []; + // Response total client count data by namespace for current/partial month + get byNamespaceTotalClients() { + return this.args.model.monthly?.byNamespaceTotalClients || []; + } + + // Response new client count data by namespace for current/partial month + get byNamespaceNewClients() { + return this.args.model.monthly?.byNamespaceNewClients || []; } get isGatheringData() { // return true if tracking IS enabled but no data collected yet - return this.args.model.config?.enabled === 'On' && this.byNamespaceCurrent.length === 0; + return ( + this.args.model.config?.enabled === 'On' && + this.byNamespaceTotalClients.length === 0 && + this.byNamespaceNewClients.length === 0 + ); } get hasAttributionData() { @@ -36,16 +45,30 @@ export default class Current extends Component { return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData; } - get filteredActivity() { + get filteredTotalData() { const namespace = this.selectedNamespace; const auth = this.selectedAuthMethod; if (!namespace && !auth) { - return this.getActivityResponse; + return this.byNamespaceTotalClients; } if (!auth) { - return this.byNamespaceCurrent.find((ns) => ns.label === namespace); + return this.byNamespaceTotalClients.find((ns) => ns.label === namespace); } - return this.byNamespaceCurrent + return this.byNamespaceTotalClients + .find((ns) => ns.label === namespace) + .mounts?.find((mount) => mount.label === auth); + } + + get filteredNewData() { + const namespace = this.selectedNamespace; + const auth = this.selectedAuthMethod; + if (!namespace && !auth) { + return this.byNamespaceNewClients; + } + if (!auth) { + return this.byNamespaceNewClients.find((ns) => ns.label === namespace); + } + return this.byNamespaceNewClients .find((ns) => ns.label === namespace) .mounts?.find((mount) => mount.label === auth); } @@ -62,15 +85,28 @@ export default class Current extends Component { // top level TOTAL client counts for current/partial month get totalUsageCounts() { - return this.selectedNamespace ? this.filteredActivity : this.args.model.monthly?.total; + return this.selectedNamespace ? this.filteredTotalData : this.args.model.monthly?.total; + } + + get newUsageCounts() { + return this.selectedNamespace ? this.filteredNewData : this.args.model.monthly?.new; } // total client data for horizontal bar chart in attribution component get totalClientsData() { if (this.selectedNamespace) { - return this.filteredActivity?.mounts || null; + return this.filteredTotalData?.mounts || null; } else { - return this.byNamespaceCurrent; + return this.byNamespaceTotalClients; + } + } + + // new client data for horizontal bar chart in attribution component + get newClientsData() { + if (this.selectedNamespace) { + return this.filteredNewData?.mounts || null; + } else { + return this.byNamespaceNewClients; } } @@ -89,7 +125,7 @@ export default class Current extends Component { this.selectedAuthMethod = null; } else { // Side effect: set auth namespaces - const mounts = this.filteredActivity.mounts?.map((mount) => ({ + const mounts = this.filteredTotalData.mounts?.map((mount) => ({ id: mount.label, name: mount.label, })); diff --git a/ui/app/components/clients/line-chart.js b/ui/app/components/clients/line-chart.js index 035982da4..419dbe2b2 100644 --- a/ui/app/components/clients/line-chart.js +++ b/ui/app/components/clients/line-chart.js @@ -28,7 +28,7 @@ export default class LineChart extends Component { @tracked tooltipNew = ''; get yKey() { - return this.args.yKey || 'total'; + return this.args.yKey || 'clients'; } get xKey() { diff --git a/ui/app/components/clients/vertical-bar-chart.js b/ui/app/components/clients/vertical-bar-chart.js index c5c3420fd..297291192 100644 --- a/ui/app/components/clients/vertical-bar-chart.js +++ b/ui/app/components/clients/vertical-bar-chart.js @@ -44,7 +44,7 @@ export default class VerticalBarChart extends Component { } get yKey() { - return this.args.yKey || 'total'; + return this.args.yKey || 'clients'; } @action diff --git a/ui/app/models/clients/monthly.js b/ui/app/models/clients/monthly.js index 6b624ceb0..cc0200469 100644 --- a/ui/app/models/clients/monthly.js +++ b/ui/app/models/clients/monthly.js @@ -2,5 +2,8 @@ import Model, { attr } from '@ember-data/model'; export default class MonthlyModel extends Model { @attr('string') responseTimestamp; @attr('array') byNamespace; - @attr('object') total; + @attr('object') total; // total clients during the current/partial month + @attr('object') new; // total NEW clients during the current/partial + @attr('array') byNamespaceTotalClients; + @attr('array') byNamespaceNewClients; } diff --git a/ui/app/serializers/clients/activity.js b/ui/app/serializers/clients/activity.js index 5fd5337d5..c3cf93653 100644 --- a/ui/app/serializers/clients/activity.js +++ b/ui/app/serializers/clients/activity.js @@ -2,8 +2,8 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; import { parseAPITimestamp, parseRFC3339 } from 'core/utils/date-formatters'; export default class ActivitySerializer extends ApplicationSerializer { - flattenDataset(byNamespaceArray) { - return byNamespaceArray.map((ns) => { + flattenDataset(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']; @@ -33,7 +33,6 @@ export default class ActivitySerializer extends ApplicationSerializer { }); } - // for vault usage - vertical bar chart flattenByMonths(payload, isNewClients = false) { const sortedPayload = [...payload]; sortedPayload.reverse(); @@ -43,7 +42,7 @@ export default class ActivitySerializer extends ApplicationSerializer { month: parseAPITimestamp(m.timestamp, 'M/yy'), entity_clients: m.new_clients.counts.entity_clients, non_entity_clients: m.new_clients.counts.non_entity_clients, - total: m.new_clients.counts.clients, + clients: m.new_clients.counts.clients, namespaces: this.flattenDataset(m.new_clients.namespaces), }; }); @@ -53,12 +52,12 @@ export default class ActivitySerializer extends ApplicationSerializer { month: parseAPITimestamp(m.timestamp, 'M/yy'), entity_clients: m.counts.entity_clients, non_entity_clients: m.counts.non_entity_clients, - total: m.counts.clients, + clients: m.counts.clients, namespaces: this.flattenDataset(m.namespaces), new_clients: { entity_clients: m.new_clients.counts.entity_clients, non_entity_clients: m.new_clients.counts.non_entity_clients, - total: m.new_clients.counts.clients, + clients: m.new_clients.counts.clients, namespaces: this.flattenDataset(m.new_clients.namespaces), }, }; diff --git a/ui/app/serializers/clients/monthly.js b/ui/app/serializers/clients/monthly.js index 0e33c2346..78d1f9b32 100644 --- a/ui/app/serializers/clients/monthly.js +++ b/ui/app/serializers/clients/monthly.js @@ -1,8 +1,9 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; + export default class MonthlySerializer extends ApplicationSerializer { - flattenDataset(byNamespaceArray) { - return byNamespaceArray.map((ns) => { + flattenDataset(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']; @@ -11,14 +12,17 @@ export default class MonthlySerializer extends ApplicationSerializer { Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key])); flattenedNs = this.homogenizeClientNaming(flattenedNs); - // TODO CMB check how this works with actual API endpoint // if no mounts, mounts will be an empty array flattenedNs.mounts = ns.mounts ? ns.mounts.map((mount) => { let flattenedMount = {}; - flattenedMount.label = mount['mount_path']; + let label = mount['mount_path']; Object.keys(mount['counts']).forEach((key) => (flattenedMount[key] = mount['counts'][key])); - return flattenedMount; + flattenedMount = this.homogenizeClientNaming(flattenedMount); + return { + label, + ...flattenedMount, + }; }) : []; @@ -29,22 +33,27 @@ export default class MonthlySerializer extends ApplicationSerializer { }); } - // For 1.10 release naming changed from 'distinct_entities' to 'entity_clients' and + // In 1.10 'distinct_entities' changed to 'entity_clients' and // 'non_entity_tokens' to 'non_entity_clients' - // accounting for deprecated API keys here and updating to latest nomenclature homogenizeClientNaming(object) { - // TODO CMB check with API payload, latest draft includes both new and old key names - // TODO CMB Delete old key names IF correct ones exist? - if (Object.keys(object).includes('distinct_entities', 'non_entity_tokens')) { - let entity_clients = object.distinct_entities; - let non_entity_clients = object.non_entity_tokens; - let { clients } = 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; } @@ -53,14 +62,29 @@ export default class MonthlySerializer extends ApplicationSerializer { return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); } let response_timestamp = formatISO(new Date()); + // TODO CMB: the following is assumed, need to confirm + // the months array will always include a single object: a timestamp of the current month and new/total count data, if available + let newClientsData = payload.data.months[0]?.new_clients || null; + let by_namespace_new_clients, new_clients; + if (newClientsData) { + by_namespace_new_clients = this.flattenDataset(newClientsData.namespaces); + new_clients = this.homogenizeClientNaming(newClientsData.counts); + } else { + by_namespace_new_clients = []; + new_clients = []; + } let transformedPayload = { ...payload, response_timestamp, - by_namespace: this.flattenDataset(payload.data.by_namespace), + by_namespace_total_clients: this.flattenDataset(payload.data.by_namespace), + by_namespace_new_clients, // nest within 'total' object to mimic /activity response shape total: this.homogenizeClientNaming(payload.data), + new: new_clients, }; delete payload.data.by_namespace; + delete payload.data.months; + delete payload.data.total; return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType); } } diff --git a/ui/app/templates/components/clients/attribution.hbs b/ui/app/templates/components/clients/attribution.hbs index bfbf27aeb..1dacdc622 100644 --- a/ui/app/templates/components/clients/attribution.hbs +++ b/ui/app/templates/components/clients/attribution.hbs @@ -1,11 +1,15 @@ -
+{{! show single chart if data is from a range, show two charts if from a single month}} +