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 @@