mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run
This commit is contained in:
commit
64230814b2
68 changed files with 885 additions and 1241 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,41 +4,56 @@
|
|||
}}
|
||||
|
||||
{{#if (eq @mode "edit")}}
|
||||
<form onsubmit={{action "onSaveChanges"}} data-test-clients-config-form>
|
||||
<form onsubmit={{this.onSubmit}} data-test-clients-config-form>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @model={{@model}} @errorMessage={{this.error}} />
|
||||
{{#each @model.formFields as |attr|}}
|
||||
{{#if (eq attr.name "enabled")}}
|
||||
{{#unless @model.reportingEnabled}}
|
||||
<label class="is-label">Usage data collection</label>
|
||||
<p class="sub-text">
|
||||
Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current
|
||||
month.
|
||||
</p>
|
||||
<div class="control is-flex has-bottom-margin-l">
|
||||
<input
|
||||
data-test-input="enabled"
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
class="toggle is-success is-small"
|
||||
checked={{eq @model.enabled "On"}}
|
||||
{{on "change" this.toggleEnabled}}
|
||||
/>
|
||||
<label for="enabled" class="has-text-weight-bold is-size-8">
|
||||
Data collection is
|
||||
{{lowercase @model.enabled}}
|
||||
</label>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.validations}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
{{! reporting_enabled is specifically for automated reporting }}
|
||||
{{! it will be false for CE clusters or enterprise customers who have opted out of automated license reporting }}
|
||||
{{#unless @config.reporting_enabled}}
|
||||
<label class="is-label">Usage data collection</label>
|
||||
<p class="sub-text">
|
||||
Enable or disable client tracking. Keep in mind that disabling tracking will delete the data for the current month.
|
||||
</p>
|
||||
<div class="control is-flex has-bottom-margin-l">
|
||||
<Hds::Form::Toggle::Field
|
||||
@id="enabled"
|
||||
data-test-input="enabled"
|
||||
checked={{this.enabled}}
|
||||
{{on "change" (pipe (pick "target.checked") (fn (mut this.enabled)))}}
|
||||
as |F|
|
||||
>
|
||||
<F.Label data-test-form-field-label>
|
||||
Data collection is
|
||||
{{if this.enabled "on" "off"}}
|
||||
</F.Label>
|
||||
</Hds::Form::Toggle::Field>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
<div class="field" data-test-field="name">
|
||||
<Hds::Form::TextInput::Field
|
||||
@value={{@config.retention_months}}
|
||||
@isInvalid={{this.validationError}}
|
||||
disabled={{this.save.isRunning}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-input="retention_months"
|
||||
{{on "input" (pipe (pick "target.value") (fn (mut @config.retention_months)))}}
|
||||
as |F|
|
||||
>
|
||||
<F.Label>Retention period</F.Label>
|
||||
<F.HelperText>
|
||||
The number of months of activity logs to maintain for client tracking.
|
||||
</F.HelperText>
|
||||
{{#if this.validationError}}
|
||||
<F.Error data-test-validation-error="retention_months">{{this.validationError}}</F.Error>
|
||||
{{/if}}
|
||||
</Hds::Form::TextInput::Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped-split box is-fullwidth is-bottomless">
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button @text="Save" type="submit" disabled={{this.buttonDisabled}} data-test-submit />
|
||||
<Hds::Button @text="Save" type="submit" disabled={{this.save.isRunning}} data-test-submit />
|
||||
<Hds::Button @text="Cancel" @color="secondary" @route="vault.cluster.clients.config" />
|
||||
</Hds::ButtonSet>
|
||||
</div>
|
||||
|
|
@ -50,7 +65,7 @@
|
|||
{{this.modalTitle}}
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
{{#if (eq @model.enabled "On")}}
|
||||
{{#if this.enabled}}
|
||||
<p class="has-bottom-margin-s" data-test-clients-config-modal="on">
|
||||
Vault will start tracking data starting from 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}}
|
||||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-clients-config-table>
|
||||
{{#each this.infoRows as |item|}}
|
||||
<InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{get @model item.valueKey}} />
|
||||
<InfoTableRow @label={{item.label}} @helperText={{item.helperText}} @value={{item.value}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module ClientsConfig
|
||||
* ClientsConfig components are used to show and edit the client count config information.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Clients::Config @model={{model}} @mode="edit" />
|
||||
* ```
|
||||
* @param {object} model - model is the DS clients/config model which should be passed in
|
||||
* @param {string} [mode=show] - mode is either show or edit. Show results in a table with the config, show has a form.
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class ConfigComponent extends Component {
|
||||
@service router;
|
||||
|
||||
@tracked mode = 'show';
|
||||
@tracked modalOpen = false;
|
||||
@tracked validations;
|
||||
@tracked error = null;
|
||||
|
||||
get infoRows() {
|
||||
return [
|
||||
{
|
||||
label: 'Usage data collection',
|
||||
helperText: 'Enable or disable collecting data to track clients.',
|
||||
valueKey: 'enabled',
|
||||
},
|
||||
{
|
||||
label: 'Retention period',
|
||||
helperText: 'The number of months of activity logs to maintain for client tracking.',
|
||||
valueKey: 'retentionMonths',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get modalTitle() {
|
||||
return `Turn usage tracking ${this.args.model.enabled.toLowerCase()}?`;
|
||||
}
|
||||
|
||||
@(task(function* () {
|
||||
try {
|
||||
yield this.args.model.save();
|
||||
this.router.transitionTo('vault.cluster.clients.config');
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
this.modalOpen = false;
|
||||
}
|
||||
}).drop())
|
||||
save;
|
||||
|
||||
@action
|
||||
toggleEnabled(event) {
|
||||
this.args.model.enabled = event.target.checked ? 'On' : 'Off';
|
||||
}
|
||||
|
||||
@action
|
||||
onSaveChanges(evt) {
|
||||
evt.preventDefault();
|
||||
const { isValid, state } = this.args.model.validate();
|
||||
const changed = this.args.model.changedAttributes();
|
||||
if (!isValid) {
|
||||
this.validations = state;
|
||||
} else if (changed.enabled) {
|
||||
this.modalOpen = true;
|
||||
} else {
|
||||
this.save.perform();
|
||||
}
|
||||
}
|
||||
}
|
||||
99
ui/app/components/clients/config.ts
Normal file
99
ui/app/components/clients/config.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type Owner from '@ember/owner';
|
||||
|
||||
interface Args {
|
||||
config: InternalClientActivityReadConfigurationResponse;
|
||||
mode: 'show' | 'edit';
|
||||
}
|
||||
|
||||
export default class ConfigComponent extends Component<Args> {
|
||||
@service declare readonly router: RouterService;
|
||||
@service declare readonly api: ApiService;
|
||||
|
||||
@tracked modalOpen = false;
|
||||
@tracked declare enabled: boolean;
|
||||
@tracked validationError = '';
|
||||
@tracked errorMessage = '';
|
||||
|
||||
constructor(owner: Owner, args: Args) {
|
||||
super(owner, args);
|
||||
const { enabled = '' } = args.config;
|
||||
// possible config values are 'enable', 'disable', 'default-enabled', 'default-disabled'
|
||||
this.enabled = enabled.includes('enable');
|
||||
}
|
||||
|
||||
get infoRows() {
|
||||
return [
|
||||
{
|
||||
label: 'Usage data collection',
|
||||
helperText: 'Enable or disable collecting data to track clients.',
|
||||
value: this.enabled ? 'On' : 'Off',
|
||||
},
|
||||
{
|
||||
label: 'Retention period',
|
||||
helperText: 'The number of months of activity logs to maintain for client tracking.',
|
||||
value: this.args.config.retention_months,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get modalTitle() {
|
||||
return `Turn usage tracking ${this.enabled ? 'on' : 'off'}?`;
|
||||
}
|
||||
|
||||
@action
|
||||
onSubmit(event: HTMLElementEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
this.validationError = '';
|
||||
// since minimum_retention_months may be returned as 0, default to 48 which is the documented minimum
|
||||
// https://developer.hashicorp.com/vault/api-docs/system/internal-counters#retention_months
|
||||
const { minimum_retention_months, retention_months, enabled = '' } = this.args.config;
|
||||
const minRetention = minimum_retention_months || 48;
|
||||
|
||||
if (Number(retention_months) < minRetention) {
|
||||
this.validationError = `Retention period must be greater than or equal to ${minRetention}.`;
|
||||
} else if (Number(retention_months) > 60) {
|
||||
this.validationError = 'Retention period must be less than or equal to 60.';
|
||||
}
|
||||
// if form is valid and enabled value has changed show the confirmation modal
|
||||
// values for enabled may include 'default-' so check for inclusion of enable or disable
|
||||
if (!this.validationError) {
|
||||
const didChange = enabled.includes('enable') ? !this.enabled : !!this.enabled;
|
||||
if (didChange) {
|
||||
// the modal confirm action will trigger the save task directly
|
||||
this.modalOpen = true;
|
||||
} else {
|
||||
this.save.perform();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save = task(async () => {
|
||||
try {
|
||||
const payload = {
|
||||
enabled: this.enabled ? 'enable' : 'disable',
|
||||
retention_months: Number(this.args.config.retention_months),
|
||||
};
|
||||
await this.api.sys.internalClientActivityConfigure(payload);
|
||||
this.router.transitionTo('vault.cluster.clients.config');
|
||||
} catch (error) {
|
||||
const { message } = await this.api.parseError(error);
|
||||
this.errorMessage = message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
{{if this.flags.isHvdManaged "Change data period" "Change billing period"}}
|
||||
</Hds::Text::Display>
|
||||
<Hds::Dropdown class="has-left-margin-xs" as |D|>
|
||||
<D.ToggleButton @text={{this.formatDropdownDate @startTimestamp}} @color="secondary" data-test-date-range-edit />
|
||||
<D.ToggleButton @text={{this.formatDate @startTimestamp}} @color="secondary" data-test-date-range-edit />
|
||||
<D.Description @text="Current period" />
|
||||
<D.Checkmark
|
||||
{{! Pass an empty string to reset query param because the current billing period is the default }}
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
@selected={{this.isSelected @billingStartTime}}
|
||||
data-test-date-range-billing-start="0"
|
||||
>
|
||||
{{this.formatDropdownDate @billingStartTime}}
|
||||
{{this.formatDate @billingStartTime}}
|
||||
</D.Checkmark>
|
||||
{{#if this.historicalBillingPeriods.length}}
|
||||
<D.Separator class="has-bottom-margin-xs" />
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
data-test-date-range-billing-start={{add idx 1}}
|
||||
@selected={{this.isSelected period}}
|
||||
>
|
||||
{{this.formatDropdownDate period}}
|
||||
{{this.formatDate period}}
|
||||
</D.Checkmark>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
<Hds::Form::TextInput::Field
|
||||
@type="month"
|
||||
@value={{this.modalStart}}
|
||||
max={{if this.version.isCommunity this.previousMonth (date-format this.currentMonth "yyyy-MM")}}
|
||||
max={{if this.version.isCommunity this.previousMonth (this.formatDate this.currentMonth "yyyy-MM")}}
|
||||
id="start-month"
|
||||
name="modalStart"
|
||||
{{on "change" this.updateDate}}
|
||||
|
|
@ -91,7 +91,7 @@
|
|||
<Hds::Form::TextInput::Field
|
||||
@type="month"
|
||||
@value={{this.modalEnd}}
|
||||
max={{if this.version.isCommunity this.previousMonth (date-format this.currentMonth "yyyy-MM")}}
|
||||
max={{if this.version.isCommunity this.previousMonth (this.formatDate this.currentMonth "yyyy-MM")}}
|
||||
id="end-month"
|
||||
name="modalEnd"
|
||||
{{on "change" this.updateDate}}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ interface Args {
|
|||
onChange: (callback: OnChangeParams) => void;
|
||||
setEditModalVisible: (visible: boolean) => void;
|
||||
showEditModal: boolean;
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
billingStartTime: string;
|
||||
startTimestamp: Date;
|
||||
endTimestamp: Date;
|
||||
billingStartTime: Date;
|
||||
retentionMonths: number;
|
||||
}
|
||||
/**
|
||||
|
|
@ -66,15 +66,16 @@ export default class ClientsDateRangeComponent extends Component<Args> {
|
|||
|
||||
get historicalBillingPeriods() {
|
||||
// we want whole billing periods
|
||||
const { billingStartTime } = this.args;
|
||||
const totalMonths = this.args.retentionMonths || 48;
|
||||
const count = Math.floor(totalMonths / 12);
|
||||
const periods: string[] = [];
|
||||
const periods: Date[] = [];
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const startDate = parseAPITimestamp(this.args.billingStartTime) as Date;
|
||||
const startDate = new Date(billingStartTime);
|
||||
const utcYear = startDate.getUTCFullYear() - i;
|
||||
startDate.setUTCFullYear(utcYear);
|
||||
periods.push(startDate.toISOString());
|
||||
periods.push(startDate);
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
|
@ -122,9 +123,10 @@ export default class ClientsDateRangeComponent extends Component<Args> {
|
|||
}
|
||||
|
||||
@action
|
||||
updateEnterpriseDateRange(start: string, close: CallableFunction) {
|
||||
updateEnterpriseDateRange(start: Date, close: CallableFunction) {
|
||||
// We do not send an end_time so the backend handles computing the expected billing period
|
||||
this.args.onChange({ start_time: start, end_time: '' });
|
||||
const start_time = start ? start.toISOString() : '';
|
||||
this.args.onChange({ start_time, end_time: '' });
|
||||
close();
|
||||
}
|
||||
|
||||
|
|
@ -138,20 +140,20 @@ export default class ClientsDateRangeComponent extends Component<Args> {
|
|||
|
||||
setTrackedFromArgs() {
|
||||
if (this.args.startTimestamp) {
|
||||
this.modalStart = parseAPITimestamp(this.args.startTimestamp, 'yyyy-MM') as string;
|
||||
this.modalStart = this.formatDate(this.args.startTimestamp, 'yyyy-MM');
|
||||
}
|
||||
if (this.args.endTimestamp) {
|
||||
this.modalEnd = parseAPITimestamp(this.args.endTimestamp, 'yyyy-MM') as string;
|
||||
this.modalEnd = this.formatDate(this.args.endTimestamp, 'yyyy-MM');
|
||||
}
|
||||
}
|
||||
|
||||
// TEMPLATE HELPERS
|
||||
formatDropdownDate = (isoTimestamp: string) => parseAPITimestamp(isoTimestamp, 'MMMM yyyy');
|
||||
formatDate = (date: Date, displayFormat = 'MMMM yyyy') => parseAPITimestamp(date, displayFormat);
|
||||
|
||||
isSelected = (dropdownTimestamp: string) => {
|
||||
isSelected = (dropdownTimestamp: Date) => {
|
||||
// Compare against this.args.startTimestamp because it's from the URL query param
|
||||
// which is used to query the client count activity API.
|
||||
const selectedStart = this.formatDropdownDate(this.args.startTimestamp);
|
||||
return this.formatDropdownDate(dropdownTimestamp) === selectedStart;
|
||||
const selectedStart = this.formatDate(this.args.startTimestamp);
|
||||
return this.formatDate(dropdownTimestamp) === selectedStart;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#if (or @config.reportingEnabled (eq @config.enabled "On"))}}
|
||||
{{#if (or @config.reporting_enabled (eq @config.enabled "default-enabled") (eq @config.enabled "enable"))}}
|
||||
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title="No data received" />
|
||||
<A.Body
|
||||
|
|
@ -18,13 +18,14 @@
|
|||
data-test-empty-state-message
|
||||
@text="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
|
||||
/>
|
||||
{{#if @config.canEdit}}
|
||||
{{#if @canUpdate}}
|
||||
<A.Footer as |F|>
|
||||
<F.LinkStandalone
|
||||
@icon="chevron-right"
|
||||
@iconPosition="trailing"
|
||||
@text="Go to configuration"
|
||||
@route="vault.cluster.clients.config"
|
||||
data-test-link-to="config"
|
||||
/>
|
||||
</A.Footer>
|
||||
{{/if}}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,19 @@ import Component from '@glimmer/component';
|
|||
import { tracked } from '@glimmer/tracking';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
import { isSameMonth } from 'date-fns';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { formatExportData } from 'core/utils/client-counts/serializers';
|
||||
|
||||
import type DownloadService from 'vault/services/download';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import type Owner from '@ember/owner';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import type { Extensions } from 'vault/services/download';
|
||||
|
||||
/**
|
||||
* @module ClientsPageHeader
|
||||
|
|
@ -21,31 +32,44 @@ import { task } from 'ember-concurrency';
|
|||
* ```js
|
||||
* <Clients::PageHeader @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @namespace="foo" @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }} />
|
||||
* ```
|
||||
* @param {string} [billingStartTime] - ISO timestamp of billing start date, to be passed to date picker
|
||||
* @param {Date} [billingStartTime] - billing start date, to be passed to date picker
|
||||
* @param {string} [activityTimestamp] - ISO timestamp created in serializer to timestamp the response to be displayed in page header
|
||||
* @param {string} [startTimestamp] - ISO timestamp of start time, to be passed to export request
|
||||
* @param {string} [endTimestamp] - ISO timestamp of end time, to be passed to export request
|
||||
* @param {Date} [startTimestamp] - start time, to be passed to export request
|
||||
* @param {Date} [endTimestamp] - end time, to be passed to export request
|
||||
* @param {number} [retentionMonths = 48] - number of months for historical billing, to be passed to date picker
|
||||
* @param {string} [upgradesDuringActivity] - array of objects containing version history upgrade data
|
||||
* @param {boolean} [noData = false] - when true, export button will hide regardless of capabilities
|
||||
* @param {function} [onChange] - callback when a new date range is saved, to be passed to date picker
|
||||
*/
|
||||
export default class ClientsPageHeaderComponent extends Component {
|
||||
@service download;
|
||||
@service flags;
|
||||
@service namespace;
|
||||
@service router;
|
||||
@service store;
|
||||
@service version;
|
||||
|
||||
interface Args {
|
||||
billingStartTime: Date;
|
||||
retentionMonths: number;
|
||||
activityTimestamp: string;
|
||||
startTimestamp: Date;
|
||||
endTimestamp: Date;
|
||||
upgradesDuringActivity: string[];
|
||||
noData: boolean;
|
||||
onChange: CallableFunction;
|
||||
}
|
||||
|
||||
export default class ClientsPageHeaderComponent extends Component<Args> {
|
||||
@service declare readonly download: DownloadService;
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
@service declare readonly router: RouterService;
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly version: VersionService;
|
||||
@service declare readonly capabilities: CapabilitiesService;
|
||||
|
||||
@tracked canDownload = false;
|
||||
@tracked showEditModal = false;
|
||||
@tracked showExportModal = false;
|
||||
@tracked exportFormat = 'csv';
|
||||
@tracked exportFormat: keyof Extensions = 'csv';
|
||||
@tracked downloadError = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
constructor(owner: Owner, args: Args) {
|
||||
super(owner, args);
|
||||
this.getExportCapabilities();
|
||||
}
|
||||
|
||||
|
|
@ -59,11 +83,9 @@ export default class ClientsPageHeaderComponent extends Component {
|
|||
const ns = this.namespace.path;
|
||||
try {
|
||||
// selected namespace usually ends in /
|
||||
const url = ns
|
||||
? `${sanitizePath(ns)}/sys/internal/counters/activity/export`
|
||||
: 'sys/internal/counters/activity/export';
|
||||
const cap = await this.store.findRecord('capabilities', url);
|
||||
this.canDownload = cap.canSudo;
|
||||
const namespace = sanitizePath(ns);
|
||||
const { canSudo } = await this.capabilities.for('clientsActivityExport', { namespace });
|
||||
this.canDownload = canSudo;
|
||||
} catch (e) {
|
||||
// if we can't read capabilities, default to show
|
||||
this.canDownload = true;
|
||||
|
|
@ -71,21 +93,16 @@ export default class ClientsPageHeaderComponent extends Component {
|
|||
}
|
||||
|
||||
get formattedStartDate() {
|
||||
if (!this.args.startTimestamp) return null;
|
||||
return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy');
|
||||
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
get formattedEndDate() {
|
||||
if (!this.args.endTimestamp) return null;
|
||||
return parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy');
|
||||
return this.args.endTimestamp ? parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
get showEndDate() {
|
||||
// displays on CSV export modal, no need to display duplicate months and years
|
||||
if (!this.args.endTimestamp) return false;
|
||||
const startDateObject = parseAPITimestamp(this.args.startTimestamp);
|
||||
const endDateObject = parseAPITimestamp(this.args.endTimestamp);
|
||||
return !isSameMonth(startDateObject, endDateObject);
|
||||
return this.formattedEndDate && this.formattedStartDate !== this.formattedEndDate;
|
||||
}
|
||||
|
||||
get formattedCsvFileName() {
|
||||
|
|
@ -96,15 +113,22 @@ export default class ClientsPageHeaderComponent extends Component {
|
|||
}
|
||||
|
||||
async getExportData() {
|
||||
const adapter = this.store.adapterFor('clients/activity');
|
||||
const { startTimestamp, endTimestamp } = this.args;
|
||||
return adapter.exportData({
|
||||
// the API only accepts json or csv
|
||||
format: this.exportFormat === 'jsonl' ? 'json' : 'csv',
|
||||
start_time: startTimestamp,
|
||||
end_time: endTimestamp,
|
||||
namespace: this.namespace.path,
|
||||
});
|
||||
const namespace = this.namespace.path;
|
||||
const headers = namespace ? this.api.buildHeaders({ namespace }) : undefined;
|
||||
const { raw } = await this.api.sys.internalClientActivityExportRaw(
|
||||
{
|
||||
// the API only accepts json or csv
|
||||
format: this.exportFormat === 'jsonl' ? 'json' : 'csv',
|
||||
start_time: startTimestamp ? startTimestamp.toISOString() : undefined,
|
||||
end_time: endTimestamp ? endTimestamp.toISOString() : undefined,
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (raw.status !== 200) {
|
||||
throw { message: 'No data to export in provided time range.' };
|
||||
}
|
||||
return formatExportData(raw, { isDownload: true });
|
||||
}
|
||||
|
||||
exportChartData = task({ drop: true }, async (filename) => {
|
||||
|
|
@ -113,13 +137,14 @@ export default class ClientsPageHeaderComponent extends Component {
|
|||
this.download.download(filename, contents, this.exportFormat);
|
||||
this.showExportModal = false;
|
||||
} catch (e) {
|
||||
this.downloadError = e.message;
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.downloadError = message;
|
||||
}
|
||||
});
|
||||
|
||||
@action
|
||||
refreshRoute() {
|
||||
this.router.refresh(this.router.currentRoute.parent.name);
|
||||
this.router.refresh(this.router.currentRoute?.parent?.name);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -129,18 +154,18 @@ export default class ClientsPageHeaderComponent extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
setEditModalVisible(visible) {
|
||||
setEditModalVisible(visible: boolean) {
|
||||
this.showEditModal = visible;
|
||||
}
|
||||
|
||||
@action
|
||||
setExportFormat(evt) {
|
||||
const { value } = evt.target;
|
||||
this.exportFormat = value;
|
||||
setExportFormat(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
const { value } = event.target;
|
||||
this.exportFormat = value as keyof Extensions;
|
||||
}
|
||||
|
||||
// LOCAL TEMPLATE HELPERS
|
||||
parseAPITimestamp = (timestamp, format) => {
|
||||
parseAPITimestamp = (timestamp: string, format: string) => {
|
||||
return parseAPITimestamp(timestamp, format);
|
||||
};
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
}}
|
||||
<div class="has-border-bottom-light">
|
||||
<Clients::PageHeader
|
||||
@billingStartTime={{this.formattedBillingStartDate}}
|
||||
@retentionMonths={{@config.retentionMonths}}
|
||||
@activityTimestamp={{@activity.responseTimestamp}}
|
||||
@billingStartTime={{@config.billing_start_timestamp}}
|
||||
@retentionMonths={{@config.retention_months}}
|
||||
@activityTimestamp={{@responseTimestamp}}
|
||||
@startTimestamp={{@startTimestamp}}
|
||||
@endTimestamp={{@endTimestamp}}
|
||||
@upgradesDuringActivity={{this.upgradesDuringActivity}}
|
||||
|
|
@ -23,26 +23,18 @@
|
|||
for more information.
|
||||
</Hds::Text::Body>
|
||||
|
||||
{{#if (eq @activity.id "no-data")}}
|
||||
<Clients::NoData @config={{@config}} />
|
||||
{{else if @activityError}}
|
||||
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title={{this.error.title}} @icon="skip" @errorCode={{this.error.httpStatus}} />
|
||||
<A.Body data-test-empty-state-message @text={{this.error.text}} />
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
{{#if (eq @config.enabled "Off")}}
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
|
||||
<A.Title data-test-counts-disabled>Tracking is disabled</A.Title>
|
||||
<A.Description>
|
||||
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
|
||||
to
|
||||
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
|
||||
to enable tracking again.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
{{#if this.trackingDisabled}}
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
|
||||
<A.Title data-test-counts-disabled>Tracking is disabled</A.Title>
|
||||
<A.Description>
|
||||
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need to
|
||||
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
|
||||
to enable tracking again.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
|
||||
{{#if @activity}}
|
||||
{{#if @activity.total}}
|
||||
{{#if this.upgradeExplanations}}
|
||||
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-m" as |A|>
|
||||
|
|
@ -83,16 +75,8 @@
|
|||
|
||||
{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
|
||||
{{yield}}
|
||||
{{else if (and this.version.isCommunity (or (not @startTimestamp) (not @endTimestamp)))}}
|
||||
{{! Empty state for community without start or end query param }}
|
||||
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title="Input the start and end dates to view client attribution by path." />
|
||||
<A.Body
|
||||
data-test-empty-state-message
|
||||
@text="Only historical data may be queried. No data is available for the current month."
|
||||
/>
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
{{! Empty state for no data in the selected date range }}
|
||||
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title="No data received" />
|
||||
<A.Body
|
||||
|
|
@ -105,5 +89,16 @@
|
|||
/>
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
{{else if (and this.version.isCommunity (or (not @startTimestamp) (not @endTimestamp)))}}
|
||||
{{! Empty state for community without start or end query param }}
|
||||
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title="Input the start and end dates to view client attribution by path." />
|
||||
<A.Body
|
||||
data-test-empty-state-message
|
||||
@text="Only historical data may be queried. No data is available for the current month."
|
||||
/>
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
<Clients::NoData @config={{@config}} @canUpdate={{@canUpdateConfig}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
@ -9,67 +9,53 @@ import { action } from '@ember/object';
|
|||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { filterVersionHistory } from 'core/utils/client-counts/helpers';
|
||||
|
||||
import type AdapterError from '@ember-data/adapter/error';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type ClientsActivityModel from 'vault/models/clients/activity';
|
||||
import type ClientsConfigModel from 'vault/models/clients/config';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
import type { VersionHistory } from 'vault/client-counts';
|
||||
import type { Activity } from 'vault/client-counts/activity-api';
|
||||
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
activityError?: AdapterError;
|
||||
config: ClientsConfigModel;
|
||||
endTimestamp: string; // ISO format
|
||||
activity: Activity;
|
||||
config: InternalClientActivityReadConfigurationResponse;
|
||||
canUpdateConfig: boolean;
|
||||
endTimestamp: Date;
|
||||
onFilterChange: CallableFunction;
|
||||
startTimestamp: string; // ISO format
|
||||
versionHistory: ClientsVersionHistoryModel[];
|
||||
startTimestamp: Date;
|
||||
versionHistory: VersionHistory[];
|
||||
responseTimestamp: Date;
|
||||
}
|
||||
|
||||
export default class ClientsCountsPageComponent extends Component<Args> {
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly version: VersionService;
|
||||
|
||||
get error() {
|
||||
const { httpStatus, message, path } = this.args.activityError || {};
|
||||
let title = 'Error',
|
||||
text = message;
|
||||
|
||||
if (httpStatus === 403) {
|
||||
const endpoint = path ? `the ${path} endpoint` : 'this endpoint';
|
||||
title = 'You are not authorized';
|
||||
text = `You must be granted permissions to view this page. Ask your administrator if you think you should have access to ${endpoint}.`;
|
||||
}
|
||||
|
||||
return { title, text, httpStatus };
|
||||
get trackingDisabled() {
|
||||
const { enabled } = this.args.config;
|
||||
return enabled === 'disable' || enabled === 'default-disabled';
|
||||
}
|
||||
|
||||
get formattedStartDate() {
|
||||
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
|
||||
const { startTimestamp } = this.args;
|
||||
return startTimestamp ? parseAPITimestamp(startTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
get formattedEndDate() {
|
||||
return this.args.endTimestamp ? parseAPITimestamp(this.args.endTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
get formattedBillingStartDate() {
|
||||
if (this.args.config?.billingStartTimestamp) {
|
||||
return this.args.config.billingStartTimestamp.toISOString();
|
||||
}
|
||||
return null;
|
||||
const { endTimestamp } = this.args;
|
||||
return endTimestamp ? parseAPITimestamp(endTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
// passed into page-header for the export modal alert
|
||||
get upgradesDuringActivity() {
|
||||
const { versionHistory, activity } = this.args;
|
||||
return filterVersionHistory(versionHistory, activity?.startTime, activity?.endTime);
|
||||
return filterVersionHistory(versionHistory, activity?.start_time, activity?.end_time);
|
||||
}
|
||||
|
||||
get upgradeExplanations() {
|
||||
if (this.upgradesDuringActivity.length) {
|
||||
return this.upgradesDuringActivity.map((upgrade: ClientsVersionHistoryModel) => {
|
||||
return this.upgradesDuringActivity.map((upgrade: VersionHistory) => {
|
||||
let explanation;
|
||||
const date = parseAPITimestamp(upgrade.timestampInstalled, 'MMM d, yyyy');
|
||||
const date = parseAPITimestamp(upgrade.timestamp_installed, 'MMM d, yyyy');
|
||||
const version = upgrade.version || '';
|
||||
switch (true) {
|
||||
case version.includes('1.9'):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<Clients::RunningTotal @byMonthClients={{this.byMonthClients}} @runningTotals={{@activity.total}} />
|
||||
|
||||
{{! by_namespace is an empty array when there is no client count activity data }}
|
||||
{{#if @activity.byNamespace}}
|
||||
{{#if @activity.by_namespace}}
|
||||
<Clients::CountsCard @title="Client attribution">
|
||||
<:subheader>
|
||||
<Clients::FilterToolbar
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import { action } from '@ember/object';
|
|||
import { filterTableData, flattenMounts } from 'core/utils/client-counts/helpers';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
|
||||
import type { ClientFilterTypes } from 'vault/vault/client-counts/activity-api';
|
||||
import type { ClientFilterTypes, Activity } from 'vault/client-counts/activity-api';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
|
||||
export interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
activity: Activity;
|
||||
onFilterChange: CallableFunction;
|
||||
filterQueryParams: Record<ClientFilterTypes, string>;
|
||||
}
|
||||
|
|
@ -28,11 +27,11 @@ export default class ClientsOverviewPageComponent extends Component<Args> {
|
|||
get byMonthClients() {
|
||||
// HVD clusters are billed differently and the monthly total is the important metric.
|
||||
if (this.flags.isHvdManaged) {
|
||||
return this.args.activity.byMonth || [];
|
||||
return this.args.activity.by_month || [];
|
||||
}
|
||||
// For self-managed clusters only the new_clients per month are relevant because clients accumulate over a billing period.
|
||||
// (Since "total" per month is not cumulative it's not a useful metric)
|
||||
return this.args.activity.byMonth?.map((m) => m?.new_clients) || [];
|
||||
return this.args.activity.by_month?.map((m) => m?.new_clients) || [];
|
||||
}
|
||||
|
||||
// Supplies data passed to dropdown filters (except months which is computed below )
|
||||
|
|
@ -42,7 +41,7 @@ export default class ClientsOverviewPageComponent extends Component<Args> {
|
|||
const selectedMonth = this.args.filterQueryParams.month;
|
||||
const namespaceData = selectedMonth
|
||||
? this.byMonthClients.find((m) => m.timestamp === selectedMonth)?.namespaces
|
||||
: this.args.activity.byNamespace;
|
||||
: this.args.activity.by_namespace;
|
||||
|
||||
// Get the array of "mounts" data nested in each namespace object and flatten
|
||||
return flattenMounts(namespaceData || []);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
</small>
|
||||
</div>
|
||||
{{else}}
|
||||
<Clients::NoData @config={{this.activityConfig}} />
|
||||
<Clients::NoData @config={{this.activityConfig}} @canUpdate={{this.canUpdateActivityConfig}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</Hds::Card::Container>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
||||
/**
|
||||
* @module DashboardClientCountCard
|
||||
* DashboardClientCountCard component are used to display total and new client count information
|
||||
*
|
||||
* @example
|
||||
* <Dashboard::ClientCountCard />
|
||||
*/
|
||||
|
||||
export default class DashboardClientCountCard extends Component {
|
||||
@service store;
|
||||
|
||||
@tracked activityData = null;
|
||||
@tracked activityConfig = null;
|
||||
@tracked updatedAt = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.fetchClientActivity.perform();
|
||||
}
|
||||
|
||||
get currentMonthActivityTotalCount() {
|
||||
return this.activityData?.byMonth?.lastObject?.new_clients.clients;
|
||||
}
|
||||
|
||||
get statSubText() {
|
||||
const format = (date) => parseAPITimestamp(date, 'MMM yyyy');
|
||||
const { startTime, endTime } = this.activityData;
|
||||
return startTime && endTime
|
||||
? {
|
||||
total: `The number of clients in this billing period (${format(startTime)} - ${format(endTime)}).`,
|
||||
new: 'The number of clients new to Vault in the current month.',
|
||||
}
|
||||
: { total: 'No total client data available.', new: 'No new client data available.' };
|
||||
}
|
||||
|
||||
@task
|
||||
@waitFor
|
||||
*fetchClientActivity(e) {
|
||||
if (e) e.preventDefault();
|
||||
this.updatedAt = timestamp.now().toISOString();
|
||||
|
||||
try {
|
||||
this.activityData = yield this.store.findRecord('clients/activity', 'clients/activity');
|
||||
} catch (error) {
|
||||
// used for rendering the "No data" empty state, swallow any errors requesting config data
|
||||
this.activityConfig = yield this.store.queryRecord('clients/config', {}).catch(() => null);
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
ui/app/components/dashboard/client-count-card.ts
Normal file
100
ui/app/components/dashboard/client-count-card.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import {
|
||||
destructureClientCounts,
|
||||
formatByMonths,
|
||||
formatByNamespace,
|
||||
} from 'core/utils/client-counts/serializers';
|
||||
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import Owner from '@ember/owner';
|
||||
import type { InternalClientActivityReadConfigurationResponse } from '@hashicorp/vault-client-typescript';
|
||||
import { HTMLElementEvent } from 'vault/forms';
|
||||
import type {
|
||||
Activity,
|
||||
ByNamespaceClients,
|
||||
NamespaceObject,
|
||||
Counts,
|
||||
ActivityMonthBlock,
|
||||
} from 'vault/client-counts/activity-api';
|
||||
|
||||
/**
|
||||
* @module DashboardClientCountCard
|
||||
* DashboardClientCountCard component are used to display total and new client count information
|
||||
*
|
||||
* @example
|
||||
* <Dashboard::ClientCountCard />
|
||||
*/
|
||||
|
||||
export default class DashboardClientCountCard extends Component<object> {
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly capabilities: CapabilitiesService;
|
||||
|
||||
@tracked activityData: Activity | null = null;
|
||||
@tracked activityConfig: InternalClientActivityReadConfigurationResponse | null = null;
|
||||
@tracked canUpdateActivityConfig = true;
|
||||
@tracked updatedAt = '';
|
||||
|
||||
constructor(owner: Owner, args: object) {
|
||||
super(owner, args);
|
||||
this.fetchClientActivity.perform();
|
||||
}
|
||||
|
||||
get currentMonthActivityTotalCount() {
|
||||
const byMonth = this.activityData?.by_month;
|
||||
return byMonth?.[byMonth.length - 1]?.new_clients.clients;
|
||||
}
|
||||
|
||||
get statSubText() {
|
||||
let formattedStart, formattedEnd;
|
||||
if (this.activityData) {
|
||||
const { start_time, end_time } = this.activityData;
|
||||
formattedStart = start_time ? parseAPITimestamp(start_time, 'MMM yyyy') : null;
|
||||
formattedEnd = end_time ? parseAPITimestamp(end_time, 'MMM yyyy') : null;
|
||||
}
|
||||
return formattedStart && formattedEnd
|
||||
? {
|
||||
total: `The number of clients in this billing period (${formattedStart} - ${formattedEnd}).`,
|
||||
new: 'The number of clients new to Vault in the current month.',
|
||||
}
|
||||
: { total: 'No total client data available.', new: 'No new client data available.' };
|
||||
}
|
||||
|
||||
fetchClientActivity = task(
|
||||
waitFor(async (e?: HTMLElementEvent<HTMLInputElement>) => {
|
||||
if (e) e.preventDefault();
|
||||
this.updatedAt = timestamp.now().toISOString();
|
||||
this.activityData = null;
|
||||
this.activityConfig = null;
|
||||
|
||||
try {
|
||||
const response = await this.api.sys.internalClientActivityReportCounts();
|
||||
if (response) {
|
||||
this.activityData = {
|
||||
...response,
|
||||
by_namespace: formatByNamespace(response.by_namespace as NamespaceObject[] | null),
|
||||
by_month: formatByMonths(response.months as ActivityMonthBlock[]),
|
||||
total: destructureClientCounts(response.total as ByNamespaceClients | Counts),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// used for rendering the "No data" empty state, swallow any errors requesting config data
|
||||
this.activityConfig = await this.api.sys.internalClientActivityReadConfiguration().catch(() => null);
|
||||
// Clients::NoData needs to know if the user can update the config
|
||||
const { canUpdate } = await this.capabilities.for('clientsConfig');
|
||||
this.canUpdateActivityConfig = canUpdate;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -4,36 +4,25 @@
|
|||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
import { service } from '@ember/service';
|
||||
import { ModelFrom } from 'vault/route';
|
||||
|
||||
import type Store from '@ember-data/store';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import { VersionHistoryListEnum } from '@hashicorp/vault-client-typescript';
|
||||
|
||||
export type ClientsRouteModel = ModelFrom<ClientsRoute>;
|
||||
|
||||
export default class ClientsRoute extends Route {
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly capabilities: CapabilitiesService;
|
||||
|
||||
getVersionHistory(): Promise<
|
||||
Array<{ version: string; previousVersion: string; timestampInstalled: string }>
|
||||
> {
|
||||
return this.store
|
||||
.findAll('clients/version-history')
|
||||
.then((response) => {
|
||||
return response.map(({ version, previousVersion, timestampInstalled }) => {
|
||||
return {
|
||||
version,
|
||||
previousVersion,
|
||||
timestampInstalled,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
model() {
|
||||
// swallow config error so activity can show if no config permissions
|
||||
return hash({
|
||||
config: this.store.queryRecord('clients/config', {}).catch(() => ({})),
|
||||
versionHistory: this.getVersionHistory(),
|
||||
});
|
||||
async model() {
|
||||
const { canRead: canReadConfig, canUpdate: canUpdateConfig } =
|
||||
await this.capabilities.for('clientsConfig');
|
||||
const response = await this.api.sys.versionHistory(VersionHistoryListEnum.TRUE).catch(() => undefined);
|
||||
const versionHistory = response ? this.api.keyInfoToArray(response, 'version') : [];
|
||||
const config = await this.api.sys.internalClientActivityReadConfiguration().catch(() => ({}));
|
||||
return { canReadConfig, canUpdateConfig, versionHistory, config };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {});
|
||||
}
|
||||
}
|
||||
21
ui/app/routes/vault/cluster/clients/config.ts
Normal file
21
ui/app/routes/vault/cluster/clients/config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
|
||||
export default class ConfigRoute extends Route {
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly capabilities: CapabilitiesService;
|
||||
|
||||
async model() {
|
||||
const capabilities = await this.capabilities.for('clientsConfig');
|
||||
const config = await this.api.sys.internalClientActivityReadConfiguration();
|
||||
return { capabilities, config };
|
||||
}
|
||||
}
|
||||
|
|
@ -5,18 +5,28 @@
|
|||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { formatExportData, formatQueryParams } from 'core/utils/client-counts/serializers';
|
||||
import {
|
||||
formatExportData,
|
||||
formatQueryParams,
|
||||
destructureClientCounts,
|
||||
formatByMonths,
|
||||
formatByNamespace,
|
||||
} from 'core/utils/client-counts/serializers';
|
||||
import { ModelFrom } from 'vault/route';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
import type AdapterError from '@ember-data/adapter/error';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type Store from '@ember-data/store';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type { ModelFrom } from 'vault/vault/route';
|
||||
import type ClientsRoute from '../clients';
|
||||
import type { ClientsRouteModel } from '../clients';
|
||||
import type ClientsCountsController from 'vault/controllers/vault/cluster/clients/counts';
|
||||
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
|
||||
import type {
|
||||
ByNamespaceClients,
|
||||
NamespaceObject,
|
||||
Counts,
|
||||
ActivityMonthBlock,
|
||||
} from 'vault/client-counts/activity-api';
|
||||
|
||||
export interface ClientsCountsRouteParams {
|
||||
start_time?: string;
|
||||
|
|
@ -27,18 +37,12 @@ export interface ClientsCountsRouteParams {
|
|||
month?: string;
|
||||
}
|
||||
|
||||
interface ActivityAdapterQuery {
|
||||
start_time: string | undefined;
|
||||
end_time: string | undefined;
|
||||
}
|
||||
|
||||
export type ClientsCountsRouteModel = ModelFrom<ClientsCountsRoute>;
|
||||
|
||||
export default class ClientsCountsRoute extends Route {
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly version: VersionService;
|
||||
|
||||
queryParams = {
|
||||
|
|
@ -56,31 +60,29 @@ export default class ClientsCountsRoute extends Route {
|
|||
return this.flags.fetchActivatedFlags();
|
||||
}
|
||||
|
||||
async getActivity(params: ClientsCountsRouteParams): Promise<{
|
||||
activity?: ClientsActivityModel;
|
||||
activityError?: AdapterError;
|
||||
}> {
|
||||
let activity, activityError;
|
||||
async getActivity(params: ClientsCountsRouteParams) {
|
||||
// if CE without both start time and end time, we want to skip the activity call
|
||||
// so that the user is forced to choose a date range
|
||||
if (this.version.isEnterprise || (this.version.isCommunity && params.start_time && params.end_time)) {
|
||||
const query: ActivityAdapterQuery = {
|
||||
start_time: params?.start_time,
|
||||
end_time: params?.end_time,
|
||||
};
|
||||
try {
|
||||
activity = await this.store.queryRecord('clients/activity', query);
|
||||
} catch (error) {
|
||||
activityError = error as AdapterError;
|
||||
const response = await this.api.sys.internalClientActivityReportCounts(
|
||||
undefined,
|
||||
params?.end_time || undefined,
|
||||
undefined,
|
||||
params?.start_time || undefined
|
||||
);
|
||||
if (response) {
|
||||
return {
|
||||
...response,
|
||||
by_namespace: formatByNamespace(response.by_namespace as NamespaceObject[] | null),
|
||||
by_month: formatByMonths(response.months as ActivityMonthBlock[]),
|
||||
total: destructureClientCounts(response.total as ByNamespaceClients | Counts),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
activity,
|
||||
activityError,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async fetchAndFormatExportData(startTimestamp: string | undefined, endTimestamp: string | undefined) {
|
||||
async fetchAndFormatExportData(startTimestamp: Date | undefined, endTimestamp: Date | undefined) {
|
||||
// The "Client List" tab is only available on enterprise versions
|
||||
// For now, it is also hidden on HVD managed clusters
|
||||
if (this.version.isEnterprise && !this.flags.isHvdManaged) {
|
||||
|
|
@ -91,9 +93,9 @@ export default class ClientsCountsRoute extends Route {
|
|||
let exportData, cannotRequestExport;
|
||||
try {
|
||||
const { raw } = await this.api.sys.internalClientActivityExportRaw({
|
||||
end_time,
|
||||
end_time: end_time?.toISOString(),
|
||||
format: 'json', // the API only accepts json or csv
|
||||
start_time,
|
||||
start_time: start_time?.toISOString(),
|
||||
});
|
||||
|
||||
// If it's not a 200 but didn't throw an error then it's likely a 204 (empty response).
|
||||
|
|
@ -120,22 +122,22 @@ export default class ClientsCountsRoute extends Route {
|
|||
}
|
||||
|
||||
async model(params: ClientsCountsRouteParams) {
|
||||
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ModelFrom<ClientsRoute>;
|
||||
const { activity, activityError } = await this.getActivity(params);
|
||||
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ClientsRouteModel;
|
||||
const activity = await this.getActivity(params);
|
||||
const { exportData, cannotRequestExport } = await this.fetchAndFormatExportData(
|
||||
activity?.startTime,
|
||||
activity?.endTime
|
||||
activity?.start_time,
|
||||
activity?.end_time
|
||||
);
|
||||
return {
|
||||
activity,
|
||||
activityError,
|
||||
cannotRequestExport,
|
||||
config,
|
||||
exportData,
|
||||
// We always want to return the start and end time from the activity response
|
||||
// so they serve as the source of truth for the time period of the displayed client count data
|
||||
startTimestamp: activity?.startTime,
|
||||
endTimestamp: activity?.endTime,
|
||||
startTimestamp: activity?.start_time,
|
||||
endTimestamp: activity?.end_time,
|
||||
responseTimestamp: timestamp.now(),
|
||||
versionHistory,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {});
|
||||
},
|
||||
});
|
||||
17
ui/app/routes/vault/cluster/clients/edit.ts
Normal file
17
ui/app/routes/vault/cluster/clients/edit.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
import type ApiService from 'vault/services/api';
|
||||
|
||||
export default class ClientsEditRoute extends Route {
|
||||
@service declare readonly api: ApiService;
|
||||
|
||||
model() {
|
||||
return this.api.sys.internalClientActivityReadConfiguration();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Sidebar::Nav::Clients @canReadConfig={{this.model.config.canRead}} />
|
||||
<Sidebar::Nav::Clients @canReadConfig={{this.model.canReadConfig}} />
|
||||
{{outlet}}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<Page::Header @title="Configuration" />
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
{{#if @model.canEdit}}
|
||||
{{#if @model.capabilities.canUpdate}}
|
||||
<LinkTo @route="vault.cluster.clients.edit" class="toolbar-link">
|
||||
Edit configuration
|
||||
</LinkTo>
|
||||
|
|
@ -14,4 +14,4 @@
|
|||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
|
||||
<Clients::Config @model={{@model}} />
|
||||
<Clients::Config @config={{@model.config}} />
|
||||
|
|
@ -5,12 +5,13 @@
|
|||
|
||||
<Clients::Page::Counts
|
||||
@activity={{this.model.activity}}
|
||||
@activityError={{this.model.activityError}}
|
||||
@config={{this.model.config}}
|
||||
@canUpdateConfig={{this.model.canUpdateConfig}}
|
||||
@endTimestamp={{this.model.endTimestamp}}
|
||||
@onFilterChange={{this.updateQueryParams}}
|
||||
@startTimestamp={{this.model.startTimestamp}}
|
||||
@versionHistory={{this.model.versionHistory}}
|
||||
@responseTimestamp={{this.model.responseTimestamp}}
|
||||
>
|
||||
{{outlet}}
|
||||
</Clients::Page::Counts>
|
||||
|
|
@ -5,4 +5,4 @@
|
|||
|
||||
<Page::Header @title="Edit Configuration" />
|
||||
|
||||
<Clients::Config @model={{@model}} @mode="edit" />
|
||||
<Clients::Config @config={{@model}} @mode="edit" />
|
||||
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
ActivityMonthBlock,
|
||||
ActivityMonthEmpty,
|
||||
ActivityMonthStandard,
|
||||
ByMonthClients,
|
||||
ByMonthNewClients,
|
||||
ByNamespaceClients,
|
||||
ClientTypes,
|
||||
|
|
@ -38,7 +39,7 @@ export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClien
|
|||
);
|
||||
};
|
||||
|
||||
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
|
||||
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthClients[] => {
|
||||
const sortedPayload = sortMonthsByTimestamp(monthsArray);
|
||||
return sortedPayload?.map((m) => {
|
||||
const { timestamp } = m;
|
||||
|
|
@ -107,9 +108,9 @@ export const formatExportData = async (resp: Response, { isDownload = false }) =
|
|||
return lines.map((line: string) => JSON.parse(line));
|
||||
};
|
||||
|
||||
export const formatQueryParams = (query: { start_time?: string; end_time?: string } = {}) => {
|
||||
export const formatQueryParams = (query: { start_time?: Date; end_time?: Date } = {}) => {
|
||||
const { start_time, end_time } = query;
|
||||
const formattedQuery: Partial<Record<'start_time' | 'end_time', string>> = {};
|
||||
const formattedQuery: Partial<Record<'start_time' | 'end_time', Date>> = {};
|
||||
|
||||
if (start_time && isValid(parseJSON(start_time))) {
|
||||
formattedQuery.start_time = start_time;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"dependencies": {
|
||||
"autosize": "*",
|
||||
"date-fns": "*",
|
||||
"date-fns-tz": "*",
|
||||
"@icholy/duration": "*",
|
||||
"base64-js": "*",
|
||||
"dompurify": "*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
ui/mirage/models/clients/activity.js
Normal file
8
ui/mirage/models/clients/activity.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { Model } from 'miragejs';
|
||||
|
||||
export default Model.extend({});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [''],
|
||||
|
|
|
|||
|
|
@ -18,26 +18,25 @@ module('Integration | Component | client count config', function (hooks) {
|
|||
hooks.beforeEach(function () {
|
||||
this.router = this.owner.lookup('service:router');
|
||||
this.transitionStub = sinon.stub(this.router, 'transitionTo');
|
||||
const store = this.owner.lookup('service:store');
|
||||
this.createModel = (enabled = 'enable', reporting_enabled = false, minimum_retention_months = 48) => {
|
||||
store.pushPayload('clients/config', {
|
||||
modelName: 'clients/config',
|
||||
id: 'foo',
|
||||
data: {
|
||||
enabled,
|
||||
reporting_enabled,
|
||||
minimum_retention_months,
|
||||
retention_months: 49,
|
||||
},
|
||||
});
|
||||
this.model = store.peekRecord('clients/config', 'foo');
|
||||
|
||||
const { sys } = this.owner.lookup('service:api');
|
||||
this.apiStub = sinon.stub(sys, 'internalClientActivityConfigure').resolves();
|
||||
|
||||
this.renderComponent = (mode, config = {}) => {
|
||||
this.mode = mode;
|
||||
this.config = {
|
||||
enabled: 'enable',
|
||||
reporting_enabled: false,
|
||||
minimum_retention_months: 48,
|
||||
retention_months: 49,
|
||||
...config,
|
||||
};
|
||||
return render(hbs`<Clients::Config @config={{this.config}} @mode={{this.mode}} />`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it shows the table with the correct rows by default', async function (assert) {
|
||||
this.createModel();
|
||||
|
||||
await render(hbs`<Clients::Config @model={{this.model}} />`);
|
||||
await this.renderComponent('show');
|
||||
|
||||
assert.dom('[data-test-clients-config-table]').exists('Clients config table exists');
|
||||
const rows = document.querySelectorAll('.info-table-row');
|
||||
|
|
@ -52,47 +51,54 @@ module('Integration | Component | client count config', function (hooks) {
|
|||
);
|
||||
});
|
||||
|
||||
test('it should function in edit mode when reporting is disabled', async function (assert) {
|
||||
assert.expect(13);
|
||||
const retentionMonths = 60;
|
||||
this.server.put('/sys/internal/counters/config', (schema, req) => {
|
||||
const { enabled, retention_months } = JSON.parse(req.requestBody);
|
||||
const expected = { enabled: 'enable', retention_months: retentionMonths };
|
||||
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (1)');
|
||||
return {};
|
||||
});
|
||||
test('it should validate retention_months', async function (assert) {
|
||||
await this.renderComponent('edit');
|
||||
|
||||
this.createModel('disable');
|
||||
assert.dom('[data-test-input="retention_months"]').hasValue('49', 'Retention months render');
|
||||
await fillIn('[data-test-input="retention_months"]', 20);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retention_months'))
|
||||
.hasText(
|
||||
'Retention period must be greater than or equal to 48.',
|
||||
'Validation error shows for min retention period'
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Config @model={{this.model}} @mode="edit" />
|
||||
`);
|
||||
await fillIn('[data-test-input="retention_months"]', 90);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retention_months'))
|
||||
.hasText(
|
||||
'Retention period must be less than or equal to 60.',
|
||||
'Validation error shows for max retention period'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should validate retention_months when minimum_retention_months is 0', async function (assert) {
|
||||
await this.renderComponent('edit', { minimum_retention_months: 0 });
|
||||
|
||||
await fillIn('[data-test-input="retention_months"]', '');
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retention_months'))
|
||||
.hasText(
|
||||
'Retention period must be greater than or equal to 48.',
|
||||
'Validation error shows for min retention period'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should function in edit mode when enabling reporting', async function (assert) {
|
||||
const retention_months = 60;
|
||||
|
||||
await this.renderComponent('edit', { enabled: 'disable' });
|
||||
|
||||
assert.dom('[data-test-input="enabled"]').isNotChecked('Data collection checkbox is not checked');
|
||||
assert
|
||||
.dom('label[for="enabled"]')
|
||||
.hasText('Data collection is off', 'Correct label renders when data collection is off');
|
||||
assert.dom('[data-test-input="retentionMonths"]').hasValue('49', 'Retention months render');
|
||||
|
||||
await click('[data-test-input="enabled"]');
|
||||
await fillIn('[data-test-input="retentionMonths"]', 20);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
|
||||
.hasText(
|
||||
'Retention period must be greater than or equal to 48.',
|
||||
'Validation error shows for min retention period'
|
||||
);
|
||||
await fillIn('[data-test-input="retentionMonths"]', 90);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
|
||||
.hasText(
|
||||
'Retention period must be less than or equal to 60.',
|
||||
'Validation error shows for max retention period'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-input="retentionMonths"]', retentionMonths);
|
||||
await fillIn('[data-test-input="retention_months"]', retention_months);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom('[data-test-clients-config-modal="title"]')
|
||||
|
|
@ -100,13 +106,23 @@ module('Integration | Component | client count config', function (hooks) {
|
|||
assert.dom('[data-test-clients-config-modal="on"]').exists('Correct modal description block renders');
|
||||
|
||||
await click('[data-test-clients-config-modal="continue"]');
|
||||
assert.true(
|
||||
this.apiStub.calledWith({ enabled: 'enable', retention_months }),
|
||||
'API called with correct params'
|
||||
);
|
||||
assert.ok(
|
||||
this.transitionStub.calledWith('vault.cluster.clients.config'),
|
||||
'Route transitions correctly on save success'
|
||||
);
|
||||
});
|
||||
|
||||
// we need to close the modal
|
||||
await click('[data-test-clients-config-modal="cancel"]');
|
||||
test('it should function in edit mode when disabling reporting', async function (assert) {
|
||||
await this.renderComponent('edit');
|
||||
|
||||
assert.dom('[data-test-input="enabled"]').isChecked('Data collection checkbox is checked');
|
||||
assert
|
||||
.dom('label[for="enabled"]')
|
||||
.hasText('Data collection is on', 'Correct label renders when data collection is on');
|
||||
|
||||
await click('[data-test-input="enabled"]');
|
||||
await click(GENERAL.submitButton);
|
||||
|
|
@ -118,56 +134,51 @@ module('Integration | Component | client count config', function (hooks) {
|
|||
|
||||
await click('[data-test-clients-config-modal="cancel"]');
|
||||
assert.dom('[data-test-clients-config-modal]').doesNotExist('Modal is hidden on cancel');
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
await click('[data-test-clients-config-modal="continue"]');
|
||||
assert.true(
|
||||
this.apiStub.calledWith({ enabled: 'disable', retention_months: 49 }),
|
||||
'API called with correct params'
|
||||
);
|
||||
assert.ok(
|
||||
this.transitionStub.calledWith('vault.cluster.clients.config'),
|
||||
'Route transitions correctly on save success'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should be hidden in edit mode when reporting is enabled', async function (assert) {
|
||||
assert.expect(4);
|
||||
test('it should hide enabled field in edit mode when reporting is enabled', async function (assert) {
|
||||
const config = { enabled: 'enable', reporting_enabled: true, minimum_retention_months: 24 };
|
||||
await this.renderComponent('edit', config);
|
||||
|
||||
this.server.put('/sys/internal/counters/config', (schema, req) => {
|
||||
const { enabled, retention_months } = JSON.parse(req.requestBody);
|
||||
const expected = { enabled: 'enable', retention_months: 48 };
|
||||
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (2)');
|
||||
return {};
|
||||
});
|
||||
assert.dom('[data-test-input="enabled"]').doesNotExist('Data collection input not shown');
|
||||
assert.dom('[data-test-input="retention_months"]').hasValue('49', 'Retention months render');
|
||||
|
||||
this.createModel('enable', true, 24);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Config @model={{this.model}} @mode="edit" />
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-input="enabled"]').doesNotExist('Data collection input not shown ');
|
||||
assert.dom('[data-test-input="retentionMonths"]').hasValue('49', 'Retention months render');
|
||||
|
||||
await fillIn('[data-test-input="retentionMonths"]', 5);
|
||||
await fillIn('[data-test-input="retention_months"]', 5);
|
||||
await click(GENERAL.submitButton);
|
||||
assert
|
||||
.dom(GENERAL.validationErrorByAttr('retentionMonths'))
|
||||
.dom(GENERAL.validationErrorByAttr('retention_months'))
|
||||
.hasText(
|
||||
'Retention period must be greater than or equal to 24.',
|
||||
'Validation error shows for incorrect retention period'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-input="retentionMonths"]', 48);
|
||||
await fillIn('[data-test-input="retention_months"]', 48);
|
||||
await click(GENERAL.submitButton);
|
||||
assert.true(
|
||||
this.apiStub.calledWith({ enabled: 'enable', retention_months: 48 }),
|
||||
'API called with correct params'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should not show modal when data collection is not changed', async function (assert) {
|
||||
assert.expect(1);
|
||||
test('it should not show modal when data collection has not changed', async function (assert) {
|
||||
await this.renderComponent('edit');
|
||||
|
||||
this.server.put('/sys/internal/counters/config', (schema, req) => {
|
||||
const { enabled, retention_months } = JSON.parse(req.requestBody);
|
||||
const expected = { enabled: 'enable', retention_months: 48 };
|
||||
assert.deepEqual({ enabled, retention_months }, expected, 'Correct data sent in PUT request (3)');
|
||||
return {};
|
||||
});
|
||||
|
||||
this.createModel();
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Config @model={{this.model}} @mode="edit" />
|
||||
`);
|
||||
await fillIn('[data-test-input="retentionMonths"]', 48);
|
||||
await fillIn('[data-test-input="retention_months"]', 48);
|
||||
await click(GENERAL.submitButton);
|
||||
assert.true(
|
||||
this.apiStub.calledWith({ enabled: 'enable', retention_months: 48 }),
|
||||
'API called with correct params'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { click, fillIn, findAll, render } from '@ember/test-helpers';
|
|||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import Sinon from 'sinon';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { format } from 'date-fns';
|
||||
import { format, subYears } from 'date-fns';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
|
||||
|
|
@ -21,18 +21,26 @@ module('Integration | Component | clients/date-range', function (hooks) {
|
|||
this.version = this.owner.lookup('service:version');
|
||||
Sinon.replace(timestamp, 'now', Sinon.fake.returns(new Date('2018-04-03T14:15:30')));
|
||||
this.now = timestamp.now();
|
||||
this.startTimestamp = '2018-01-01T14:15:30';
|
||||
this.endTimestamp = '2019-01-31T14:15:30';
|
||||
this.startTimestamp = new Date('2018-01-01T14:15:30');
|
||||
this.endTimestamp = new Date('2019-01-31T14:15:30');
|
||||
this.billingStartTime = '';
|
||||
this.retentionMonths = 48;
|
||||
this.onChange = Sinon.spy();
|
||||
this.onChange = Sinon.stub();
|
||||
this.setEditModalVisible = Sinon.stub().callsFake((visible) => {
|
||||
this.set('showEditModal', visible);
|
||||
});
|
||||
this.showEditModal = false;
|
||||
this.renderComponent = async () => {
|
||||
await render(
|
||||
hbs`<Clients::DateRange @startTimestamp={{this.startTimestamp}} @endTimestamp={{this.endTimestamp}} @onChange={{this.onChange}} @billingStartTime={{this.billingStartTime}} @retentionMonths={{this.retentionMonths}} @setEditModalVisible={{this.setEditModalVisible}} @showEditModal={{this.showEditModal}}/>`
|
||||
hbs`<Clients::DateRange
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@onChange={{this.onChange}}
|
||||
@billingStartTime={{this.billingStartTime}}
|
||||
@retentionMonths={{this.retentionMonths}}
|
||||
@setEditModalVisible={{this.setEditModalVisible}}
|
||||
@showEditModal={{this.showEditModal}}
|
||||
/>`
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
@ -127,7 +135,7 @@ module('Integration | Component | clients/date-range', function (hooks) {
|
|||
hooks.beforeEach(function () {
|
||||
this.version = this.owner.lookup('service:version');
|
||||
this.version.type = 'enterprise';
|
||||
this.billingStartTime = '2018-01-01T14:15:30';
|
||||
this.billingStartTime = new Date('2018-01-01T14:15:30');
|
||||
});
|
||||
|
||||
test('it renders billing start date dropdown for enterprise', async function (assert) {
|
||||
|
|
@ -161,7 +169,7 @@ module('Integration | Component | clients/date-range', function (hooks) {
|
|||
});
|
||||
|
||||
test('it updates toggle text when a new date is selected', async function (assert) {
|
||||
this.onChange = ({ start_time }) => this.set('startTimestamp', start_time);
|
||||
this.onChange.callsFake(({ start_time }) => this.set('startTimestamp', new Date(start_time)));
|
||||
|
||||
await this.renderComponent();
|
||||
assert.dom(DATE_RANGE.edit).hasText('January 2018').hasAttribute('aria-expanded', 'false');
|
||||
|
|
@ -186,5 +194,26 @@ module('Integration | Component | clients/date-range', function (hooks) {
|
|||
await this.renderComponent();
|
||||
assert.dom(this.element).hasText('Change data period January 2018');
|
||||
});
|
||||
|
||||
test('it should send an empty string for start_time when selecting current period', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
await click(DATE_RANGE.edit);
|
||||
await click(DATE_RANGE.dropdownOption(1));
|
||||
assert.true(
|
||||
this.onChange.calledWith({
|
||||
start_time: subYears(this.billingStartTime, 1).toISOString(),
|
||||
end_time: '',
|
||||
}),
|
||||
'correct start_time sent on change for prior period'
|
||||
);
|
||||
|
||||
await click(DATE_RANGE.edit);
|
||||
await click(DATE_RANGE.dropdownOption(0));
|
||||
assert.true(
|
||||
this.onChange.calledWith({ start_time: '', end_time: '' }),
|
||||
'start_time is empty string on current period change'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,31 +17,19 @@ module('Integration | Component | clients/no-data', function (hooks) {
|
|||
|
||||
hooks.beforeEach(async function () {
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.setConfig = async (data) => {
|
||||
// the clients/config model does some funky serializing for the "enabled" param
|
||||
// so stubbing the request here instead of just the model for additional coverage
|
||||
this.server.get('sys/internal/counters/config', () => {
|
||||
return {
|
||||
request_id: '25a94b99-b49a-c4ac-cb7b-5ba0eb390a25',
|
||||
data,
|
||||
};
|
||||
});
|
||||
return this.store.queryRecord('clients/config', {});
|
||||
};
|
||||
this.canUpdate = false;
|
||||
this.setConfig = (enabled, reporting_enabled) => ({
|
||||
enabled: enabled ? 'default-enabled' : 'default-disabled',
|
||||
reporting_enabled,
|
||||
});
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`<Clients::NoData @config={{this.config}} />`);
|
||||
return render(hbs`<Clients::NoData @config={{this.config}} @canUpdate={{this.canUpdate}} />`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders empty state when enabled is "on"', async function (assert) {
|
||||
test('it renders empty state when enabled', async function (assert) {
|
||||
assert.expect(2);
|
||||
const data = {
|
||||
enabled: 'default-enabled',
|
||||
reporting_enabled: false,
|
||||
};
|
||||
``;
|
||||
this.config = await this.setConfig(data);
|
||||
this.config = this.setConfig(true, false);
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No data received');
|
||||
assert
|
||||
|
|
@ -49,13 +37,9 @@ module('Integration | Component | clients/no-data', function (hooks) {
|
|||
.hasText('Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes.');
|
||||
});
|
||||
|
||||
test('it renders empty state when reporting_enabled is true', async function (assert) {
|
||||
test('it renders empty state when reporting is fully enabled', async function (assert) {
|
||||
assert.expect(2);
|
||||
const data = {
|
||||
enabled: 'default-disabled',
|
||||
reporting_enabled: true,
|
||||
};
|
||||
this.config = await this.setConfig(data);
|
||||
this.config = this.setConfig(true, true);
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No data received');
|
||||
assert
|
||||
|
|
@ -64,12 +48,8 @@ module('Integration | Component | clients/no-data', function (hooks) {
|
|||
});
|
||||
|
||||
test('it renders empty state when reporting is fully disabled', async function (assert) {
|
||||
assert.expect(2);
|
||||
const data = {
|
||||
enabled: 'default-disabled',
|
||||
reporting_enabled: false,
|
||||
};
|
||||
this.config = await this.setConfig(data);
|
||||
assert.expect(4);
|
||||
this.config = this.setConfig(false, false);
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('Data tracking is disabled');
|
||||
assert
|
||||
|
|
@ -77,6 +57,11 @@ module('Integration | Component | clients/no-data', function (hooks) {
|
|||
.hasText(
|
||||
'Tracking is disabled, and no data is being collected. To turn it on, edit the configuration.'
|
||||
);
|
||||
assert.dom(GENERAL.linkTo('config')).doesNotExist('Config link does not render without capabilities');
|
||||
|
||||
this.canUpdate = true;
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.linkTo('config')).exists('Config link renders with update capabilities');
|
||||
});
|
||||
|
||||
test('it renders empty state when config data is not available', async function (assert) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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))];
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
ui/types/ember-data/types/registries/model.d.ts
vendored
18
ui/types/ember-data/types/registries/model.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
ui/types/vault/adapters/clients/activity.d.ts
vendored
18
ui/types/vault/adapters/clients/activity.d.ts
vendored
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Store from '@ember-data/store';
|
||||
import { AdapterRegistry } from 'ember-data/adapter';
|
||||
|
||||
interface ExportDataQuery {
|
||||
format?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export default interface ActivityAdapter extends AdapterRegistry {
|
||||
exportData(query?: ExportDataQuery): Promise<Blob>;
|
||||
}
|
||||
7
ui/types/vault/api.d.ts
vendored
7
ui/types/vault/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface MountClients extends TotalClients {
|
|||
namespace_path: string;
|
||||
}
|
||||
|
||||
export interface ByMonthClients extends TotalClients {
|
||||
export interface ByMonthClients extends TotalClientsSometimes {
|
||||
timestamp: string;
|
||||
namespaces: ByNamespaceClients[];
|
||||
new_clients: ByMonthNewClients;
|
||||
|
|
@ -125,3 +125,11 @@ export interface Counts {
|
|||
non_entity_clients: number;
|
||||
secret_syncs: number;
|
||||
}
|
||||
|
||||
export type Activity = {
|
||||
start_time?: Date;
|
||||
end_time?: Date;
|
||||
total: TotalClients;
|
||||
by_month: ByMonthClients[];
|
||||
by_namespace: ByNamespaceClients[];
|
||||
};
|
||||
|
|
|
|||
10
ui/types/vault/client-counts/index.d.ts
vendored
Normal file
10
ui/types/vault/client-counts/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
export type VersionHistory = {
|
||||
version: string;
|
||||
build_date: string;
|
||||
previous_version: string;
|
||||
timestamp_installed: string;
|
||||
};
|
||||
20
ui/types/vault/models/clients/activity.d.ts
vendored
20
ui/types/vault/models/clients/activity.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
15
ui/types/vault/models/clients/config.d.ts
vendored
15
ui/types/vault/models/clients/config.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue