Skip to content

Commit

Permalink
UI: Use Client Count export API (#27455)
Browse files Browse the repository at this point in the history
  • Loading branch information
hashishaw authored and Monkeychip committed Aug 1, 2024
1 parent 5d2b82a commit f5e6bc3
Show file tree
Hide file tree
Showing 19 changed files with 745 additions and 405 deletions.
3 changes: 3 additions & 0 deletions changelog/27455.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:change
ui: Uses the internal/counters/activity/export endpoint for client count export data.
```
25 changes: 25 additions & 0 deletions ui/app/adapters/clients/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/

import queryParamString from 'vault/utils/query-param-string';
import ApplicationAdapter from '../application';
import { formatDateObject } from 'core/utils/client-count-utils';
import { debug } from '@ember/debug';
Expand Down Expand Up @@ -46,6 +47,30 @@ export default class ActivityAdapter extends ApplicationAdapter {
});
}

async exportData(query) {
const url = `${this.buildURL()}/internal/counters/activity/export${queryParamString({
format: query?.format || 'csv',
start_time: query?.start_time ?? undefined,
end_time: query?.end_time ?? undefined,
})}`;
let errorMsg;
try {
const options = query?.namespace ? { namespace: query.namespace } : {};
const resp = await this.rawRequest(url, 'GET', options);
if (resp.status === 200) {
return resp.blob();
}
// If it's an empty response (eg 204), there's no data so return an error
errorMsg = 'no data to export in provided time range.';
} catch (e) {
const { errors } = await e.json();
errorMsg = errors?.join('. ');
}
if (errorMsg) {
throw new Error(errorMsg);
}
}

urlForFindRecord(id) {
// debug reminder so model is stored in Ember data with the same id for consistency
if (id !== 'clients/activity') {
Expand Down
111 changes: 39 additions & 72 deletions ui/app/components/clients/attribution.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,44 @@
<p class="chart-description" data-test-attribution-description>{{this.chartText.description}}</p>
</div>
<div class="header-right">
{{#if this.hasCsvData}}
<Hds::Button
data-test-attribution-export-button
@text="Export attribution data"
@color="secondary"
{{on "click" (fn (mut this.showCSVDownloadModal) true)}}
/>
{{#if this.showExportButton}}
<Clients::ExportButton
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@selectedNamespace={{@selectedNamespace}}
>
<:alert>
{{#if @upgradesDuringActivity}}
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
<A.Description>
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
</A.Description>
<A.Description>
<ul class="bullet">
{{#each @upgradesDuringActivity as |upgrade|}}
<li>
{{upgrade.version}}
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
</li>
{{/each}}
</ul>
</A.Description>
<A.Description>
Visit our
<Hds::Link::Inline
@isHrefExternal={{true}}
@href={{doc-link
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
}}
>
Client count FAQ
</Hds::Link::Inline>
for more information.
</A.Description>
</Hds::Alert>
{{/if}}
</:alert>
</Clients::ExportButton>
{{/if}}
</div>
</div>
Expand Down Expand Up @@ -79,68 +110,4 @@
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
</div>

{{! MODAL FOR CSV DOWNLOAD }}
{{#if this.showCSVDownloadModal}}
<Hds::Modal id="attribution-csv-download-modal" @onClose={{fn (mut this.showCSVDownloadModal) false}} as |M|>
<M.Header @icon="info" data-test-export-modal-title>
Export attribution data
</M.Header>
<M.Body>
<p class="has-bottom-margin-s">
{{this.modalExportText}}
</p>
<p class="has-bottom-margin-s">
The
<code>mount_path</code>
for entity/non-entity clients is the corresponding authentication method path
{{if @isSecretsSyncActivated "and for secrets sync clients is the KV v2 engine path"}}.
</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
<p class="has-bottom-margin-s" data-test-export-date-range>
{{this.formattedStartDate}}
{{if this.formattedEndDate "-"}}
{{this.formattedEndDate}}</p>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Export"
{{on "click" (fn this.exportChartData this.formattedCsvFileName)}}
data-test-confirm-button
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} />
</Hds::ButtonSet>
{{#if @upgradesDuringActivity}}
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
<A.Description>
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
</A.Description>
<A.Description>
<ul class="bullet">
{{#each @upgradesDuringActivity as |upgrade|}}
<li>
{{upgrade.version}}
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
</li>
{{/each}}
</ul>
</A.Description>
<A.Description>
Visit our
<Hds::Link::Inline
@isHrefExternal={{true}}
@href={{doc-link
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
}}
>
Client count FAQ
</Hds::Link::Inline>
for more information.
</A.Description>
</Hds::Alert>
{{/if}}
</M.Footer>
</Hds::Modal>
{{/if}}
</div>
171 changes: 37 additions & 134 deletions ui/app/components/clients/attribution.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
*/

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { format, isSameMonth } from 'date-fns';
import { isSameMonth } from 'date-fns';
import { sanitizePath } from 'core/utils/sanitize-path';
import { waitFor } from '@ember/test-waiters';

/**
* @module Attribution
Expand All @@ -17,7 +18,6 @@ import { format, isSameMonth } from 'date-fns';
*
* @example
* <Clients::Attribution
* @totalUsageCounts={{this.totalUsageCounts}}
* @newUsageCounts={{this.newUsageCounts}}
* @totalClientAttribution={{this.totalClientAttribution}}
* @newClientAttribution={{this.newClientAttribution}}
Expand All @@ -29,7 +29,6 @@ import { format, isSameMonth } from 'date-fns';
* @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }}
* />
*
* @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} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients
* @param {array} newClientAttribution - array of objects containing a label and breakdown of client counts for new clients
Expand All @@ -44,7 +43,33 @@ import { format, isSameMonth } from 'date-fns';

export default class Attribution extends Component {
@service download;
@tracked showCSVDownloadModal = false;
@service store;
@service namespace;

@tracked canDownload = false;
@tracked showExportModal = false;
@tracked exportFormat = 'csv';
@tracked downloadError = '';

constructor() {
super(...arguments);
this.getExportCapabilities(this.args.selectedNamespace);
}

@waitFor
async getExportCapabilities(ns = '') {
try {
// selected namespace usually ends in /
const url = ns
? `${sanitizePath(ns)}/sys/internal/counters/activity/export`
: 'sys/internal/counters/activity/export';
const cap = await this.store.findRecord('capabilities', url);
this.canDownload = cap.canSudo;
} catch (e) {
// if we can't read capabilities, default to show
this.canDownload = true;
}
}

get attributionLegend() {
const attributionLegend = [
Expand All @@ -59,21 +84,16 @@ export default class Attribution extends Component {
return attributionLegend;
}

get formattedStartDate() {
if (!this.args.startTimestamp) return null;
return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy');
}

get formattedEndDate() {
if (!this.args.startTimestamp && !this.args.endTimestamp) return null;
// displays on CSV export modal, no need to display duplicate months and years
get isSingleMonth() {
if (!this.args.startTimestamp && !this.args.endTimestamp) return false;
const startDateObject = parseAPITimestamp(this.args.startTimestamp);
const endDateObject = parseAPITimestamp(this.args.endTimestamp);
return isSameMonth(startDateObject, endDateObject) ? null : format(endDateObject, 'MMMM yyyy');
return isSameMonth(startDateObject, endDateObject);
}

get hasCsvData() {
return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
get showExportButton() {
const hasData = this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
return hasData && this.canDownload;
}

get isSingleNamespace() {
Expand Down Expand Up @@ -104,7 +124,7 @@ export default class Attribution extends Component {
if (!this.args.totalClientAttribution) {
return { description: 'There is a problem gathering data' };
}
const dateText = this.formattedEndDate ? 'date range' : 'month';
const dateText = this.isSingleMonth ? 'month' : 'date range';
switch (this.isSingleNamespace) {
case true:
return {
Expand All @@ -129,121 +149,4 @@ export default class Attribution extends Component {
return '';
}
}

destructureCountsToArray(object) {
// destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, acme_clients: 6, secret_syncs: 10, clients: 207}
// to get integers for CSV file
const { clients, entity_clients, non_entity_clients, acme_clients, secret_syncs } = object;
const { isSecretsSyncActivated } = this.args;

return [
clients,
entity_clients,
non_entity_clients,
acme_clients,
...(isSecretsSyncActivated ? [secret_syncs] : []),
];
}

constructCsvRow(namespaceColumn, mountColumn = null, totalColumns, newColumns = null) {
// if namespaceColumn is a string, then we're at mount level attribution, otherwise it is an object
// if constructing a namespace row, mountColumn=null so the column is blank, otherwise it is an object
const otherColumns = newColumns ? [...totalColumns, ...newColumns] : [...totalColumns];
return [
`${typeof namespaceColumn === 'string' ? namespaceColumn : namespaceColumn.label}`,
`${mountColumn ? mountColumn.label : '*'}`,
...otherColumns,
];
}

generateCsvData() {
const totalAttribution = this.args.totalClientAttribution;
const newAttribution = this.barChartNewClients ? this.args.newClientAttribution : null;
const { isSecretsSyncActivated } = this.args;
const csvData = [];
// added to clarify that the row of namespace totals without an auth method (blank) are not additional clients
// but indicate the total clients for that ns, including its auth methods
const upgrade = this.args.upgradesDuringActivity?.length
? `\n **data contains an upgrade (mount summation may not equal namespace totals)`
: '';
const descriptionOfBlanks = this.isSingleNamespace
? ''
: `\n *namespace totals, inclusive of mount clients${upgrade}`;
// client type order here should match array order returned by destructureCountsToArray
let csvHeader = [
'Namespace path',
`"Mount path${descriptionOfBlanks}"`, // double quotes necessary so description stays inside this cell
'Total clients',
'Entity clients',
'Non-entity clients',
'ACME clients',
...(isSecretsSyncActivated ? ['Secrets sync clients'] : []),
];

if (newAttribution) {
csvHeader = [
...csvHeader,
'Total new clients',
'New entity clients',
'New non-entity clients',
'New ACME clients',
...(isSecretsSyncActivated ? 'New secrets sync clients' : []),
];
}

totalAttribution.forEach((totalClientsObject) => {
const namespace = this.isSingleNamespace ? this.args.selectedNamespace : totalClientsObject;
const mount = this.isSingleNamespace ? totalClientsObject : null;

// find new client data for namespace/mount object we're iterating over
const newClientsObject = newAttribution
? newAttribution.find((d) => d.label === totalClientsObject.label)
: null;

const totalClients = this.destructureCountsToArray(totalClientsObject);
const newClients = newClientsObject ? this.destructureCountsToArray(newClientsObject) : null;

csvData.push(this.constructCsvRow(namespace, mount, totalClients, newClients));
// constructCsvRow returns an array that corresponds to a row in the csv file:
// ['ns label', 'mount label', total client #, entity #, non-entity #, acme #, secrets sync #, ...new client #'s]

// only iterate through mounts if NOT viewing a single namespace
if (!this.isSingleNamespace && namespace.mounts) {
namespace.mounts.forEach((mount) => {
const newMountData = newAttribution
? newClientsObject?.mounts.find((m) => m.label === mount.label)
: null;
const mountTotalClients = this.destructureCountsToArray(mount);
const mountNewClients = newMountData ? this.destructureCountsToArray(newMountData) : null;
csvData.push(this.constructCsvRow(namespace, mount, mountTotalClients, mountNewClients));
});
}
});

csvData.unshift(csvHeader);
// make each nested array a comma separated string, join each array "row" in csvData with line break (\n)
return csvData.map((d) => d.join()).join('\n');
}

get formattedCsvFileName() {
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : '';
return this.isSingleNamespace
? `clients_by_mount_path${csvDateRange}`
: `clients_by_namespace${csvDateRange}`;
}

get modalExportText() {
const { isSecretsSyncActivated } = this.args;
return `This export will include the namespace path, mount path and associated total entity, non-entity${
isSecretsSyncActivated ? ', ACME and secrets sync clients' : ' and ACME clients'
} for the ${this.formattedEndDate ? 'date range' : 'month'} below.`;
}

@action
exportChartData(filename) {
const contents = this.generateCsvData();
this.download.csv(filename, contents);
this.showCSVDownloadModal = false;
}
}
Loading

0 comments on commit f5e6bc3

Please sign in to comment.