diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js deleted file mode 100644 index e227c68011..0000000000 --- a/ui/app/adapters/clients/activity.js +++ /dev/null @@ -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 }); - } -} diff --git a/ui/app/adapters/clients/config.js b/ui/app/adapters/clients/config.js deleted file mode 100644 index 4cf576ab2d..0000000000 --- a/ui/app/adapters/clients/config.js +++ /dev/null @@ -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'; - }, -}); diff --git a/ui/app/adapters/clients/version-history.js b/ui/app/adapters/clients/version-history.js deleted file mode 100644 index 268379043c..0000000000 --- a/ui/app/adapters/clients/version-history.js +++ /dev/null @@ -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; - }); - } -} diff --git a/ui/app/components/clients/config.hbs b/ui/app/components/clients/config.hbs index 35694158ac..e96f104c5a 100644 --- a/ui/app/components/clients/config.hbs +++ b/ui/app/components/clients/config.hbs @@ -4,41 +4,56 @@ }} {{#if (eq @mode "edit")}} -
+
- - {{#each @model.formFields as |attr|}} - {{#if (eq attr.name "enabled")}} - {{#unless @model.reportingEnabled}} - -

- Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current - month. -

-
- - -
- {{/unless}} - {{else}} - - {{/if}} - {{/each}} + + {{! 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}} + +

+ Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current month. +

+
+ + + Data collection is + {{if this.enabled "on" "off"}} + + +
+ {{/unless}} + +
+ + Retention period + + The number of months of activity logs to maintain for client tracking. + + {{#if this.validationError}} + {{this.validationError}} + {{/if}} + +
- +
@@ -50,7 +65,7 @@ {{this.modalTitle}} - {{#if (eq @model.enabled "On")}} + {{#if this.enabled}}

Vault will start tracking data starting from today’s date, {{date-format (now) "MMMM d, yyyy"}}. If you’ve previously enabled usage tracking, that historical data will @@ -75,7 +90,7 @@ {{else}}

{{#each this.infoRows as |item|}} - + {{/each}}
{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/config.js b/ui/app/components/clients/config.js deleted file mode 100644 index 36b2c2b2f1..0000000000 --- a/ui/app/components/clients/config.js +++ /dev/null @@ -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 - * - * ``` - * @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(); - } - } -} diff --git a/ui/app/components/clients/config.ts b/ui/app/components/clients/config.ts new file mode 100644 index 0000000000..fb901a885d --- /dev/null +++ b/ui/app/components/clients/config.ts @@ -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 { + @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) { + 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; + } + }); +} diff --git a/ui/app/components/clients/date-range.hbs b/ui/app/components/clients/date-range.hbs index 5aef1d2db6..ba6260d964 100644 --- a/ui/app/components/clients/date-range.hbs +++ b/ui/app/components/clients/date-range.hbs @@ -11,7 +11,7 @@ {{if this.flags.isHvdManaged "Change data period" "Change billing period"}} - + - {{this.formatDropdownDate @billingStartTime}} + {{this.formatDate @billingStartTime}} {{#if this.historicalBillingPeriods.length}} @@ -30,7 +30,7 @@ data-test-date-range-billing-start={{add idx 1}} @selected={{this.isSelected period}} > - {{this.formatDropdownDate period}} + {{this.formatDate period}} {{/each}} {{/if}} @@ -77,7 +77,7 @@ 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 { 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 { } @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 { 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; }; } diff --git a/ui/app/components/clients/no-data.hbs b/ui/app/components/clients/no-data.hbs index 57e9dacfc5..9ab6a96f11 100644 --- a/ui/app/components/clients/no-data.hbs +++ b/ui/app/components/clients/no-data.hbs @@ -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"))}} - {{#if @config.canEdit}} + {{#if @canUpdate}} {{/if}} diff --git a/ui/app/components/clients/page-header.js b/ui/app/components/clients/page-header.ts similarity index 50% rename from ui/app/components/clients/page-header.js rename to ui/app/components/clients/page-header.ts index f110c723b0..768e7d3397 100644 --- a/ui/app/components/clients/page-header.js +++ b/ui/app/components/clients/page-header.ts @@ -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 * * ``` - * @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 { + @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) { + 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); }; } diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs index 79088b9c2b..52dcb706f0 100644 --- a/ui/app/components/clients/page/counts.hbs +++ b/ui/app/components/clients/page/counts.hbs @@ -4,9 +4,9 @@ }}
- {{#if (eq @activity.id "no-data")}} - - {{else if @activityError}} - - - - - {{else}} - {{#if (eq @config.enabled "Off")}} - - Tracking is disabled - - Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need - to - edit the configuration - to enable tracking again. - - - {{/if}} + {{#if this.trackingDisabled}} + + Tracking is disabled + + Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need to + edit the configuration + to enable tracking again. + + + {{/if}} + {{#if @activity}} {{#if @activity.total}} {{#if this.upgradeExplanations}} @@ -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 }} - - - - {{else}} + {{! Empty state for no data in the selected date range }} {{/if}} + {{else if (and this.version.isCommunity (or (not @startTimestamp) (not @endTimestamp)))}} + {{! Empty state for community without start or end query param }} + + + + + {{else}} + {{/if}}
\ No newline at end of file diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts index be6c2e7a6a..6ea4b9b7b4 100644 --- a/ui/app/components/clients/page/counts.ts +++ b/ui/app/components/clients/page/counts.ts @@ -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 { @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'): diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs index 2b3b291911..bd0f7e35d7 100644 --- a/ui/app/components/clients/page/overview.hbs +++ b/ui/app/components/clients/page/overview.hbs @@ -6,7 +6,7 @@ {{! by_namespace is an empty array when there is no client count activity data }} -{{#if @activity.byNamespace}} +{{#if @activity.by_namespace}} <:subheader> ; } @@ -28,11 +27,11 @@ export default class ClientsOverviewPageComponent extends Component { 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 { 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 || []); diff --git a/ui/app/components/dashboard/client-count-card.hbs b/ui/app/components/dashboard/client-count-card.hbs index 9e74b1101e..876e7e18a7 100644 --- a/ui/app/components/dashboard/client-count-card.hbs +++ b/ui/app/components/dashboard/client-count-card.hbs @@ -40,7 +40,7 @@ {{else}} - + {{/if}} {{/if}} \ No newline at end of file diff --git a/ui/app/components/dashboard/client-count-card.js b/ui/app/components/dashboard/client-count-card.js deleted file mode 100644 index 184d85de99..0000000000 --- a/ui/app/components/dashboard/client-count-card.js +++ /dev/null @@ -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 - * - */ - -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; - } - } -} diff --git a/ui/app/components/dashboard/client-count-card.ts b/ui/app/components/dashboard/client-count-card.ts new file mode 100644 index 0000000000..1c02e2771f --- /dev/null +++ b/ui/app/components/dashboard/client-count-card.ts @@ -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 + * + */ + +export default class DashboardClientCountCard extends Component { + @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) => { + 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; + } + }) + ); +} diff --git a/ui/app/models/clients/activity.js b/ui/app/models/clients/activity.js deleted file mode 100644 index 585d47fb64..0000000000 --- a/ui/app/models/clients/activity.js +++ /dev/null @@ -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; -} diff --git a/ui/app/models/clients/config.js b/ui/app/models/clients/config.js deleted file mode 100644 index c9020abfcf..0000000000 --- a/ui/app/models/clients/config.js +++ /dev/null @@ -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 hasn’t 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; - } -} diff --git a/ui/app/models/clients/version-history.js b/ui/app/models/clients/version-history.js deleted file mode 100644 index 15b48d005f..0000000000 --- a/ui/app/models/clients/version-history.js +++ /dev/null @@ -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; -} diff --git a/ui/app/routes/vault/cluster/clients.ts b/ui/app/routes/vault/cluster/clients.ts index ee8c9d1415..8a2747e7e2 100644 --- a/ui/app/routes/vault/cluster/clients.ts +++ b/ui/app/routes/vault/cluster/clients.ts @@ -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; 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 }; } } diff --git a/ui/app/routes/vault/cluster/clients/config.js b/ui/app/routes/vault/cluster/clients/config.js deleted file mode 100644 index 61c98b394f..0000000000 --- a/ui/app/routes/vault/cluster/clients/config.js +++ /dev/null @@ -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', {}); - } -} diff --git a/ui/app/routes/vault/cluster/clients/config.ts b/ui/app/routes/vault/cluster/clients/config.ts new file mode 100644 index 0000000000..8b1ec1d74f --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/config.ts @@ -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 }; + } +} diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts index 71c933af24..0bad7efc66 100644 --- a/ui/app/routes/vault/cluster/clients/counts.ts +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -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; 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; - 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, }; } diff --git a/ui/app/routes/vault/cluster/clients/edit.js b/ui/app/routes/vault/cluster/clients/edit.js deleted file mode 100644 index 9a3734a9d2..0000000000 --- a/ui/app/routes/vault/cluster/clients/edit.js +++ /dev/null @@ -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', {}); - }, -}); diff --git a/ui/app/routes/vault/cluster/clients/edit.ts b/ui/app/routes/vault/cluster/clients/edit.ts new file mode 100644 index 0000000000..4159d8f5c9 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/edit.ts @@ -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(); + } +} diff --git a/ui/app/routes/vault/cluster/clients/index.js b/ui/app/routes/vault/cluster/clients/index.ts similarity index 72% rename from ui/app/routes/vault/cluster/clients/index.js rename to ui/app/routes/vault/cluster/clients/index.ts index f161e5a43e..ec6bddbe07 100644 --- a/ui/app/routes/vault/cluster/clients/index.js +++ b/ui/app/routes/vault/cluster/clients/index.ts @@ -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'); diff --git a/ui/app/serializers/clients/activity.js b/ui/app/serializers/clients/activity.js deleted file mode 100644 index fbd511d27c..0000000000 --- a/ui/app/serializers/clients/activity.js +++ /dev/null @@ -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); - } -} diff --git a/ui/app/serializers/clients/config.js b/ui/app/serializers/clients/config.js deleted file mode 100644 index 4de338c45f..0000000000 --- a/ui/app/serializers/clients/config.js +++ /dev/null @@ -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; - } -} diff --git a/ui/app/serializers/clients/version-history.js b/ui/app/serializers/clients/version-history.js deleted file mode 100644 index 4f3d6c16fc..0000000000 --- a/ui/app/serializers/clients/version-history.js +++ /dev/null @@ -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] })); - } - } -} diff --git a/ui/app/services/download.ts b/ui/app/services/download.ts index e062eabf64..ff9399af7e 100644 --- a/ui/app/services/download.ts +++ b/ui/app/services/download.ts @@ -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; diff --git a/ui/app/services/flags.ts b/ui/app/services/flags.ts index 3ad14b9ae3..0456abfc4d 100644 --- a/ui/app/services/flags.ts +++ b/ui/app/services/flags.ts @@ -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); } } }); diff --git a/ui/app/templates/vault/cluster/clients.hbs b/ui/app/templates/vault/cluster/clients.hbs index 808b1bb5c0..b986d0ae36 100644 --- a/ui/app/templates/vault/cluster/clients.hbs +++ b/ui/app/templates/vault/cluster/clients.hbs @@ -3,5 +3,5 @@ SPDX-License-Identifier: BUSL-1.1 }} - + {{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/clients/config.hbs b/ui/app/templates/vault/cluster/clients/config.hbs index e0fa4c5a46..4b3f599a7e 100644 --- a/ui/app/templates/vault/cluster/clients/config.hbs +++ b/ui/app/templates/vault/cluster/clients/config.hbs @@ -6,7 +6,7 @@ - {{#if @model.canEdit}} + {{#if @model.capabilities.canUpdate}} Edit configuration @@ -14,4 +14,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/clients/counts.hbs b/ui/app/templates/vault/cluster/clients/counts.hbs index 719b6ffe50..50b4aa56df 100644 --- a/ui/app/templates/vault/cluster/clients/counts.hbs +++ b/ui/app/templates/vault/cluster/clients/counts.hbs @@ -5,12 +5,13 @@ {{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/clients/edit.hbs b/ui/app/templates/vault/cluster/clients/edit.hbs index 79c8e74192..21e497be80 100644 --- a/ui/app/templates/vault/cluster/clients/edit.hbs +++ b/ui/app/templates/vault/cluster/clients/edit.hbs @@ -5,4 +5,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index a49bf14223..d17d38d834 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -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`, }; diff --git a/ui/lib/core/addon/utils/client-counts/helpers.ts b/ui/lib/core/addon/utils/client-counts/helpers.ts index 74f154ba32..c7cf1c78a0 100644 --- a/ui/lib/core/addon/utils/client-counts/helpers.ts +++ b/ui/lib/core/addon/utils/client-counts/helpers.ts @@ -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 }); }); } } diff --git a/ui/lib/core/addon/utils/client-counts/serializers.ts b/ui/lib/core/addon/utils/client-counts/serializers.ts index 2bdbb8394d..22398f6fd1 100644 --- a/ui/lib/core/addon/utils/client-counts/serializers.ts +++ b/ui/lib/core/addon/utils/client-counts/serializers.ts @@ -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> = {}; + const formattedQuery: Partial> = {}; if (start_time && isValid(parseJSON(start_time))) { formattedQuery.start_time = start_time; diff --git a/ui/lib/core/addon/utils/date-formatters.ts b/ui/lib/core/addon/utils/date-formatters.ts index 58b2184ca9..c36d0d66a3 100644 --- a/ui/lib/core/addon/utils/date-formatters.ts +++ b/ui/lib/core/addon/utils/date-formatters.ts @@ -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; diff --git a/ui/lib/core/package.json b/ui/lib/core/package.json index b2c7e9ef56..2bd1f6d3ec 100644 --- a/ui/lib/core/package.json +++ b/ui/lib/core/package.json @@ -6,6 +6,7 @@ "dependencies": { "autosize": "*", "date-fns": "*", + "date-fns-tz": "*", "@icholy/duration": "*", "base64-js": "*", "dompurify": "*", diff --git a/ui/mirage/handlers/clients.js b/ui/mirage/handlers/clients.js index 1d746367f6..631b7e509e 100644 --- a/ui/mirage/handlers/clients.js +++ b/ui/mirage/handlers/clients.js @@ -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 diff --git a/ui/mirage/models/clients/activity.js b/ui/mirage/models/clients/activity.js new file mode 100644 index 0000000000..766d95488f --- /dev/null +++ b/ui/mirage/models/clients/activity.js @@ -0,0 +1,8 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { Model } from 'miragejs'; + +export default Model.extend({}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index d1a3a15019..72e040b919 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -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) { diff --git a/ui/tests/acceptance/clients/counts-test.js b/ui/tests/acceptance/clients/counts-test.js index 6c61ecd56f..837160bc5d 100644 --- a/ui/tests/acceptance/clients/counts-test.js +++ b/ui/tests/acceptance/clients/counts-test.js @@ -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) { diff --git a/ui/tests/acceptance/clients/counts/client-list-test.js b/ui/tests/acceptance/clients/counts/client-list-test.js index 11a7fda640..b35692373e 100644 --- a/ui/tests/acceptance/clients/counts/client-list-test.js +++ b/ui/tests/acceptance/clients/counts/client-list-test.js @@ -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(); diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index af4fa7f465..a8c022e335 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -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(); }); }); diff --git a/ui/tests/acceptance/sync/secrets/overview-test.js b/ui/tests/acceptance/sync/secrets/overview-test.js index 74d8ba5a81..18d649a76f 100644 --- a/ui/tests/acceptance/sync/secrets/overview-test.js +++ b/ui/tests/acceptance/sync/secrets/overview-test.js @@ -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: [''], diff --git a/ui/tests/integration/components/clients/config-test.js b/ui/tests/integration/components/clients/config-test.js index 42330ee79e..29ac827cfa 100644 --- a/ui/tests/integration/components/clients/config-test.js +++ b/ui/tests/integration/components/clients/config-test.js @@ -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``); }; }); test('it shows the table with the correct rows by default', async function (assert) { - this.createModel(); - - await render(hbs``); + 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` - - `); + 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` - - `); - - 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` - - `); - 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' + ); }); }); diff --git a/ui/tests/integration/components/clients/date-range-test.js b/ui/tests/integration/components/clients/date-range-test.js index ac5633802b..be16965cab 100644 --- a/ui/tests/integration/components/clients/date-range-test.js +++ b/ui/tests/integration/components/clients/date-range-test.js @@ -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`` + hbs`` ); }; }); @@ -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' + ); + }); }); }); diff --git a/ui/tests/integration/components/clients/no-data-test.js b/ui/tests/integration/components/clients/no-data-test.js index ffa300e79d..414819d9da 100644 --- a/ui/tests/integration/components/clients/no-data-test.js +++ b/ui/tests/integration/components/clients/no-data-test.js @@ -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``); + return render(hbs``); }; }); - 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) { diff --git a/ui/tests/integration/components/clients/page-header-test.js b/ui/tests/integration/components/clients/page-header-test.js index 184546d957..90b520278c 100644 --- a/ui/tests/integration/components/clients/page-header-test.js +++ b/ui/tests/integration/components/clients/page-header-test.js @@ -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', diff --git a/ui/tests/integration/components/clients/page/counts-test.js b/ui/tests/integration/components/clients/page/counts-test.js index ed139f1407..370f74c402 100644 --- a/ui/tests/integration/components/clients/page/counts-test.js +++ b/ui/tests/integration/components/clients/page/counts-test.js @@ -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(); diff --git a/ui/tests/integration/components/clients/page/overview-test.js b/ui/tests/integration/components/clients/page/overview-test.js index 238a2cc302..440838c336 100644 --- a/ui/tests/integration/components/clients/page/overview-test.js +++ b/ui/tests/integration/components/clients/page/overview-test.js @@ -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))]; diff --git a/ui/tests/integration/components/clients/running-total-test.js b/ui/tests/integration/components/clients/running-total-test.js index 662e3ae983..2e88d72794 100644 --- a/ui/tests/integration/components/clients/running-total-test.js +++ b/ui/tests/integration/components/clients/running-total-test.js @@ -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` diff --git a/ui/tests/integration/utils/client-counts/helpers-test.js b/ui/tests/integration/utils/client-counts/helpers-test.js index 725c76831e..6a019f55a6 100644 --- a/ui/tests/integration/utils/client-counts/helpers-test.js +++ b/ui/tests/integration/utils/client-counts/helpers-test.js @@ -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' ); diff --git a/ui/tests/integration/utils/date-formatters-test.js b/ui/tests/integration/utils/date-formatters-test.js index d2a2a1877a..79c3895612 100644 --- a/ui/tests/integration/utils/date-formatters-test.js +++ b/ui/tests/integration/utils/date-formatters-test.js @@ -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( diff --git a/ui/tests/unit/adapters/clients-activity-test.js b/ui/tests/unit/adapters/clients-activity-test.js deleted file mode 100644 index af8977fdc8..0000000000 --- a/ui/tests/unit/adapters/clients-activity-test.js +++ /dev/null @@ -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', - }); - }); - }); -}); diff --git a/ui/tests/unit/decorators/model-form-fields-test.js b/ui/tests/unit/decorators/model-form-fields-test.js index b0dca24ec4..2ca54660ad 100644 --- a/ui/tests/unit/decorators/model-form-fields-test.js +++ b/ui/tests/unit/decorators/model-form-fields-test.js @@ -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' - ); - }); }); diff --git a/ui/types/ember-data/types/registries/adapter.d.ts b/ui/types/ember-data/types/registries/adapter.d.ts index 04737eb3c8..91fe559d85 100644 --- a/ui/types/ember-data/types/registries/adapter.d.ts +++ b/ui/types/ember-data/types/registries/adapter.d.ts @@ -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; } diff --git a/ui/types/ember-data/types/registries/model.d.ts b/ui/types/ember-data/types/registries/model.d.ts index e42a874901..b7cfcd9b09 100644 --- a/ui/types/ember-data/types/registries/model.d.ts +++ b/ui/types/ember-data/types/registries/model.d.ts @@ -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; } diff --git a/ui/types/vault/adapters/clients/activity.d.ts b/ui/types/vault/adapters/clients/activity.d.ts deleted file mode 100644 index 533822639e..0000000000 --- a/ui/types/vault/adapters/clients/activity.d.ts +++ /dev/null @@ -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; -} diff --git a/ui/types/vault/api.d.ts b/ui/types/vault/api.d.ts index 5228e12251..106422cf4d 100644 --- a/ui/types/vault/api.d.ts +++ b/ui/types/vault/api.d.ts @@ -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; diff --git a/ui/types/vault/client-counts/activity-api.ts b/ui/types/vault/client-counts/activity-api.ts index 1d33a2d467..22e584e8e9 100644 --- a/ui/types/vault/client-counts/activity-api.ts +++ b/ui/types/vault/client-counts/activity-api.ts @@ -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[]; +}; diff --git a/ui/types/vault/client-counts/index.d.ts b/ui/types/vault/client-counts/index.d.ts new file mode 100644 index 0000000000..81e435d246 --- /dev/null +++ b/ui/types/vault/client-counts/index.d.ts @@ -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; +}; diff --git a/ui/types/vault/models/clients/activity.d.ts b/ui/types/vault/models/clients/activity.d.ts deleted file mode 100644 index 5f538e4ea9..0000000000 --- a/ui/types/vault/models/clients/activity.d.ts +++ /dev/null @@ -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; -} diff --git a/ui/types/vault/models/clients/config.d.ts b/ui/types/vault/models/clients/config.d.ts deleted file mode 100644 index c4228b2fa3..0000000000 --- a/ui/types/vault/models/clients/config.d.ts +++ /dev/null @@ -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; -} diff --git a/ui/types/vault/models/clients/version-history.d.ts b/ui/types/vault/models/clients/version-history.d.ts deleted file mode 100644 index 62137a03ef..0000000000 --- a/ui/types/vault/models/clients/version-history.d.ts +++ /dev/null @@ -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; -}