From 446648cba6ffece686977c9dccefa3785959b393 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 2 Feb 2022 11:46:59 -0800 Subject: [PATCH] UI/Add CSV export, update history and current tabs (#13812) * add timestamp to attribution * create usage stat component * updates stat text boxes * remove flex-header css * remove comment * add empty state if no data * update monthly serializer * remove empty state - unnecessary * change tab to 'history' * add usage stats to history view * change css styling for upcased grey subtitle * correctly exports namespace and auth data * close modal on download * test making a service? * fix monthly attrs * update csv content format * remove component and make downloadCsv a service * update function name * wip//add warning labels, fixing up current and history tabs * wip//clean up serializer fix with real data * fix link styling: * add conditionals for no data, add warning for 1.9 counting changes * naming comment * fix tooltip formatting * fix number format and consolidate actions * remove outdated test * add revokeObjectURL and rename variable * fix errors and empty state views when no activity data at all * fix end time error * fix comment * return truncating to serializer * PR review cleanup * return new object --- ui/app/adapters/clients/activity.js | 1 - ui/app/components/clients/attribution.js | 86 +++-- ui/app/components/clients/current.js | 39 +- ui/app/components/clients/dashboard.js | 66 ++-- .../clients/horizontal-bar-chart.js | 10 +- ui/app/models/clients/activity.js | 2 +- ui/app/routes/vault/cluster/clients/index.js | 10 +- ui/app/serializers/clients/activity.js | 152 ++++---- ui/app/serializers/clients/monthly.js | 41 ++- ui/app/services/download-csv.js | 25 ++ ui/app/styles/components/calendar-widget.scss | 9 +- ui/app/styles/components/doc-link.scss | 3 + ui/app/styles/components/modal.scss | 4 - ui/app/styles/core/charts.scss | 14 +- ui/app/styles/core/title.scss | 6 + .../templates/components/calendar-widget.hbs | 2 +- .../components/clients/attribution.hbs | 71 ++-- .../templates/components/clients/current.hbs | 59 ++- .../components/clients/dashboard.hbs | 346 +++++++++--------- .../clients/horizontal-bar-chart.hbs | 2 +- .../components/clients/usage-stats.hbs | 6 +- .../templates/vault/cluster/clients/index.hbs | 2 +- ui/app/utils/chart-helpers.js | 8 + ui/lib/core/addon/components/download-csv.js | 31 -- .../templates/components/download-csv.hbs | 3 - ui/lib/core/app/components/download-csv.js | 1 - .../components/clients-history-test.js | 73 ++-- 27 files changed, 583 insertions(+), 489 deletions(-) create mode 100644 ui/app/services/download-csv.js delete mode 100644 ui/lib/core/addon/components/download-csv.js delete mode 100644 ui/lib/core/addon/templates/components/download-csv.hbs delete mode 100644 ui/lib/core/app/components/download-csv.js diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js index adbf9f9159..ad0c65e3f2 100644 --- a/ui/app/adapters/clients/activity.js +++ b/ui/app/adapters/clients/activity.js @@ -41,7 +41,6 @@ export default Application.extend({ if (queryParams) { return this.ajax(url, 'GET', { data: queryParams }).then((resp) => { let response = resp || {}; - // if the response is a 204 it has no request id (ARG TODO test that it returns a 204) response.id = response.request_id || 'no-data'; return response; }); diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index f9a0816d47..ba354a3337 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; +import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; - +import { inject as service } from '@ember/service'; /** * @module Attribution * Attribution components display the top 10 total client counts for namespaces or auth methods (mounts) during a billing period. @@ -10,8 +11,8 @@ import { tracked } from '@glimmer/tracking'; * ```js * * ``` * @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked - * @param {array} topTenNamespaces - (passed to child chart) array of top 10 namespace objects - * @param {object} runningTotals - object with total client counts for chart tooltip text + * @param {array} totalClientsData - (passed to child chart) array of top 10 namespace objects + * @param {object} totalUsageCounts - object with total client counts for chart tooltip text * @param {string} selectedNamespace - namespace selected from filter bar * @param {string} startTimeDisplay - start date for CSV modal * @param {string} endTimeDisplay - end date for CSV modal @@ -31,6 +32,7 @@ import { tracked } from '@glimmer/tracking'; export default class Attribution extends Component { @tracked showCSVDownloadModal = false; + @service downloadCsv; get isDateRange() { return this.args.isDateRange; @@ -42,10 +44,7 @@ export default class Attribution extends Component { } get totalClientsData() { - // get dataset for bar chart displaying top 10 namespaces/mounts with highest # of total clients - return this.isSingleNamespace - ? this.filterByNamespace(this.args.selectedNamespace) - : this.args.topTenNamespaces; + return this.args.totalClientsData; } get topClientCounts() { @@ -54,8 +53,8 @@ export default class Attribution extends Component { } get attributionBreakdown() { - // display 'Auth method' or 'Namespace' respectively in CSV file - return this.isSingleNamespace ? 'Auth method' : 'Namespace'; + // display text for hbs + return this.isSingleNamespace ? 'auth method' : 'namespace'; } get chartText() { @@ -86,37 +85,50 @@ export default class Attribution extends Component { } } - // TODO CMB update with proper data format when we have get getCsvData() { - let results = '', - data, - fields; + let csvData = [], + graphData = this.totalClientsData, + csvHeader = [ + `Namespace path`, + 'Authentication method', + 'Total clients', + 'Entity clients', + 'Non-entity clients', + ]; - // TODO CMB will CSV for namespaces include mounts? - fields = [`${this.attributionBreakdown}`, 'Active clients', 'Unique entities', 'Non-entity tokens']; - - results = fields.join(',') + '\n'; - data.forEach(function (item) { - let path = item.label !== '' ? item.label : 'root', - total = item.total, - unique = item.entity_clients, - non_entity = item.non_entity_clients; - - results += path + ',' + total + ',' + unique + ',' + non_entity + '\n'; - }); - return results; + // each array will be a row in the csv file + if (this.isSingleNamespace) { + graphData.forEach((mount) => { + csvData.push(['', mount.label, mount.clients, mount.entity_clients, mount.non_entity_clients]); + }); + csvData.forEach((d) => (d[0] = this.args.selectedNamespace)); + } else { + graphData.forEach((ns) => { + csvData.push([ns.label, '', ns.clients, ns.entity_clients, ns.non_entity_clients]); + if (ns.mounts) { + ns.mounts.forEach((m) => { + csvData.push([ns.label, m.label, m.clients, m.entity_clients, m.non_entity_clients]); + }); + } + }); + } + csvData.unshift(csvHeader); + // make each nested array a comma separated string, join each array in csvData with line break (\n) + return csvData.map((d) => d.join()).join('\n'); } - // TODO CMB - confirm with design file name structure + get getCsvFileName() { - let activityDateRange = `${this.args.startTimeDisplay} - ${this.args.endTimeDisplay}`; - return activityDateRange - ? `clients-by-${this.attributionBreakdown}-${activityDateRange}` - : `clients-by-${this.attributionBreakdown}-${new Date()}`; + let endRange = this.isDateRange ? `-${this.args.endTimeDisplay}` : ''; + let csvDateRange = this.args.startTimeDisplay + endRange; + return this.isSingleNamespace + ? `clients_by_auth_method_${csvDateRange}` + : `clients_by_namespace_${csvDateRange}`; } - // HELPERS - filterByNamespace(namespace) { - // return top 10 mounts for a namespace - return this.args.topTenNamespaces.find((ns) => ns.label === namespace).mounts.slice(0, 10); + // ACTIONS + @action + exportChartData(filename, contents) { + this.downloadCsv.download(filename, contents); + this.showCSVDownloadModal = false; } } diff --git a/ui/app/components/clients/current.js b/ui/app/components/clients/current.js index 8f0f36281b..17f50c96f4 100644 --- a/ui/app/components/clients/current.js +++ b/ui/app/components/clients/current.js @@ -1,22 +1,51 @@ import Component from '@glimmer/component'; - +import { tracked } from '@glimmer/tracking'; export default class Current extends Component { chartLegend = [ { key: 'entity_clients', label: 'entity clients' }, { key: 'non_entity_clients', label: 'non-entity clients' }, ]; + @tracked selectedNamespace = null; + + // TODO CMB get from model + get upgradeDate() { + return this.args.upgradeDate || null; + } + + get licenseStartDate() { + return this.args.licenseStartDate || null; + } + + // by namespace client count data for partial month + get byNamespaceCurrent() { + return this.args.model.monthly?.byNamespace || null; + } // data for horizontal bar chart in attribution component - get topTenNamespaces() { - return this.args.model.monthly?.byNamespace; + get topTenChartData() { + if (this.selectedNamespace) { + let filteredNamespace = this.filterByNamespace(this.selectedNamespace); + return filteredNamespace.mounts + ? this.filterByNamespace(this.selectedNamespace).mounts.slice(0, 10) + : null; + } else { + return this.byNamespaceCurrent; + } } // top level TOTAL client counts from response for given month - get runningTotals() { - return this.args.model.monthly?.total; + get totalUsageCounts() { + return this.selectedNamespace + ? this.filterByNamespace(this.selectedNamespace) + : this.args.model.monthly?.total; } get responseTimestamp() { return this.args.model.monthly?.responseTimestamp; } + + // HELPERS + filterByNamespace(namespace) { + return this.byNamespaceCurrent.find((ns) => ns.label === namespace); + } } diff --git a/ui/app/components/clients/dashboard.js b/ui/app/components/clients/dashboard.js index cc747980d5..0d95920f9a 100644 --- a/ui/app/components/clients/dashboard.js +++ b/ui/app/components/clients/dashboard.js @@ -40,11 +40,12 @@ export default class Dashboard extends Component { @tracked responseRangeDiffMessage = null; @tracked startTimeRequested = null; @tracked startTimeFromResponse = this.args.model.startTimeFromLicense; // ex: ['2021', 3] is April 2021 (0 indexed) - @tracked endTimeFromResponse = this.args.model.endTimeFromLicense; + @tracked endTimeFromResponse = this.args.model.endTimeFromResponse; @tracked startMonth = null; @tracked startYear = null; @tracked selectedNamespace = null; - // @tracked selectedNamespace = 'namespacelonglonglong4/'; // for testing namespace selection view + @tracked noActivityDate = ''; + // @tracked selectedNamespace = 'namespace18anotherlong/'; // for testing namespace selection view with mirage get startTimeDisplay() { if (!this.startTimeFromResponse) { @@ -73,36 +74,32 @@ export default class Dashboard extends Component { ); } - // Determine if we have client count data based on the current tab - get hasClientData() { - if (this.args.tab === 'current') { - // Show the current numbers as long as config is on - return this.args.model.config?.enabled !== 'Off'; - } - return this.args.model.activity && this.args.model.activity.total; - } - // top level TOTAL client counts from response for given date range - get runningTotals() { - if (!this.args.model.activity || !this.args.model.activity.total) { - return null; - } - return this.args.model.activity.total; + get totalUsageCounts() { + return this.selectedNamespace + ? this.filterByNamespace(this.selectedNamespace) + : this.args.model.activity?.total; } - // for horizontal bar chart in Attribution component - get topTenNamespaces() { - if (!this.args.model.activity || !this.args.model.activity.byNamespace) { - return null; + // by namespace client count data for date range + get byNamespaceActivity() { + return this.args.model.activity?.byNamespace || null; + } + + // for horizontal bar chart in attribution component + get topTenChartData() { + if (this.selectedNamespace) { + let filteredNamespace = this.filterByNamespace(this.selectedNamespace); + return filteredNamespace.mounts + ? this.filterByNamespace(this.selectedNamespace).mounts.slice(0, 10) + : null; + } else { + return this.byNamespaceActivity; } - return this.args.model.activity.byNamespace; } get responseTimestamp() { - if (!this.args.model.activity || !this.args.model.activity.responseTimestamp) { - return null; - } - return this.args.model.activity.responseTimestamp; + return this.args.model.activity?.responseTimestamp; } // HELPERS areArraysTheSame(a1, a2) { @@ -150,13 +147,15 @@ export default class Dashboard extends Component { start_time: this.startTimeRequested, end_time: this.endTimeRequested, }); - if (!response) { - // this.endTime will be null and use this to show EmptyState message on the template. - return; + if (response.id === 'no-data') { + // empty response is the only time we want to update the displayed date with the requested time + this.startTimeFromResponse = this.startTimeRequested; + this.noActivityDate = this.startTimeDisplay; + } else { + // note: this.startTimeDisplay (getter) is updated by this.startTimeFromResponse + this.startTimeFromResponse = response.formattedStartTime; + this.endTimeFromResponse = response.formattedEndTime; } - // note: this.startTimeDisplay (at getter) is updated by this.startTimeFromResponse - this.startTimeFromResponse = response.formattedStartTime; - this.endTimeFromResponse = response.formattedEndTime; // compare if the response and what you requested are the same. If they are not throw a warning. // this only gets triggered if the data was returned, which does not happen if the user selects a startTime after for which we have data. That's an adapter error and is captured differently. if (!this.areArraysTheSame(this.startTimeFromResponse, this.startTimeRequested)) { @@ -197,4 +196,9 @@ export default class Dashboard extends Component { selectStartYear(year) { this.startYear = year; } + + // HELPERS + filterByNamespace(namespace) { + return this.byNamespaceActivity.find((ns) => ns.label === namespace); + } } diff --git a/ui/app/components/clients/horizontal-bar-chart.js b/ui/app/components/clients/horizontal-bar-chart.js index a9b31e117b..a79dfb8cc9 100644 --- a/ui/app/components/clients/horizontal-bar-chart.js +++ b/ui/app/components/clients/horizontal-bar-chart.js @@ -6,7 +6,7 @@ import { select, event, selectAll } from 'd3-selection'; import { scaleLinear, scaleBand } from 'd3-scale'; import { axisLeft } from 'd3-axis'; import { max, maxIndex } from 'd3-array'; -import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE } from '../../utils/chart-helpers'; +import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from '../../utils/chart-helpers'; import { tracked } from '@glimmer/tracking'; /** @@ -32,6 +32,7 @@ const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick export default class HorizontalBarChart extends Component { @tracked tooltipTarget = ''; @tracked tooltipText = ''; + @tracked isLabel = null; get labelKey() { return this.args.labelKey || 'label'; @@ -150,9 +151,11 @@ export default class HorizontalBarChart extends Component { .on('mouseover', (data) => { let hoveredElement = actionBars.filter((bar) => bar.label === data.label).node(); this.tooltipTarget = hoveredElement; - this.tooltipText = `${Math.round((data.clients * 100) / this.args.clientTotals.clients)}% + this.isLabel = false; + this.tooltipText = `${Math.round((data.clients * 100) / this.args.totalUsageCounts.clients)}% of total client counts: - ${data.entity_clients} entity clients, ${data.non_entity_clients} non-entity clients.`; + ${formatTooltipNumber(data.entity_clients)} entity clients, + ${formatTooltipNumber(data.non_entity_clients)} non-entity clients.`; select(hoveredElement).style('opacity', 1); @@ -177,6 +180,7 @@ export default class HorizontalBarChart extends Component { if (data.label.length >= CHAR_LIMIT) { let hoveredElement = yLegendBars.filter((bar) => bar.label === data.label).node(); this.tooltipTarget = hoveredElement; + this.isLabel = true; this.tooltipText = data.label; } else { this.tooltipTarget = null; diff --git a/ui/app/models/clients/activity.js b/ui/app/models/clients/activity.js index 4d0545c04b..f4edac595b 100644 --- a/ui/app/models/clients/activity.js +++ b/ui/app/models/clients/activity.js @@ -2,9 +2,9 @@ import Model, { attr } from '@ember-data/model'; export default class Activity extends Model { @attr('string') responseTimestamp; @attr('array') byNamespace; - @attr('string') endTime; @attr('array') formattedEndTime; @attr('array') formattedStartTime; @attr('string') startTime; + @attr('string') endTime; @attr('object') total; } diff --git a/ui/app/routes/vault/cluster/clients/index.js b/ui/app/routes/vault/cluster/clients/index.js index 9f1e7a7262..4b921ac1ec 100644 --- a/ui/app/routes/vault/cluster/clients/index.js +++ b/ui/app/routes/vault/cluster/clients/index.js @@ -43,8 +43,10 @@ export default Route.extend(ClusterRoute, { }, rfc33395ToMonthYear(timestamp) { - // return [2021, 04 (e.g. 2021 March, make 0-indexed) - return [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1]; + // return ['2021', 2] (e.g. 2021 March, make 0-indexed) + return timestamp + ? [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1] + : null; }, async model() { @@ -57,7 +59,7 @@ export default Route.extend(ClusterRoute, { let license = await this.getLicense(); // get default start_time let activity = await this.getActivity(license.startTime); // returns client counts using license start_time. let monthly = await this.getMonthly(); // returns the partial month endpoint - let endTimeFromLicense = this.rfc33395ToMonthYear(activity.endTime); + let endTimeFromResponse = activity ? this.rfc33395ToMonthYear(activity.endTime) : null; let startTimeFromLicense = this.rfc33395ToMonthYear(license.startTime); return hash({ @@ -65,7 +67,7 @@ export default Route.extend(ClusterRoute, { activity, monthly, config, - endTimeFromLicense, + endTimeFromResponse, startTimeFromLicense, }); }, diff --git a/ui/app/serializers/clients/activity.js b/ui/app/serializers/clients/activity.js index 49d3f3a618..b061f4ba5e 100644 --- a/ui/app/serializers/clients/activity.js +++ b/ui/app/serializers/clients/activity.js @@ -1,5 +1,75 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; +export default class ActivitySerializer extends ApplicationSerializer { + flattenDataset(byNamespaceArray) { + let topTen = byNamespaceArray ? byNamespaceArray.slice(0, 10) : []; + + return topTen.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 = {}; + // we don't want client counts nested within the 'counts' object for stacked charts + Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key])); + flattenedNs = this.homogenizeClientNaming(flattenedNs); + + // if mounts attribution unavailable, mounts will be undefined + flattenedNs.mounts = ns.mounts?.map((mount) => { + let flattenedMount = {}; + flattenedMount.label = mount['path']; + Object.keys(mount['counts']).forEach((key) => (flattenedMount[key] = mount['counts'][key])); + return flattenedMount; + }); + return { + label, + ...flattenedNs, + }; + }); + } + + // For 1.10 release naming changed from 'distinct_entities' 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 + // Add else to 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; + return { + clients, + entity_clients, + non_entity_clients, + }; + } + } + + rfc33395ToMonthYear(timestamp) { + // return ['2021', 2] (e.g. 2021 March, make 0-indexed) + return timestamp + ? [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1] + : null; + } + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + if (payload.id === 'no-data') { + return super.normalizeResponse(store, primaryModelClass, payload, id, requestType); + } + let response_timestamp = formatISO(new Date()); + let transformedPayload = { + ...payload, + response_timestamp, + by_namespace: this.flattenDataset(payload.data.by_namespace), + total: this.homogenizeClientNaming(payload.data.total), + formatted_end_time: this.rfc33395ToMonthYear(payload.data.end_time), + formatted_start_time: this.rfc33395ToMonthYear(payload.data.start_time), + }; + delete payload.data.by_namespace; + delete payload.data.total; + return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType); + } +} /* SAMPLE PAYLOAD BEFORE/AFTER: @@ -45,85 +115,3 @@ transformedPayload.by_namespace = [ }, ] */ - -export default class ActivitySerializer extends ApplicationSerializer { - flattenDataset(payload) { - let topTen = payload.slice(0, 10); - - return topTen.map((ns) => { - // 'namespace_path' is an empty string for root - if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root'; - let label = ns['namespace_path'] || ns['namespace_id']; // TODO CMB will namespace_path ever be empty? - let flattenedNs = {}; - // we don't want client counts nested within the 'counts' object for stacked charts - Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key])); - - // homogenize client naming for all namespaces - if (Object.keys(flattenedNs).includes('distinct_entities', 'non_entity_tokens')) { - flattenedNs.entity_clients = flattenedNs.distinct_entities; - flattenedNs.non_entity_clients = flattenedNs.non_entity_tokens; - delete flattenedNs.distinct_entities; - delete flattenedNs.non_entity_tokens; - } - - // if mounts attribution unavailable, mounts will be undefined - flattenedNs.mounts = ns.mounts?.map((mount) => { - let flattenedMount = {}; - flattenedMount.label = mount['path']; - Object.keys(mount['counts']).forEach((key) => (flattenedMount[key] = mount['counts'][key])); - return flattenedMount; - }); - - return { - label, - ...flattenedNs, - }; - }); - } - - // TODO CMB remove and use abstracted function above - // prior to 1.10, client count key names are "distinct_entities" and "non_entity_tokens" so mapping below wouldn't work - flattenByNamespace(payload) { - // keys in the object created here must match the legend keys in dashboard.js ('entity_clients') - let topTen = payload.slice(0, 10); - return topTen.map((ns) => { - if (ns['namespace_path'] === '') ns['namespace_path'] = 'root'; - // this may need to change when we have real data - // right now under months, namespaces have key value of "path" or "id", not "namespace_path" - let label = ns['namespace_path'] || ns['id']; - let namespaceMounts = ns.mounts.map((m) => { - return { - label: m['path'], - entity_clients: m['counts']['entity_clients'], - non_entity_clients: m['counts']['non_entity_clients'], - total: m['counts']['clients'], - }; - }); - return { - label, - entity_clients: ns['counts']['entity_clients'], - non_entity_clients: ns['counts']['non_entity_clients'], - total: ns['counts']['clients'], - mounts: namespaceMounts, - }; - }); - } - - rfc33395ToMonthYear(timestamp) { - // return ['2021,' 04 (e.g. 2021 March, make 0-indexed) - return [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1]; - } - - normalizeResponse(store, primaryModelClass, payload, id, requestType) { - let response_timestamp = formatISO(new Date()); - let transformedPayload = { - ...payload, - response_timestamp, - by_namespace: this.flattenDataset(payload.data.by_namespace), - formatted_end_time: this.rfc33395ToMonthYear(payload.data.end_time), - formatted_start_time: this.rfc33395ToMonthYear(payload.data.start_time), - }; - delete payload.data.by_namespace; - return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType); - } -} diff --git a/ui/app/serializers/clients/monthly.js b/ui/app/serializers/clients/monthly.js index f628c21f50..1f03e67b6a 100644 --- a/ui/app/serializers/clients/monthly.js +++ b/ui/app/serializers/clients/monthly.js @@ -1,9 +1,8 @@ import ApplicationSerializer from '../application'; import { formatISO } from 'date-fns'; - export default class MonthlySerializer extends ApplicationSerializer { - flattenDataset(payload) { - let topTen = payload ? payload.slice(0, 10) : []; + flattenDataset(byNamespaceArray) { + let topTen = byNamespaceArray ? byNamespaceArray.slice(0, 10) : []; return topTen.map((ns) => { // 'namespace_path' is an empty string for root @@ -13,13 +12,7 @@ export default class MonthlySerializer extends ApplicationSerializer { // we don't want client counts nested within the 'counts' object for stacked charts Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key])); - // homogenize client naming for all namespaces - if (Object.keys(flattenedNs).includes('distinct_entities', 'non_entity_tokens')) { - flattenedNs.entity_clients = flattenedNs.distinct_entities; - flattenedNs.non_entity_clients = flattenedNs.non_entity_tokens; - delete flattenedNs.distinct_entities; - delete flattenedNs.non_entity_tokens; - } + flattenedNs = this.homogenizeClientNaming(flattenedNs); // if mounts attribution unavailable, mounts will be undefined flattenedNs.mounts = ns.mounts?.map((mount) => { @@ -36,20 +29,32 @@ export default class MonthlySerializer extends ApplicationSerializer { }); } + // For 1.10 release naming changed from 'distinct_entities' 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 + // Add else to 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; + return { + clients, + entity_clients, + non_entity_clients, + }; + } + } + normalizeResponse(store, primaryModelClass, payload, id, requestType) { - let { data } = payload; - let { clients, distinct_entities, non_entity_tokens } = data; let response_timestamp = formatISO(new Date()); let transformedPayload = { ...payload, response_timestamp, - by_namespace: this.flattenDataset(data.by_namespace), + by_namespace: this.flattenDataset(payload.data.by_namespace), // nest within 'total' object to mimic /activity response shape - total: { - clients, - entityClients: distinct_entities, - nonEntityClients: non_entity_tokens, - }, + total: this.homogenizeClientNaming(payload.data), }; delete payload.data.by_namespace; return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType); diff --git a/ui/app/services/download-csv.js b/ui/app/services/download-csv.js new file mode 100644 index 0000000000..618aea32ba --- /dev/null +++ b/ui/app/services/download-csv.js @@ -0,0 +1,25 @@ +import Service from '@ember/service'; + +// SAMPLE CSV FORMAT ('content' argument) +// Must be a string with each row \n separated and each column comma separated +// 'Namespace path,Authentication method,Total clients,Entity clients,Non-entity clients\n +// namespacelonglonglong4/,,191,171,20\n +// namespacelonglonglong4/,auth/method/uMGBU,35,20,15\n' + +export default class DownloadCsvService extends Service { + download(filename, content) { + let formattedFilename = filename?.replace(/\s+/g, '-') || 'vault-data.csv'; + let { document, URL } = window; + let downloadElement = document.createElement('a'); + downloadElement.download = formattedFilename; + downloadElement.href = URL.createObjectURL( + new Blob([content], { + type: 'text/csv', + }) + ); + document.body.appendChild(downloadElement); + downloadElement.click(); + URL.revokeObjectURL(downloadElement.href); + downloadElement.remove(); + } +} diff --git a/ui/app/styles/components/calendar-widget.scss b/ui/app/styles/components/calendar-widget.scss index 0f155594ac..0117f22ffc 100644 --- a/ui/app/styles/components/calendar-widget.scss +++ b/ui/app/styles/components/calendar-widget.scss @@ -9,14 +9,7 @@ $dark-gray: #535f73; } } .calendar-title { - color: $ui-gray-300; - text-transform: uppercase; - font-size: $size-7; - font-weight: $font-weight-semibold; - - &.popup-menu-item { - padding: $size-10 $size-8; - } + padding: $size-10 $size-8; } .calendar-widget-dropdown { @extend .button; diff --git a/ui/app/styles/components/doc-link.scss b/ui/app/styles/components/doc-link.scss index 8d97c0d2c9..d2a2f806a3 100644 --- a/ui/app/styles/components/doc-link.scss +++ b/ui/app/styles/components/doc-link.scss @@ -2,4 +2,7 @@ color: $link; text-decoration: none; font-weight: $font-weight-semibold; + &:hover { + text-decoration: underline !important; + } } diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss index fee8734e49..9378ced2f1 100644 --- a/ui/app/styles/components/modal.scss +++ b/ui/app/styles/components/modal.scss @@ -24,10 +24,6 @@ margin: 0; } - .is-subtitle-gray { - color: $ui-gray-500; - } - .copy-text { background-color: $grey-lightest; padding: $spacing-s; diff --git a/ui/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss index f2a0d752e6..f2b74d76b3 100644 --- a/ui/app/styles/core/charts.scss +++ b/ui/app/styles/core/charts.scss @@ -54,11 +54,6 @@ border-color: darken($ui-gray-300, 5%); } } - > a { - &:hover { - text-decoration: underline; - } - } } } @@ -224,27 +219,28 @@ p.data-details { font-size: $size-9; padding: 6px; border-radius: $radius-large; + width: 140px; .bold { font-weight: $font-weight-bold; } - .line-chart { width: 117px; } - .vertical-chart { text-align: center; flex-wrap: nowrap; width: fit-content; } - .horizontal-chart { - width: 200px; padding: $spacing-s; } } +.is-label-fit-content { + max-width: fit-content !important; +} + .chart-tooltip-arrow { width: 0; height: 0; diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index 0df581575e..3892bf956a 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -16,3 +16,9 @@ .form-section .title { margin-bottom: $spacing-s; } + +.is-subtitle-gray { + text-transform: uppercase; + font-size: $size-7; + color: $ui-gray-500; +} diff --git a/ui/app/templates/components/calendar-widget.hbs b/ui/app/templates/components/calendar-widget.hbs index 595cd0ce2f..e4f84c00a4 100644 --- a/ui/app/templates/components/calendar-widget.hbs +++ b/ui/app/templates/components/calendar-widget.hbs @@ -11,7 +11,7 @@