UI: Update dashboard client count query to use billing_start_timestamp (#26729)

* remvoe request tolicense in dashboard client count card

* cleanup jsdoc

* add changelog

* use helper to set start time

* update component tests

* update overview test

* update util tests

* throw error instead, add comment to util file

* fix accidentally removed type from import

* remove typo arg from test component

* rename token stat getter to avoid future typos
This commit is contained in:
claire bontempo
2024-05-02 18:45:33 +01:00
committed by GitHub
parent e7778e2018
commit 6d0e4f654e
15 changed files with 171 additions and 116 deletions

3
changelog/26729.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui (enterprise): Update dashboard to make activity log query using the same start time as the metrics overview
```

View File

@@ -15,7 +15,7 @@
<StatText
@label="Total clients"
@subText="The total number of entity and non-entity clients for this date range."
@value={{this.totalUsageCounts.clients}}
@value={{this.tokenStats.total}}
@tooltipText="This number is the total for the queried date range. The chart displays a monthly breakdown of total clients per month."
@size="l"
/>
@@ -100,21 +100,21 @@
<StatText
class="column"
@label="Total clients"
@value={{this.tokenUsageCounts.clients}}
@value={{this.tokenStats.total}}
@size="l"
@subText="The number of clients which interacted with Vault during this month. This is Vaults primary billing metric."
/>
<StatText
class="column"
@label="Entity"
@value={{this.tokenUsageCounts.entity_clients}}
@value={{this.tokenStats.entity_clients}}
@size="l"
@subText="Representations of a particular user, client, or application that created a token via login."
/>
<StatText
class="column"
@label="Non-entity"
@value={{this.tokenUsageCounts.non_entity_clients}}
@value={{this.tokenStats.non_entity_clients}}
@size="l"
@subText="Clients created with a shared set of permissions, but not associated with an entity."
/>

View File

@@ -37,11 +37,11 @@ export default class ClientsTokenPageComponent extends ActivityComponent {
return this.calculateClientAverages(this.byMonthNewClients);
}
get tokenUsageCounts() {
get tokenStats() {
if (this.totalUsageCounts) {
const { entity_clients, non_entity_clients } = this.totalUsageCounts;
return {
clients: entity_clients + non_entity_clients,
total: entity_clients + non_entity_clients,
entity_clients,
non_entity_clients,
};

View File

@@ -23,23 +23,8 @@
<VaultLogoSpinner />
{{else}}
<div class="is-grid grid-2-columns grid-gap-2 has-top-margin-m grid-align-items-start is-flex-v-centered">
<StatText
@label="Total"
@value={{this.activityData.total.clients}}
@size="l"
@subText="The number of clients in this billing period ({{date-format
this.licenseStartTime
'MMM yyyy'
}} - {{date-format this.updatedAt 'MMM yyyy'}})."
data-test-stat-text="total-clients"
/>
<StatText
@label="New"
@value={{this.currentMonthActivityTotalCount}}
@size="l"
@subText="The number of clients new to Vault in the current month."
data-test-stat-text="new-clients"
/>
<StatText @label="Total" @value={{this.activityData.total.clients}} @size="l" @subText={{this.statSubText.total}} />
<StatText @label="New" @value={{this.currentMonthActivityTotalCount}} @size="l" @subText={{this.statSubText.new}} />
</div>
<div class="has-top-margin-l is-flex-center">
@@ -55,7 +40,7 @@
/>
<small class="has-left-margin-xs has-text-grey">
Updated
{{date-format this.updatedAt "MMM dd, yyyy hh:mm:ss"}}
{{date-format this.updatedAt "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
</small>
</div>
{{/if}}

View File

@@ -4,43 +4,52 @@
*/
import Component from '@glimmer/component';
import getStorage from 'vault/lib/token-storage';
import timestamp from 'core/utils/timestamp';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { setStartTimeQuery } from 'core/utils/client-count-utils';
import { dateFormat } from 'core/helpers/date-format';
/**
* @module DashboardClientCountCard
* DashboardClientCountCard component are used to display total and new client count information
*
* @example
* ```js
* <Dashboard::ClientCountCard @license={{@model.license}} />
* ```
* @param {object} license - license object passed from the parent
*
* <Dashboard::ClientCountCard @isEnterprise={{@version.isEnterprise}} />
*
* @param {boolean} isEnterprise - used for setting the start time for the activity log query
*/
export default class DashboardClientCountCard extends Component {
@service store;
clientConfig = null;
licenseStartTime = null;
@tracked activityData = null;
@tracked clientConfig = null;
@tracked updatedAt = timestamp.now().toISOString();
constructor() {
super(...arguments);
this.fetchClientActivity.perform();
this.clientConfig = this.store.queryRecord('clients/config', {}).catch(() => {});
}
get currentMonthActivityTotalCount() {
return this.activityData?.byMonth?.lastObject?.new_clients.clients;
}
get licenseStartTime() {
return this.args.license.startTime || getStorage().getItem('vault:ui-inputted-start-date') || null;
get statSubText() {
const format = (date) => dateFormat([date, 'MMM yyyy'], {});
return this.licenseStartTime
? {
total: `The number of clients in this billing period (${format(this.licenseStartTime)} - ${format(
this.updatedAt
)}).`,
new: 'The number of clients new to Vault in the current month.',
}
: { total: 'No total client data available.', new: 'No new client data available.' };
}
@task
@@ -48,6 +57,13 @@ export default class DashboardClientCountCard extends Component {
*fetchClientActivity(e) {
if (e) e.preventDefault();
this.updatedAt = timestamp.now().toISOString();
if (!this.clientConfig) {
// set config and license start time when component initializes
this.clientConfig = yield this.store.queryRecord('clients/config', {}).catch(() => {});
this.licenseStartTime = setStartTimeQuery(this.args.isEnterprise, this.clientConfig);
}
// only make the network request if we have a start_time
if (!this.licenseStartTime) return {};
try {

View File

@@ -7,11 +7,9 @@
<div class="has-bottom-margin-xl">
<div class="is-flex-row gap-24">
{{#if (and @version.isEnterprise (or @license @isRootNamespace))}}
{{#if (and @version.isEnterprise @isRootNamespace)}}
<div class="is-flex-column is-flex-1 gap-24">
{{#if @license}}
<Dashboard::ClientCountCard @license={{@license}} />
{{/if}}
<Dashboard::ClientCountCard @isEnterprise={{@version.isEnterprise}} />
{{#if
(and @isRootNamespace (has-permission "status" routeParams="replication") (not (is-empty-value @replication)))
}}

View File

@@ -14,9 +14,10 @@ import type VersionService from 'vault/services/version';
import type { ModelFrom } from 'vault/vault/route';
import type ClientsRoute from '../clients';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsCountsController from 'vault/controllers/vault/cluster/clients/counts';
import { setStartTimeQuery } from 'core/utils/client-count-utils';
export interface ClientsCountsRouteParams {
start_time?: string | number | undefined;
end_time?: string | number | undefined;
@@ -86,10 +87,7 @@ export default class ClientsCountsRoute extends Route {
async model(params: ClientsCountsRouteParams) {
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ModelFrom<ClientsRoute>;
// only enterprise versions will have a relevant billing start date, if null users must select initial start time
let startTime = null;
if (this.version.isEnterprise && this._hasConfig(config)) {
startTime = getUnixTime(config.billingStartTimestamp);
}
const startTime = setStartTimeQuery(this.version.isEnterprise, config);
const startTimestamp = Number(params.start_time) || startTime;
const endTimestamp = Number(params.end_time) || getUnixTime(timestamp.now());
@@ -118,8 +116,4 @@ export default class ClientsCountsRoute extends Route {
});
}
}
_hasConfig(model: ClientsConfigModel | object): model is ClientsConfigModel {
return 'billingStartTimestamp' in model;
}
}

View File

@@ -40,7 +40,6 @@ export default class VaultClusterDashboardRoute extends Route.extend(ClusterRout
return hash({
replication,
secretsEngines: this.store.query('secret-engine', {}),
license: this.store.queryRecord('license', {}).catch(() => null),
isRootNamespace: this.namespace.inRootNamespace && !hasChroot,
version: this.version,
vaultConfiguration: hasChroot ? null : this.getVaultConfiguration(),

View File

@@ -6,7 +6,6 @@
<Dashboard::Overview
@replication={{this.model.replication}}
@secretsEngines={{this.model.secretsEngines}}
@license={{this.model.license}}
@isRootNamespace={{this.model.isRootNamespace}}
@version={{this.model.version}}
@vaultConfiguration={{this.model.vaultConfiguration}}

View File

@@ -6,6 +6,7 @@
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc, getUnixTime, isWithinInterval } from 'date-fns';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
/*
@@ -59,6 +60,18 @@ export const filterVersionHistory = (
return [];
};
export const setStartTimeQuery = (
isEnterprise: boolean,
config: ClientsConfigModel | Record<string, never>
) => {
// CE versions have no license and so the start time defaults to "0001-01-01T00:00:00Z"
if (isEnterprise && _hasConfig(config)) {
return getUnixTime(config.billingStartTimestamp);
}
return null;
};
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
export const formatDateObject = (dateObj: { monthIdx: number; year: number }, isEnd: boolean) => {
const { year, monthIdx } = dateObj;
// day=0 for Date.UTC() returns the last day of the month before
@@ -188,6 +201,11 @@ export const namespaceArrayToObject = (
};
// type guards for conditionals
function _hasConfig(model: ClientsConfigModel | object): model is ClientsConfigModel {
if (!model) return false;
return 'billingStartTimestamp' in model;
}
export function hasMountsKey(
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
): obj is NamespaceNewClients {
@@ -201,7 +219,6 @@ export function hasNamespacesKey(
}
// TYPES RETURNED BY UTILS (serialized)
export interface TotalClients {
clients: number;
entity_clients: number;

View File

@@ -404,16 +404,14 @@ module('Acceptance | landing page dashboard', function (hooks) {
assert.dom(DASHBOARD.cardName('client-count')).exists();
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
assert.dom('[data-test-client-count-title]').hasText('Client count');
assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total');
assert.dom('[data-test-stat-text="Total"] .stat-label').hasText('Total');
assert.dom('[data-test-stat-text="Total"] .stat-value').hasText(formatNumber([response.total.clients]));
assert.dom('[data-test-stat-text="New"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText(formatNumber([response.total.clients]));
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.dom('[data-test-stat-text="New"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert
.dom('[data-test-stat-text="new-clients"] .stat-value')
.dom('[data-test-stat-text="New"] .stat-value')
.hasText(formatNumber([response.byMonth.lastObject.new_clients.clients]));
});
});

View File

@@ -69,21 +69,25 @@ module('Integration | Component | clients | Clients::Page::Token', function (hoo
test('it should render monthly total chart', async function (assert) {
const count = this.activity.byMonth.length;
assert.expect(count + 7);
const { entity_clients, non_entity_clients } = this.activity.total;
assert.expect(count + 8);
const getAverage = (data) => {
const average = ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
return (count += calculateAverage(data, key) || 0);
}, 0);
return formatNumber([average]);
};
const expectedTotal = getAverage(this.activity.byMonth);
const expectedAvg = getAverage(this.activity.byMonth);
const expectedTotal = formatNumber([entity_clients + non_entity_clients]);
const chart = CHARTS.container('Entity/Non-entity clients usage');
await this.renderComponent();
assert
.dom(CLIENT_COUNT.statTextValue('Average total clients per month'))
.dom(CLIENT_COUNT.statTextValue('Total clients'))
.hasText(expectedTotal, 'renders correct total clients');
assert
.dom(CLIENT_COUNT.statTextValue('Average total clients per month'))
.hasText(expectedAvg, 'renders correct average clients');
// assert bar chart is correct
assert.dom(`${chart} ${CHARTS.xAxis}`).hasText('7/23 8/23 9/23 10/23 11/23 12/23 1/24');

View File

@@ -13,6 +13,7 @@ import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import timestamp from 'core/utils/timestamp';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
module('Integration | Component | dashboard/client-count-card', function (hooks) {
setupRenderingTest(hooks);
@@ -22,18 +23,12 @@ module('Integration | Component | dashboard/client-count-card', function (hooks)
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(function () {
this.license = {
startTime: LICENSE_START.toISOString(),
};
});
hooks.after(function () {
timestamp.now.restore();
});
test('it should display client count information', async function (assert) {
assert.expect(9);
assert.expect(6);
const { months, total } = ACTIVITY_RESPONSE_STUB;
const [latestMonth] = months.slice(-1);
this.server.get('sys/internal/counters/activity', () => {
@@ -44,24 +39,62 @@ module('Integration | Component | dashboard/client-count-card', function (hooks)
data: ACTIVITY_RESPONSE_STUB,
};
});
this.server.get('sys/internal/counters/config', function () {
assert.true(true, 'sys/internal/counters/config');
return {
request_id: 'some-config-id',
data: {
billing_start_timestamp: LICENSE_START.toISOString(),
},
};
});
await render(hbs`<Dashboard::ClientCountCard @license={{this.license}} />`);
await render(hbs`<Dashboard::ClientCountCard @isEnterprise={{true}} />`);
assert.dom('[data-test-client-count-title]').hasText('Client count');
assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total');
assert
.dom('[data-test-stat-text="total-clients"] .stat-text')
.hasText('The number of clients in this billing period (Jul 2023 - Jan 2024).');
.dom(CLIENT_COUNT.statText('Total'))
.hasText(
`Total The number of clients in this billing period (Jul 2023 - Jan 2024). ${formatNumber([
total.clients,
])}`
);
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText(`${formatNumber([total.clients])}`);
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert
.dom('[data-test-stat-text="new-clients"] .stat-value')
.hasText(`${formatNumber([latestMonth.new_clients.counts.clients])}`);
.dom(CLIENT_COUNT.statText('New'))
.hasText(
`New The number of clients new to Vault in the current month. ${formatNumber([
latestMonth.new_clients.counts.clients,
])}`
);
// fires second request to /activity
await click('[data-test-refresh]');
});
test('it does not query activity for community edition', async function (assert) {
assert.expect(3);
// in the template this component is wrapped in an isEnterprise conditional so this
// state is currently not possible, adding a test to safeguard against introducing
// regressions during future refactors
this.server.get(
'sys/internal/counters/activity',
() => new Error('uh oh! a request was made to sys/internal/counters/activity')
);
this.server.get('sys/internal/counters/config', function () {
assert.true(true, 'sys/internal/counters/config');
return {
request_id: 'some-config-id',
data: {
billing_start_timestamp: '0001-01-01T00:00:00Z',
},
};
});
await render(hbs`<Dashboard::ClientCountCard @isEnterprise={{false}} />`);
assert.dom(CLIENT_COUNT.statText('Total')).hasText('Total No total client data available. -');
assert.dom(CLIENT_COUNT.statText('New')).hasText('New No new client data available. -');
// attempt second request to /activity but component task should return instead of hitting endpoint
await click('[data-test-refresh]');
});
});

View File

@@ -9,6 +9,7 @@ import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { DASHBOARD } from 'vault/tests/helpers/components/dashboard/dashboard-selectors';
import { LICENSE_START } from 'vault/mirage/handlers/clients';
module('Integration | Component | dashboard/overview', function (hooks) {
setupRenderingTest(hooks);
@@ -60,6 +61,14 @@ module('Integration | Component | dashboard/overview', function (hooks) {
],
};
this.refreshModel = () => {};
this.server.get('sys/internal/counters/config', function () {
return {
request_id: 'some-config-id',
data: {
billing_start_timestamp: LICENSE_START.toISOString(),
},
};
});
});
test('it should show dashboard empty states', async function (assert) {
@@ -129,7 +138,6 @@ module('Integration | Component | dashboard/overview', function (hooks) {
@replication={{this.replication}}
@version={{this.version}}
@isRootNamespace={{true}}
@license={{this.license}}
@refreshModel={{this.refreshModel}} />`
);
assert.dom(DASHBOARD.cardHeader('Vault version')).exists();
@@ -140,43 +148,11 @@ module('Integration | Component | dashboard/overview', function (hooks) {
assert.dom(DASHBOARD.cardName('client-count')).exists();
});
test('it should hide client count on enterprise w/o license ', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1+ent';
this.version.type = 'enterprise';
this.isRootNamespace = true;
await render(
hbs`
<Dashboard::Overview
@secretsEngines={{this.secretsEngines}}
@vaultConfiguration={{this.vaultConfiguration}}
@replication={{this.replication}}
@version={{this.version}}
@isRootNamespace={{this.isRootNamespace}}
@refreshModel={{this.refreshModel}}
/>`
);
assert.dom(DASHBOARD.cardHeader('Vault version')).exists();
assert.dom('[data-test-badge-namespace]').exists();
assert.dom(DASHBOARD.cardName('secrets-engines')).exists();
assert.dom(DASHBOARD.cardName('learn-more')).exists();
assert.dom(DASHBOARD.cardName('quick-actions')).exists();
assert.dom(DASHBOARD.cardName('configuration-details')).exists();
assert.dom(DASHBOARD.cardName('client-count')).doesNotExist();
});
test('it should hide replication on enterprise not on root namespace', async function (assert) {
this.version = this.owner.lookup('service:version');
this.version.version = '1.13.1+ent';
this.version.type = 'enterprise';
this.isRootNamespace = false;
this.license = {
autoloaded: {
license_id: '7adbf1f4-56ef-35cd-3a6c-50ef2627865d',
},
};
await render(
hbs`
@@ -186,7 +162,6 @@ module('Integration | Component | dashboard/overview', function (hooks) {
@secretsEngines={{this.secretsEngines}}
@vaultConfiguration={{this.vaultConfiguration}}
@replication={{this.replication}}
@license={{this.license}}
@refreshModel={{this.refreshModel}} />`
);
@@ -197,7 +172,7 @@ module('Integration | Component | dashboard/overview', function (hooks) {
assert.dom(DASHBOARD.cardName('quick-actions')).exists();
assert.dom(DASHBOARD.cardName('configuration-details')).exists();
assert.dom(DASHBOARD.cardName('replication')).doesNotExist();
assert.dom(DASHBOARD.cardName('client-count')).exists();
assert.dom(DASHBOARD.cardName('client-count')).doesNotExist();
});
module('learn more card', function () {
@@ -238,7 +213,6 @@ module('Integration | Component | dashboard/overview', function (hooks) {
<Dashboard::Overview
@version={{this.version}}
@isRootNamespace={{this.isRootNamespace}}
@license={{this.license}}
@secretsEngines={{this.secretsEngines}}
@vaultConfiguration={{this.vaultConfiguration}}
@replication={{this.replication}}

View File

@@ -12,6 +12,7 @@ import {
destructureClientCounts,
namespaceArrayToObject,
sortMonthsByTimestamp,
setStartTimeQuery,
} from 'core/utils/client-count-utils';
import { LICENSE_START } from 'vault/mirage/handlers/clients';
import {
@@ -27,6 +28,9 @@ used to normalize the sys/counters/activity response in the clients/activity
serializer. these functions are tested individually here, instead of all at once
in a serializer test for easier debugging
*/
// TODO refactor tests into a module for each util method, then make each assertion its separate tests
module('Integration | Util | client count utils', function (hooks) {
setupTest(hooks);
@@ -372,4 +376,35 @@ module('Integration | Util | client count utils', function (hooks) {
'it formats combined data for monthly namespaces_by_key spanning upgrade to 1.10'
);
});
test('setStartTimeQuery: it returns start time query for activity log', async function (assert) {
assert.expect(6);
const apiPath = 'sys/internal/counters/config';
assert.strictEqual(setStartTimeQuery(true, {}), null, `it returns null if no permission to ${apiPath}`);
assert.strictEqual(
setStartTimeQuery(false, {}),
null,
`it returns null for community edition and no permission to ${apiPath}`
);
assert.strictEqual(
setStartTimeQuery(true, { billingStartTimestamp: new Date('2022-06-08T00:00:00Z') }),
1654646400,
'it returns unix time if enterprise and billing_start_timestamp exists'
);
assert.strictEqual(
setStartTimeQuery(false, { billingStartTimestamp: new Date('0001-01-01T00:00:00Z') }),
null,
'it returns null time for community edition even if billing_start_timestamp exists'
);
assert.strictEqual(
setStartTimeQuery(false, { foo: 'bar' }),
null,
'it returns null if billing_start_timestamp key does not exist'
);
assert.strictEqual(
setStartTimeQuery(false, undefined),
null,
'fails gracefully if no config model is passed'
);
});
});