mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* move utils to a folder * separate methods for serializing * separate test coverage too * make formatQueryParams a separate util * use api service to request export data * consolidate error templates * move export request to parent route * replace EmptyState with ApplicationState Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
parent
318d6a2843
commit
04d1d4ca76
24 changed files with 686 additions and 598 deletions
|
|
@ -6,25 +6,13 @@
|
|||
import queryParamString from 'vault/utils/query-param-string';
|
||||
import ApplicationAdapter from '../application';
|
||||
import { debug } from '@ember/debug';
|
||||
import { parseJSON, isValid } from 'date-fns';
|
||||
import { formatQueryParams } from 'core/utils/client-counts/serializers';
|
||||
|
||||
export default class ActivityAdapter extends ApplicationAdapter {
|
||||
formatQueryParams({ start_time, end_time }) {
|
||||
const query = {};
|
||||
|
||||
if (start_time && isValid(parseJSON(start_time))) {
|
||||
query.start_time = start_time;
|
||||
}
|
||||
if (end_time && isValid(parseJSON(end_time))) {
|
||||
query.end_time = end_time;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
queryRecord(store, type, query) {
|
||||
const url = `${this.buildURL()}/internal/counters/activity`;
|
||||
const options = {
|
||||
data: this.formatQueryParams(query),
|
||||
data: formatQueryParams(query),
|
||||
};
|
||||
|
||||
if (query?.namespace) {
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
{{!
|
||||
Copyright IBM Corp. 2016, 2025
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::ApplicationState class="top-padding-32" ...attributes as |A|>
|
||||
<A.Header
|
||||
@icon="skip"
|
||||
@errorCode={{@error.httpStatus}}
|
||||
@title={{or @title (if (eq @error.httpStatus 403) "You are not authorized" "Error")}}
|
||||
data-test-empty-state-title
|
||||
/>
|
||||
|
||||
<A.Body data-test-empty-state-message>
|
||||
{{#if (eq @error.httpStatus 403)}}
|
||||
<p>
|
||||
You must be granted permissions to view this page. Ask your administrator if you think you should have access to the
|
||||
<code>{{@error.path}}</code>
|
||||
endpoint.
|
||||
</p>
|
||||
{{else}}
|
||||
<ul>
|
||||
{{#if @error.message}}
|
||||
<li>{{@error.message}}</li>
|
||||
<hr />
|
||||
{{/if}}
|
||||
{{#each @error.errors as |error|}}
|
||||
<li>
|
||||
{{error}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</A.Body>
|
||||
|
||||
<A.Footer data-test-empty-state-actions>
|
||||
{{#if (has-block "actions")}}
|
||||
{{yield to="actions"}}
|
||||
{{/if}}
|
||||
</A.Footer>
|
||||
</Hds::ApplicationState>
|
||||
|
|
@ -9,7 +9,7 @@ import { action } from '@ember/object';
|
|||
import { debounce } from '@ember/runloop';
|
||||
import { capitalize } from '@ember/string';
|
||||
import { buildISOTimestamp, parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { ClientFilters } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters } from 'core/utils/client-counts/helpers';
|
||||
|
||||
import type {
|
||||
ActivityExportData,
|
||||
|
|
|
|||
|
|
@ -3,88 +3,95 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">The client list data below
|
||||
comes from the
|
||||
<Hds::Link::Inline
|
||||
@icon="docs-link"
|
||||
@iconPosition="trailing"
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
|
||||
>
|
||||
Activity Export API
|
||||
</Hds::Link::Inline>. It may take up to ten minutes for new client IDs to appear in the export data.
|
||||
</Hds::Text::Body>
|
||||
{{#if @exportData}}
|
||||
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">The client list data
|
||||
below comes from the
|
||||
<Hds::Link::Inline
|
||||
@icon="docs-link"
|
||||
@iconPosition="trailing"
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
|
||||
>
|
||||
Activity Export API
|
||||
</Hds::Link::Inline>. It may take up to ten minutes for new client IDs to appear in the export data.
|
||||
</Hds::Text::Body>
|
||||
|
||||
<Clients::CountsCard data-test-card="Export activity data">
|
||||
<:subheader>
|
||||
<Clients::FilterToolbar
|
||||
@dataset={{@exportData}}
|
||||
@onFilter={{this.handleFilter}}
|
||||
@filterQueryParams={{@filterQueryParams}}
|
||||
@isExportData={{true}}
|
||||
/>
|
||||
</:subheader>
|
||||
<Clients::CountsCard data-test-card="Export activity data">
|
||||
<:subheader>
|
||||
<Clients::FilterToolbar
|
||||
@dataset={{@exportData}}
|
||||
@onFilter={{this.handleFilter}}
|
||||
@filterQueryParams={{@filterQueryParams}}
|
||||
@isExportData={{true}}
|
||||
/>
|
||||
</:subheader>
|
||||
|
||||
<:table>
|
||||
<Hds::Tabs @onClickTab={{this.onClickTab}} @selectedTabIndex={{this.selectedTabIndex}} as |T|>
|
||||
{{#each-in this.exportDataByTab as |tabName exportData|}}
|
||||
{{#let (this.filterData exportData) as |tableData|}}
|
||||
<T.Tab @count={{or tableData.length "0"}} data-test-tab={{tabName}}>{{tabName}}</T.Tab>
|
||||
<T.Panel>
|
||||
<div class="has-top-margin-xs">
|
||||
{{#if this.filtersAreApplied}}
|
||||
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-xs" data-test-table-summary={{tabName}}>
|
||||
Summary:
|
||||
{{pluralize tableData.length "client"}}
|
||||
{{if (eq tableData.length 1) "matches" "match"}}
|
||||
the filter criteria.
|
||||
</Hds::Text::Body>
|
||||
{{/if}}
|
||||
<:table>
|
||||
<Hds::Tabs @onClickTab={{this.onClickTab}} @selectedTabIndex={{this.selectedTabIndex}} as |T|>
|
||||
{{#each-in this.exportDataByTab as |tabName exportData|}}
|
||||
{{#let (this.filterData exportData) as |tableData|}}
|
||||
<T.Tab @count={{or tableData.length "0"}} data-test-tab={{tabName}}>{{tabName}}</T.Tab>
|
||||
<T.Panel>
|
||||
<div class="has-top-margin-xs">
|
||||
{{#if this.filtersAreApplied}}
|
||||
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-xs" data-test-table-summary={{tabName}}>
|
||||
Summary:
|
||||
{{pluralize tableData.length "client"}}
|
||||
{{if (eq tableData.length 1) "matches" "match"}}
|
||||
the filter criteria.
|
||||
</Hds::Text::Body>
|
||||
{{/if}}
|
||||
|
||||
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. }}
|
||||
{{! The export data can be many rows so for performance only render the currently selected tab }}
|
||||
{{#if (eq tabName this.selectedTab)}}
|
||||
<Clients::Table
|
||||
data-test-table="attribution"
|
||||
@data={{tableData}}
|
||||
@columns={{this.tableColumns tabName}}
|
||||
@setPageSize={{50}}
|
||||
@showPaginationSizeSelector={{true}}
|
||||
>
|
||||
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. }}
|
||||
{{! The export data can be many rows so for performance only render the currently selected tab }}
|
||||
{{#if (eq tabName this.selectedTab)}}
|
||||
<Clients::Table
|
||||
data-test-table="attribution"
|
||||
@data={{tableData}}
|
||||
@columns={{this.tableColumns tabName}}
|
||||
@setPageSize={{50}}
|
||||
@showPaginationSizeSelector={{true}}
|
||||
>
|
||||
|
||||
<:emptyState>
|
||||
{{#if (and (eq tabName "Secret sync") (not this.flags.secretsSyncIsActivated))}}
|
||||
<Hds::ApplicationState as |A|>
|
||||
<A.Header @title="No secret sync clients" />
|
||||
<A.Body @text="No data is available because Secrets Sync has not been activated." />
|
||||
<A.Body>
|
||||
<Hds::Link::Standalone
|
||||
@icon="chevron-right"
|
||||
@iconPosition="trailing"
|
||||
@text="Activate Secrets Sync"
|
||||
@route="vault.cluster.sync.secrets.overview"
|
||||
<:emptyState>
|
||||
{{#if (and (eq tabName "Secret sync") (not this.flags.secretsSyncIsActivated))}}
|
||||
<Hds::ApplicationState as |A|>
|
||||
<A.Header @title="No secret sync clients" />
|
||||
<A.Body @text="No data is available because Secrets Sync has not been activated." />
|
||||
<A.Body>
|
||||
<Hds::Link::Standalone
|
||||
@icon="chevron-right"
|
||||
@iconPosition="trailing"
|
||||
@text="Activate Secrets Sync"
|
||||
@route="vault.cluster.sync.secrets.overview"
|
||||
/>
|
||||
</A.Body>
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
<Hds::ApplicationState as |A|>
|
||||
<A.Header @title="No data found" />
|
||||
<A.Body
|
||||
@text="Select another client type {{if
|
||||
this.filtersAreApplied
|
||||
'or update filters'
|
||||
''
|
||||
}} to view client count data."
|
||||
/>
|
||||
</A.Body>
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
<Hds::ApplicationState as |A|>
|
||||
<A.Header @title="No data found" />
|
||||
<A.Body
|
||||
@text="Select another client type {{if
|
||||
this.filtersAreApplied
|
||||
'or update filters'
|
||||
''
|
||||
}} to view client count data."
|
||||
/>
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
</:emptyState>
|
||||
</Clients::Table>
|
||||
{{/if}}
|
||||
</div>
|
||||
</T.Panel>
|
||||
{{/let}}
|
||||
{{/each-in}}
|
||||
</Hds::Tabs>
|
||||
</:table>
|
||||
</Clients::CountsCard>
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
</:emptyState>
|
||||
</Clients::Table>
|
||||
{{/if}}
|
||||
</div>
|
||||
</T.Panel>
|
||||
{{/let}}
|
||||
{{/each-in}}
|
||||
</Hds::Tabs>
|
||||
</:table>
|
||||
</Clients::CountsCard>
|
||||
{{else}}
|
||||
<Hds::ApplicationState class="top-padding-32" as |A|>
|
||||
<A.Header data-test-empty-state-title @title="No data found" />
|
||||
<A.Body data-test-empty-state-message @text="No data to export in provided time range." />
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
|
|
@ -7,7 +7,7 @@ import Component from '@glimmer/component';
|
|||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { HTMLElementEvent } from 'vault/forms';
|
||||
import { filterIsSupported, filterTableData } from 'core/utils/client-count-utils';
|
||||
import { filterIsSupported, filterTableData } from 'core/utils/client-counts/helpers';
|
||||
import { service } from '@ember/service';
|
||||
import FlagsService from 'vault/services/flags';
|
||||
|
||||
|
|
@ -44,13 +44,15 @@ export default class ClientsClientListPageComponent extends Component<Args> {
|
|||
constructor(owner: unknown, args: Args) {
|
||||
super(owner, args);
|
||||
|
||||
this.args.exportData.forEach((data: ActivityExportData) => {
|
||||
const tabName = CLIENT_TYPE_MAP[data.client_type];
|
||||
this.exportDataByTab[tabName].push(data);
|
||||
});
|
||||
if (this.args.exportData) {
|
||||
this.args.exportData.forEach((data: ActivityExportData) => {
|
||||
const tabName = CLIENT_TYPE_MAP[data.client_type];
|
||||
this.exportDataByTab[tabName].push(data);
|
||||
});
|
||||
|
||||
const firstTab = Object.keys(this.exportDataByTab)[0] as ClientListTabs;
|
||||
this.selectedTab = firstTab;
|
||||
const firstTab = Object.keys(this.exportDataByTab)[0] as ClientListTabs;
|
||||
this.selectedTab = firstTab;
|
||||
}
|
||||
}
|
||||
|
||||
get selectedTabIndex() {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@
|
|||
{{#if (eq @activity.id "no-data")}}
|
||||
<Clients::NoData @config={{@config}} />
|
||||
{{else if @activityError}}
|
||||
<Clients::Counts::Error @error={{@activityError}} />
|
||||
<Hds::ApplicationState 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|>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import Component from '@glimmer/component';
|
|||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { filterVersionHistory } from 'core/utils/client-count-utils';
|
||||
import { filterVersionHistory } from 'core/utils/client-counts/helpers';
|
||||
|
||||
import type AdapterError from '@ember-data/adapter/error';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
|
|
@ -30,6 +30,20 @@ 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 formattedStartDate() {
|
||||
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { cached, tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { filterTableData, flattenMounts } from 'core/utils/client-count-utils';
|
||||
import { filterTableData, flattenMounts } from 'core/utils/client-counts/helpers';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { action, set } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { ClientFilters } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters } from 'core/utils/client-counts/helpers';
|
||||
|
||||
import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { formatExportData, formatQueryParams } from 'core/utils/client-counts/serializers';
|
||||
|
||||
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';
|
||||
|
|
@ -32,6 +35,7 @@ interface ActivityAdapterQuery {
|
|||
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;
|
||||
|
|
@ -80,25 +84,37 @@ export default class ClientsCountsRoute extends Route {
|
|||
// 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) {
|
||||
const adapter = this.store.adapterFor('clients/activity');
|
||||
let exportData, exportError;
|
||||
const { start_time, end_time } = formatQueryParams({
|
||||
start_time: startTimestamp,
|
||||
end_time: endTimestamp,
|
||||
});
|
||||
let exportData, cannotRequestExport;
|
||||
try {
|
||||
const resp = await adapter.exportData({
|
||||
// the API only accepts json or csv
|
||||
format: 'json',
|
||||
start_time: startTimestamp,
|
||||
end_time: endTimestamp,
|
||||
const { raw } = await this.api.sys.internalClientActivityExportRaw({
|
||||
end_time,
|
||||
format: 'json', // the API only accepts json or csv
|
||||
start_time,
|
||||
});
|
||||
const jsonLines = await resp.text();
|
||||
const lines = jsonLines.trim().split('\n');
|
||||
exportData = lines.map((line: string) => JSON.parse(line));
|
||||
} catch (error) {
|
||||
// Ideally we would not handle errors manually but this is the pattern the other client.counts
|
||||
// route follow since the sys/internal/counters API doesn't always return helpful error messages.
|
||||
// When these routes are migrated away from ember data we should revisit the error handling.
|
||||
exportError = error as AdapterError;
|
||||
|
||||
// If it's not a 200 but didn't throw an error then it's likely a 204 (empty response).
|
||||
exportData = raw.status === 200 ? await formatExportData(raw, { isDownload: false }) : null;
|
||||
} catch (e) {
|
||||
const { status, path, response } = await this.api.parseError(e);
|
||||
// Show a custom error message when the user does not have permission
|
||||
if (status === 403) {
|
||||
cannotRequestExport = true;
|
||||
} else {
|
||||
// re-throw if not a permissions error
|
||||
throw {
|
||||
httpStatus: status,
|
||||
path,
|
||||
message: response?.message,
|
||||
errors: response?.errors || [],
|
||||
error: response?.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { exportData, exportError };
|
||||
return { exportData, cannotRequestExport };
|
||||
}
|
||||
return { exportData: null, exportError: null };
|
||||
}
|
||||
|
|
@ -106,16 +122,16 @@ 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 { exportData, exportError } = await this.fetchAndFormatExportData(
|
||||
const { exportData, cannotRequestExport } = await this.fetchAndFormatExportData(
|
||||
activity?.startTime,
|
||||
activity?.endTime
|
||||
);
|
||||
return {
|
||||
activity,
|
||||
activityError,
|
||||
cannotRequestExport,
|
||||
config,
|
||||
exportData,
|
||||
exportError,
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
|
||||
import ApplicationSerializer from '../application';
|
||||
import { formatISO } from 'date-fns';
|
||||
import { formatByMonths, formatByNamespace, destructureClientCounts } from 'core/utils/client-count-utils';
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -3,28 +3,27 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#if this.model.exportError}}
|
||||
<Clients::Counts::Error
|
||||
@title={{if (eq this.model.exportError.message "No data to export in provided time range.") "No data found"}}
|
||||
@error={{this.model.exportError}}
|
||||
>
|
||||
<:actions>
|
||||
{{#if (eq this.model.exportError.httpStatus 403)}}
|
||||
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-s">
|
||||
Viewing export data requires
|
||||
<Hds::Text::Code class="code-in-text">sudo</Hds::Text::Code>
|
||||
permissions.
|
||||
<Hds::Link::Standalone
|
||||
@icon="docs-link"
|
||||
@iconPosition="trailing"
|
||||
@text="Client Export Documentation"
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
|
||||
/>
|
||||
</Hds::Text::Body>
|
||||
{{/if}}
|
||||
</:actions>
|
||||
</Clients::Counts::Error>
|
||||
{{#if this.model.cannotRequestExport}}
|
||||
<Hds::ApplicationState class="has-top-margin-xxl" as |A|>
|
||||
<A.Header @icon="skip" @title="You are not authorized" data-test-empty-state-title />
|
||||
|
||||
<A.Body data-test-empty-state-message>
|
||||
Viewing export data requires
|
||||
<Hds::Text::Code class="code-in-text">sudo</Hds::Text::Code>
|
||||
permissions to
|
||||
<Hds::Text::Code class="code-in-text">/sys/internal/counters/activity/export</Hds::Text::Code>.
|
||||
</A.Body>
|
||||
|
||||
<A.Footer data-test-empty-state-actions>
|
||||
<Hds::Link::Standalone
|
||||
@icon="docs-link"
|
||||
@iconPosition="trailing"
|
||||
@text="Client Export Documentation"
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
|
||||
/>
|
||||
</A.Footer>
|
||||
</Hds::ApplicationState>
|
||||
{{else}}
|
||||
<Clients::Page::ClientList
|
||||
@exportData={{this.model.exportData}}
|
||||
|
|
|
|||
|
|
@ -3,39 +3,4 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#if (eq @model.httpStatus 404)}}
|
||||
<NotFound @model={{this.model}} />
|
||||
{{else}}
|
||||
<div class="is-flex is-flex-grow-1">
|
||||
<Hds::ApplicationState class="align-self-center" as |A|>
|
||||
<A.Header
|
||||
data-test-empty-state-title
|
||||
@title={{if (eq @model.httpStatus 403) "You are not authorized" "Error"}}
|
||||
@icon="skip"
|
||||
@errorCode={{@model.httpStatus}}
|
||||
/>
|
||||
<A.Body data-test-empty-state-message>
|
||||
{{#if (eq @model.httpStatus 403)}}
|
||||
<p>
|
||||
You must be granted permissions to view this page. Ask your administrator if you think you should have access to
|
||||
the
|
||||
<code>{{@model.path}}</code>
|
||||
endpoint.
|
||||
</p>
|
||||
{{else}}
|
||||
<ul>
|
||||
{{#if @model.message}}
|
||||
<li>{{@model.message}}</li>
|
||||
<hr />
|
||||
{{/if}}
|
||||
{{#each @model.errors as |error|}}
|
||||
<li>
|
||||
{{error}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</A.Body>
|
||||
</Hds::ApplicationState>
|
||||
</div>
|
||||
{{/if}}
|
||||
<Page::Error @error={{this.model}} />
|
||||
|
|
@ -4,32 +4,19 @@
|
|||
*/
|
||||
|
||||
import { isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { compareAsc, isWithinInterval } from 'date-fns';
|
||||
import { isWithinInterval } from 'date-fns';
|
||||
import { ROOT_NAMESPACE } from 'vault/services/namespace';
|
||||
import { sanitizePath } from './sanitize-path';
|
||||
|
||||
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
|
||||
import type {
|
||||
ActivityExportData,
|
||||
ActivityMonthBlock,
|
||||
ActivityMonthEmpty,
|
||||
ActivityMonthStandard,
|
||||
ByMonthNewClients,
|
||||
ByNamespaceClients,
|
||||
ClientFilterTypes,
|
||||
ClientTypes,
|
||||
Counts,
|
||||
MountClients,
|
||||
MountNewClients,
|
||||
NamespaceNewClients,
|
||||
NamespaceObject,
|
||||
} from 'vault/vault/client-counts/activity-api';
|
||||
|
||||
/*
|
||||
The client count utils are responsible for serializing the sys/internal/counters/activity API response
|
||||
The initial API response shape and serialized types are defined below.
|
||||
|
||||
To help visualize there are sample responses in ui/tests/helpers/clients.js
|
||||
The client count utils define consts and methods, such as filtering, related to client count data.
|
||||
*/
|
||||
|
||||
// Add new sys/activity/counters client count types here
|
||||
|
|
@ -93,85 +80,6 @@ export const filterVersionHistory = (
|
|||
return [];
|
||||
};
|
||||
|
||||
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
|
||||
|
||||
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
|
||||
const sortedPayload = sortMonthsByTimestamp(monthsArray);
|
||||
return sortedPayload?.map((m) => {
|
||||
const { timestamp } = m;
|
||||
if (monthIsEmpty(m)) {
|
||||
// empty month
|
||||
return {
|
||||
timestamp,
|
||||
namespaces: [],
|
||||
new_clients: { timestamp, namespaces: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
|
||||
if (monthWithAllCounts(m)) {
|
||||
newClients = {
|
||||
timestamp,
|
||||
...destructureClientCounts(m?.new_clients.counts),
|
||||
namespaces: formatByNamespace(m.new_clients.namespaces),
|
||||
};
|
||||
}
|
||||
return {
|
||||
timestamp,
|
||||
...destructureClientCounts(m.counts),
|
||||
namespaces: formatByNamespace(m.namespaces),
|
||||
new_clients: newClients,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByNamespaceClients[] => {
|
||||
if (!Array.isArray(namespaceArray)) return [];
|
||||
return namespaceArray.map((ns) => {
|
||||
// i.e. 'namespace_path' is an empty string for 'root', so use namespace_id
|
||||
const nsLabel = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path;
|
||||
// data prior to adding mount granularity will still have a mounts array,
|
||||
// but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go)
|
||||
// transform to an empty array for type consistency
|
||||
let mounts: MountClients[] | [] = [];
|
||||
if (Array.isArray(ns.mounts)) {
|
||||
mounts = ns.mounts.map((m) => ({
|
||||
label: m.mount_path,
|
||||
namespace_path: nsLabel,
|
||||
mount_path: m.mount_path,
|
||||
// sanitized so it matches activity export data because mount_type there does NOT have a trailing slash
|
||||
mount_type: sanitizePath(m.mount_type),
|
||||
...destructureClientCounts(m.counts),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
label: nsLabel,
|
||||
...destructureClientCounts(ns.counts),
|
||||
mounts,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// This method returns only client types from the passed object, excluding other keys such as "label".
|
||||
// when querying historical data the response will always contain the latest client type keys because the activity log is
|
||||
// constructed based on the version of Vault the user is on (key values will be 0)
|
||||
export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClients) => {
|
||||
return CLIENT_TYPES.reduce(
|
||||
(newObj: Record<ClientTypes, Counts[ClientTypes]>, clientType: ClientTypes) => {
|
||||
newObj[clientType] = verboseObject[clientType];
|
||||
return newObj;
|
||||
},
|
||||
{} as Record<ClientTypes, Counts[ClientTypes]>
|
||||
);
|
||||
};
|
||||
|
||||
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
|
||||
const sortedPayload = [...monthsArray];
|
||||
return sortedPayload.sort((a, b) =>
|
||||
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
|
||||
);
|
||||
};
|
||||
|
||||
// *Performance note*
|
||||
// The client dashboard renders dropdown lists that specify filters. When the user selects a dropdown item (filter)
|
||||
// it updates the query param and this method is called to filter the data passed to the displayed table.
|
||||
|
|
@ -224,27 +132,5 @@ const matchesFilter = (
|
|||
export const flattenMounts = (namespaceArray: ByNamespaceClients[]) =>
|
||||
namespaceArray.map((n) => n.mounts).flat();
|
||||
|
||||
// TYPE GUARDS FOR CONDITIONALS
|
||||
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
|
||||
return !month || month?.counts === null;
|
||||
}
|
||||
|
||||
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
|
||||
return month?.counts !== null && month?.new_clients?.counts !== null;
|
||||
}
|
||||
|
||||
export function filterIsSupported(f: string): f is ClientFilterTypes {
|
||||
return Object.values(ClientFilters).includes(f as ClientFilterTypes);
|
||||
}
|
||||
|
||||
export function hasMountsKey(
|
||||
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
|
||||
): obj is NamespaceNewClients {
|
||||
return 'mounts' in obj;
|
||||
}
|
||||
|
||||
export function hasNamespacesKey(
|
||||
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
|
||||
): obj is ByMonthNewClients {
|
||||
return 'namespaces' in obj;
|
||||
}
|
||||
export const filterIsSupported = (f: string): f is ClientFilterTypes =>
|
||||
Object.values(ClientFilters).includes(f as ClientFilterTypes);
|
||||
138
ui/lib/core/addon/utils/client-counts/serializers.ts
Normal file
138
ui/lib/core/addon/utils/client-counts/serializers.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { compareAsc, isValid, parseJSON } from 'date-fns';
|
||||
import { sanitizePath } from '../sanitize-path';
|
||||
|
||||
import type {
|
||||
ActivityMonthBlock,
|
||||
ActivityMonthEmpty,
|
||||
ActivityMonthStandard,
|
||||
ByMonthNewClients,
|
||||
ByNamespaceClients,
|
||||
ClientTypes,
|
||||
Counts,
|
||||
MountClients,
|
||||
NamespaceObject,
|
||||
} from 'vault/vault/client-counts/activity-api';
|
||||
import { CLIENT_TYPES } from './helpers';
|
||||
|
||||
/*
|
||||
These client count utils are responsible for serializing the sys/internal/counters/activity API response.
|
||||
To help visualize there are sample responses in ui/tests/helpers/clients.js
|
||||
*/
|
||||
|
||||
// This method returns only client types from the passed object, excluding other keys such as "label".
|
||||
// when querying historical data the response will always contain the latest client type keys because the activity log is
|
||||
// constructed based on the version of Vault the user is on (key values will be 0)
|
||||
export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClients) => {
|
||||
return CLIENT_TYPES.reduce(
|
||||
(newObj: Record<ClientTypes, Counts[ClientTypes]>, clientType: ClientTypes) => {
|
||||
newObj[clientType] = verboseObject[clientType];
|
||||
return newObj;
|
||||
},
|
||||
{} as Record<ClientTypes, Counts[ClientTypes]>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
|
||||
const sortedPayload = sortMonthsByTimestamp(monthsArray);
|
||||
return sortedPayload?.map((m) => {
|
||||
const { timestamp } = m;
|
||||
if (monthIsEmpty(m)) {
|
||||
// empty month
|
||||
return {
|
||||
timestamp,
|
||||
namespaces: [],
|
||||
new_clients: { timestamp, namespaces: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
|
||||
if (monthWithAllCounts(m)) {
|
||||
newClients = {
|
||||
timestamp,
|
||||
...destructureClientCounts(m?.new_clients.counts),
|
||||
namespaces: formatByNamespace(m.new_clients.namespaces),
|
||||
};
|
||||
}
|
||||
return {
|
||||
timestamp,
|
||||
...destructureClientCounts(m.counts),
|
||||
namespaces: formatByNamespace(m.namespaces),
|
||||
new_clients: newClients,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByNamespaceClients[] => {
|
||||
if (!Array.isArray(namespaceArray)) return [];
|
||||
return namespaceArray.map((ns) => {
|
||||
// i.e. 'namespace_path' is an empty string for 'root', so use namespace_id
|
||||
const nsLabel = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path;
|
||||
// data prior to adding mount granularity will still have a mounts array,
|
||||
// but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go)
|
||||
// transform to an empty array for type consistency
|
||||
let mounts: MountClients[] | [] = [];
|
||||
if (Array.isArray(ns.mounts)) {
|
||||
mounts = ns.mounts.map((m) => ({
|
||||
label: m.mount_path,
|
||||
namespace_path: nsLabel,
|
||||
mount_path: m.mount_path,
|
||||
// sanitized so it matches activity export data because mount_type there does NOT have a trailing slash
|
||||
mount_type: sanitizePath(m.mount_type),
|
||||
...destructureClientCounts(m.counts),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
label: nsLabel,
|
||||
...destructureClientCounts(ns.counts),
|
||||
mounts,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const formatExportData = async (resp: Response, { isDownload = false }) => {
|
||||
// The response from the export API is a ReadableStream
|
||||
const blob = await resp.blob();
|
||||
// If the user wants to download the export data just return the blob.
|
||||
if (isDownload) return blob;
|
||||
|
||||
// Otherwise format to JSON to render dataset in a table.
|
||||
const jsonLines = await blob.text();
|
||||
const lines = jsonLines.trim().split('\n');
|
||||
return lines.map((line: string) => JSON.parse(line));
|
||||
};
|
||||
|
||||
export const formatQueryParams = (query: { start_time?: string; end_time?: string } = {}) => {
|
||||
const { start_time, end_time } = query;
|
||||
const formattedQuery: Partial<Record<'start_time' | 'end_time', string>> = {};
|
||||
|
||||
if (start_time && isValid(parseJSON(start_time))) {
|
||||
formattedQuery.start_time = start_time;
|
||||
}
|
||||
if (end_time && isValid(parseJSON(end_time))) {
|
||||
formattedQuery.end_time = end_time;
|
||||
}
|
||||
|
||||
return formattedQuery;
|
||||
};
|
||||
|
||||
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
|
||||
const sortedPayload = [...monthsArray];
|
||||
return sortedPayload.sort((a, b) =>
|
||||
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
|
||||
);
|
||||
};
|
||||
|
||||
// TYPE GUARDS FOR CONDITIONALS
|
||||
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
|
||||
return !month || month?.counts === null;
|
||||
}
|
||||
|
||||
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
|
||||
return month?.counts !== null && month?.new_clients?.counts !== null;
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
subMonths,
|
||||
} from 'date-fns';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { CLIENT_TYPES } from 'core/utils/client-count-utils';
|
||||
import { CLIENT_TYPES } from 'core/utils/client-counts/helpers';
|
||||
|
||||
/*
|
||||
HOW TO ADD NEW TYPES:
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import { visit, click, currentURL } from '@ember/test-helpers';
|
|||
import sinon from 'sinon';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { login } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { ClientFilters } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters } from 'core/utils/client-counts/helpers';
|
||||
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
import { ACTIVITY_EXPORT_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import clientsHandler, { STATIC_NOW } from 'vault/mirage/handlers/clients';
|
||||
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
|
||||
|
||||
// integration test handle general display assertions, acceptance handles nav + filtering
|
||||
module('Acceptance | clients | counts | client list', function (hooks) {
|
||||
|
|
@ -32,16 +33,17 @@ module('Acceptance | clients | counts | client list', function (hooks) {
|
|||
//* End CE setup
|
||||
|
||||
// The activity export endpoint returns a ReadableStream of json lines, this is not easily mocked using mirage.
|
||||
// Stubbing the adapter method return instead.
|
||||
// Stubbing the api service method instead.
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
text: () => Promise.resolve(ACTIVITY_EXPORT_STUB.trim()),
|
||||
raw: new Response(ACTIVITY_EXPORT_STUB, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
};
|
||||
const store = this.owner.lookup('service:store');
|
||||
const adapter = store.adapterFor('clients/activity');
|
||||
this.exportDataStub = sinon.stub(adapter, 'exportData');
|
||||
const api = this.owner.lookup('service:api');
|
||||
this.exportDataStub = sinon.stub(api.sys, 'internalClientActivityExportRaw');
|
||||
this.exportDataStub.resolves(mockResponse);
|
||||
|
||||
await login();
|
||||
return visit('/vault');
|
||||
});
|
||||
|
|
@ -126,7 +128,10 @@ module('Acceptance | clients | counts | client list', function (hooks) {
|
|||
});
|
||||
|
||||
test('it renders error message if export has no data', async function (assert) {
|
||||
this.exportDataStub.throws(new Error('No data to export in provided time range.'));
|
||||
const emptyResponse = {
|
||||
raw: new Response({}, { status: 204, headers: { 'Content-Type': 'application/json' } }),
|
||||
};
|
||||
this.exportDataStub.resolves(emptyResponse);
|
||||
await visit('/vault/clients/counts/client-list');
|
||||
await click(CLIENT_COUNT.dateRange.edit);
|
||||
await click(CLIENT_COUNT.dateRange.dropdownOption(4));
|
||||
|
|
@ -138,14 +143,22 @@ module('Acceptance | clients | counts | client list', function (hooks) {
|
|||
});
|
||||
|
||||
test('it renders error message for permission denied', async function (assert) {
|
||||
this.exportDataStub.throws(new Error('permission denied'));
|
||||
const error = { errors: ['1 error occurred:\n\t* permission denied\n\n'] };
|
||||
this.exportDataStub.rejects(getErrorResponse(error, 403));
|
||||
await visit('/vault/clients/counts/client-list');
|
||||
await click(CLIENT_COUNT.dateRange.edit);
|
||||
await click(CLIENT_COUNT.dateRange.dropdownOption(4));
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('Error');
|
||||
assert.dom(GENERAL.emptyStateMessage).hasText('permission denied');
|
||||
// Assert the empty state message renders below the page header so user can query other dates
|
||||
assert.dom(GENERAL.tab('overview')).exists('Overview tab still renders');
|
||||
assert.dom(GENERAL.tab('client list')).exists('Client list tab still renders');
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('You are not authorized');
|
||||
assert
|
||||
.dom(GENERAL.emptyStateMessage)
|
||||
.hasText('Viewing export data requires sudo permissions to /sys/internal/counters/activity/export.');
|
||||
assert.dom(GENERAL.emptyStateActions).hasText('Client Export Documentation');
|
||||
});
|
||||
|
||||
// since permissions errors are specially handled, test that a non 403 is handled correctly
|
||||
test('it renders error message for a server error', async function (assert) {
|
||||
const error = { errors: ['uh oh'] };
|
||||
this.exportDataStub.rejects(getErrorResponse(error, 500));
|
||||
await visit('/vault/clients/counts/client-list');
|
||||
assert.dom(GENERAL.pageError.errorTitle(500)).hasText('Error');
|
||||
assert.dom(GENERAL.pageError.errorDetails).hasText('uh oh');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
ACTIVITY_EXPORT_STUB,
|
||||
ACTIVITY_RESPONSE_STUB,
|
||||
} from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
||||
module('Acceptance | clients | overview', function (hooks) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { hbs } from 'ember-cli-htmlbars';
|
|||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import sinon from 'sinon';
|
||||
import { FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
import { ClientFilters } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters } from 'core/utils/client-counts/helpers';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
||||
module('Integration | Component | clients/filter-toolbar', function (hooks) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { ACTIVITY_EXPORT_STUB } from 'vault/tests/helpers/clients/client-count-h
|
|||
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import sinon from 'sinon';
|
||||
import { ClientFilters } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters } from 'core/utils/client-counts/helpers';
|
||||
|
||||
const EXPORT_TAB_TO_TYPE = {
|
||||
Entity: 'entity',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count
|
|||
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import sinon from 'sinon';
|
||||
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
|
||||
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
||||
module('Integration | Component | clients/page/overview', function (hooks) {
|
||||
|
|
|
|||
|
|
@ -6,30 +6,14 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import {
|
||||
filterVersionHistory,
|
||||
formatByMonths,
|
||||
formatByNamespace,
|
||||
destructureClientCounts,
|
||||
sortMonthsByTimestamp,
|
||||
flattenMounts,
|
||||
filterTableData,
|
||||
} from 'core/utils/client-count-utils';
|
||||
import { filterVersionHistory, flattenMounts, filterTableData } from 'core/utils/client-counts/helpers';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import {
|
||||
ACTIVITY_RESPONSE_STUB as RESPONSE,
|
||||
MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE,
|
||||
SERIALIZED_ACTIVITY_RESPONSE,
|
||||
ENTITY_EXPORT,
|
||||
} from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
|
||||
/*
|
||||
formatByNamespace, formatByMonths, destructureClientCounts are utils
|
||||
used to normalize the sys/counters/activity response in the clients/activity
|
||||
serializer. these functions are tested individually here, instead of all at once
|
||||
in a serializer test for easier debugging
|
||||
*/
|
||||
module('Integration | Util | client count utils', function (hooks) {
|
||||
module('Unit | Util | client counts | helpers', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
module('filterVersionHistory', function (hooks) {
|
||||
|
|
@ -143,221 +127,6 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
);
|
||||
});
|
||||
|
||||
test('formatByMonths: it formats the months array', async function (assert) {
|
||||
assert.expect(7);
|
||||
const original = [...RESPONSE.months];
|
||||
|
||||
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
|
||||
|
||||
// instead of asserting the whole expected response, broken up so tests are easier to debug
|
||||
// but kept whole above to copy/paste updated response expectations in the future
|
||||
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
|
||||
|
||||
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
|
||||
['namespaces', 'new_clients'].forEach((key) => {
|
||||
assert.propEqual(
|
||||
formattedWithActivity[key],
|
||||
expectedWithActivity[key],
|
||||
`it formats ${key} array for months with data`
|
||||
);
|
||||
assert.propEqual(
|
||||
formattedNoNew[key],
|
||||
expectedNoNew[key],
|
||||
`it formats the ${key} array for months with no new clients`
|
||||
);
|
||||
});
|
||||
|
||||
assert.propEqual(RESPONSE.months, original, 'it does not modify original months array');
|
||||
assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty');
|
||||
});
|
||||
|
||||
test('formatByNamespace: it formats namespace array with mounts', async function (assert) {
|
||||
const original = [...RESPONSE.by_namespace];
|
||||
const expectedNs1 = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find((ns) => ns.label === 'ns1/');
|
||||
const formattedNs1 = formatByNamespace(RESPONSE.by_namespace).find((ns) => ns.label === 'ns1/');
|
||||
assert.expect(2 + formattedNs1.mounts.length);
|
||||
|
||||
assert.propEqual(formattedNs1, expectedNs1, 'it formats ns1/ namespace');
|
||||
assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array');
|
||||
|
||||
formattedNs1.mounts.forEach((mount) => {
|
||||
const expectedMount = expectedNs1.mounts.find((m) => m.label === mount.label);
|
||||
assert.propEqual(mount, expectedMount, `${mount.label} has expected key/value pairs`);
|
||||
});
|
||||
});
|
||||
|
||||
test('destructureClientCounts: it returns relevant key names when both old and new keys exist', async function (assert) {
|
||||
assert.expect(2);
|
||||
const original = { ...RESPONSE.total };
|
||||
const expected = {
|
||||
acme_clients: 9702,
|
||||
clients: 35287,
|
||||
entity_clients: 8258,
|
||||
non_entity_clients: 8227,
|
||||
secret_syncs: 9100,
|
||||
};
|
||||
assert.propEqual(destructureClientCounts(RESPONSE.total), expected);
|
||||
assert.propEqual(RESPONSE.total, original, 'it does not modify original object');
|
||||
});
|
||||
|
||||
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
|
||||
assert.expect(2);
|
||||
// API returns them in order so this test is extra extra
|
||||
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0], RESPONSE.months[3], RESPONSE.months[2]]; // mixup order
|
||||
const original = [...RESPONSE.months];
|
||||
const expected = RESPONSE.months;
|
||||
assert.propEqual(sortMonthsByTimestamp(unOrdered), expected);
|
||||
assert.propEqual(RESPONSE.months, original, 'it does not modify original array');
|
||||
});
|
||||
|
||||
// TESTS FOR COMBINED ACTIVITY DATA - no mount attribution < 1.10
|
||||
test('it formats the namespaces array with no mount attribution (activity log data < 1.10)', async function (assert) {
|
||||
assert.expect(2);
|
||||
const noMounts = [
|
||||
{
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
counts: {
|
||||
entity_clients: 10,
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
entity_clients: 10,
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
},
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
entity_clients: 10,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
entity_clients: 10,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: '',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
];
|
||||
assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts');
|
||||
assert.propEqual(formatByNamespace([]), [], 'it returns an empty array if the by_namespace key is empty');
|
||||
});
|
||||
|
||||
test('it formats the months array with mixed activity data', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months);
|
||||
// mirage isn't set up to generate mixed data, so hardcoding the expected responses here
|
||||
assert.propEqual(
|
||||
formattedWithActivity.namespaces,
|
||||
[
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: 'no mount path (pre-1.10 upgrade?)',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/userpass/0/',
|
||||
mount_path: 'auth/userpass/0/',
|
||||
mount_type: 'userpass',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
'it formats combined data for monthly namespaces spanning upgrade to 1.10'
|
||||
);
|
||||
assert.propEqual(
|
||||
formattedWithActivity.new_clients,
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
|
||||
namespaces: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: 'no mount path (pre-1.10 upgrade?)',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/userpass/0/',
|
||||
mount_path: 'auth/userpass/0/',
|
||||
mount_type: 'userpass',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
},
|
||||
'it formats combined data for monthly new_clients spanning upgrade to 1.10'
|
||||
);
|
||||
});
|
||||
|
||||
module('filterTableData', function (hooks) {
|
||||
hooks.beforeEach(async function () {
|
||||
const activityByMount = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);
|
||||
325
ui/tests/integration/utils/client-counts/serializers-test.js
Normal file
325
ui/tests/integration/utils/client-counts/serializers-test.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import {
|
||||
destructureClientCounts,
|
||||
formatByMonths,
|
||||
formatByNamespace,
|
||||
formatQueryParams,
|
||||
sortMonthsByTimestamp,
|
||||
} from 'core/utils/client-counts/serializers';
|
||||
import {
|
||||
ACTIVITY_RESPONSE_STUB as RESPONSE,
|
||||
MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE,
|
||||
SERIALIZED_ACTIVITY_RESPONSE,
|
||||
} from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
|
||||
module('Unit | Util | client counts | serializers', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('destructureClientCounts: it returns relevant key names when both old and new keys exist', async function (assert) {
|
||||
assert.expect(2);
|
||||
const original = { ...RESPONSE.total };
|
||||
const expected = {
|
||||
acme_clients: 9702,
|
||||
clients: 35287,
|
||||
entity_clients: 8258,
|
||||
non_entity_clients: 8227,
|
||||
secret_syncs: 9100,
|
||||
};
|
||||
assert.propEqual(destructureClientCounts(RESPONSE.total), expected);
|
||||
assert.propEqual(RESPONSE.total, original, 'it does not modify original object');
|
||||
});
|
||||
|
||||
test('formatByMonths: it formats the months array', async function (assert) {
|
||||
assert.expect(7);
|
||||
const original = [...RESPONSE.months];
|
||||
|
||||
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
|
||||
|
||||
// instead of asserting the whole expected response, broken up so tests are easier to debug
|
||||
// but kept whole above to copy/paste updated response expectations in the future
|
||||
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
|
||||
|
||||
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
|
||||
['namespaces', 'new_clients'].forEach((key) => {
|
||||
assert.propEqual(
|
||||
formattedWithActivity[key],
|
||||
expectedWithActivity[key],
|
||||
`it formats ${key} array for months with data`
|
||||
);
|
||||
assert.propEqual(
|
||||
formattedNoNew[key],
|
||||
expectedNoNew[key],
|
||||
`it formats the ${key} array for months with no new clients`
|
||||
);
|
||||
});
|
||||
|
||||
assert.propEqual(RESPONSE.months, original, 'it does not modify original months array');
|
||||
assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty');
|
||||
});
|
||||
|
||||
test('formatByNamespace: it formats namespace array with mounts', async function (assert) {
|
||||
const original = [...RESPONSE.by_namespace];
|
||||
const expectedNs1 = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find((ns) => ns.label === 'ns1/');
|
||||
const formattedNs1 = formatByNamespace(RESPONSE.by_namespace).find((ns) => ns.label === 'ns1/');
|
||||
assert.expect(2 + formattedNs1.mounts.length);
|
||||
|
||||
assert.propEqual(formattedNs1, expectedNs1, 'it formats ns1/ namespace');
|
||||
assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array');
|
||||
|
||||
formattedNs1.mounts.forEach((mount) => {
|
||||
const expectedMount = expectedNs1.mounts.find((m) => m.label === mount.label);
|
||||
assert.propEqual(mount, expectedMount, `${mount.label} has expected key/value pairs`);
|
||||
});
|
||||
});
|
||||
|
||||
module('formatQueryParams', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.assertQuery = ({ query, expected }, assert) => {
|
||||
const result = formatQueryParams(query);
|
||||
|
||||
assert.propEqual(
|
||||
result,
|
||||
expected,
|
||||
`returned params: ${JSON.stringify(result)} matches expected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
assert.strictEqual(result?.start_time, expected?.start_time, 'query has expected start_time');
|
||||
assert.strictEqual(result?.end_time, expected?.end_time, 'query has expected end_time');
|
||||
};
|
||||
});
|
||||
|
||||
test('formatQueryParams: it returns formatted query params with valid ISO date strings', function (assert) {
|
||||
const query = { start_time: '2023-01-01T00:00:00.000Z', end_time: '2023-12-31T23:59:59.999Z' };
|
||||
this.assertQuery({ query, expected: query }, assert);
|
||||
});
|
||||
|
||||
test('it returns undefined for invalid date strings', function (assert) {
|
||||
const query = { start_time: 'invalid-date', end_time: 'not-a-date' };
|
||||
const expected = {};
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it handles mixed valid and invalid dates', function (assert) {
|
||||
const query = { start_time: '2023-01-01T00:00:00.000Z', end_time: 'invalid-date' };
|
||||
const expected = { start_time: '2023-01-01T00:00:00.000Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it handles empty strings', function (assert) {
|
||||
let query = { start_time: '', end_time: '' };
|
||||
let expected = {};
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: '' };
|
||||
expected = { start_time: '2023-01-01T00:00:00.000Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: '', end_time: '2023-12-31T23:59:59.999Z' };
|
||||
expected = { end_time: '2023-12-31T23:59:59.999Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it handles undefined values', function (assert) {
|
||||
let query = { start_time: undefined, end_time: undefined };
|
||||
let expected = {};
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: undefined };
|
||||
expected = { start_time: '2023-01-01T00:00:00.000Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: undefined, end_time: '2023-12-31T23:59:59.999Z' };
|
||||
expected = { end_time: '2023-12-31T23:59:59.999Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it handles null values', function (assert) {
|
||||
let query = { start_time: null, end_time: null };
|
||||
let expected = {};
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: null };
|
||||
expected = { start_time: '2023-01-01T00:00:00.000Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
|
||||
query = { start_time: null, end_time: '2023-12-31T23:59:59.999Z' };
|
||||
expected = { end_time: '2023-12-31T23:59:59.999Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it handles missing properties', function (assert) {
|
||||
const query = {};
|
||||
const expected = {};
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
|
||||
test('it does not accept date strings that are not ISO formatted', function (assert) {
|
||||
const query = { start_time: '2023-06-15', end_time: '2023-06-16T14:30:00Z' };
|
||||
const expected = { end_time: '2023-06-16T14:30:00Z' };
|
||||
this.assertQuery({ query, expected }, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
|
||||
assert.expect(2);
|
||||
// API returns them in order so this test is extra extra
|
||||
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0], RESPONSE.months[3], RESPONSE.months[2]]; // mixup order
|
||||
const original = [...RESPONSE.months];
|
||||
const expected = RESPONSE.months;
|
||||
assert.propEqual(sortMonthsByTimestamp(unOrdered), expected);
|
||||
assert.propEqual(RESPONSE.months, original, 'it does not modify original array');
|
||||
});
|
||||
|
||||
// TESTS FOR COMBINED ACTIVITY DATA - no mount attribution < 1.10
|
||||
test('it formats the namespaces array with no mount attribution (activity log data < 1.10)', async function (assert) {
|
||||
assert.expect(2);
|
||||
const noMounts = [
|
||||
{
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
counts: {
|
||||
entity_clients: 10,
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
entity_clients: 10,
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
},
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
entity_clients: 10,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 30,
|
||||
entity_clients: 10,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: '',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
];
|
||||
assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts');
|
||||
assert.propEqual(formatByNamespace([]), [], 'it returns an empty array if the by_namespace key is empty');
|
||||
});
|
||||
|
||||
test('it formats the months array with mixed activity data', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months);
|
||||
// mirage isn't set up to generate mixed data, so hardcoding the expected responses here
|
||||
assert.propEqual(
|
||||
formattedWithActivity.namespaces,
|
||||
[
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: 'no mount path (pre-1.10 upgrade?)',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/userpass/0/',
|
||||
mount_path: 'auth/userpass/0/',
|
||||
mount_type: 'userpass',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
'it formats combined data for monthly namespaces spanning upgrade to 1.10'
|
||||
);
|
||||
assert.propEqual(
|
||||
formattedWithActivity.new_clients,
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
|
||||
namespaces: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
label: 'root',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
mount_type: 'no mount path (pre-1.10 upgrade?)',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/userpass/0/',
|
||||
mount_path: 'auth/userpass/0/',
|
||||
mount_type: 'userpass',
|
||||
namespace_path: 'root',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
},
|
||||
'it formats combined data for monthly new_clients spanning upgrade to 1.10'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { CLIENT_TYPES, ClientFilters, EXPORT_CLIENT_TYPES } from 'core/utils/client-count-utils';
|
||||
import { CLIENT_TYPES, ClientFilters, EXPORT_CLIENT_TYPES } from 'core/utils/client-counts/helpers';
|
||||
|
||||
// At time of writing ClientTypes are: 'acme_clients' | 'clients' | 'entity_clients' | 'non_entity_clients' | 'secret_syncs'
|
||||
export type ClientTypes = (typeof CLIENT_TYPES)[number];
|
||||
|
|
|
|||
Loading…
Reference in a new issue