UI: Update Enterprise Client Count Datepicker (#30349)

* date picker changes (mostly) for ent client counts

* Move edit modal button + padding

* only show start time in dropdown and add changelog

* remove unused variable and update toggle width

* remove unnecessary period end dates

* tidy

* update tests

* Update changelog/30349.txt

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* improve date logic

* add export button back in, re-arrange header, update dropdown

* update when date is shown

* add default for retention months

* update tests and remove unnecessary tests

* account for retention months that are not whole periods

* update logic to show end date on export modal

* update exported file name

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
lane-wetmore 2025-05-01 11:42:58 -05:00 committed by GitHub
parent c019fa2bad
commit 9c8d8e8013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 155 additions and 131 deletions

4
changelog/30349.txt Normal file
View file

@ -0,0 +1,4 @@
```release-note:improvement
ui (enterprise): Replace date selector in client count usage page with fixed start and end dates that align with billing periods in order to return more relevant client counting data.
```

View file

@ -4,35 +4,47 @@
}}
<div ...attributes>
<Hds::Text::Display @tag="p" class="has-bottom-margin-xs">
Client counting period
</Hds::Text::Display>
<Hds::Text::Body @tag="p" @color="faint" @size="300">
The dashboard displays client count activity during the specified date range below. Click edit to update the date range.
</Hds::Text::Body>
<div class="is-flex-column align-items-end">
<Hds::Text::Display @tag="p" @size="100" class="has-bottom-margin-xs">
Change billing period
</Hds::Text::Display>
<div class="is-flex">
{{#if this.version.isEnterprise}}
<Hds::Dropdown class="has-left-margin-xs" @matchToggleWidth={{true}} as |D|>
<D.ToggleButton @text="Billing start date" @color="secondary" data-test-date-range-edit />
<D.Description @text="Current period" />
<D.Checkmark
{{on "click" (fn this.updateEnterpriseDateRange @billingStartTime)}}
@selected={{eq this.selectedStart (this.formattedDate @billingStartTime)}}
data-test-date-range-billing-start="0"
>
{{this.formattedDate @billingStartTime}}
</D.Checkmark>
{{#if this.historicalBillingPeriods.length}}
<D.Separator />
<D.Description @text="Historical periods" />
{{#each this.historicalBillingPeriods as |period idx|}}
<D.Checkmark
{{on "click" (fn this.updateEnterpriseDateRange period)}}
data-test-date-range-billing-start={{add idx 1}}
@selected={{eq this.selectedStart (this.formattedDate period)}}
>
{{this.formattedDate period}}
</D.Checkmark>
{{/each}}
{{/if}}
</Hds::Dropdown>
{{else}}
<Hds::Button
class="has-left-margin-xs"
@text="Set date range"
@icon="edit"
{{on "click" (fn (mut this.showEditModal) true)}}
data-test-date-range-edit
/>
{{/if}}
</div>
<div class="is-flex-align-baseline">
{{#if (and @startTime @endTime)}}
<p class="is-size-6" data-test-date-range="start">{{this.formattedDate @startTime}}</p>
<p class="has-left-margin-xs"> — </p>
<p class="is-size-6 has-left-margin-xs" data-test-date-range="end">{{this.formattedDate @endTime}}</p>
<Hds::Button
class="has-left-margin-xs"
@text="Edit"
@color="tertiary"
@icon="edit"
@iconPosition="trailing"
data-test-date-range-edit
{{on "click" (fn (mut this.showEditModal) true)}}
/>
{{else}}
<Hds::Button
@text="Set date range"
@icon="edit"
{{on "click" (fn (mut this.showEditModal) true)}}
data-test-date-range-edit
/>
{{/if}}
</div>
{{#if this.showEditModal}}
@ -43,11 +55,6 @@
<M.Body>
<p class="has-bottom-margin-s">
The start date will be used as the client counting start time and all clients in that month will be considered new.
{{#if this.version.isEnterprise}}
We recommend setting this date as your license or billing start date to get the most accurate new and total
client count estimations. These dates are only for querying data in storage. Editing the date range does not
change any license or billing configurations.
{{/if}}
</p>
<div class="clients-date-range-display">
<div>
@ -78,15 +85,6 @@
<F.Label>End</F.Label>
</Hds::Form::TextInput::Field>
</div>
{{#if this.version.isEnterprise}}
<Hds::Button
@text="Reset"
@color="tertiary"
@icon="reload"
{{on "click" this.resetDates}}
data-test-date-edit="reset"
/>
{{/if}}
</div>
{{#if this.validationError}}
<Hds::Form::Error
@ -94,11 +92,6 @@
data-test-date-range-validation
>{{this.validationError}}</Hds::Form::Error>
{{/if}}
{{#if (and this.version.isEnterprise this.useDefaultDates)}}
<Hds::Alert @type="compact" @color="highlight" class="has-top-margin-xs" data-test-range-default-alert as |A|>
<A.Description>Dashboard will use the default date range from the API.</A.Description>
</Hds::Alert>
{{/if}}
</M.Body>
<M.Footer as |F|>
<Hds::Button data-test-save @text="Save" {{on "click" this.handleSave}} />

View file

@ -22,6 +22,8 @@ interface Args {
onChange: (callback: OnChangeParams) => void;
startTime: string;
endTime: string;
billingStartTime: string;
retentionMonths: number;
}
/**
* @module ClientsDateRange
@ -34,6 +36,8 @@ interface Args {
* @param {function} onChange - callback when a new range is saved.
* @param {string} [startTime] - ISO string timestamp of the current start date
* @param {string} [endTime] - ISO string timestamp of the current end date
* @param {int} [retentionMonths=48] - number of months for historical billing
* @param {string} [billingStartTime] - ISO string timestamp of billing start date
*/
export default class ClientsDateRangeComponent extends Component<Args> {
@ -42,6 +46,7 @@ export default class ClientsDateRangeComponent extends Component<Args> {
@tracked showEditModal = false;
@tracked startDate = ''; // format yyyy-MM
@tracked endDate = ''; // format yyyy-MM
@tracked selectedStart = this.args.billingStartTime;
currentMonth = format(timestamp.now(), 'yyyy-MM');
constructor(owner: unknown, args: Args) {
@ -52,6 +57,7 @@ export default class ClientsDateRangeComponent extends Component<Args> {
setTrackedFromArgs() {
if (this.args.startTime) {
this.startDate = parseAPITimestamp(this.args.startTime, 'yyyy-MM') as string;
this.selectedStart = this.formattedDate(this.args.startTime) as string;
}
if (this.args.endTime) {
this.endDate = parseAPITimestamp(this.args.endTime, 'yyyy-MM') as string;
@ -62,6 +68,25 @@ export default class ClientsDateRangeComponent extends Component<Args> {
return parseAPITimestamp(isoTimestamp, 'MMMM yyyy');
};
get historicalBillingPeriods() {
// we want whole billing periods
const count = Math.floor(this.args.retentionMonths / 12);
const periods: string[] = [];
for (let i = 1; i <= count; i++) {
const startDate = new Date(this.args.billingStartTime);
const utcMonth = startDate.getUTCMonth();
const utcYear = startDate.getUTCFullYear() - i;
startDate.setUTCFullYear(utcYear);
startDate.setUTCMonth(utcMonth);
periods.push(startDate.toISOString());
}
return periods;
}
get useDefaultDates() {
return !this.startDate && !this.endDate;
}
@ -100,6 +125,7 @@ export default class ClientsDateRangeComponent extends Component<Args> {
}
}
// used for CE date picker
@action handleSave() {
if (this.validationError) return;
const params: OnChangeParams = {
@ -122,4 +148,21 @@ export default class ClientsDateRangeComponent extends Component<Args> {
this.args.onChange(params);
this.onClose();
}
@action
updateEnterpriseDateRange(start: string) {
const params: OnChangeParams = {
start_time: undefined,
end_time: undefined,
};
const [year, month] = start.split('-');
if (year && month) {
// pass true for isEnd even for start because we want to go off last day of month here, otherwise we risk
// setting it to a start_time that is for the previous billing period
params.start_time = formatDateObject({ monthIdx: parseInt(month) - 1, year: parseInt(year) }, true);
}
this.args.onChange(params);
}
}

View file

@ -4,15 +4,23 @@
}}
<Hds::PageHeader class="has-top-padding-l has-bottom-padding-m" as |PH|>
<PH.Title>Vault Usage Metrics</PH.Title>
<PH.Description>
This dashboard surfaces Vault client usage over time.
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
Date queries are sent in UTC.
</PH.Description>
<PH.Actions>
<PH.Title>
Client Usage
</PH.Title>
{{#if (and this.formattedStartDate this.formattedEndDate)}}
<PH.Description class="has-text-weight-semibold">
<p>
For billing period:
<span data-test-date-range="start">{{this.formattedStartDate}}</span>
-
<span data-test-date-range="end">{{this.formattedEndDate}}</span>
</p>
</PH.Description>
{{/if}}
<PH.Actions class="align-items-end">
{{#if this.showExportButton}}
<Hds::Button
class="has-font-weight-normal"
data-test-export-button
@text="Export activity data"
@color="secondary"
@ -20,6 +28,13 @@
{{on "click" (fn (mut this.showExportModal) true)}}
/>
{{/if}}
<Clients::DateRange
@startTime={{@startTimestamp}}
@endTime={{@endTimestamp}}
@billingStartTime={{@billingStartTime}}
@retentionMonths={{@retentionMonths}}
@onChange={{@onChange}}
/>
</PH.Actions>
</Hds::PageHeader>
@ -45,8 +60,11 @@
<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.showEndDate}}
{{"-"}}
{{this.formattedEndDate}}
{{/if}}
</p>
<Hds::Form::Select::Field
class="has-bottom-margin-s"

View file

@ -10,7 +10,7 @@ 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 { isSameMonth } from 'date-fns';
import { task } from 'ember-concurrency';
/**
@ -31,6 +31,7 @@ export default class ClientsPageHeaderComponent extends Component {
@service download;
@service namespace;
@service store;
@service version;
@tracked canDownload = false;
@tracked showExportModal = false;
@ -68,15 +69,20 @@ export default class ClientsPageHeaderComponent extends Component {
}
get formattedEndDate() {
if (!this.args.startTimestamp && !this.args.endTimestamp) return null;
if (!this.args.endTimestamp) return null;
return parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy');
}
get showEndDate() {
// displays on CSV export modal, no need to display duplicate months and years
if (!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 formattedCsvFileName() {
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
const endRange = this.showEndDate ? `-${this.formattedEndDate}` : '';
const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : '';
const ns = this.namespaceFilter ? `_${this.namespaceFilter}` : '';
return `clients_export${ns}${csvDateRange}`;

View file

@ -2,22 +2,20 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Clients::PageHeader
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@namespace={{@namespace}}
@upgradesDuringActivity={{this.upgradesDuringActivity}}
@noData={{not @activity.total.clients}}
/>
<div class="has-border-bottom-light">
<Clients::PageHeader
@billingStartTime={{this.formattedBillingStartDate}}
@retentionMonths={{@config.retentionMonths}}
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@namespace={{@namespace}}
@upgradesDuringActivity={{this.upgradesDuringActivity}}
@noData={{not @activity.total.clients}}
@onChange={{this.onDateChange}}
/>
</div>
<div class="box is-sideless is-fullwidth is-marginless is-bottomless is-shadowless">
<Clients::DateRange
@startTime={{@startTimestamp}}
@endTime={{@endTimestamp}}
@onChange={{this.onDateChange}}
class="has-bottom-margin-l"
/>
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} @dateRangeMessage={{this.dateRangeMessage}} />

View file

@ -42,6 +42,10 @@ export default class ClientsCountsPageComponent extends Component<Args> {
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
}
get formattedBillingStartDate() {
return this.args.config.billingStartTimestamp.toISOString();
}
// returns text for empty state message if noActivityData
get dateRangeMessage() {
if (this.args.startTimestamp && this.args.endTimestamp) {

View file

@ -199,3 +199,7 @@
.align-items-center {
align-items: center;
}
.align-items-end {
align-items: end;
}

View file

@ -44,6 +44,7 @@ module('Acceptance | clients | counts', function (hooks) {
});
test('it should persist filter query params between child routes', async function (assert) {
this.owner.lookup('service:version').type = 'community';
await visit('/vault/clients/counts/overview');
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-03');

View file

@ -63,7 +63,8 @@ module('Acceptance | clients | overview', function (hooks) {
assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query');
});
test('it should update charts when querying date ranges', async function (assert) {
// TODO revisit once CE changes are finalized
test.skip('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts (July 2023)
const licenseStartMonth = format(LICENSE_START, 'yyyy-MM');
const upgradeMonth = format(UPGRADE_DATE, 'yyyy-MM');

View file

@ -13,6 +13,7 @@ export const CLIENT_COUNT = {
startDiscrepancy: '[data-test-counts-start-discrepancy]',
},
dateRange: {
dropdownOption: (idx = 0) => `[data-test-date-range-billing-start="${idx}"]`,
dateDisplay: (name: string) => (name ? `[data-test-date-range="${name}"]` : '[data-test-date-range]'),
edit: '[data-test-date-range-edit]',
editModal: '[data-test-date-range-edit-modal]',

View file

@ -21,10 +21,12 @@ module('Integration | Component | clients/date-range', function (hooks) {
this.now = timestamp.now();
this.startTime = '2018-01-01T14:15:30';
this.endTime = '2019-01-31T14:15:30';
this.billingStartTime = '2018-01-01T14:15:30';
this.retentionMonths = 48;
this.onChange = Sinon.spy();
this.renderComponent = async () => {
await render(
hbs`<Clients::DateRange @startTime={{this.startTime}} @endTime={{this.endTime}} @onChange={{this.onChange}} />`
hbs`<Clients::DateRange @startTime={{this.startTime}} @endTime={{this.endTime}} @onChange={{this.onChange}} @billingStartTime={{this.billingStartTime}} @retentionMonths={{this.retentionMonths}}/>`
);
};
});
@ -52,61 +54,18 @@ module('Integration | Component | clients/date-range', function (hooks) {
assert.dom(DATE_RANGE.editModal).doesNotExist('closes modal');
});
test('it renders the date range passed and can reset it (ent)', async function (assert) {
this.owner.lookup('service:version').type = 'enterprise';
await this.renderComponent();
assert.dom(DATE_RANGE.dateDisplay('start')).hasText('January 2018');
assert.dom(DATE_RANGE.dateDisplay('end')).hasText('January 2019');
assert.dom(DATE_RANGE.edit).hasText('Edit');
await click(DATE_RANGE.edit);
assert.dom(DATE_RANGE.editModal).exists();
assert.dom(DATE_RANGE.editDate('start')).hasValue('2018-01');
assert.dom(DATE_RANGE.editDate('end')).hasValue('2019-01');
assert.dom(DATE_RANGE.defaultRangeAlert).doesNotExist();
await click(DATE_RANGE.editDate('reset'));
assert.dom(DATE_RANGE.editDate('start')).hasValue('');
assert.dom(DATE_RANGE.editDate('end')).hasValue('');
assert.dom(DATE_RANGE.defaultRangeAlert).exists();
await click(GENERAL.saveButton);
assert.deepEqual(this.onChange.args[0], [{ start_time: undefined, end_time: undefined }]);
});
test('it renders the date range passed and cannot reset it when community', async function (assert) {
test('it does not trigger onChange if date range invalid', async function (assert) {
this.owner.lookup('service:version').type = 'community';
await this.renderComponent();
assert.dom(DATE_RANGE.dateDisplay('start')).hasText('January 2018');
assert.dom(DATE_RANGE.dateDisplay('end')).hasText('January 2019');
assert.dom(DATE_RANGE.edit).hasText('Edit');
await click(DATE_RANGE.edit);
assert.dom(DATE_RANGE.editModal).exists();
assert.dom(DATE_RANGE.editDate('reset')).doesNotExist();
assert.dom(DATE_RANGE.editDate('start')).hasValue('2018-01');
assert.dom(DATE_RANGE.editDate('end')).hasValue('2019-01');
assert.dom(DATE_RANGE.defaultRangeAlert).doesNotExist();
await fillIn(DATE_RANGE.editDate('start'), '');
assert.dom(DATE_RANGE.validation).hasText('You must supply both start and end dates.');
await click(GENERAL.saveButton);
assert.false(this.onChange.called);
});
test('it does not trigger onChange if date range invalid', async function (assert) {
this.owner.lookup('service:version').type = 'enterprise';
await this.renderComponent();
await click(DATE_RANGE.edit);
await click(DATE_RANGE.editDate('reset'));
await fillIn(DATE_RANGE.editDate('end'), '2017-05');
await fillIn(DATE_RANGE.editDate('end'), '');
assert.dom(DATE_RANGE.validation).hasText('You must supply both start and end dates.');
await click(GENERAL.saveButton);
assert.false(this.onChange.called);
await fillIn(DATE_RANGE.editDate('start'), '2018-01');
await fillIn(DATE_RANGE.editDate('end'), '2017-05');
assert.dom(DATE_RANGE.validation).hasText('Start date must be before end date.');
await click(GENERAL.saveButton);
assert.false(this.onChange.called);

View file

@ -124,18 +124,10 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
expectedStart: 'January 2023',
expectedEnd: 'December 2023',
},
{
scenario: 'reset',
expected: { start_time: undefined, end_time: undefined },
reset: true,
expectedStart: 'July 2023',
expectedEnd: 'January 2024',
},
].forEach((testCase) => {
test(`it should send correct millis value on filter change when ${testCase.scenario}`, async function (assert) {
assert.expect(5);
// set to enterprise so reset will save correctly
this.owner.lookup('service:version').type = 'enterprise';
this.owner.lookup('service:version').type = 'community';
this.onFilterChange = (params) => {
assert.deepEqual(params, testCase.expected, 'Correct values sent on filter change');
// in the app, the timestamp choices trigger a qp refresh as millis from epoch,