mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-30 02:02:43 +00:00
UI: Use Client Count export API (#27455)
This commit is contained in:
3
changelog/27455.txt
Normal file
3
changelog/27455.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:change
|
||||
ui: Uses the internal/counters/activity/export endpoint for client count export data.
|
||||
```
|
||||
@@ -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';
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
@@ -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() {
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
72
ui/app/components/clients/export-button.hbs
Normal file
72
ui/app/components/clients/export-button.hbs
Normal file
@@ -0,0 +1,72 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Hds::Button
|
||||
data-test-attribution-export-button
|
||||
@text="Export activity data"
|
||||
@color="secondary"
|
||||
{{on "click" (fn (mut this.showExportModal) true)}}
|
||||
/>
|
||||
|
||||
{{! MODAL FOR CSV DOWNLOAD }}
|
||||
{{#if this.showExportModal}}
|
||||
<Hds::Modal id="attribution-csv-download-modal" class="has-text-left" @onClose={{this.resetModal}} as |M|>
|
||||
<M.Header @icon="info" data-test-export-modal-title>
|
||||
Export activity data
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
{{#if this.exportChartData.isRunning}}
|
||||
<p class="has-bottom-margin-s">
|
||||
Your export request is being processed. This may take some time; please do not navigate away from this page.
|
||||
</p>
|
||||
<p class="has-bottom-margin-s has-text-centered">
|
||||
<Icon @name="loading" @size="24" />
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="has-bottom-margin-s">
|
||||
This file will include an export of the clients that had activity within the date range below. See the
|
||||
<DocLink @path="/vault/api-docs/system/internal-counters#activity-export">activity export documentation</DocLink>
|
||||
for more details.
|
||||
</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>
|
||||
|
||||
<Hds::Form::Select::Field
|
||||
class="has-bottom-margin-s"
|
||||
{{on "change" this.setExportFormat}}
|
||||
data-test-download-format
|
||||
as |F|
|
||||
>
|
||||
<F.Label>Export format</F.Label>
|
||||
<F.Options>
|
||||
<option value="csv" selected={{eq this.exportFormat "csv"}}>CSV</option>
|
||||
<option value="jsonl" selected={{eq this.exportFormat "jsonl"}}>JSON Lines</option>
|
||||
</F.Options>
|
||||
</Hds::Form::Select::Field>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.downloadError}}
|
||||
<Hds::Alert @type="inline" @color="critical" as |A|>
|
||||
<A.Title>CSV export failed</A.Title>
|
||||
<A.Description data-test-export-error>{{this.downloadError}}</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
</M.Body>
|
||||
<M.Footer as |F|>
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button
|
||||
@text="Export"
|
||||
{{on "click" (perform this.exportChartData this.formattedCsvFileName)}}
|
||||
data-test-confirm-button
|
||||
/>
|
||||
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} />
|
||||
</Hds::ButtonSet>
|
||||
{{yield to="alert"}}
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
93
ui/app/components/clients/export-button.js
Normal file
93
ui/app/components/clients/export-button.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
import { format, isSameMonth } from 'date-fns';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
/**
|
||||
* @module ClientsExportButtonComponent
|
||||
* ClientsExportButton components are used to display the export button, manage the modal, and download the file from the clients export API
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Clients::ExportButton @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @selectedNamespace="foo" />
|
||||
* ```
|
||||
* @param {string} [startTimestamp] - ISO timestamp of start time, to be passed to export request
|
||||
* @param {string} [endTimestamp] - ISO timestamp of end time, to be passed to export request
|
||||
* @param {string} [namespace] - namespace filter. Will be appended to the current namespace in the export request.
|
||||
*/
|
||||
export default class ClientsExportButtonComponent extends Component {
|
||||
@service download;
|
||||
@service namespace;
|
||||
@service store;
|
||||
|
||||
@tracked showExportModal = false;
|
||||
@tracked exportFormat = 'csv';
|
||||
@tracked downloadError = '';
|
||||
|
||||
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
|
||||
const startDateObject = parseAPITimestamp(this.args.startTimestamp);
|
||||
const endDateObject = parseAPITimestamp(this.args.endTimestamp);
|
||||
return isSameMonth(startDateObject, endDateObject) ? null : format(endDateObject, 'MMMM yyyy');
|
||||
}
|
||||
|
||||
get formattedCsvFileName() {
|
||||
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
|
||||
const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : '';
|
||||
const ns = this.namespaceFilter ? `_${this.namespaceFilter}` : '';
|
||||
return `clients_export${ns}${csvDateRange}`;
|
||||
}
|
||||
|
||||
get namespaceFilter() {
|
||||
const currentNs = this.namespace.path;
|
||||
const { selectedNamespace } = this.args;
|
||||
return selectedNamespace ? sanitizePath(`${currentNs}/${selectedNamespace}`) : sanitizePath(currentNs);
|
||||
}
|
||||
|
||||
async getExportData() {
|
||||
const adapter = this.store.adapterFor('clients/activity');
|
||||
const { startTimestamp, endTimestamp } = this.args;
|
||||
return adapter.exportData({
|
||||
// the API only accepts json or csv
|
||||
format: this.exportFormat === 'jsonl' ? 'json' : 'csv',
|
||||
start_time: startTimestamp,
|
||||
end_time: endTimestamp,
|
||||
namespace: this.namespaceFilter,
|
||||
});
|
||||
}
|
||||
|
||||
exportChartData = task({ drop: true }, async (filename) => {
|
||||
try {
|
||||
const contents = await this.getExportData();
|
||||
this.download.download(filename, contents, this.exportFormat);
|
||||
this.showExportModal = false;
|
||||
} catch (e) {
|
||||
this.downloadError = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
@action setExportFormat(evt) {
|
||||
const { value } = evt.target;
|
||||
this.exportFormat = value;
|
||||
}
|
||||
|
||||
@action resetModal() {
|
||||
this.showExportModal = false;
|
||||
this.downloadError = '';
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
{{#if this.hasAttributionData}}
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@newUsageCounts={{this.newClientCounts}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@newClientAttribution={{this.newClientAttribution}}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
<div class="is-flex-row">
|
||||
<StatText @label="Entity" @value={{@runningTotals.entity_clients}} @size="m" />
|
||||
<StatText @label="Non-entity" @value={{@runningTotals.non_entity_clients}} @size="m" class="has-left-margin-l" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-details-bottom is-flex-row">
|
||||
|
||||
@@ -29,7 +29,7 @@ export default class ClientsCountsRoute extends Route {
|
||||
queryParams = {
|
||||
start_time: { refreshModel: true, replace: true },
|
||||
end_time: { refreshModel: true, replace: true },
|
||||
ns: { refreshModel: false, replace: true },
|
||||
ns: { refreshModel: true, replace: true },
|
||||
mountPath: { refreshModel: false, replace: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Extensions {
|
||||
hcl: string;
|
||||
sentinel: string;
|
||||
json: string;
|
||||
jsonl: string;
|
||||
pem: string;
|
||||
txt: string;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ const EXTENSION_TO_MIME: Extensions = {
|
||||
hcl: 'text/plain',
|
||||
sentinel: 'text/plain',
|
||||
json: 'application/json',
|
||||
jsonl: 'application/json',
|
||||
pem: 'application/x-pem-file',
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
31
ui/app/utils/query-param-string.js
Normal file
31
ui/app/utils/query-param-string.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { isEmptyValue } from 'core/helpers/is-empty-value';
|
||||
|
||||
/**
|
||||
* queryParamString converts an object to a query param string with URL encoded keys and values.
|
||||
* It does not include values that are falsey.
|
||||
* @param {object} queryObject with key-value pairs of desired URL params
|
||||
* @returns string like ?key=val1&key2=val2
|
||||
*/
|
||||
export default function queryParamString(queryObject) {
|
||||
if (
|
||||
!queryObject ||
|
||||
isEmptyValue(queryObject) ||
|
||||
typeof queryObject !== 'object' ||
|
||||
Array.isArray(queryObject)
|
||||
)
|
||||
return '';
|
||||
return Object.keys(queryObject).reduce((prev, key) => {
|
||||
const value = queryObject[key];
|
||||
if (!value) return prev;
|
||||
const keyval = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
if (prev === '?') {
|
||||
return `${prev}${keyval}`;
|
||||
}
|
||||
return `${prev}&${keyval}`;
|
||||
}, '?');
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export default helper(function isEmptyValue([value], { hasDefault = false }) {
|
||||
export function isEmptyValue(value, hasDefault = false) {
|
||||
if (hasDefault) {
|
||||
value = hasDefault;
|
||||
}
|
||||
@@ -13,4 +13,8 @@ export default helper(function isEmptyValue([value], { hasDefault = false }) {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
return value == null || value === '';
|
||||
}
|
||||
|
||||
export default helper(function ([value], { hasDefault = false }) {
|
||||
return isEmptyValue(value, hasDefault);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import { clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
|
||||
import { selectChoose } from 'ember-power-select/test-support';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const searchSelect = create(ss);
|
||||
@@ -146,7 +148,14 @@ module('Acceptance | clients | overview', function (hooks) {
|
||||
|
||||
test('totals filter correctly with full data', async function (assert) {
|
||||
// stub secrets sync being activated
|
||||
this.owner.lookup('service:flags').activatedFlags = ['secrets-sync'];
|
||||
this.server.get('/sys/activation-flags', function () {
|
||||
return {
|
||||
data: {
|
||||
activated: ['secrets-sync'],
|
||||
unactivated: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
@@ -221,6 +230,29 @@ module('Acceptance | clients | overview', function (hooks) {
|
||||
.includesText(`${expectedStats[label]}`, `label: ${label} is back to unfiltered value`);
|
||||
}
|
||||
});
|
||||
|
||||
test('it updates export button visibility as namespace is filtered', async function (assert) {
|
||||
const ns = 'ns7';
|
||||
// create a user that only has export access for specific namespace
|
||||
const userToken = await runCmd(
|
||||
tokenWithPolicyCmd(
|
||||
'cc-export',
|
||||
`
|
||||
path "${ns}/sys/internal/counters/activity/export" {
|
||||
capabilities = ["sudo"]
|
||||
}
|
||||
`
|
||||
)
|
||||
);
|
||||
await authPage.login(userToken);
|
||||
await visit('/vault/clients/counts/overview');
|
||||
assert.dom(CLIENT_COUNT.exportButton).doesNotExist();
|
||||
|
||||
// FILTER BY ALLOWED NAMESPACE
|
||||
await selectChoose('#namespace-search-select', ns);
|
||||
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
});
|
||||
});
|
||||
|
||||
module('Acceptance | clients | overview | sync in license, activated', function (hooks) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import enablePage from 'vault/tests/pages/settings/auth/enable';
|
||||
import { visit, settled, currentURL, waitFor, currentRouteName } from '@ember/test-helpers';
|
||||
import { clearRecord } from 'vault/tests/helpers/oidc-config';
|
||||
import { runCmd } from 'vault/tests/helpers/commands';
|
||||
import queryParamString from 'vault/utils/query-param-string';
|
||||
|
||||
const authFormComponent = create(authForm);
|
||||
|
||||
@@ -82,18 +83,13 @@ const getAuthzUrl = (providerName, redirect, clientId, params) => {
|
||||
const queryParams = {
|
||||
client_id: clientId,
|
||||
nonce: 'abc123',
|
||||
redirect_uri: encodeURIComponent(redirect),
|
||||
redirect_uri: redirect,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
state: 'foobar',
|
||||
...params,
|
||||
};
|
||||
const queryString = Object.keys(queryParams).reduce((prev, key, idx) => {
|
||||
if (idx === 0) {
|
||||
return `${prev}${key}=${queryParams[key]}`;
|
||||
}
|
||||
return `${prev}&${key}=${queryParams[key]}`;
|
||||
}, '?');
|
||||
const queryString = queryParamString(queryParams);
|
||||
return `/vault/identity/oidc/provider/${providerName}/authorize${queryString}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const CLIENT_COUNT = {
|
||||
selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div',
|
||||
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
|
||||
upgradeWarning: '[data-test-clients-upgrade-warning]',
|
||||
exportButton: '[data-test-attribution-export-button]',
|
||||
};
|
||||
|
||||
export const CHARTS = {
|
||||
|
||||
@@ -9,14 +9,15 @@ import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { endOfMonth, formatRFC3339 } from 'date-fns';
|
||||
import { click } from '@ember/test-helpers';
|
||||
import subMonths from 'date-fns/subMonths';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { SERIALIZED_ACTIVITY_RESPONSE } from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs';
|
||||
|
||||
module('Integration | Component | clients/attribution', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2018-04-03T14:15:30')));
|
||||
@@ -24,7 +25,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const { total, by_namespace } = SERIALIZED_ACTIVITY_RESPONSE;
|
||||
this.csvDownloadStub = sinon.stub(this.owner.lookup('service:download'), 'csv');
|
||||
const mockNow = this.timestampStub();
|
||||
this.mockNow = mockNow;
|
||||
this.startTimestamp = formatRFC3339(subMonths(mockNow, 6));
|
||||
@@ -35,10 +35,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
this.namespaceMountsData = by_namespace.find((ns) => ns.label === 'ns1').mounts;
|
||||
});
|
||||
|
||||
hooks.after(function () {
|
||||
this.csvDownloadStub.restore();
|
||||
});
|
||||
|
||||
test('it renders empty state with no data', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution />
|
||||
@@ -207,120 +203,10 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
assert.dom('[data-test-attribution-clients]').includesText('auth method').includesText('8,394');
|
||||
});
|
||||
|
||||
test('it renders modal', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.namespaceMountsData}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp="2022-06-01T23:00:11.050Z"
|
||||
@endTimestamp="2022-12-01T23:00:11.050Z"
|
||||
/>
|
||||
`);
|
||||
await click('[data-test-attribution-export-button]');
|
||||
assert
|
||||
.dom('[data-test-export-modal-title]')
|
||||
.hasText('Export attribution data', 'modal appears to export csv');
|
||||
assert.dom('[ data-test-export-date-range]').includesText('June 2022 - December 2022');
|
||||
});
|
||||
|
||||
test('it downloads csv data for date range', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{true}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp="2022-06-01T23:00:11.050Z"
|
||||
@endTimestamp="2022-12-01T23:00:11.050Z"
|
||||
/>
|
||||
`);
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename, content] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_by_namespace_June 2022-December 2022', 'csv has expected filename');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
`Namespace path,"Mount path
|
||||
*namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients
|
||||
ns1,*,18903,4256,4138,5699,4810
|
||||
ns1,auth/authid/0,8394,4256,4138,0,0
|
||||
ns1,kvv2-engine-0,4810,0,0,0,4810
|
||||
ns1,pki-engine-0,5699,0,0,5699,0
|
||||
root,*,16384,4002,4089,4003,4290
|
||||
root,auth/authid/0,8091,4002,4089,0,0
|
||||
root,kvv2-engine-0,4290,0,0,0,4290
|
||||
root,pki-engine-0,4003,0,0,4003,0`,
|
||||
'csv has expected content'
|
||||
test('it shows the export button if user does has SUDO capabilities', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['sudo'])
|
||||
);
|
||||
});
|
||||
|
||||
test('it downloads csv data for a single month', async function (assert) {
|
||||
assert.expect(2);
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{true}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp="2022-06-01T23:00:11.050Z"
|
||||
@endTimestamp="2022-06-21T23:00:11.050Z"
|
||||
/>
|
||||
`);
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename, content] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_by_namespace_June 2022', 'csv has single month in filename');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
`Namespace path,"Mount path
|
||||
*namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients
|
||||
ns1,*,18903,4256,4138,5699,4810
|
||||
ns1,auth/authid/0,8394,4256,4138,0,0
|
||||
ns1,kvv2-engine-0,4810,0,0,0,4810
|
||||
ns1,pki-engine-0,5699,0,0,5699,0
|
||||
root,*,16384,4002,4089,4003,4290
|
||||
root,auth/authid/0,8091,4002,4089,0,0
|
||||
root,kvv2-engine-0,4290,0,0,0,4290
|
||||
root,pki-engine-0,4003,0,0,4003,0`,
|
||||
'csv has expected content'
|
||||
);
|
||||
});
|
||||
|
||||
test('it downloads csv data when a namespace is selected', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.selectedNamespace = 'ns1';
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{true}}
|
||||
@totalClientAttribution={{this.namespaceMountsData}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp="2022-06-01T23:00:11.050Z"
|
||||
@endTimestamp="2022-12-21T23:00:11.050Z"
|
||||
/>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename, content] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(
|
||||
filename,
|
||||
'clients_by_mount_path_June 2022-December 2022',
|
||||
'csv has expected filename for a selected namespace'
|
||||
);
|
||||
assert.strictEqual(
|
||||
content,
|
||||
`Namespace path,"Mount path",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients
|
||||
ns1,auth/authid/0,8394,4256,4138,0,0
|
||||
ns1,kvv2-engine-0,4810,0,0,0,4810
|
||||
ns1,pki-engine-0,5699,0,0,5699,0`,
|
||||
'csv has expected content for a selected namespace'
|
||||
);
|
||||
});
|
||||
|
||||
test('csv filename omits date if no start/end timestamp', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@@ -328,85 +214,32 @@ ns1,pki-engine-0,5699,0,0,5699,0`,
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename, ,] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_by_namespace');
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
});
|
||||
|
||||
test('csv filename omits sync clients if not activated', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.totalClientAttribution = this.totalClientAttribution.map((ns) => {
|
||||
const namespace = { ...ns };
|
||||
delete namespace.secret_syncs;
|
||||
return namespace;
|
||||
});
|
||||
test('it hides the export button if user does not have SUDO capabilities', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['read'])
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{false}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [, content] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(
|
||||
content,
|
||||
`Namespace path,"Mount path
|
||||
*namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients
|
||||
ns1,*,18903,4256,4138,5699
|
||||
ns1,auth/authid/0,8394,4256,4138,0
|
||||
ns1,kvv2-engine-0,4810,0,0,0
|
||||
ns1,pki-engine-0,5699,0,0,5699
|
||||
root,*,16384,4002,4089,4003
|
||||
root,auth/authid/0,8091,4002,4089,0
|
||||
root,kvv2-engine-0,4290,0,0,0
|
||||
root,pki-engine-0,4003,0,0,4003`
|
||||
);
|
||||
assert.dom('[data-test-attribution-export-button]').doesNotExist();
|
||||
});
|
||||
|
||||
test('csv filename includes upgrade mention if there is upgrade activity', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.totalClientAttribution = this.totalClientAttribution.map((ns) => {
|
||||
const namespace = { ...ns };
|
||||
delete namespace.secret_syncs;
|
||||
return namespace;
|
||||
});
|
||||
this.upgradeActivity = [
|
||||
{
|
||||
previousVersion: '1.9.0',
|
||||
timestampInstalled: '2023-08-02T00:00:00.000Z',
|
||||
version: '1.9.1',
|
||||
},
|
||||
];
|
||||
test('defaults to show the export button if capabilities cannot be read', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () => overrideResponse(403));
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{false}}
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@upgradesDuringActivity={{this.upgradeActivity}}
|
||||
/>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [, content] = this.csvDownloadStub.lastCall.args;
|
||||
assert.strictEqual(
|
||||
content,
|
||||
`Namespace path,"Mount path
|
||||
*namespace totals, inclusive of mount clients
|
||||
**data contains an upgrade (mount summation may not equal namespace totals)",Total clients,Entity clients,Non-entity clients,ACME clients
|
||||
ns1,*,18903,4256,4138,5699
|
||||
ns1,auth/authid/0,8394,4256,4138,0
|
||||
ns1,kvv2-engine-0,4810,0,0,0
|
||||
ns1,pki-engine-0,5699,0,0,5699
|
||||
root,*,16384,4002,4089,4003
|
||||
root,auth/authid/0,8091,4002,4089,0
|
||||
root,kvv2-engine-0,4290,0,0,0
|
||||
root,pki-engine-0,4003,0,0,4003`
|
||||
);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
});
|
||||
});
|
||||
|
||||
294
ui/tests/integration/components/clients/export-button-test.js
Normal file
294
ui/tests/integration/components/clients/export-button-test.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import Sinon from 'sinon';
|
||||
import { Response } from 'miragejs';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { overrideResponse } from 'vault/tests/helpers/stubs';
|
||||
|
||||
module('Integration | Component | clients/export-button', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.downloadStub = Sinon.stub(this.owner.lookup('service:download'), 'download');
|
||||
this.startTimestamp = '2022-06-01T23:00:11.050Z';
|
||||
this.endTimestamp = '2022-12-01T23:00:11.050Z';
|
||||
this.selectedNamespace = undefined;
|
||||
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`
|
||||
<Clients::ExportButton
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders modal with yielded alert', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
>
|
||||
<:alert>
|
||||
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
|
||||
<A.Description data-test-custom-alert>Yielded alert!</A.Description>
|
||||
</Hds::Alert>
|
||||
</:alert>
|
||||
</Clients::ExportButton>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
assert.dom('[data-test-custom-alert]').hasText('Yielded alert!');
|
||||
});
|
||||
|
||||
test('shows the API error on the modal', async function (assert) {
|
||||
this.server.get('/sys/internal/counters/activity/export', function () {
|
||||
return new Response(
|
||||
403,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
{ errors: ['this is an error from the API'] }
|
||||
);
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
assert.dom('[data-test-export-error]').hasText('this is an error from the API');
|
||||
});
|
||||
|
||||
test('it works for json format', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'json',
|
||||
start_time: '2022-06-01T23:00:11.050Z',
|
||||
end_time: '2022-12-01T23:00:11.050Z',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'application/json' }, { example: 'data' });
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await fillIn('[data-test-download-format]', 'jsonl');
|
||||
await click(GENERAL.confirmButton);
|
||||
const extension = this.downloadStub.lastCall.args[2];
|
||||
assert.strictEqual(extension, 'jsonl');
|
||||
});
|
||||
|
||||
test('it works for csv format', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
start_time: '2022-06-01T23:00:11.050Z',
|
||||
end_time: '2022-12-01T23:00:11.050Z',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, 'example,data');
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await fillIn('[data-test-download-format]', 'csv');
|
||||
await click(GENERAL.confirmButton);
|
||||
const extension = this.downloadStub.lastCall.args[2];
|
||||
assert.strictEqual(extension, 'csv');
|
||||
});
|
||||
|
||||
test('it sends the current namespace in export request', async function (assert) {
|
||||
assert.expect(2);
|
||||
const namespaceSvc = this.owner.lookup('service:namespace');
|
||||
namespaceSvc.path = 'foo';
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.requestHeaders, {
|
||||
'X-Vault-Namespace': 'foo',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
test('it sends the selected namespace in export request', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.requestHeaders, {
|
||||
'X-Vault-Namespace': 'foobar',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
this.selectedNamespace = 'foobar/';
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
|
||||
test('it sends the current + selected namespace in export request', async function (assert) {
|
||||
assert.expect(2);
|
||||
const namespaceSvc = this.owner.lookup('service:namespace');
|
||||
namespaceSvc.path = 'foo';
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.requestHeaders, {
|
||||
'X-Vault-Namespace': 'foo/bar',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
this.selectedNamespace = 'bar/';
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
|
||||
test('it shows a no data message if endpoint returns 204', async function (assert) {
|
||||
this.server.get('/sys/internal/counters/activity/export', () => overrideResponse(204));
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
assert.dom('[data-test-export-error]').hasText('no data to export in provided time range.');
|
||||
});
|
||||
|
||||
module('download naming', function () {
|
||||
test('is correct for date range', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
start_time: '2022-06-01T23:00:11.050Z',
|
||||
end_time: '2022-12-01T23:00:11.050Z',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const args = this.downloadStub.lastCall.args;
|
||||
const [filename] = args;
|
||||
assert.strictEqual(filename, 'clients_export_June 2022-December 2022', 'csv has expected filename');
|
||||
});
|
||||
|
||||
test('is correct for a single month', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.endTimestamp = '2022-06-21T23:00:11.050Z';
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
start_time: '2022-06-01T23:00:11.050Z',
|
||||
end_time: '2022-06-21T23:00:11.050Z',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_June 2022', 'csv has single month in filename');
|
||||
});
|
||||
test('omits date if no start/end timestamp', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.startTimestamp = undefined;
|
||||
this.endTimestamp = undefined;
|
||||
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export');
|
||||
});
|
||||
|
||||
test('includes current namespace', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.startTimestamp = undefined;
|
||||
this.endTimestamp = undefined;
|
||||
const namespace = this.owner.lookup('service:namespace');
|
||||
namespace.path = 'bar/';
|
||||
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_bar');
|
||||
});
|
||||
|
||||
test('includes selectedNamespace', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.startTimestamp = undefined;
|
||||
this.endTimestamp = undefined;
|
||||
this.selectedNamespace = 'foo/';
|
||||
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
format: 'csv',
|
||||
});
|
||||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_foo');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,13 @@ module('Unit | Adapter | clients activity', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2023-01-13T09:30:15')));
|
||||
});
|
||||
hooks.beforeEach(function () {
|
||||
this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2023-01-13T09:30:15')));
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.modelName = 'clients/activity';
|
||||
this.startDate = subMonths(this.timestampStub(), 6);
|
||||
this.endDate = this.timestampStub();
|
||||
const mockNow = timestamp.now();
|
||||
this.startDate = subMonths(mockNow, 6);
|
||||
this.endDate = mockNow;
|
||||
this.readableUnix = (unix) => parseAPITimestamp(fromUnixTime(unix).toISOString(), 'MMMM dd yyyy');
|
||||
});
|
||||
|
||||
@@ -166,4 +165,49 @@ module('Unit | Adapter | clients activity', function (hooks) {
|
||||
|
||||
this.store.queryRecord(this.modelName, {});
|
||||
});
|
||||
|
||||
module('exportData', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.adapter = this.store.adapterFor('clients/activity');
|
||||
});
|
||||
test('it requests with correct params when no query', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
this.server.get('sys/internal/counters/activity/export', (schema, req) => {
|
||||
assert.propEqual(req.queryParams, { format: 'csv' });
|
||||
});
|
||||
|
||||
await this.adapter.exportData();
|
||||
});
|
||||
|
||||
test('it requests with correct params when start only', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
this.server.get('sys/internal/counters/activity/export', (schema, req) => {
|
||||
assert.propEqual(req.queryParams, { format: 'csv', start_time: '2024-04-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
await this.adapter.exportData({ start_time: '2024-04-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
test('it requests with correct params when all params', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
this.server.get('sys/internal/counters/activity/export', (schema, req) => {
|
||||
assert.propEqual(req.requestHeaders, { 'X-Vault-Namespace': 'foo/bar' });
|
||||
assert.propEqual(req.queryParams, {
|
||||
format: 'json',
|
||||
start_time: '2024-04-01T00:00:00.000Z',
|
||||
end_time: '2024-05-31T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
await this.adapter.exportData({
|
||||
start_time: '2024-04-01T00:00:00.000Z',
|
||||
end_time: '2024-05-31T00:00:00.000Z',
|
||||
format: 'json',
|
||||
namespace: 'foo/bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
42
ui/tests/unit/utils/query-param-string-test.js
Normal file
42
ui/tests/unit/utils/query-param-string-test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import queryParamString from 'vault/utils/query-param-string';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
module('Unit | Utility | query-param-string', function () {
|
||||
[
|
||||
{
|
||||
scenario: 'object with nonencoded keys and values',
|
||||
obj: { redirect: 'https://hashicorp.com', some$key: 'normal-value', number: 7 },
|
||||
expected: '?redirect=https%3A%2F%2Fhashicorp.com&some%24key=normal-value&number=7',
|
||||
},
|
||||
{
|
||||
scenario: 'object with falsey values',
|
||||
obj: { redirect: '', null: null, foo: 'bar', number: 0 },
|
||||
expected: '?foo=bar',
|
||||
},
|
||||
{
|
||||
scenario: 'empty object',
|
||||
obj: {},
|
||||
expected: '',
|
||||
},
|
||||
{
|
||||
scenario: 'array',
|
||||
obj: ['some', 'array'],
|
||||
expected: '',
|
||||
},
|
||||
{
|
||||
scenario: 'string',
|
||||
obj: 'foobar',
|
||||
expected: '',
|
||||
},
|
||||
].forEach((testCase) => {
|
||||
test(`it works when ${testCase.scenario}`, function (assert) {
|
||||
const result = queryParamString(testCase.obj);
|
||||
assert.strictEqual(result, testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user