Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run

This commit is contained in:
hc-github-team-secure-vault-core 2026-02-03 17:17:58 +00:00
commit 64230814b2
68 changed files with 885 additions and 1241 deletions

View file

@ -1,68 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import queryParamString from 'vault/utils/query-param-string';
import ApplicationAdapter from '../application';
import { debug } from '@ember/debug';
import { formatQueryParams } from 'core/utils/client-counts/serializers';
export default class ActivityAdapter extends ApplicationAdapter {
queryRecord(store, type, query) {
const url = `${this.buildURL()}/internal/counters/activity`;
const options = {
data: formatQueryParams(query),
};
if (query?.namespace) {
options.namespace = query.namespace;
}
return this.ajax(url, 'GET', options).then((resp) => {
const response = resp || {};
response.id = response.request_id || 'no-data';
return response;
});
}
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, httpStatus;
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.';
httpStatus = resp.status;
} catch (e) {
const { errors } = await e.json();
errorMsg = errors?.join('. ');
httpStatus = e.status;
}
// counters/activity/export returns a ReadableStream so we manually handle errors here
// hopefully this can be improved when this file is migrated to use the api service.
if (errorMsg) {
const error = new Error(errorMsg);
error.httpStatus = httpStatus;
error.path = 'sys/internal/counters/activity/export';
throw error;
}
}
// Only dashboard uses findRecord, the client count dashboard uses queryRecord
findRecord(store, type, id) {
if (id !== 'clients/activity') {
debug(`findRecord('clients/activity') should pass 'clients/activity' as the id, you passed: '${id}'`);
}
const url = `${this.buildURL()}/internal/counters/activity`;
return this.ajax(url, 'GET', { skipWarnings: true });
}
}

View file

@ -1,23 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Application from '../application';
export default Application.extend({
queryRecord() {
return this.ajax(this.urlForQuery(), 'GET').then((resp) => {
resp.id = resp.request_id;
return resp;
});
},
urlForUpdateRecord() {
return this.buildURL() + '/internal/counters/config';
},
urlForQuery() {
return this.buildURL() + '/internal/counters/config';
},
});

View file

@ -1,18 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
export default class VersionHistoryAdapter extends ApplicationAdapter {
findAll() {
return this.ajax(this.buildURL() + '/version-history', 'GET', {
data: {
list: true,
},
}).then((resp) => {
return resp;
});
}
}

View file

@ -4,41 +4,56 @@
}}
{{#if (eq @mode "edit")}}
<form onsubmit={{action "onSaveChanges"}} data-test-clients-config-form>
<form onsubmit={{this.onSubmit}} data-test-clients-config-form>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{@model}} @errorMessage={{this.error}} />
{{#each @model.formFields as |attr|}}
{{#if (eq attr.name "enabled")}}
{{#unless @model.reportingEnabled}}
<label class="is-label">Usage data collection</label>
<p class="sub-text">
Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current
month.
</p>
<div class="control is-flex has-bottom-margin-l">
<input
data-test-input="enabled"
type="checkbox"
id="enabled"
name="enabled"
class="toggle is-success is-small"
checked={{eq @model.enabled "On"}}
{{on "change" this.toggleEnabled}}
/>
<label for="enabled" class="has-text-weight-bold is-size-8">
Data collection is
{{lowercase @model.enabled}}
</label>
</div>
{{/unless}}
{{else}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.validations}} />
{{/if}}
{{/each}}
<MessageError @errorMessage={{this.errorMessage}} />
{{! reporting_enabled is specifically for automated reporting }}
{{! it will be false for CE clusters or enterprise customers who have opted out of automated license reporting }}
{{#unless @config.reporting_enabled}}
<label class="is-label">Usage data collection</label>
<p class="sub-text">
Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current month.
</p>
<div class="control is-flex has-bottom-margin-l">
<Hds::Form::Toggle::Field
@id="enabled"
data-test-input="enabled"
checked={{this.enabled}}
{{on "change" (pipe (pick "target.checked") (fn (mut this.enabled)))}}
as |F|
>
<F.Label data-test-form-field-label>
Data collection is
{{if this.enabled "on" "off"}}
</F.Label>
</Hds::Form::Toggle::Field>
</div>
{{/unless}}
<div class="field" data-test-field="name">
<Hds::Form::TextInput::Field
@value={{@config.retention_months}}
@isInvalid={{this.validationError}}
disabled={{this.save.isRunning}}
autocomplete="off"
spellcheck="false"
data-test-input="retention_months"
{{on "input" (pipe (pick "target.value") (fn (mut @config.retention_months)))}}
as |F|
>
<F.Label>Retention period</F.Label>
<F.HelperText>
The number of months of activity logs to maintain for client tracking.
</F.HelperText>
{{#if this.validationError}}
<F.Error data-test-validation-error="retention_months">{{this.validationError}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
</div>
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text="Save" type="submit" disabled={{this.buttonDisabled}} data-test-submit />
<Hds::Button @text="Save" type="submit" disabled={{this.save.isRunning}} data-test-submit />
<Hds::Button @text="Cancel" @color="secondary" @route="vault.cluster.clients.config" />
</Hds::ButtonSet>
</div>
@ -50,7 +65,7 @@
{{this.modalTitle}}
</M.Header>
<M.Body>
{{#if (eq @model.enabled "On")}}
{{#if this.enabled}}
<p class="has-bottom-margin-s" data-test-clients-config-modal="on">
Vault will start tracking data starting from todays date,
{{date-format (now) "MMMM d, yyyy"}}. If youve previously enabled usage tracking, that historical data will
@ -75,7 +90,7 @@
{{else}}
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-clients-config-table>
{{#each this.infoRows as |item|}}
<InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{get @model item.valueKey}} />
<InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{item.value}} />
{{/each}}
</div>
{{/if}}

View file

@ -1,80 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
/**
* @module ClientsConfig
* ClientsConfig components are used to show and edit the client count config information.
*
* @example
* ```js
* <Clients::Config @model={{model}} @mode="edit" />
* ```
* @param {object} model - model is the DS clients/config model which should be passed in
* @param {string} [mode=show] - mode is either show or edit. Show results in a table with the config, show has a form.
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class ConfigComponent extends Component {
@service router;
@tracked mode = 'show';
@tracked modalOpen = false;
@tracked validations;
@tracked error = null;
get infoRows() {
return [
{
label: 'Usage data collection',
helperText: 'Enable or disable collecting data to track clients.',
valueKey: 'enabled',
},
{
label: 'Retention period',
helperText: 'The number of months of activity logs to maintain for client tracking.',
valueKey: 'retentionMonths',
},
];
}
get modalTitle() {
return `Turn usage tracking ${this.args.model.enabled.toLowerCase()}?`;
}
@(task(function* () {
try {
yield this.args.model.save();
this.router.transitionTo('vault.cluster.clients.config');
} catch (err) {
this.error = err.message;
this.modalOpen = false;
}
}).drop())
save;
@action
toggleEnabled(event) {
this.args.model.enabled = event.target.checked ? 'On' : 'Off';
}
@action
onSaveChanges(evt) {
evt.preventDefault();
const { isValid, state } = this.args.model.validate();
const changed = this.args.model.changedAttributes();
if (!isValid) {
this.validations = state;
} else if (changed.enabled) {
this.modalOpen = true;
} else {
this.save.perform();
}
}
}

View file

@ -0,0 +1,99 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
import type RouterService from '@ember/routing/router-service';
import type { HTMLElementEvent } from 'vault/forms';
import type ApiService from 'vault/services/api';
import type Owner from '@ember/owner';
interface Args {
config: InternalClientActivityReadConfigurationResponse;
mode: 'show' | 'edit';
}
export default class ConfigComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly api: ApiService;
@tracked modalOpen = false;
@tracked declare enabled: boolean;
@tracked validationError = '';
@tracked errorMessage = '';
constructor(owner: Owner, args: Args) {
super(owner, args);
const { enabled = '' } = args.config;
// possible config values are 'enable', 'disable', 'default-enabled', 'default-disabled'
this.enabled = enabled.includes('enable');
}
get infoRows() {
return [
{
label: 'Usage data collection',
helperText: 'Enable or disable collecting data to track clients.',
value: this.enabled ? 'On' : 'Off',
},
{
label: 'Retention period',
helperText: 'The number of months of activity logs to maintain for client tracking.',
value: this.args.config.retention_months,
},
];
}
get modalTitle() {
return `Turn usage tracking ${this.enabled ? 'on' : 'off'}?`;
}
@action
onSubmit(event: HTMLElementEvent<HTMLFormElement>) {
event.preventDefault();
this.validationError = '';
// since minimum_retention_months may be returned as 0, default to 48 which is the documented minimum
// https://developer.hashicorp.com/vault/api-docs/system/internal-counters#retention_months
const { minimum_retention_months, retention_months, enabled = '' } = this.args.config;
const minRetention = minimum_retention_months || 48;
if (Number(retention_months) < minRetention) {
this.validationError = `Retention period must be greater than or equal to ${minRetention}.`;
} else if (Number(retention_months) > 60) {
this.validationError = 'Retention period must be less than or equal to 60.';
}
// if form is valid and enabled value has changed show the confirmation modal
// values for enabled may include 'default-' so check for inclusion of enable or disable
if (!this.validationError) {
const didChange = enabled.includes('enable') ? !this.enabled : !!this.enabled;
if (didChange) {
// the modal confirm action will trigger the save task directly
this.modalOpen = true;
} else {
this.save.perform();
}
}
}
save = task(async () => {
try {
const payload = {
enabled: this.enabled ? 'enable' : 'disable',
retention_months: Number(this.args.config.retention_months),
};
await this.api.sys.internalClientActivityConfigure(payload);
this.router.transitionTo('vault.cluster.clients.config');
} catch (error) {
const { message } = await this.api.parseError(error);
this.errorMessage = message;
}
});
}

View file

@ -11,7 +11,7 @@
{{if this.flags.isHvdManaged "Change data period" "Change billing period"}}
</Hds::Text::Display>
<Hds::Dropdown class="has-left-margin-xs" as |D|>
<D.ToggleButton @text={{this.formatDropdownDate @startTimestamp}} @color="secondary" data-test-date-range-edit />
<D.ToggleButton @text={{this.formatDate @startTimestamp}} @color="secondary" data-test-date-range-edit />
<D.Description @text="Current period" />
<D.Checkmark
{{! Pass an empty string to reset query param because the current billing period is the default }}
@ -19,7 +19,7 @@
@selected={{this.isSelected @billingStartTime}}
data-test-date-range-billing-start="0"
>
{{this.formatDropdownDate @billingStartTime}}
{{this.formatDate @billingStartTime}}
</D.Checkmark>
{{#if this.historicalBillingPeriods.length}}
<D.Separator class="has-bottom-margin-xs" />
@ -30,7 +30,7 @@
data-test-date-range-billing-start={{add idx 1}}
@selected={{this.isSelected period}}
>
{{this.formatDropdownDate period}}
{{this.formatDate period}}
</D.Checkmark>
{{/each}}
{{/if}}
@ -77,7 +77,7 @@
<Hds::Form::TextInput::Field
@type="month"
@value={{this.modalStart}}
max={{if this.version.isCommunity this.previousMonth (date-format this.currentMonth "yyyy-MM")}}
max={{if this.version.isCommunity this.previousMonth (this.formatDate this.currentMonth "yyyy-MM")}}
id="start-month"
name="modalStart"
{{on "change" this.updateDate}}
@ -91,7 +91,7 @@
<Hds::Form::TextInput::Field
@type="month"
@value={{this.modalEnd}}
max={{if this.version.isCommunity this.previousMonth (date-format this.currentMonth "yyyy-MM")}}
max={{if this.version.isCommunity this.previousMonth (this.formatDate this.currentMonth "yyyy-MM")}}
id="end-month"
name="modalEnd"
{{on "change" this.updateDate}}

View file

@ -24,9 +24,9 @@ interface Args {
onChange: (callback: OnChangeParams) => void;
setEditModalVisible: (visible: boolean) => void;
showEditModal: boolean;
startTimestamp: string;
endTimestamp: string;
billingStartTime: string;
startTimestamp: Date;
endTimestamp: Date;
billingStartTime: Date;
retentionMonths: number;
}
/**
@ -66,15 +66,16 @@ export default class ClientsDateRangeComponent extends Component<Args> {
get historicalBillingPeriods() {
// we want whole billing periods
const { billingStartTime } = this.args;
const totalMonths = this.args.retentionMonths || 48;
const count = Math.floor(totalMonths / 12);
const periods: string[] = [];
const periods: Date[] = [];
for (let i = 1; i <= count; i++) {
const startDate = parseAPITimestamp(this.args.billingStartTime) as Date;
const startDate = new Date(billingStartTime);
const utcYear = startDate.getUTCFullYear() - i;
startDate.setUTCFullYear(utcYear);
periods.push(startDate.toISOString());
periods.push(startDate);
}
return periods;
}
@ -122,9 +123,10 @@ export default class ClientsDateRangeComponent extends Component<Args> {
}
@action
updateEnterpriseDateRange(start: string, close: CallableFunction) {
updateEnterpriseDateRange(start: Date, close: CallableFunction) {
// We do not send an end_time so the backend handles computing the expected billing period
this.args.onChange({ start_time: start, end_time: '' });
const start_time = start ? start.toISOString() : '';
this.args.onChange({ start_time, end_time: '' });
close();
}
@ -138,20 +140,20 @@ export default class ClientsDateRangeComponent extends Component<Args> {
setTrackedFromArgs() {
if (this.args.startTimestamp) {
this.modalStart = parseAPITimestamp(this.args.startTimestamp, 'yyyy-MM') as string;
this.modalStart = this.formatDate(this.args.startTimestamp, 'yyyy-MM');
}
if (this.args.endTimestamp) {
this.modalEnd = parseAPITimestamp(this.args.endTimestamp, 'yyyy-MM') as string;
this.modalEnd = this.formatDate(this.args.endTimestamp, 'yyyy-MM');
}
}
// TEMPLATE HELPERS
formatDropdownDate = (isoTimestamp: string) => parseAPITimestamp(isoTimestamp, 'MMMM yyyy');
formatDate = (date: Date, displayFormat = 'MMMM yyyy') => parseAPITimestamp(date, displayFormat);
isSelected = (dropdownTimestamp: string) => {
isSelected = (dropdownTimestamp: Date) => {
// Compare against this.args.startTimestamp because it's from the URL query param
// which is used to query the client count activity API.
const selectedStart = this.formatDropdownDate(this.args.startTimestamp);
return this.formatDropdownDate(dropdownTimestamp) === selectedStart;
const selectedStart = this.formatDate(this.args.startTimestamp);
return this.formatDate(dropdownTimestamp) === selectedStart;
};
}

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (or @config.reportingEnabled (eq @config.enabled "On"))}}
{{#if (or @config.reporting_enabled (eq @config.enabled "default-enabled") (eq @config.enabled "enable"))}}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title="No data received" />
<A.Body
@ -18,13 +18,14 @@
data-test-empty-state-message
@text="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
/>
{{#if @config.canEdit}}
{{#if @canUpdate}}
<A.Footer as |F|>
<F.LinkStandalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Go to configuration"
@route="vault.cluster.clients.config"
data-test-link-to="config"
/>
</A.Footer>
{{/if}}

View file

@ -10,8 +10,19 @@ 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 { isSameMonth } from 'date-fns';
import { task } from 'ember-concurrency';
import { formatExportData } from 'core/utils/client-counts/serializers';
import type DownloadService from 'vault/services/download';
import type FlagsService from 'vault/services/flags';
import type NamespaceService from 'vault/services/namespace';
import type RouterService from '@ember/routing/router-service';
import type ApiService from 'vault/services/api';
import type VersionService from 'vault/services/version';
import type CapabilitiesService from 'vault/services/capabilities';
import type Owner from '@ember/owner';
import type { HTMLElementEvent } from 'vault/forms';
import type { Extensions } from 'vault/services/download';
/**
* @module ClientsPageHeader
@ -21,31 +32,44 @@ import { task } from 'ember-concurrency';
* ```js
* <Clients::PageHeader @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @namespace="foo" @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }} />
* ```
* @param {string} [billingStartTime] - ISO timestamp of billing start date, to be passed to date picker
* @param {Date} [billingStartTime] - billing start date, to be passed to date picker
* @param {string} [activityTimestamp] - ISO timestamp created in serializer to timestamp the response to be displayed in page header
* @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 {Date} [startTimestamp] - start time, to be passed to export request
* @param {Date} [endTimestamp] - end time, to be passed to export request
* @param {number} [retentionMonths = 48] - number of months for historical billing, to be passed to date picker
* @param {string} [upgradesDuringActivity] - array of objects containing version history upgrade data
* @param {boolean} [noData = false] - when true, export button will hide regardless of capabilities
* @param {function} [onChange] - callback when a new date range is saved, to be passed to date picker
*/
export default class ClientsPageHeaderComponent extends Component {
@service download;
@service flags;
@service namespace;
@service router;
@service store;
@service version;
interface Args {
billingStartTime: Date;
retentionMonths: number;
activityTimestamp: string;
startTimestamp: Date;
endTimestamp: Date;
upgradesDuringActivity: string[];
noData: boolean;
onChange: CallableFunction;
}
export default class ClientsPageHeaderComponent extends Component<Args> {
@service declare readonly download: DownloadService;
@service declare readonly flags: FlagsService;
@service declare readonly namespace: NamespaceService;
@service declare readonly router: RouterService;
@service declare readonly api: ApiService;
@service declare readonly version: VersionService;
@service declare readonly capabilities: CapabilitiesService;
@tracked canDownload = false;
@tracked showEditModal = false;
@tracked showExportModal = false;
@tracked exportFormat = 'csv';
@tracked exportFormat: keyof Extensions = 'csv';
@tracked downloadError = '';
constructor() {
super(...arguments);
constructor(owner: Owner, args: Args) {
super(owner, args);
this.getExportCapabilities();
}
@ -59,11 +83,9 @@ export default class ClientsPageHeaderComponent extends Component {
const ns = this.namespace.path;
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;
const namespace = sanitizePath(ns);
const { canSudo } = await this.capabilities.for('clientsActivityExport', { namespace });
this.canDownload = canSudo;
} catch (e) {
// if we can't read capabilities, default to show
this.canDownload = true;
@ -71,21 +93,16 @@ export default class ClientsPageHeaderComponent extends Component {
}
get formattedStartDate() {
if (!this.args.startTimestamp) return null;
return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy');
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
}
get formattedEndDate() {
if (!this.args.endTimestamp) return null;
return parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy');
return this.args.endTimestamp ? parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy') : null;
}
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);
return this.formattedEndDate && this.formattedStartDate !== this.formattedEndDate;
}
get formattedCsvFileName() {
@ -96,15 +113,22 @@ export default class ClientsPageHeaderComponent extends Component {
}
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.namespace.path,
});
const namespace = this.namespace.path;
const headers = namespace ? this.api.buildHeaders({ namespace }) : undefined;
const { raw } = await this.api.sys.internalClientActivityExportRaw(
{
// the API only accepts json or csv
format: this.exportFormat === 'jsonl' ? 'json' : 'csv',
start_time: startTimestamp ? startTimestamp.toISOString() : undefined,
end_time: endTimestamp ? endTimestamp.toISOString() : undefined,
},
headers
);
if (raw.status !== 200) {
throw { message: 'No data to export in provided time range.' };
}
return formatExportData(raw, { isDownload: true });
}
exportChartData = task({ drop: true }, async (filename) => {
@ -113,13 +137,14 @@ export default class ClientsPageHeaderComponent extends Component {
this.download.download(filename, contents, this.exportFormat);
this.showExportModal = false;
} catch (e) {
this.downloadError = e.message;
const { message } = await this.api.parseError(e);
this.downloadError = message;
}
});
@action
refreshRoute() {
this.router.refresh(this.router.currentRoute.parent.name);
this.router.refresh(this.router.currentRoute?.parent?.name);
}
@action
@ -129,18 +154,18 @@ export default class ClientsPageHeaderComponent extends Component {
}
@action
setEditModalVisible(visible) {
setEditModalVisible(visible: boolean) {
this.showEditModal = visible;
}
@action
setExportFormat(evt) {
const { value } = evt.target;
this.exportFormat = value;
setExportFormat(event: HTMLElementEvent<HTMLInputElement>) {
const { value } = event.target;
this.exportFormat = value as keyof Extensions;
}
// LOCAL TEMPLATE HELPERS
parseAPITimestamp = (timestamp, format) => {
parseAPITimestamp = (timestamp: string, format: string) => {
return parseAPITimestamp(timestamp, format);
};
}

View file

@ -4,9 +4,9 @@
}}
<div class="has-border-bottom-light">
<Clients::PageHeader
@billingStartTime={{this.formattedBillingStartDate}}
@retentionMonths={{@config.retentionMonths}}
@activityTimestamp={{@activity.responseTimestamp}}
@billingStartTime={{@config.billing_start_timestamp}}
@retentionMonths={{@config.retention_months}}
@activityTimestamp={{@responseTimestamp}}
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@upgradesDuringActivity={{this.upgradesDuringActivity}}
@ -23,26 +23,18 @@
for more information.
</Hds::Text::Body>
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} />
{{else if @activityError}}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title={{this.error.title}} @icon="skip" @errorCode={{this.error.httpStatus}} />
<A.Body data-test-empty-state-message @text={{this.error.text}} />
</Hds::ApplicationState>
{{else}}
{{#if (eq @config.enabled "Off")}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title data-test-counts-disabled>Tracking is disabled</A.Title>
<A.Description>
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
to
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
to enable tracking again.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if this.trackingDisabled}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title data-test-counts-disabled>Tracking is disabled</A.Title>
<A.Description>
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need to
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
to enable tracking again.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if @activity}}
{{#if @activity.total}}
{{#if this.upgradeExplanations}}
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-m" as |A|>
@ -83,16 +75,8 @@
{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
{{yield}}
{{else if (and this.version.isCommunity (or (not @startTimestamp) (not @endTimestamp)))}}
{{! Empty state for community without start or end query param }}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title="Input the start and end dates to view client attribution by path." />
<A.Body
data-test-empty-state-message
@text="Only historical data may be queried. No data is available for the current month."
/>
</Hds::ApplicationState>
{{else}}
{{! Empty state for no data in the selected date range }}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title="No data received" />
<A.Body
@ -105,5 +89,16 @@
/>
</Hds::ApplicationState>
{{/if}}
{{else if (and this.version.isCommunity (or (not @startTimestamp) (not @endTimestamp)))}}
{{! Empty state for community without start or end query param }}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title="Input the start and end dates to view client attribution by path." />
<A.Body
data-test-empty-state-message
@text="Only historical data may be queried. No data is available for the current month."
/>
</Hds::ApplicationState>
{{else}}
<Clients::NoData @config={{@config}} @canUpdate={{@canUpdateConfig}} />
{{/if}}
</div>

View file

@ -9,67 +9,53 @@ import { action } from '@ember/object';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { filterVersionHistory } from 'core/utils/client-counts/helpers';
import type AdapterError from '@ember-data/adapter/error';
import type FlagsService from 'vault/services/flags';
import type VersionService from 'vault/services/version';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
import type { VersionHistory } from 'vault/client-counts';
import type { Activity } from 'vault/client-counts/activity-api';
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
interface Args {
activity: ClientsActivityModel;
activityError?: AdapterError;
config: ClientsConfigModel;
endTimestamp: string; // ISO format
activity: Activity;
config: InternalClientActivityReadConfigurationResponse;
canUpdateConfig: boolean;
endTimestamp: Date;
onFilterChange: CallableFunction;
startTimestamp: string; // ISO format
versionHistory: ClientsVersionHistoryModel[];
startTimestamp: Date;
versionHistory: VersionHistory[];
responseTimestamp: Date;
}
export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
get error() {
const { httpStatus, message, path } = this.args.activityError || {};
let title = 'Error',
text = message;
if (httpStatus === 403) {
const endpoint = path ? `the ${path} endpoint` : 'this endpoint';
title = 'You are not authorized';
text = `You must be granted permissions to view this page. Ask your administrator if you think you should have access to ${endpoint}.`;
}
return { title, text, httpStatus };
get trackingDisabled() {
const { enabled } = this.args.config;
return enabled === 'disable' || enabled === 'default-disabled';
}
get formattedStartDate() {
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
const { startTimestamp } = this.args;
return startTimestamp ? parseAPITimestamp(startTimestamp, 'MMMM yyyy') : null;
}
get formattedEndDate() {
return this.args.endTimestamp ? parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy') : null;
}
get formattedBillingStartDate() {
if (this.args.config?.billingStartTimestamp) {
return this.args.config.billingStartTimestamp.toISOString();
}
return null;
const { endTimestamp } = this.args;
return endTimestamp ? parseAPITimestamp(endTimestamp, 'MMMM yyyy') : null;
}
// passed into page-header for the export modal alert
get upgradesDuringActivity() {
const { versionHistory, activity } = this.args;
return filterVersionHistory(versionHistory, activity?.startTime, activity?.endTime);
return filterVersionHistory(versionHistory, activity?.start_time, activity?.end_time);
}
get upgradeExplanations() {
if (this.upgradesDuringActivity.length) {
return this.upgradesDuringActivity.map((upgrade: ClientsVersionHistoryModel) => {
return this.upgradesDuringActivity.map((upgrade: VersionHistory) => {
let explanation;
const date = parseAPITimestamp(upgrade.timestampInstalled, 'MMM d, yyyy');
const date = parseAPITimestamp(upgrade.timestamp_installed, 'MMM d, yyyy');
const version = upgrade.version || '';
switch (true) {
case version.includes('1.9'):

View file

@ -6,7 +6,7 @@
<Clients::RunningTotal @byMonthClients={{this.byMonthClients}} @runningTotals={{@activity.total}} />
{{! by_namespace is an empty array when there is no client count activity data }}
{{#if @activity.byNamespace}}
{{#if @activity.by_namespace}}
<Clients::CountsCard @title="Client attribution">
<:subheader>
<Clients::FilterToolbar

View file

@ -9,12 +9,11 @@ import { action } from '@ember/object';
import { filterTableData, flattenMounts } from 'core/utils/client-counts/helpers';
import { service } from '@ember/service';
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
import type { ClientFilterTypes } from 'vault/vault/client-counts/activity-api';
import type { ClientFilterTypes, Activity } from 'vault/client-counts/activity-api';
import type FlagsService from 'vault/services/flags';
export interface Args {
activity: ClientsActivityModel;
activity: Activity;
onFilterChange: CallableFunction;
filterQueryParams: Record<ClientFilterTypes, string>;
}
@ -28,11 +27,11 @@ export default class ClientsOverviewPageComponent extends Component<Args> {
get byMonthClients() {
// HVD clusters are billed differently and the monthly total is the important metric.
if (this.flags.isHvdManaged) {
return this.args.activity.byMonth || [];
return this.args.activity.by_month || [];
}
// For self-managed clusters only the new_clients per month are relevant because clients accumulate over a billing period.
// (Since "total" per month is not cumulative it's not a useful metric)
return this.args.activity.byMonth?.map((m) => m?.new_clients) || [];
return this.args.activity.by_month?.map((m) => m?.new_clients) || [];
}
// Supplies data passed to dropdown filters (except months which is computed below )
@ -42,7 +41,7 @@ export default class ClientsOverviewPageComponent extends Component<Args> {
const selectedMonth = this.args.filterQueryParams.month;
const namespaceData = selectedMonth
? this.byMonthClients.find((m) => m.timestamp === selectedMonth)?.namespaces
: this.args.activity.byNamespace;
: this.args.activity.by_namespace;
// Get the array of "mounts" data nested in each namespace object and flatten
return flattenMounts(namespaceData || []);

View file

@ -40,7 +40,7 @@
</small>
</div>
{{else}}
<Clients::NoData @config={{this.activityConfig}} />
<Clients::NoData @config={{this.activityConfig}} @canUpdate={{this.canUpdateActivityConfig}} />
{{/if}}
{{/if}}
</Hds::Card::Container>

View file

@ -1,63 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
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 { parseAPITimestamp } from 'core/utils/date-formatters';
/**
* @module DashboardClientCountCard
* DashboardClientCountCard component are used to display total and new client count information
*
* @example
* <Dashboard::ClientCountCard />
*/
export default class DashboardClientCountCard extends Component {
@service store;
@tracked activityData = null;
@tracked activityConfig = null;
@tracked updatedAt = null;
constructor() {
super(...arguments);
this.fetchClientActivity.perform();
}
get currentMonthActivityTotalCount() {
return this.activityData?.byMonth?.lastObject?.new_clients.clients;
}
get statSubText() {
const format = (date) => parseAPITimestamp(date, 'MMM yyyy');
const { startTime, endTime } = this.activityData;
return startTime && endTime
? {
total: `The number of clients in this billing period (${format(startTime)} - ${format(endTime)}).`,
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
@waitFor
*fetchClientActivity(e) {
if (e) e.preventDefault();
this.updatedAt = timestamp.now().toISOString();
try {
this.activityData = yield this.store.findRecord('clients/activity', 'clients/activity');
} catch (error) {
// used for rendering the "No data" empty state, swallow any errors requesting config data
this.activityConfig = yield this.store.queryRecord('clients/config', {}).catch(() => null);
this.error = error;
}
}
}

View file

@ -0,0 +1,100 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
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 { parseAPITimestamp } from 'core/utils/date-formatters';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
import type ApiService from 'vault/services/api';
import type CapabilitiesService from 'vault/services/capabilities';
import Owner from '@ember/owner';
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
import { HTMLElementEvent } from 'vault/forms';
import type {
Activity,
ByNamespaceClients,
NamespaceObject,
Counts,
ActivityMonthBlock,
} from 'vault/client-counts/activity-api';
/**
* @module DashboardClientCountCard
* DashboardClientCountCard component are used to display total and new client count information
*
* @example
* <Dashboard::ClientCountCard />
*/
export default class DashboardClientCountCard extends Component<object> {
@service declare readonly api: ApiService;
@service declare readonly capabilities: CapabilitiesService;
@tracked activityData: Activity | null = null;
@tracked activityConfig: InternalClientActivityReadConfigurationResponse | null = null;
@tracked canUpdateActivityConfig = true;
@tracked updatedAt = '';
constructor(owner: Owner, args: object) {
super(owner, args);
this.fetchClientActivity.perform();
}
get currentMonthActivityTotalCount() {
const byMonth = this.activityData?.by_month;
return byMonth?.[byMonth.length - 1]?.new_clients.clients;
}
get statSubText() {
let formattedStart, formattedEnd;
if (this.activityData) {
const { start_time, end_time } = this.activityData;
formattedStart = start_time ? parseAPITimestamp(start_time, 'MMM yyyy') : null;
formattedEnd = end_time ? parseAPITimestamp(end_time, 'MMM yyyy') : null;
}
return formattedStart && formattedEnd
? {
total: `The number of clients in this billing period (${formattedStart} - ${formattedEnd}).`,
new: 'The number of clients new to Vault in the current month.',
}
: { total: 'No total client data available.', new: 'No new client data available.' };
}
fetchClientActivity = task(
waitFor(async (e?: HTMLElementEvent<HTMLInputElement>) => {
if (e) e.preventDefault();
this.updatedAt = timestamp.now().toISOString();
this.activityData = null;
this.activityConfig = null;
try {
const response = await this.api.sys.internalClientActivityReportCounts();
if (response) {
this.activityData = {
...response,
by_namespace: formatByNamespace(response.by_namespace as NamespaceObject[] | null),
by_month: formatByMonths(response.months as ActivityMonthBlock[]),
total: destructureClientCounts(response.total as ByNamespaceClients | Counts),
};
}
} catch (error) {
// used for rendering the "No data" empty state, swallow any errors requesting config data
this.activityConfig = await this.api.sys.internalClientActivityReadConfiguration().catch(() => null);
// Clients::NoData needs to know if the user can update the config
const { canUpdate } = await this.capabilities.for('clientsConfig');
this.canUpdateActivityConfig = canUpdate;
}
})
);
}

View file

@ -1,14 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
export default class Activity extends Model {
@attr('array') byMonth;
@attr('array') byNamespace;
@attr('object') total;
@attr('string') startTime;
@attr('string') endTime;
@attr('string') responseTimestamp;
}

View file

@ -1,54 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
retentionMonths: [
{
validator: (model) => parseInt(model.retentionMonths) >= model.minimumRetentionMonths,
message: (model) =>
`Retention period must be greater than or equal to ${model.minimumRetentionMonths}.`,
},
{
validator: (model) => parseInt(model.retentionMonths) <= 60,
message: 'Retention period must be less than or equal to 60.',
},
],
};
@withModelValidations(validations)
@withFormFields(['enabled', 'retentionMonths'])
export default class ClientsConfigModel extends Model {
@attr('boolean') queriesAvailable; // true only if historical data exists, will be false if there is only current month data
@attr('number', {
label: 'Retention period',
subText: 'The number of months of activity logs to maintain for client tracking.',
})
retentionMonths;
@attr('number') minimumRetentionMonths;
// refers specifically to the activitylog and will always be on for enterprise
@attr('string') enabled;
// reporting_enabled is for automated reporting and only true of the customer hasnt opted-out of automated license reporting
@attr('boolean') reportingEnabled;
@attr('date') billingStartTimestamp;
@lazyCapabilities(apiPath`sys/internal/counters/config`) configPath;
get canRead() {
return this.configPath.get('canRead') !== false;
}
get canEdit() {
return this.configPath.get('canUpdate') !== false;
}
}

View file

@ -1,11 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
export default class VersionHistoryModel extends Model {
@attr('string') version;
@attr('string') previousVersion;
@attr('string') timestampInstalled;
}

View file

@ -4,36 +4,25 @@
*/
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
import { service } from '@ember/service';
import { ModelFrom } from 'vault/route';
import type Store from '@ember-data/store';
import type ApiService from 'vault/services/api';
import type CapabilitiesService from 'vault/services/capabilities';
import { VersionHistoryListEnum } from '@hashicorp/vault-client-typescript';
export type ClientsRouteModel = ModelFrom<ClientsRoute>;
export default class ClientsRoute extends Route {
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@service declare readonly capabilities: CapabilitiesService;
getVersionHistory(): Promise<
Array<{ version: string; previousVersion: string; timestampInstalled: string }>
> {
return this.store
.findAll('clients/version-history')
.then((response) => {
return response.map(({ version, previousVersion, timestampInstalled }) => {
return {
version,
previousVersion,
timestampInstalled,
};
});
})
.catch(() => []);
}
model() {
// swallow config error so activity can show if no config permissions
return hash({
config: this.store.queryRecord('clients/config', {}).catch(() => ({})),
versionHistory: this.getVersionHistory(),
});
async model() {
const { canRead: canReadConfig, canUpdate: canUpdateConfig } =
await this.capabilities.for('clientsConfig');
const response = await this.api.sys.versionHistory(VersionHistoryListEnum.TRUE).catch(() => undefined);
const versionHistory = response ? this.api.keyInfoToArray(response, 'version') : [];
const config = await this.api.sys.internalClientActivityReadConfiguration().catch(() => ({}));
return { canReadConfig, canUpdateConfig, versionHistory, config };
}
}

View file

@ -1,15 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ConfigRoute extends Route {
@service store;
model() {
return this.store.queryRecord('clients/config', {});
}
}

View file

@ -0,0 +1,21 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type ApiService from 'vault/services/api';
import type CapabilitiesService from 'vault/services/capabilities';
export default class ConfigRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly capabilities: CapabilitiesService;
async model() {
const capabilities = await this.capabilities.for('clientsConfig');
const config = await this.api.sys.internalClientActivityReadConfiguration();
return { capabilities, config };
}
}

View file

@ -5,18 +5,28 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { formatExportData, formatQueryParams } from 'core/utils/client-counts/serializers';
import {
formatExportData,
formatQueryParams,
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
import { ModelFrom } from 'vault/route';
import timestamp from 'core/utils/timestamp';
import type AdapterError from '@ember-data/adapter/error';
import type ApiService from 'vault/services/api';
import type FlagsService from 'vault/services/flags';
import type NamespaceService from 'vault/services/namespace';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type { ModelFrom } from 'vault/vault/route';
import type ClientsRoute from '../clients';
import type { ClientsRouteModel } from '../clients';
import type ClientsCountsController from 'vault/controllers/vault/cluster/clients/counts';
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
import type {
ByNamespaceClients,
NamespaceObject,
Counts,
ActivityMonthBlock,
} from 'vault/client-counts/activity-api';
export interface ClientsCountsRouteParams {
start_time?: string;
@ -27,18 +37,12 @@ export interface ClientsCountsRouteParams {
month?: string;
}
interface ActivityAdapterQuery {
start_time: string | undefined;
end_time: string | undefined;
}
export type ClientsCountsRouteModel = ModelFrom<ClientsCountsRoute>;
export default class ClientsCountsRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly flags: FlagsService;
@service declare readonly namespace: NamespaceService;
@service declare readonly store: Store;
@service declare readonly version: VersionService;
queryParams = {
@ -56,31 +60,29 @@ export default class ClientsCountsRoute extends Route {
return this.flags.fetchActivatedFlags();
}
async getActivity(params: ClientsCountsRouteParams): Promise<{
activity?: ClientsActivityModel;
activityError?: AdapterError;
}> {
let activity, activityError;
async getActivity(params: ClientsCountsRouteParams) {
// if CE without both start time and end time, we want to skip the activity call
// so that the user is forced to choose a date range
if (this.version.isEnterprise || (this.version.isCommunity && params.start_time && params.end_time)) {
const query: ActivityAdapterQuery = {
start_time: params?.start_time,
end_time: params?.end_time,
};
try {
activity = await this.store.queryRecord('clients/activity', query);
} catch (error) {
activityError = error as AdapterError;
const response = await this.api.sys.internalClientActivityReportCounts(
undefined,
params?.end_time || undefined,
undefined,
params?.start_time || undefined
);
if (response) {
return {
...response,
by_namespace: formatByNamespace(response.by_namespace as NamespaceObject[] | null),
by_month: formatByMonths(response.months as ActivityMonthBlock[]),
total: destructureClientCounts(response.total as ByNamespaceClients | Counts),
};
}
}
return {
activity,
activityError,
};
return undefined;
}
async fetchAndFormatExportData(startTimestamp: string | undefined, endTimestamp: string | undefined) {
async fetchAndFormatExportData(startTimestamp: Date | undefined, endTimestamp: Date | undefined) {
// The "Client List" tab is only available on enterprise versions
// For now, it is also hidden on HVD managed clusters
if (this.version.isEnterprise && !this.flags.isHvdManaged) {
@ -91,9 +93,9 @@ export default class ClientsCountsRoute extends Route {
let exportData, cannotRequestExport;
try {
const { raw } = await this.api.sys.internalClientActivityExportRaw({
end_time,
end_time: end_time?.toISOString(),
format: 'json', // the API only accepts json or csv
start_time,
start_time: start_time?.toISOString(),
});
// If it's not a 200 but didn't throw an error then it's likely a 204 (empty response).
@ -120,22 +122,22 @@ export default class ClientsCountsRoute extends Route {
}
async model(params: ClientsCountsRouteParams) {
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ModelFrom<ClientsRoute>;
const { activity, activityError } = await this.getActivity(params);
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ClientsRouteModel;
const activity = await this.getActivity(params);
const { exportData, cannotRequestExport } = await this.fetchAndFormatExportData(
activity?.startTime,
activity?.endTime
activity?.start_time,
activity?.end_time
);
return {
activity,
activityError,
cannotRequestExport,
config,
exportData,
// We always want to return the start and end time from the activity response
// so they serve as the source of truth for the time period of the displayed client count data
startTimestamp: activity?.startTime,
endTimestamp: activity?.endTime,
startTimestamp: activity?.start_time,
endTimestamp: activity?.end_time,
responseTimestamp: timestamp.now(),
versionHistory,
};
}

View file

@ -1,15 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default Route.extend({
store: service(),
model() {
return this.store.queryRecord('clients/config', {});
},
});

View file

@ -0,0 +1,17 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type ApiService from 'vault/services/api';
export default class ClientsEditRoute extends Route {
@service declare readonly api: ApiService;
model() {
return this.api.sys.internalClientActivityReadConfiguration();
}
}

View file

@ -6,8 +6,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
export default class ClientsIndexRoute extends Route {
@service router;
@service declare readonly router: RouterService;
redirect() {
this.router.transitionTo('vault.cluster.clients.counts.overview');

View file

@ -1,35 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
import timestamp from 'core/utils/timestamp';
// see tests/helpers/clients/client-count-helpers for sample API response (ACTIVITY_RESPONSE_STUB)
// and transformed by_namespace and by_month examples (SERIALIZED_ACTIVITY_RESPONSE)
export default class ActivitySerializer extends ApplicationSerializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.id === 'no-data') {
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
const response_timestamp = formatISO(timestamp.now());
const transformedPayload = {
...payload,
response_timestamp,
by_namespace: formatByNamespace(payload.data.by_namespace),
by_month: formatByMonths(payload.data.months),
total: destructureClientCounts(payload.data.total),
};
delete payload.data.by_namespace;
delete payload.data.months;
delete payload.data.total;
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
}
}

View file

@ -1,43 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from '../application';
export default class ClientsConfigSerializer extends ApplicationSerializer {
// these attrs are readOnly
attrs = {
billingStartTimestamp: { serialize: false },
minimumRetentionMonths: { serialize: false },
reportingEnabled: { serialize: false },
};
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (!payload.data) {
return super.normalizeResponse(...arguments);
}
const normalizedPayload = {
id: payload.id,
data: {
...payload.data,
enabled: payload.data.enabled?.includes('enable') ? 'On' : 'Off',
},
};
return super.normalizeResponse(store, primaryModelClass, normalizedPayload, id, requestType);
}
serialize() {
const json = super.serialize(...arguments);
if (json.enabled === 'On' || json.enabled === 'Off') {
const oldEnabled = json.enabled;
json.enabled = oldEnabled === 'On' ? 'enable' : 'disable';
}
json.retention_months = parseInt(json.retention_months, 10);
if (isNaN(json.retention_months)) {
throw new Error('Invalid number value');
}
delete json.queries_available;
return json;
}
}

View file

@ -1,16 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from '../application';
export default class VersionHistorySerializer extends ApplicationSerializer {
primaryKey = 'version';
normalizeItems(payload) {
if (payload.data.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => ({ version: key, ...payload.data.key_info[key] }));
}
}
}

View file

@ -6,7 +6,7 @@
import Service from '@ember/service';
import timestamp from 'core/utils/timestamp';
interface Extensions {
export interface Extensions {
csv: string;
hcl: string;
sentinel: string;

View file

@ -9,13 +9,18 @@ import { keepLatestTask } from 'ember-concurrency';
import { macroCondition, isDevelopingApp } from '@embroider/macros';
import { ADMINISTRATIVE_NAMESPACE } from 'vault/services/namespace';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type ApiService from 'vault/services/api';
const FLAGS = {
vaultCloudNamespace: 'VAULT_CLOUD_ADMIN_NAMESPACE',
};
export type ActivationFlags = {
activated: string[];
unactivated: string[];
};
/**
* This service returns information about cluster flags. For now, the two available flags are from sys/internal/ui/feature-flags and sys/activation-flags.
* The feature-flags endpoint returns VAULT_CLOUD_ADMIN_NAMESPACE which indicates that the Vault cluster is managed rather than self-managed.
@ -24,7 +29,7 @@ const FLAGS = {
export default class FlagsService extends Service {
@service declare readonly version: VersionService;
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@tracked activatedFlags: string[] = [];
@tracked featureFlags: string[] = [];
@ -40,17 +45,16 @@ export default class FlagsService extends Service {
getFeatureFlags = keepLatestTask(async () => {
try {
const result = await fetch('/v1/sys/internal/ui/feature-flags', {
method: 'GET',
});
if (result.status === 200) {
const body = await result.json();
this.featureFlags = body.feature_flags || [];
}
// unable to use internalUiListEnabledFeatureFlags method since the response does not conform to expected format
// example -> { feature_flags: string[] } instead of the standard { data: { feature_flags: string[] } }
// since it is typed as JSONApiResponse and not VoidResponse the client attempts to parse the body at
const response = await this.api.request.get('/sys/internal/ui/feature-flags');
const { feature_flags } = await response.json();
this.featureFlags = feature_flags || [];
} catch (error) {
const { response } = await this.api.parseError(error);
if (macroCondition(isDevelopingApp())) {
console.error(error);
console.error(response);
}
}
});
@ -68,14 +72,15 @@ export default class FlagsService extends Service {
// Fire off endpoint without checking if activated features are already set.
if (this.version.isCommunity) return;
try {
const response = await this.store
.adapterFor('application')
.ajax('/v1/sys/activation-flags', 'GET', { unauthenticated: true, namespace: null });
this.activatedFlags = response.data?.activated;
const { data } = await this.api.sys.readActivationFlags(
this.api.buildHeaders({ token: '', namespace: '' })
);
this.activatedFlags = (data as ActivationFlags)?.activated;
return;
} catch (error) {
const { response } = await this.api.parseError(error);
if (macroCondition(isDevelopingApp())) {
console.error(error);
console.error(response);
}
}
});

View file

@ -3,5 +3,5 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Sidebar::Nav::Clients @canReadConfig={{this.model.config.canRead}} />
<Sidebar::Nav::Clients @canReadConfig={{this.model.canReadConfig}} />
{{outlet}}

View file

@ -6,7 +6,7 @@
<Page::Header @title="Configuration" />
<Toolbar>
<ToolbarActions>
{{#if @model.canEdit}}
{{#if @model.capabilities.canUpdate}}
<LinkTo @route="vault.cluster.clients.edit" class="toolbar-link">
Edit configuration
</LinkTo>
@ -14,4 +14,4 @@
</ToolbarActions>
</Toolbar>
<Clients::Config @model={{@model}} />
<Clients::Config @config={{@model.config}} />

View file

@ -5,12 +5,13 @@
<Clients::Page::Counts
@activity={{this.model.activity}}
@activityError={{this.model.activityError}}
@config={{this.model.config}}
@canUpdateConfig={{this.model.canUpdateConfig}}
@endTimestamp={{this.model.endTimestamp}}
@onFilterChange={{this.updateQueryParams}}
@startTimestamp={{this.model.startTimestamp}}
@versionHistory={{this.model.versionHistory}}
@responseTimestamp={{this.model.responseTimestamp}}
>
{{outlet}}
</Clients::Page::Counts>

View file

@ -5,4 +5,4 @@
<Page::Header @title="Edit Configuration" />
<Clients::Config @model={{@model}} @mode="edit" />
<Clients::Config @config={{@model}} @mode="edit" />

View file

@ -61,4 +61,6 @@ export const PATH_MAP = {
kmipScope: apiPath`${'backend'}/scopes/${'name'}`,
kmipRole: apiPath`${'backend'}/scopes/${'scope'}/roles/${'name'}`,
kmipCredentialsRevoke: apiPath`${'backend'}/scope/${'scope'}/role/${'role'}/credentials/revoke`,
clientsConfig: apiPath`sys/internal/counters/config`,
clientsActivityExport: apiPath`${'namespace'}/sys/internal/counters/activity/export`,
};

View file

@ -7,7 +7,7 @@ import { isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
import { isWithinInterval } from 'date-fns';
import { ROOT_NAMESPACE } from 'vault/services/namespace';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
import type { VersionHistory } from 'vault/client-counts';
import type {
ActivityExportData,
ByNamespaceClients,
@ -43,20 +43,16 @@ export const EXPORT_CLIENT_TYPES = ['non-entity-token', 'pki-acme', 'secret-sync
// returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10
// that occurred between timestamps (i.e. queried activity data)
export const filterVersionHistory = (
versionHistory: ClientsVersionHistoryModel[],
start: string,
end: string
) => {
export const filterVersionHistory = (versionHistory: VersionHistory[], start?: Date, end?: Date) => {
if (versionHistory && start && end) {
const upgrades = versionHistory.reduce((array: ClientsVersionHistoryModel[], upgradeData) => {
const upgrades = versionHistory.reduce((array: VersionHistory[], upgradeData) => {
const isRelevantHistory = (v: string) => {
return (
upgradeData.version.match(v) &&
// only add if there is a previous version, otherwise this upgrade is the users' first version
upgradeData.previousVersion &&
upgradeData.previous_version &&
// only add first match, disregard subsequent patch releases of the same version
!array.some((d: ClientsVersionHistoryModel) => d.version.match(v))
!array.some((d: VersionHistory) => d.version.match(v))
);
};
@ -69,11 +65,9 @@ export const filterVersionHistory = (
// if there are noteworthy upgrades, only return those during queried date range
if (upgrades.length) {
const startDate = parseAPITimestamp(start) as Date;
const endDate = parseAPITimestamp(end) as Date;
return upgrades.filter(({ timestampInstalled }) => {
const upgradeDate = parseAPITimestamp(timestampInstalled) as Date;
return isWithinInterval(upgradeDate, { start: startDate, end: endDate });
return upgrades.filter(({ timestamp_installed }) => {
const upgradeDate = parseAPITimestamp(timestamp_installed) as Date;
return isWithinInterval(upgradeDate, { start, end });
});
}
}

View file

@ -11,6 +11,7 @@ import type {
ActivityMonthBlock,
ActivityMonthEmpty,
ActivityMonthStandard,
ByMonthClients,
ByMonthNewClients,
ByNamespaceClients,
ClientTypes,
@ -38,7 +39,7 @@ export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClien
);
};
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthClients[] => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const { timestamp } = m;
@ -107,9 +108,9 @@ export const formatExportData = async (resp: Response, { isDownload = false }) =
return lines.map((line: string) => JSON.parse(line));
};
export const formatQueryParams = (query: { start_time?: string; end_time?: string } = {}) => {
export const formatQueryParams = (query: { start_time?: Date; end_time?: Date } = {}) => {
const { start_time, end_time } = query;
const formattedQuery: Partial<Record<'start_time' | 'end_time', string>> = {};
const formattedQuery: Partial<Record<'start_time' | 'end_time', Date>> = {};
if (start_time && isValid(parseJSON(start_time))) {
formattedQuery.start_time = start_time;

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { format, parse, parseISO } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import isValid from 'date-fns/isValid';
export const datetimeLocalStringFormat = "yyyy-MM-dd'T'HH:mm";
@ -23,21 +23,28 @@ export const ARRAY_OF_MONTHS = [
'December',
];
// convert API timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp: string, style?: string): Date | string | null => {
if (!timestamp || typeof timestamp !== 'string') return null;
if (!style) {
// If no style, return a date object in UTC
const parsed = parseISO(timestamp) as Date;
return isValid(parsed) ? parsed : null;
// datetime may be returned from the API client as either a Date object or an ISO string
// strings will be converted from an ISO string ('2021-03-21T00:00:00Z') to date object and optionally formatted
// the timezone of the formatted output will be in UTC and not the local timezone of the user
export function parseAPITimestamp(timestamp: string | Date, style: string): string;
export function parseAPITimestamp(timestamp: string | Date): Date | null;
export function parseAPITimestamp(timestamp: string | Date, style?: string) {
if (timestamp) {
if (timestamp instanceof Date && isValid(timestamp)) {
// if no style (format) is provided return the Date object as is since there is nothing more to parse
return style ? formatInTimeZone(timestamp, 'UTC', style) : timestamp;
} else if (typeof timestamp === 'string') {
// if no style return a date object
if (!style) {
const date = new Date(timestamp);
return isValid(date) ? date : null;
}
// otherwise format it as a calendar date that is in UTC.
return formatInTimeZone(timestamp, 'UTC', style);
}
}
// Otherwise format it as a calendar date that is timezone agnostic.
const yearMonthDay = timestamp.split('T')[0] ?? '';
const date = parse(yearMonthDay, 'yyyy-MM-dd', new Date()); // 'yyyy-MM-dd' lets parse() know the format of yearMonthDay
return format(date, style) as string;
};
return null;
}
export const buildISOTimestamp = (args: { monthIdx: number; year: number; isEndDate: boolean }) => {
const { monthIdx, year, isEndDate } = args;

View file

@ -6,6 +6,7 @@
"dependencies": {
"autosize": "*",
"date-fns": "*",
"date-fns-tz": "*",
"@icholy/duration": "*",
"base64-js": "*",
"dompurify": "*",

View file

@ -16,6 +16,7 @@ import {
} from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { CLIENT_TYPES } from 'core/utils/client-counts/helpers';
import { Response } from 'miragejs';
/*
HOW TO ADD NEW TYPES:
@ -326,7 +327,7 @@ export default function (server) {
data = generateActivityResponse(start_time, end_time);
activities.create(data);
}
return {
const response = {
request_id: 'some-activity-id',
lease_id: '',
renewable: false,
@ -340,6 +341,8 @@ export default function (server) {
],
auth: null,
};
// need to set Content-Length header for api service to show warnings
return new Response(200, { 'Content-Length': JSON.stringify(response).length }, response);
});
// client counting has changed in different ways since 1.9 see link below for details

View file

@ -0,0 +1,8 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { Model } from 'miragejs';
export default Model.extend({});

View file

@ -8,7 +8,6 @@ const { handler } = ENV['ember-cli-mirage'];
import scenarios from './index';
export default function (server) {
server.create('clients/config');
server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] });
if (handler in scenarios) {

View file

@ -24,7 +24,6 @@ module('Acceptance | clients | counts', function (hooks) {
this.timestampStub = sinon.stub(timestamp, 'now');
this.timestampStub.returns(STATIC_NOW);
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
return login();
});
@ -62,12 +61,10 @@ module('Acceptance | clients | counts', function (hooks) {
return overrideResponse(403);
});
await visit('/vault/clients/counts/overview');
assert.dom(GENERAL.emptyStateTitle).hasText('ERROR 403 You are not authorized');
assert.dom(GENERAL.pageError.errorTitle('403')).hasText('Not authorized');
assert
.dom(GENERAL.emptyStateMessage)
.hasText(
'You must be granted permissions to view this page. Ask your administrator if you think you should have access to the /v1/sys/internal/counters/activity endpoint.'
);
.dom(GENERAL.pageError.errorSubtitle)
.hasText('You are not authorized to access content at /v1/sys/internal/counters/activity.');
});
test('it should use the response start_time as the timestamp', async function (assert) {

View file

@ -41,17 +41,11 @@ module('Acceptance | clients | counts | client list', function (hooks) {
}),
};
const api = this.owner.lookup('service:api');
this.exportDataStub = sinon.stub(api.sys, 'internalClientActivityExportRaw');
this.exportDataStub.resolves(mockResponse);
this.exportDataStub = sinon.stub(api.sys, 'internalClientActivityExportRaw').resolves(mockResponse);
await login();
return visit('/vault');
});
hooks.afterEach(async function () {
this.exportDataStub.restore();
});
test('it hides client list tab on community', async function (assert) {
this.version.type = 'community';
assert.dom(GENERAL.tab('client list')).doesNotExist();

View file

@ -25,6 +25,7 @@ import {
} from 'vault/tests/helpers/clients/client-count-helpers';
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { formatByMonths } from 'core/utils/client-counts/serializers';
module('Acceptance | clients | overview', function (hooks) {
setupApplicationTest(hooks);
@ -34,7 +35,6 @@ module('Acceptance | clients | overview', function (hooks) {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
// These tests use the clientsHandler which dynamically generates activity data, used for asserting date querying, etc
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
});
@ -174,8 +174,8 @@ module('Acceptance | clients | overview', function (hooks) {
data: ACTIVITY_RESPONSE_STUB,
};
});
const staticActivity = await this.store.findRecord('clients/activity', 'some-activity-id');
this.staticMostRecentMonth = staticActivity.byMonth[staticActivity.byMonth.length - 1];
const byMonth = formatByMonths(ACTIVITY_RESPONSE_STUB.months);
this.staticMostRecentMonth = byMonth[byMonth.length - 1];
await login();
return visit('/vault/clients/counts/overview');
});
@ -282,9 +282,8 @@ module('Acceptance | clients | overview', function (hooks) {
ok: true,
text: () => Promise.resolve(ACTIVITY_EXPORT_STUB.trim()),
};
const adapter = this.store.adapterFor('clients/activity');
const exportDataStub = sinon.stub(adapter, 'exportData');
exportDataStub.resolves(mockResponse);
const api = this.owner.lookup('service:api');
sinon.stub(api.sys, 'internalClientActivityExportRaw').resolves(mockResponse);
const timestamp = this.staticMostRecentMonth.timestamp;
await click(GENERAL.dropdownToggle(ClientFilters.MONTH));
await click(FILTERS.dropdownItem(timestamp));
@ -299,7 +298,6 @@ module('Acceptance | clients | overview', function (hooks) {
`${url}?month=${monthQp}&mount_path=${mPath}&mount_type=${mType}&namespace_path=${ns}`,
'url query params match filters'
);
exportDataStub.restore();
});
});

View file

@ -172,7 +172,12 @@ module('Acceptance | sync | overview', function (hooks) {
assert.expect(3);
this.server.get('/sys/activation-flags', (_, req) => {
assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace');
const expectedHeaders = { 'x-vault-namespace': '', 'x-vault-token': '' };
assert.deepEqual(
req.requestHeaders,
expectedHeaders,
'Request is unauthenticated and in root namespace'
);
return {
data: {
activated: [''],
@ -217,7 +222,12 @@ module('Acceptance | sync | overview', function (hooks) {
flagService.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.server.get('/sys/activation-flags', (_, req) => {
assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace');
const expectedHeaders = { 'x-vault-namespace': '', 'x-vault-token': '' };
assert.deepEqual(
req.requestHeaders,
expectedHeaders,
'Request is unauthenticated and in root namespace'
);
return {
data: {
activated: [''],

View file

@ -18,26 +18,25 @@ module('Integration | Component | client count config', function (hooks) {
hooks.beforeEach(function () {
this.router = this.owner.lookup('service:router');
this.transitionStub = sinon.stub(this.router, 'transitionTo');
const store = this.owner.lookup('service:store');
this.createModel = (enabled = 'enable', reporting_enabled = false, minimum_retention_months = 48) => {
store.pushPayload('clients/config', {
modelName: 'clients/config',
id: 'foo',
data: {
enabled,
reporting_enabled,
minimum_retention_months,
retention_months: 49,
},
});
this.model = store.peekRecord('clients/config', 'foo');
const { sys } = this.owner.lookup('service:api');
this.apiStub = sinon.stub(sys, 'internalClientActivityConfigure').resolves();
this.renderComponent = (mode, config = {}) => {
this.mode = mode;
this.config = {
enabled: 'enable',
reporting_enabled: false,
minimum_retention_months: 48,
retention_months: 49,
...config,
};
return render(hbs`<Clients::Config @config={{this.config}} @mode={{this.mode}} />`);
};
});
test('it shows the table with the correct rows by default', async function (assert) {
this.createModel();
await render(hbs`<Clients::Config @model={{this.model}} />`);
await this.renderComponent('show');
assert.dom('[data-test-clients-config-table]').exists('Clients config table exists');
const rows = document.querySelectorAll('.info-table-row');
@ -52,47 +51,54 @@ module('Integration | Component | client count config', function (hooks) {
);
});
test('it should function in edit mode when reporting is disabled', async function (assert) {
assert.expect(13);
const retentionMonths = 60;
this.server.put('/sys/internal/counters/config', (schema, req) => {
const { enabled, retention_months } = JSON.parse(req.requestBody);
const expected = { enabled: 'enable', retention_months: retentionMonths };
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (1)');
return {};
});
test('it should validate retention_months', async function (assert) {
await this.renderComponent('edit');
this.createModel('disable');
assert.dom('[data-test-input="retention_months"]').hasValue('49', 'Retention months render');
await fillIn('[data-test-input="retention_months"]', 20);
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retention_months'))
.hasText(
'Retention period must be greater than or equal to 48.',
'Validation error shows for min retention period'
);
await render(hbs`
<Clients::Config @model={{this.model}} @mode="edit" />
`);
await fillIn('[data-test-input="retention_months"]', 90);
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retention_months'))
.hasText(
'Retention period must be less than or equal to 60.',
'Validation error shows for max retention period'
);
});
test('it should validate retention_months when minimum_retention_months is 0', async function (assert) {
await this.renderComponent('edit', { minimum_retention_months: 0 });
await fillIn('[data-test-input="retention_months"]', '');
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retention_months'))
.hasText(
'Retention period must be greater than or equal to 48.',
'Validation error shows for min retention period'
);
});
test('it should function in edit mode when enabling reporting', async function (assert) {
const retention_months = 60;
await this.renderComponent('edit', { enabled: 'disable' });
assert.dom('[data-test-input="enabled"]').isNotChecked('Data collection checkbox is not checked');
assert
.dom('label[for="enabled"]')
.hasText('Data collection is off', 'Correct label renders when data collection is off');
assert.dom('[data-test-input="retentionMonths"]').hasValue('49', 'Retention months render');
await click('[data-test-input="enabled"]');
await fillIn('[data-test-input="retentionMonths"]', 20);
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
.hasText(
'Retention period must be greater than or equal to 48.',
'Validation error shows for min retention period'
);
await fillIn('[data-test-input="retentionMonths"]', 90);
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
.hasText(
'Retention period must be less than or equal to 60.',
'Validation error shows for max retention period'
);
await fillIn('[data-test-input="retentionMonths"]', retentionMonths);
await fillIn('[data-test-input="retention_months"]', retention_months);
await click(GENERAL.submitButton);
assert
.dom('[data-test-clients-config-modal="title"]')
@ -100,13 +106,23 @@ module('Integration | Component | client count config', function (hooks) {
assert.dom('[data-test-clients-config-modal="on"]').exists('Correct modal description block renders');
await click('[data-test-clients-config-modal="continue"]');
assert.true(
this.apiStub.calledWith({ enabled: 'enable', retention_months }),
'API called with correct params'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.clients.config'),
'Route transitions correctly on save success'
);
});
// we need to close the modal
await click('[data-test-clients-config-modal="cancel"]');
test('it should function in edit mode when disabling reporting', async function (assert) {
await this.renderComponent('edit');
assert.dom('[data-test-input="enabled"]').isChecked('Data collection checkbox is checked');
assert
.dom('label[for="enabled"]')
.hasText('Data collection is on', 'Correct label renders when data collection is on');
await click('[data-test-input="enabled"]');
await click(GENERAL.submitButton);
@ -118,56 +134,51 @@ module('Integration | Component | client count config', function (hooks) {
await click('[data-test-clients-config-modal="cancel"]');
assert.dom('[data-test-clients-config-modal]').doesNotExist('Modal is hidden on cancel');
await click(GENERAL.submitButton);
await click('[data-test-clients-config-modal="continue"]');
assert.true(
this.apiStub.calledWith({ enabled: 'disable', retention_months: 49 }),
'API called with correct params'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.clients.config'),
'Route transitions correctly on save success'
);
});
test('it should be hidden in edit mode when reporting is enabled', async function (assert) {
assert.expect(4);
test('it should hide enabled field in edit mode when reporting is enabled', async function (assert) {
const config = { enabled: 'enable', reporting_enabled: true, minimum_retention_months: 24 };
await this.renderComponent('edit', config);
this.server.put('/sys/internal/counters/config', (schema, req) => {
const { enabled, retention_months } = JSON.parse(req.requestBody);
const expected = { enabled: 'enable', retention_months: 48 };
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (2)');
return {};
});
assert.dom('[data-test-input="enabled"]').doesNotExist('Data collection input not shown');
assert.dom('[data-test-input="retention_months"]').hasValue('49', 'Retention months render');
this.createModel('enable', true, 24);
await render(hbs`
<Clients::Config @model={{this.model}} @mode="edit" />
`);
assert.dom('[data-test-input="enabled"]').doesNotExist('Data collection input not shown ');
assert.dom('[data-test-input="retentionMonths"]').hasValue('49', 'Retention months render');
await fillIn('[data-test-input="retentionMonths"]', 5);
await fillIn('[data-test-input="retention_months"]', 5);
await click(GENERAL.submitButton);
assert
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
.dom(GENERAL.validationErrorByAttr('retention_months'))
.hasText(
'Retention period must be greater than or equal to 24.',
'Validation error shows for incorrect retention period'
);
await fillIn('[data-test-input="retentionMonths"]', 48);
await fillIn('[data-test-input="retention_months"]', 48);
await click(GENERAL.submitButton);
assert.true(
this.apiStub.calledWith({ enabled: 'enable', retention_months: 48 }),
'API called with correct params'
);
});
test('it should not show modal when data collection is not changed', async function (assert) {
assert.expect(1);
test('it should not show modal when data collection has not changed', async function (assert) {
await this.renderComponent('edit');
this.server.put('/sys/internal/counters/config', (schema, req) => {
const { enabled, retention_months } = JSON.parse(req.requestBody);
const expected = { enabled: 'enable', retention_months: 48 };
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (3)');
return {};
});
this.createModel();
await render(hbs`
<Clients::Config @model={{this.model}} @mode="edit" />
`);
await fillIn('[data-test-input="retentionMonths"]', 48);
await fillIn('[data-test-input="retention_months"]', 48);
await click(GENERAL.submitButton);
assert.true(
this.apiStub.calledWith({ enabled: 'enable', retention_months: 48 }),
'API called with correct params'
);
});
});

View file

@ -9,7 +9,7 @@ import { click, fillIn, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Sinon from 'sinon';
import timestamp from 'core/utils/timestamp';
import { format } from 'date-fns';
import { format, subYears } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
@ -21,18 +21,26 @@ module('Integration | Component | clients/date-range', function (hooks) {
this.version = this.owner.lookup('service:version');
Sinon.replace(timestamp, 'now', Sinon.fake.returns(new Date('2018-04-03T14:15:30')));
this.now = timestamp.now();
this.startTimestamp = '2018-01-01T14:15:30';
this.endTimestamp = '2019-01-31T14:15:30';
this.startTimestamp = new Date('2018-01-01T14:15:30');
this.endTimestamp = new Date('2019-01-31T14:15:30');
this.billingStartTime = '';
this.retentionMonths = 48;
this.onChange = Sinon.spy();
this.onChange = Sinon.stub();
this.setEditModalVisible = Sinon.stub().callsFake((visible) => {
this.set('showEditModal', visible);
});
this.showEditModal = false;
this.renderComponent = async () => {
await render(
hbs`<Clients::DateRange @startTimestamp={{this.startTimestamp}} @endTimestamp={{this.endTimestamp}} @onChange={{this.onChange}} @billingStartTime={{this.billingStartTime}} @retentionMonths={{this.retentionMonths}} @setEditModalVisible={{this.setEditModalVisible}} @showEditModal={{this.showEditModal}}/>`
hbs`<Clients::DateRange
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@onChange={{this.onChange}}
@billingStartTime={{this.billingStartTime}}
@retentionMonths={{this.retentionMonths}}
@setEditModalVisible={{this.setEditModalVisible}}
@showEditModal={{this.showEditModal}}
/>`
);
};
});
@ -127,7 +135,7 @@ module('Integration | Component | clients/date-range', function (hooks) {
hooks.beforeEach(function () {
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
this.billingStartTime = '2018-01-01T14:15:30';
this.billingStartTime = new Date('2018-01-01T14:15:30');
});
test('it renders billing start date dropdown for enterprise', async function (assert) {
@ -161,7 +169,7 @@ module('Integration | Component | clients/date-range', function (hooks) {
});
test('it updates toggle text when a new date is selected', async function (assert) {
this.onChange = ({ start_time }) => this.set('startTimestamp', start_time);
this.onChange.callsFake(({ start_time }) => this.set('startTimestamp', new Date(start_time)));
await this.renderComponent();
assert.dom(DATE_RANGE.edit).hasText('January 2018').hasAttribute('aria-expanded', 'false');
@ -186,5 +194,26 @@ module('Integration | Component | clients/date-range', function (hooks) {
await this.renderComponent();
assert.dom(this.element).hasText('Change data period January 2018');
});
test('it should send an empty string for start_time when selecting current period', async function (assert) {
await this.renderComponent();
await click(DATE_RANGE.edit);
await click(DATE_RANGE.dropdownOption(1));
assert.true(
this.onChange.calledWith({
start_time: subYears(this.billingStartTime, 1).toISOString(),
end_time: '',
}),
'correct start_time sent on change for prior period'
);
await click(DATE_RANGE.edit);
await click(DATE_RANGE.dropdownOption(0));
assert.true(
this.onChange.calledWith({ start_time: '', end_time: '' }),
'start_time is empty string on current period change'
);
});
});
});

View file

@ -17,31 +17,19 @@ module('Integration | Component | clients/no-data', function (hooks) {
hooks.beforeEach(async function () {
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.store = this.owner.lookup('service:store');
this.setConfig = async (data) => {
// the clients/config model does some funky serializing for the "enabled" param
// so stubbing the request here instead of just the model for additional coverage
this.server.get('sys/internal/counters/config', () => {
return {
request_id: '25a94b99-b49a-c4ac-cb7b-5ba0eb390a25',
data,
};
});
return this.store.queryRecord('clients/config', {});
};
this.canUpdate = false;
this.setConfig = (enabled, reporting_enabled) => ({
enabled: enabled ? 'default-enabled' : 'default-disabled',
reporting_enabled,
});
this.renderComponent = async () => {
return render(hbs`<Clients::NoData @config={{this.config}} />`);
return render(hbs`<Clients::NoData @config={{this.config}} @canUpdate={{this.canUpdate}} />`);
};
});
test('it renders empty state when enabled is "on"', async function (assert) {
test('it renders empty state when enabled', async function (assert) {
assert.expect(2);
const data = {
enabled: 'default-enabled',
reporting_enabled: false,
};
``;
this.config = await this.setConfig(data);
this.config = this.setConfig(true, false);
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No data received');
assert
@ -49,13 +37,9 @@ module('Integration | Component | clients/no-data', function (hooks) {
.hasText('Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes.');
});
test('it renders empty state when reporting_enabled is true', async function (assert) {
test('it renders empty state when reporting is fully enabled', async function (assert) {
assert.expect(2);
const data = {
enabled: 'default-disabled',
reporting_enabled: true,
};
this.config = await this.setConfig(data);
this.config = this.setConfig(true, true);
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No data received');
assert
@ -64,12 +48,8 @@ module('Integration | Component | clients/no-data', function (hooks) {
});
test('it renders empty state when reporting is fully disabled', async function (assert) {
assert.expect(2);
const data = {
enabled: 'default-disabled',
reporting_enabled: false,
};
this.config = await this.setConfig(data);
assert.expect(4);
this.config = this.setConfig(false, false);
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('Data tracking is disabled');
assert
@ -77,6 +57,11 @@ module('Integration | Component | clients/no-data', function (hooks) {
.hasText(
'Tracking is disabled, and no data is being collected. To turn it on, edit the configuration.'
);
assert.dom(GENERAL.linkTo('config')).doesNotExist('Config link does not render without capabilities');
this.canUpdate = true;
await this.renderComponent();
assert.dom(GENERAL.linkTo('config')).exists('Config link renders with update capabilities');
});
test('it renders empty state when config data is not available', async function (assert) {

View file

@ -23,13 +23,14 @@ module('Integration | Component | clients/page-header', function (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.startTimestamp = new Date('2022-06-01T23:00:11.050Z');
this.endTimestamp = new Date('2022-12-01T23:00:11.050Z');
this.billingStartTime = this.startTimestamp;
this.upgradesDuringActivity = [];
this.noData = undefined;
this.server.post('/sys/capabilities-self', () =>
capabilitiesStub('sys/internal/counters/activity/export', ['sudo'])
capabilitiesStub('/sys/internal/counters/activity/export', ['sudo'])
);
this.renderComponent = async () => {
@ -45,12 +46,12 @@ module('Integration | Component | clients/page-header', function (hooks) {
};
});
test('it shows the export button if user does has SUDO capabilities', async function (assert) {
test('it shows the export button if user does have SUDO capabilities', async function (assert) {
await this.renderComponent();
assert.dom(CLIENT_COUNT.exportButton).exists();
});
test('it hides the export button if user does has SUDO capabilities but there is no data', async function (assert) {
test('it hides the export button if user does have SUDO capabilities but there is no data', async function (assert) {
this.noData = true;
await this.renderComponent();
assert.dom(CLIENT_COUNT.exportButton).doesNotExist();
@ -58,7 +59,7 @@ module('Integration | Component | clients/page-header', function (hooks) {
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'])
capabilitiesStub('/sys/internal/counters/activity/export', ['read'])
);
await this.renderComponent();
@ -133,7 +134,7 @@ module('Integration | Component | clients/page-header', function (hooks) {
const namespaceSvc = this.owner.lookup('service:namespace');
namespaceSvc.path = 'foo';
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.strictEqual(req.requestHeaders['X-Vault-Namespace'], 'foo');
assert.strictEqual(req.requestHeaders['x-vault-namespace'], 'foo');
return new Response(200, { 'Content-Type': 'text/csv' }, '');
});
@ -200,7 +201,7 @@ module('Integration | Component | clients/page-header', function (hooks) {
test('is correct for a single month', async function (assert) {
assert.expect(2);
this.endTimestamp = '2022-06-21T23:00:11.050Z';
this.endTimestamp = new Date('2022-06-21T23:00:11.050Z');
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.deepEqual(req.queryParams, {
format: 'csv',

View file

@ -18,6 +18,11 @@ import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors
import timestamp from 'core/utils/timestamp';
import sinon from 'sinon';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
const START_TIME = LICENSE_START.toISOString();
const END_TIME = STATIC_PREVIOUS_MONTH.toISOString();
@ -32,15 +37,22 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
clientsHandler(this.server);
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: START_TIME,
end_time: END_TIME,
this.api = this.owner.lookup('service:api');
const response = await this.api.sys.internalClientActivityReportCounts(
undefined,
END_TIME,
undefined,
START_TIME
);
this.activity = {
...response,
by_namespace: formatByNamespace(response.by_namespace),
by_month: formatByMonths(response.months),
total: destructureClientCounts(response.total),
};
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.config = await this.store.queryRecord('clients/config', {});
this.startTimestamp = START_ISO;
this.endTimestamp = END_ISO;
this.config = await this.api.sys.internalClientActivityReadConfiguration();
this.startTimestamp = new Date(START_ISO);
this.endTimestamp = new Date(END_ISO);
this.versionHistory = [];
this.renderComponent = () =>
render(hbs`
@ -66,32 +78,26 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
});
test('it should render no data empty state', async function (assert) {
this.activity = { id: 'no-data' };
this.activity = undefined;
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No data received', 'No data empty state renders');
});
test('it should render activity error', async function (assert) {
this.activity = null;
this.activityError = { httpStatus: 403 };
await this.renderComponent();
assert
.dom(GENERAL.emptyStateTitle)
.hasText('ERROR 403 You are not authorized', 'Activity error empty state renders');
});
test('it should render config disabled alert', async function (assert) {
this.config.enabled = 'Off';
this.config.enabled = 'default-disabled';
await this.renderComponent();
assert
.dom(CLIENT_COUNT.counts.configDisabled)
.hasText('Tracking is disabled', 'Config disabled alert renders');
// ensure the alert also renders when there is no activity data
this.activity = undefined;
await this.renderComponent();
assert
.dom(CLIENT_COUNT.counts.configDisabled)
.hasText('Tracking is disabled', 'Config disabled alert renders with no activity data');
});
const jan23start = '2023-01-01T00:00:00Z';
@ -128,8 +134,8 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
this.owner.lookup('service:version').type = 'community';
this.onFilterChange = (params) => {
assert.deepEqual(params, testCase.expected, 'Correct values sent on filter change');
this.set('startTimestamp', params?.start_time ? params.start_time : START_ISO);
this.set('endTimestamp', params?.end_time ? params.end_time : END_ISO);
this.set('startTimestamp', params?.start_time ? new Date(params.start_time) : new Date(START_ISO));
this.set('endTimestamp', params?.end_time ? new Date(params.end_time) : new Date(END_ISO));
};
await this.renderComponent();
await click(CLIENT_COUNT.dateRange.edit);
@ -155,15 +161,9 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
test('it renders alert if upgrade happened within queried activity', async function (assert) {
assert.expect(5);
this.versionHistory = await this.store.findAll('clients/version-history').then((resp) => {
return resp.map(({ version, previousVersion, timestampInstalled }) => {
return {
version,
previousVersion,
timestampInstalled,
};
});
});
const response = await this.api.sys.versionHistory(true);
this.versionHistory = this.api.keyInfoToArray(response, 'version');
await this.renderComponent();
@ -204,7 +204,7 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
test('it should render empty state for no start or no end when CE', async function (assert) {
this.owner.lookup('service:version').type = 'community';
this.startTimestamp = null;
this.activity = {};
this.activity = null;
await this.renderComponent();

View file

@ -14,6 +14,11 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
module('Integration | Component | clients/page/overview', function (hooks) {
setupRenderingTest(hooks);
@ -26,10 +31,15 @@ module('Integration | Component | clients/page/overview', function (hooks) {
data: ACTIVITY_RESPONSE_STUB,
};
});
this.store = this.owner.lookup('service:store');
this.activity = await this.store.queryRecord('clients/activity', {});
this.mostRecentMonth = this.activity.byMonth[this.activity.byMonth.length - 1];
this.api = this.owner.lookup('service:api');
const response = await this.api.sys.internalClientActivityReportCounts();
this.activity = {
...response,
by_namespace: formatByNamespace(response.by_namespace),
by_month: formatByMonths(response.months),
total: destructureClientCounts(response.total),
};
this.mostRecentMonth = this.activity.by_month[this.activity.by_month.length - 1];
this.onFilterChange = sinon.spy();
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '', month: '' };
this.renderComponent = () =>
@ -41,7 +51,7 @@ module('Integration | Component | clients/page/overview', function (hooks) {
/>`);
this.assertTableData = async (assert, filterKey, filterValue) => {
const expectedData = flattenMounts(this.activity.byNamespace).filter(
const expectedData = flattenMounts(this.activity.by_namespace).filter(
(d) => d[filterKey] === filterValue
);
// Find all rendered rows and assert they satisfy the filter value and table data matches expected values
@ -81,14 +91,20 @@ module('Integration | Component | clients/page/overview', function (hooks) {
},
};
});
this.activity = await this.store.queryRecord('clients/activity', {});
const response = await this.api.sys.internalClientActivityReportCounts();
this.activity = {
...response,
by_namespace: formatByNamespace(response.by_namespace),
by_month: formatByMonths(response.months),
total: destructureClientCounts(response.total),
};
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('Client attribution')).doesNotExist('it does not render attribution card');
});
test('it initially renders attribution with by_namespace data', async function (assert) {
await this.renderComponent();
const topNamespace = this.activity.byNamespace[0];
const topNamespace = this.activity.by_namespace[0];
const topMount = topNamespace.mounts[0];
// Assert table renders namespace with the highest counts at the top
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(topNamespace.label);
@ -96,10 +112,10 @@ module('Integration | Component | clients/page/overview', function (hooks) {
});
test('it renders dropdown lists from activity response to filter table data', async function (assert) {
const expectedMonths = this.activity.byMonth
const expectedMonths = this.activity.by_month
.map((m) => parseAPITimestamp(m.timestamp, 'MMMM yyyy'))
.reverse();
const mounts = flattenMounts(this.activity.byNamespace);
const mounts = flattenMounts(this.activity.by_namespace);
const expectedNamespaces = [...new Set(mounts.map((m) => m.namespace_path))];
const expectedMountPaths = [...new Set(mounts.map((m) => m.mount_path))];
const expectedMountTypes = [...new Set(mounts.map((m) => m.mount_type))];

View file

@ -17,6 +17,11 @@ import timestamp from 'core/utils/timestamp';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
const START_TIME = getUnixTime(LICENSE_START);
@ -30,13 +35,16 @@ module('Integration | Component | clients/running-total', function (hooks) {
this.flags.activatedFlags = ['secrets-sync'];
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
clientsHandler(this.server);
const store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: getUnixTime(timestamp.now()) },
const activityResponse = await this.owner
.lookup('service:api')
.sys.internalClientActivityReportCounts(undefined, getUnixTime(timestamp.now()), undefined, START_TIME);
this.activity = {
...activityResponse,
by_namespace: formatByNamespace(activityResponse.by_namespace),
by_month: formatByMonths(activityResponse.months),
total: destructureClientCounts(activityResponse.total),
};
this.activity = await store.queryRecord('clients/activity', activityQuery);
this.byMonthClients = this.activity.byMonth.map((d) => d.new_clients);
this.byMonthClients = this.activity.by_month.map((d) => d.new_clients);
this.renderComponent = async () => {
await render(hbs`

View file

@ -21,18 +21,17 @@ module('Unit | Util | client counts | helpers', function (hooks) {
hooks.beforeEach(async function () {
clientsHandler(this.server);
const store = this.owner.lookup('service:store');
const api = this.owner.lookup('service:api');
// format returned by model hook in routes/vault/cluster/clients.ts
this.versionHistory = await store.findAll('clients/version-history').then((resp) => {
return resp.map(({ version, previousVersion, timestampInstalled }) => {
return {
// order of keys needs to match expected order
previousVersion,
timestampInstalled,
version,
};
});
});
const response = await api.sys.versionHistory(true);
this.versionHistory = api
.keyInfoToArray(response, 'version')
.map(({ version, previous_version, timestamp_installed }) => ({
// order of keys needs to match expected order
previous_version,
timestamp_installed,
version,
}));
});
test('it returns version data for upgrade to notable versions: 1.9, 1.10, 1.17', async function (assert) {
@ -40,24 +39,24 @@ module('Unit | Util | client counts | helpers', function (hooks) {
const original = [...this.versionHistory];
const expected = [
{
previousVersion: '1.9.0',
timestampInstalled: '2023-08-02T00:00:00Z',
previous_version: '1.9.0',
timestamp_installed: '2023-08-02T00:00:00Z',
version: '1.9.1',
},
{
previousVersion: '1.9.1',
timestampInstalled: '2023-09-02T00:00:00Z',
previous_version: '1.9.1',
timestamp_installed: '2023-09-02T00:00:00Z',
version: '1.10.1',
},
{
previousVersion: '1.16.0',
timestampInstalled: '2023-12-02T00:00:00Z',
previous_version: '1.16.0',
timestamp_installed: '2023-12-02T00:00:00Z',
version: '1.17.0',
},
];
// set start/end times longer than version history to test all relevant upgrades return
const startTime = '2023-06-02T00:00:00Z'; // first upgrade installed '2023-07-02T00:00:00Z'
const endTime = '2024-03-04T16:14:21Z'; // latest upgrade installed '2023-12-02T00:00:00Z'
const startTime = new Date('2023-06-02T00:00:00Z'); // first upgrade installed '2023-07-02T00:00:00Z'
const endTime = new Date('2024-03-04T16:14:21Z'); // latest upgrade installed '2023-12-02T00:00:00Z'
const filteredHistory = filterVersionHistory(this.versionHistory, startTime, endTime);
assert.deepEqual(
JSON.stringify(filteredHistory),
@ -68,8 +67,8 @@ module('Unit | Util | client counts | helpers', function (hooks) {
filteredHistory,
{
version: '1.9.0',
previousVersion: null,
timestampInstalled: '2023-07-02T00:00:00Z',
previous_version: null,
timestamp_installed: '2023-07-02T00:00:00Z',
},
'does not include version history if previous_version is null'
);
@ -80,18 +79,18 @@ module('Unit | Util | client counts | helpers', function (hooks) {
assert.expect(2);
const expected = [
{
previousVersion: '1.9.0',
timestampInstalled: '2023-08-02T00:00:00Z',
previous_version: '1.9.0',
timestamp_installed: '2023-08-02T00:00:00Z',
version: '1.9.1',
},
{
previousVersion: '1.9.1',
timestampInstalled: '2023-09-02T00:00:00Z',
previous_version: '1.9.1',
timestamp_installed: '2023-09-02T00:00:00Z',
version: '1.10.1',
},
];
const startTime = '2023-08-02T00:00:00Z'; // same date as 1.9.1 install date to catch same day edge cases
const endTime = '2023-11-02T00:00:00Z';
const startTime = new Date('2023-08-02T00:00:00Z'); // same date as 1.9.1 install date to catch same day edge cases
const endTime = new Date('2023-11-02T00:00:00Z');
const filteredHistory = filterVersionHistory(this.versionHistory, startTime, endTime);
assert.deepEqual(
JSON.stringify(filteredHistory),
@ -102,8 +101,8 @@ module('Unit | Util | client counts | helpers', function (hooks) {
filteredHistory,
{
version: '1.10.3',
previousVersion: '1.10.1',
timestampInstalled: '2023-09-23T00:00:00Z',
previous_version: '1.10.1',
timestamp_installed: '2023-09-23T00:00:00Z',
},
'it does not return subsequent patch versions of the same notable upgrade version'
);

View file

@ -6,6 +6,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { buildISOTimestamp, isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
import { formatInTimeZone } from 'date-fns-tz';
module('Integration | Util | date formatters utils', function (hooks) {
setupTest(hooks);
@ -55,6 +56,18 @@ module('Integration | Util | date formatters utils', function (hooks) {
assert.strictEqual(parsed.getUTCDate(), 31, 'parsed future date has correct day');
});
test('parseAPITimestamp: it handles date objects and formats in UTC', async function (assert) {
const date = new Date();
const parsed = parseAPITimestamp(date, 'MM dd yyyy');
assert.strictEqual(parsed, formatInTimeZone(date, 'UTC', 'MM dd yyyy'), 'it formats date object in UTC');
});
test('parseAPITimestamp: it returns null for Date object that is invalid', async function (assert) {
const invalidDate = new Date('invalid date string');
const parsed = parseAPITimestamp(invalidDate);
assert.strictEqual(parsed, null, 'it returns null for an invalid Date object');
});
test('buildISOTimestamp: it formats an ISO timestamp for the start of the month', async function (assert) {
const timestamp = buildISOTimestamp({ monthIdx: 0, year: 2025, isEndDate: false });
assert.strictEqual(

View file

@ -1,138 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { subMonths, fromUnixTime } from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import timestamp from 'core/utils/timestamp';
module('Unit | Adapter | clients activity', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
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';
const mockNow = timestamp.now();
this.startDate = subMonths(mockNow, 6);
this.endDate = mockNow;
this.readableUnix = (unix) => parseAPITimestamp(fromUnixTime(unix).toISOString(), 'MMMM dd yyyy');
});
test('it does not format if both params are timestamp strings', async function (assert) {
assert.expect(1);
const queryParams = {
start_time: this.startDate.toISOString(),
end_time: this.endDate.toISOString(),
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {
start_time: this.startDate.toISOString(),
end_time: this.endDate.toISOString(),
});
});
this.store.queryRecord(this.modelName, queryParams);
});
test('it sends without query if no dates provided', async function (assert) {
assert.expect(1);
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {});
});
this.store.queryRecord(this.modelName, { foo: 'bar' });
});
test('it sends without query if no valid dates provided', async function (assert) {
assert.expect(1);
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {});
});
this.store.queryRecord(this.modelName, { start_time: 'bar', end_time: 'baz' });
});
test('it handles empty query gracefully', async function (assert) {
assert.expect(1);
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {});
});
this.store.queryRecord(this.modelName, {});
});
test('it adds the passed namespace to the request header', async function (assert) {
assert.expect(2);
const queryParams = {
start_time: this.startDate.toISOString(),
end_time: this.endDate.toISOString(),
// the adapter does not do any more transformations, so it must be called
// with the combined current + selected namespace
namespace: 'foobar/baz',
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {
start_time: this.startDate.toISOString(),
end_time: this.endDate.toISOString(),
});
assert.strictEqual(req.requestHeaders['X-Vault-Namespace'], 'foobar/baz');
});
this.store.queryRecord(this.modelName, queryParams);
});
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.strictEqual(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',
});
});
});
});

View file

@ -142,28 +142,4 @@ module('Unit | Decorators | ModelFormFields', function (hooks) {
'allFields set on Model class'
);
});
test('it should set formFields prop on Model class', function (assert) {
// this model uses withFormFields
const record = this.store.createRecord('clients/config');
assert.deepEqual(
record.formFields,
[
{
name: 'enabled',
options: {},
type: 'string',
},
{
name: 'retentionMonths',
options: {
label: 'Retention period',
subText: 'The number of months of activity logs to maintain for client tracking.',
},
type: 'number',
},
],
'formFields set on Model class'
);
});
});

View file

@ -6,26 +6,10 @@
import Application from 'vault/adapters/application';
import Adapter from 'ember-data/adapter';
import ModelRegistry from 'ember-data/types/registries/model';
import ClientsActivityAdapter from 'vault/vault/adapters/clients/activity';
import LdapLibraryAdapter from 'vault/adapters/ldap/library';
import LdapRoleAdapter from 'vault/adapters/ldap/role';
import PkiIssuerAdapter from 'vault/adapters/pki/issuer';
import PkiTidyAdapter from 'vault/adapters/pki/tidy';
import SyncAssociationAdapter from 'vault/adapters/sync/association';
import SyncDestinationAdapter from 'vault/adapters/sync/destination';
/**
* Catch-all for ember-data.
*/
export default interface AdapterRegistry {
'clients/activity': ClientsActivityAdapter;
'ldap/library': LdapLibraryAdapter;
'ldap/role': LdapRoleAdapter;
'pki/issuer': PkiIssuerAdapter;
'pki/tidy': PkiTidyAdapter;
'sync/destination': SyncDestinationAdapter;
'sync/association': SyncAssociationAdapter;
application: Application;
[key: keyof ModelRegistry]: Adapter;
}

View file

@ -4,27 +4,9 @@
*/
import Model from '@ember-data/model';
import PkiActionModel from 'vault/models/pki/action';
import PkiCertificateGenerateModel from 'vault/models/pki/certificate/generate';
import PkiConfigAcmeModel from 'vault/models/pki/config/acme';
import PkiConfigClusterModel from 'vault/models/pki/config/cluster';
import PkiConfigCrlModel from 'vault/models/pki/config/crl';
import PkiConfigUrlsModel from 'vault/models/pki/config/urls';
import ClientsActivityModel from 'vault/models/clients/activity';
import ClientsConfigModel from 'vault/models/clients/config';
import ClientsVersionHistoryModel from 'vault/models/clients/version-history';
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
'pki/action': PkiActionModel;
'pki/certificate/generate': PkiCertificateGenerateModel;
'pki/config/acme': PkiConfigAcmeModel;
'pki/config/cluster': PkiConfigClusterModel;
'pki/config/crl': PkiConfigCrlModel;
'pki/config/urls': PkiConfigUrlModel;
'clients/activity': ClientsActivityModel;
'clients/config': ClientsConfigModel;
'clients/version-history': ClientsVersionHistoryModel;
// Catchall for any other models
[key: string]: any;
}

View file

@ -1,18 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Store from '@ember-data/store';
import { AdapterRegistry } from 'ember-data/adapter';
interface ExportDataQuery {
format?: string;
start_time?: string;
end_time?: string;
namespace?: string;
}
export default interface ActivityAdapter extends AdapterRegistry {
exportData(query?: ExportDataQuery): Promise<Blob>;
}

View file

@ -24,6 +24,13 @@ export interface ApiResponse {
wrap_info: WrapInfo | null;
}
export type ApiParsedError = {
message: string;
status: number;
path: string;
response: unknown;
};
export type HeaderMap =
| {
namespace: string;

View file

@ -45,7 +45,7 @@ export interface MountClients extends TotalClients {
namespace_path: string;
}
export interface ByMonthClients extends TotalClients {
export interface ByMonthClients extends TotalClientsSometimes {
timestamp: string;
namespaces: ByNamespaceClients[];
new_clients: ByMonthNewClients;
@ -125,3 +125,11 @@ export interface Counts {
non_entity_clients: number;
secret_syncs: number;
}
export type Activity = {
start_time?: Date;
end_time?: Date;
total: TotalClients;
by_month: ByMonthClients[];
by_namespace: ByNamespaceClients[];
};

10
ui/types/vault/client-counts/index.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export type VersionHistory = {
version: string;
build_date: string;
previous_version: string;
timestamp_installed: string;
};

View file

@ -1,20 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Model } from 'vault/app-types';
import type {
ByMonthClients,
ByNamespaceClients,
TotalClients,
} from 'vault/vault/client-counts/activity-api';
export default interface ClientsActivityModel extends Model {
byMonth: ByMonthClients[];
byNamespace: ByNamespaceClients[];
total: TotalClients;
startTime: string;
endTime: string;
responseTimestamp: string;
}

View file

@ -1,15 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { WithFormFieldsAndValidationsModel } from 'vault/vault/app-types';
export default interface ClientsConfigModel extends WithFormFieldsAndValidationsModel {
queriesAvailable: boolean;
retentionMonths: number;
minimumRetentionMonths: number;
enabled: string;
reportingEnabled: boolean;
billingStartTimestamp: Date;
}

View file

@ -1,12 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Model } from 'vault/app-types';
export default interface ClientsVersionHistoryModel extends Model {
version: string;
previousVersion: string;
timestampInstalled: string;
}