Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Use Client Count export API #27455

Merged
merged 44 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
64511f7
Add qp string util
hashishaw Jun 11, 2024
9568921
use queryParamString util for oidc provider test
hashishaw Jun 11, 2024
013a3d4
Add export data method to clients/activity adapter
hashishaw Jun 11, 2024
3d1e1cd
correctly parse error from export API
hashishaw Jun 11, 2024
9e03a26
Update attribution component to use export api
hashishaw Jun 11, 2024
023e7ee
update modal title
hashishaw Jun 11, 2024
f82237c
Only call export from root ns
hashishaw Jun 11, 2024
69aee66
remove unused methods & update tests
hashishaw Jun 11, 2024
6d90222
error test coverage
hashishaw Jun 11, 2024
46af7e7
Add changelog
hashishaw Jun 11, 2024
9b67277
Address PR comments, add headers
hashishaw Jun 20, 2024
57ec07d
Merge branch 'main' into ui/VAULT-27595/use-cc-export-api
hashishaw Jul 10, 2024
1accba8
rename showCSVDownloadModal to showExportModal
hashishaw Jul 10, 2024
b44c180
Add export format option to modal
hashishaw Jul 10, 2024
bcc145f
Export action is a task; add loading state
hashishaw Jul 10, 2024
a432837
correctly check for object-ness
hashishaw Jul 10, 2024
3e2d69a
hides export button unless sudo capabilities for export on selected n…
hashishaw Jul 10, 2024
a09c1f4
namespace sent with export and capabilities check
hashishaw Jul 11, 2024
46674c5
cleanup
hashishaw Jul 11, 2024
d5f1fe1
fix namespace when none selected
hashishaw Jul 11, 2024
5decb70
update test
hashishaw Jul 11, 2024
9df6619
more namespace test coverage
hashishaw Jul 11, 2024
31e0970
Merge branch 'main' into ui/VAULT-27595/use-cc-export-api
hashishaw Jul 11, 2024
d9a4d40
remove unused arg
hashishaw Jul 11, 2024
2a6ec99
Merge branch 'main' into ui/VAULT-27595/use-cc-export-api
hashishaw Jul 11, 2024
a88b88b
safeguard against non-200 success code on export
hashishaw Jul 11, 2024
7228071
Revert "remove unused arg"
hashishaw Jul 11, 2024
90d4dd9
export modal tests
hashishaw Jul 12, 2024
8b30c30
create clients/export-button component and tests
hashishaw Jul 12, 2024
90fa908
use clients::export-button component in clients::attribution component
hashishaw Jul 12, 2024
a1eb023
update attribution tests
hashishaw Jul 12, 2024
6cd34bc
Merge branch 'main' into ui/VAULT-27595/use-cc-export-api
hashishaw Jul 30, 2024
e95d016
add copyright headers
hashishaw Jul 30, 2024
39efdcc
copyright the test
hashishaw Jul 30, 2024
36b6253
Fix failing tests
hashishaw Jul 31, 2024
7032ef4
Cleanup
hashishaw Jul 31, 2024
c810570
fix timestamp stub test error
hashishaw Jul 31, 2024
251b935
fix query-param-string logic
hashishaw Jul 31, 2024
a157851
fix query-param-string logic
hashishaw Jul 31, 2024
f49557e
Merge branch 'main' into ui/VAULT-27595/use-cc-export-api
hashishaw Jul 31, 2024
78f5a26
correctly download when json
hashishaw Jul 31, 2024
c4daf92
fix bad merge conflict
hashishaw Jul 31, 2024
96d3779
fix alignment
hashishaw Aug 1, 2024
78e610a
Update JSON option to use JSON Lines
hashishaw Aug 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
```
17 changes: 17 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';

Expand Down Expand Up @@ -33,4 +34,20 @@ export default class ActivityAdapter extends ApplicationAdapter {
});
}
}

async exportData(query) {
const url = `${this.buildURL()}/internal/counters/activity/export${queryParamString({
format: 'csv',
start_time: query?.start_time ?? undefined,
end_time: query?.end_time ?? undefined,
})}`;
try {
// This endpoint can only be called from root namespace
const resp = await this.rawRequest(url, 'GET', { namespace: undefined });
return resp.blob();
} catch (e) {
const { errors } = await e.json();
throw new Error(errors?.join('. '));
}
}
}
22 changes: 12 additions & 10 deletions ui/app/components/clients/attribution.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
{{#if this.hasCsvData}}
<Hds::Button
data-test-attribution-export-button
@text="Export attribution data"
@text="Export activity data"
@color="secondary"
{{on "click" (fn (mut this.showCSVDownloadModal) true)}}
/>
Expand Down Expand Up @@ -83,25 +83,27 @@

{{! MODAL FOR CSV DOWNLOAD }}
{{#if this.showCSVDownloadModal}}
<Hds::Modal id="attribution-csv-download-modal" @onClose={{fn (mut this.showCSVDownloadModal) false}} as |M|>
<Hds::Modal id="attribution-csv-download-modal" @onClose={{this.resetModal}} as |M|>
<M.Header @icon="info" data-test-export-modal-title>
Export attribution data
Export activity 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"}}.
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>
{{#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>
Expand Down
120 changes: 19 additions & 101 deletions ui/app/components/clients/attribution.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import { format, isSameMonth } from 'date-fns';

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

get attributionLegend() {
const attributionLegend = [
Expand Down Expand Up @@ -130,99 +132,13 @@ export default class Attribution extends Component {
}
}

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;
hashishaw marked this conversation as resolved.
Show resolved Hide resolved

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));
});
}
async generateCsvData() {
const adapter = this.store.adapterFor('clients/activity');
const { startTimestamp, endTimestamp } = this.args;
return adapter.exportData({
start_time: startTimestamp,
end_time: endTimestamp,
});

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() {
Expand All @@ -233,17 +149,19 @@ export default class Attribution extends Component {
: `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
async exportChartData(filename) {
try {
const contents = await this.generateCsvData();
this.download.csv(filename, contents);
this.showCSVDownloadModal = false;
} catch (e) {
this.downloadError = e.message;
}
}

@action
exportChartData(filename) {
const contents = this.generateCsvData();
this.download.csv(filename, contents);
@action resetModal() {
this.showCSVDownloadModal = false;
this.downloadError = '';
}
}
18 changes: 18 additions & 0 deletions ui/app/utils/query-param-string.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* queryParamString converts an object to a query param string with URL encoded keys and values.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great idea making this a util!

* 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 (typeof queryObject !== 'object') return '';
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
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}`;
}, '?');
}
10 changes: 3 additions & 7 deletions ui/tests/acceptance/oidc-provider-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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}`;
};

Expand Down
Loading
Loading