mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +00:00
Secrets Sync Client Count Updates (#24752)
* Client Count Routing Updates (#24733) * updates client count routing for sync and future additions * adds copyright header to clients sync template * adds missing copyright headers * UI: Adds secret_syncs to mirage /activity endpoint (#24846) * add secret_syncs to mirage endpoint * import clients handler * UI: Set up client charts for incoming sync data (#24852) * sum stacked bar values for tooltip total * make tooltip dynamic based on chartLegend * remove redundant helper * add secret_syncs to client count utils * move sum function to helper * update horizontal bar chart to include sync_clients * calculate sum of bars in tooltip * rename color palette const, define chart legends in each parent component instead of token.js * update tooltips * update mirage handler to add sys/ namespace * update mirage handler to add sys/ namespace * use pushObject * update test * UI: Secret sync bar chart (#24926) * install lineal * add ember-style-modifier dep * Add client count types for serialized data * Add sync bar chart component with tests * Chart is responsive * address comments * Clients Counts Parent Route (#24899) * adds interfaces for clients models * moves date formatting logic from clients activity adapter to utils file * adds clients counts route * updates links to clients route to point to top level and updates redirect to counts overview route * removes clients base route and moves overview and sync routes under counts * adds clients counts page component * converts clients route to ts * adds billing start timestamp to clients config mirage response and updates counts route to always attempt to fetch activity * fixes issue with updating namespace and auth mount query params always triggering client counts route model hook * adds tests for clients counts page component * adds missing copyright header to client-counts type file * Update ui/app/components/clients/page/counts.hbs Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * fixes bad import in sync-bar-chart * updates clients counts route to bypass query if there is not start_time * pins d3-shape to 1.3.7 for now -- makes lineal play nice with old charts * fixes sync bar chart tooltip assertion --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * UI: convert line-chart to lineal (#24961) * lineal chart alongside svg * Add version-history to sync handler for testing * line chart is TS, test updated * remove d3-shape resolution * fix clients/token-test * use chartHeight in running-total template * use M/yy key instead of timestamp, chart is responsive * Add test for swapping datasets * add more edge case tests * more test * remove untrue assertion * fix weird decimal when between 1.1k and 2k * address feedback * Update line-chart to use timestamp instead of month key * Add timestamp to all places where month is on the clients activity response * Client Counts Overview (#24969) * adds counts base component for use in client counts child routes * adds clients counts overview page component * splits out monthly new chart from clients running total component * adds missing copyright headers * moves running total related assertions from token to overview acceptance test * removes new client assertions from running-total test and adds tests for monthly-new component * updates copy in running-total component * fixes clients overview tests * fixes timestamp stub not being restored in monthly-new test * fixes mfa-login test * renames counts component to activity * removes unused selectedAuthMethod arg from running-total component * adds timestamp back to running-total component * Secrets sync UI: add sync page component (#24982) * adds counts base component for use in client counts child routes * adds clients counts overview page component * splits out monthly new chart from clients running total component * adds missing copyright headers * move sync-bar-chart to charts/ folder * update types and rename chart * rename template file * moves running total related assertions from token to overview acceptance test * removes new client assertions from running-total test and adds tests for monthly-new component * updates copy in running-total component * fixes clients overview tests * fixes timestamp stub not being restored in monthly-new test * fixes mfa-login test * fix 0 values erroring charts * separate timestamp again * address merge conflicts * finish building sync chart component WIP css * renames counts component to activity * update import * revert name to dataKey * update styling for charts without legends * use monthly stat chart component for layout * use monthly chart stats in monthly new * implement stat wrapper; * remove extra grid div * rename component * fix legend css; * update test[ * remove arbitrarily setting max * add single month view * use stat text * update line chart tests * rename line chart * update tests --------- Co-authored-by: Jordan Reimer <zofskeez@gmail.com> * update selectors * add sync page tests * Secrets Sync UI: Add secrets syncs to csv export (#25056) * update mirage and add sync clients to export csv * fix sync legend label * remove word * update copy in modal * update mirage * fix attribution tooltip text * Clients Counts Token Route (#25019) * renames token route and page component back to dashboard * adds client counts token route and page component * updates charts in token page to use ChartContainer component * adds tests for clients token page component * restore clients dashboard test * use var for chart title sync page * updates clients token page to show usage stats when querying single month * updates token page clients averages to only include entity and non-entity clients in calculation * fixes monthly total counts lower than new clients in mirage handler * fixes token test --------- Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com> * Clients Usage Stats/Running Total Updates (#25094) * updates clients usage counts and running totals * updates usage stats total copy * fixes client counts overview tests * Secrets sync UI: cleanup and consolidation of components (#25090) * rename authMethod to mountPath * generalize count template copy * add todo to delete monthly new component * rename to tokenTab * wrap filters in conditional checking for start timestamp * some users may not have access to /config endpoint * fix querying when user has no billing date permissions and clicks current billing period * extend activity component from counts page * Revert "extend activity component from counts page" This reverts commit 1d0e85c82faf88c4385a04b1a5841cdde7fd00e0. * rename to startTimestampISO * remove timestamp from route and just use activity model responseTimestamp * fix chart y domain max * fix typos in usage stat and running totals component * delete backing class for display only template; * updates tests * adds comment for fetching license to get start date for billing * cleans up unused client counts files (#25157) * adds changelog * fix assertion copy * adds changelog description * updates enterprise sidebar nav test --------- Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com> Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
This commit is contained in:
3
changelog/24752.txt
Normal file
3
changelog/24752.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
ui: Separates out client counts dashboard to overview and entity/non-entity tabs
|
||||
```
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import ApplicationAdapter from '../application';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { formatDateObject } from 'core/utils/client-count-utils';
|
||||
|
||||
export default class ActivityAdapter extends ApplicationAdapter {
|
||||
// javascript localizes new Date() objects but all activity log data is stored in UTC
|
||||
@@ -12,10 +12,8 @@ export default class ActivityAdapter extends ApplicationAdapter {
|
||||
// time params from the backend are formatted as a zulu timestamp
|
||||
formatQueryParams(queryParams) {
|
||||
let { start_time, end_time } = queryParams;
|
||||
start_time = start_time.timestamp || getUnixTime(Date.UTC(start_time.year, start_time.monthIdx, 1));
|
||||
// day=0 for Date.UTC() returns the last day of the month before
|
||||
// increase monthIdx by one to get last day of queried month
|
||||
end_time = end_time.timestamp || getUnixTime(Date.UTC(end_time.year, end_time.monthIdx + 1, 0));
|
||||
start_time = start_time.timestamp || formatDateObject(start_time);
|
||||
end_time = end_time.timestamp || formatDateObject(end_time, true);
|
||||
return { start_time, end_time };
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export default class App extends Application {
|
||||
services: ['flash-messages', 'router', 'store', 'version'],
|
||||
externalRoutes: {
|
||||
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
|
||||
clientCountDashboard: 'vault.cluster.clients.dashboard',
|
||||
clientCountOverview: 'vault.cluster.clients',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
198
ui/app/components/clients/activity.ts
Normal file
198
ui/app/components/clients/activity.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// base component for counts child routes that can be extended as needed
|
||||
// contains getters that filter and extract data from activity model for use in charts
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { isAfter, isBefore, isSameMonth, fromUnixTime } from 'date-fns';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
|
||||
import type ClientsActivityModel from 'vault/models/clients/activity';
|
||||
import type {
|
||||
ClientActivityNewClients,
|
||||
ClientActivityMonthly,
|
||||
ClientActivityResourceByKey,
|
||||
} from 'vault/models/clients/activity';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
|
||||
interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
versionHistory: ClientsVersionHistoryModel[];
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}
|
||||
|
||||
export default class ClientsActivityComponent extends Component<Args> {
|
||||
average = (
|
||||
data:
|
||||
| ClientActivityMonthly[]
|
||||
| (ClientActivityResourceByKey | undefined)[]
|
||||
| (ClientActivityNewClients | undefined)[]
|
||||
| undefined,
|
||||
key: string
|
||||
) => {
|
||||
return calculateAverage(data, key);
|
||||
};
|
||||
|
||||
get startTimeISO() {
|
||||
return fromUnixTime(this.args.startTimestamp).toISOString();
|
||||
}
|
||||
|
||||
get endTimeISO() {
|
||||
return fromUnixTime(this.args.endTimestamp).toISOString();
|
||||
}
|
||||
|
||||
get byMonthActivityData() {
|
||||
const { activity, namespace } = this.args;
|
||||
return namespace ? this.filteredActivityByMonth : activity.byMonth;
|
||||
}
|
||||
|
||||
get byMonthNewClients() {
|
||||
return this.byMonthActivityData ? this.byMonthActivityData?.map((m) => m?.new_clients) : [];
|
||||
}
|
||||
|
||||
get filteredActivityByMonth() {
|
||||
const { namespace, mountPath, activity } = this.args;
|
||||
if (!namespace && !mountPath) {
|
||||
return activity.byMonth;
|
||||
}
|
||||
const namespaceData = activity.byMonth
|
||||
.map((m) => m.namespaces_by_key[namespace as keyof typeof m.namespaces_by_key])
|
||||
.filter((d) => d !== undefined);
|
||||
|
||||
if (!mountPath) {
|
||||
return namespaceData.length === 0 ? undefined : namespaceData;
|
||||
}
|
||||
|
||||
const mountData = mountPath
|
||||
? namespaceData.map((namespace) => namespace?.mounts_by_key[mountPath]).filter((d) => d !== undefined)
|
||||
: namespaceData;
|
||||
|
||||
return mountData.length === 0 ? undefined : mountData;
|
||||
}
|
||||
|
||||
get filteredActivityByNamespace() {
|
||||
const { namespace, activity } = this.args;
|
||||
return activity.byNamespace.find((ns) => ns.label === namespace);
|
||||
}
|
||||
|
||||
get filteredActivityByAuthMount() {
|
||||
return this.filteredActivityByNamespace?.mounts?.find((mount) => mount.label === this.args.mountPath);
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
return this.args.mountPath ? this.filteredActivityByAuthMount : this.filteredActivityByNamespace;
|
||||
}
|
||||
|
||||
get isCurrentMonth() {
|
||||
const { activity } = this.args;
|
||||
const current = parseAPITimestamp(activity.responseTimestamp) as Date;
|
||||
const start = parseAPITimestamp(activity.startTime) as Date;
|
||||
const end = parseAPITimestamp(activity.endTime) as Date;
|
||||
return isSameMonth(start, current) && isSameMonth(end, current);
|
||||
}
|
||||
|
||||
get isDateRange() {
|
||||
const { activity } = this.args;
|
||||
return !isSameMonth(
|
||||
parseAPITimestamp(activity.startTime) as Date,
|
||||
parseAPITimestamp(activity.endTime) as Date
|
||||
);
|
||||
}
|
||||
|
||||
// (object) top level TOTAL client counts for given date range
|
||||
get totalUsageCounts() {
|
||||
const { namespace, activity } = this.args;
|
||||
return namespace ? this.filteredActivity : activity.total;
|
||||
}
|
||||
|
||||
get upgradeDuringActivity() {
|
||||
const { versionHistory, activity } = this.args;
|
||||
if (versionHistory) {
|
||||
// filter for upgrade data of noteworthy upgrades (1.9 and/or 1.10)
|
||||
const upgradeVersionHistory = versionHistory.filter(
|
||||
({ version }) => version.match('1.9') || version.match('1.10')
|
||||
);
|
||||
if (upgradeVersionHistory.length) {
|
||||
const activityStart = parseAPITimestamp(activity.startTime) as Date;
|
||||
const activityEnd = parseAPITimestamp(activity.endTime) as Date;
|
||||
// filter and return all upgrades that happened within date range of queried activity
|
||||
const upgradesWithinData = upgradeVersionHistory.filter(({ timestampInstalled }) => {
|
||||
const upgradeDate = parseAPITimestamp(timestampInstalled) as Date;
|
||||
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
|
||||
});
|
||||
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// (object) single month new client data with total counts + array of namespace breakdown
|
||||
get newClientCounts() {
|
||||
if (this.isDateRange || !this.byMonthActivityData) {
|
||||
return null;
|
||||
}
|
||||
return this.byMonthActivityData[0]?.new_clients;
|
||||
}
|
||||
|
||||
// total client data for horizontal bar chart in attribution component
|
||||
get totalClientAttribution() {
|
||||
const { namespace, activity } = this.args;
|
||||
if (namespace) {
|
||||
return this.filteredActivityByNamespace?.mounts || null;
|
||||
} else {
|
||||
return activity.byNamespace || null;
|
||||
}
|
||||
}
|
||||
|
||||
// new client data for horizontal bar chart
|
||||
get newClientAttribution() {
|
||||
// new client attribution only available in a single, historical month (not a date range or current month)
|
||||
if (this.isDateRange || this.isCurrentMonth) return null;
|
||||
|
||||
if (this.args.namespace) {
|
||||
return this.newClientCounts?.mounts || null;
|
||||
} else {
|
||||
return this.newClientCounts?.namespaces || null;
|
||||
}
|
||||
}
|
||||
|
||||
get hasAttributionData() {
|
||||
const { mountPath, namespace } = this.args;
|
||||
if (!mountPath) {
|
||||
if (namespace) {
|
||||
const mounts = this.filteredActivityByNamespace?.mounts?.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
return mounts && mounts.length > 0;
|
||||
}
|
||||
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
get upgradeExplanation() {
|
||||
if (this.upgradeDuringActivity) {
|
||||
if (this.upgradeDuringActivity.length === 1) {
|
||||
const version = this.upgradeDuringActivity[0]?.version || '';
|
||||
if (version.match('1.9')) {
|
||||
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data.';
|
||||
}
|
||||
if (version.match('1.10')) {
|
||||
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data.';
|
||||
}
|
||||
}
|
||||
// return combined explanation if spans multiple upgrades
|
||||
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
<p class="chart-description">{{this.chartText.newCopy}}</p>
|
||||
<Clients::HorizontalBarChart
|
||||
@dataset={{this.barChartNewClients}}
|
||||
@chartLegend={{@chartLegend}}
|
||||
@chartLegend={{this.attributionLegend}}
|
||||
@totalCounts={{@newUsageCounts}}
|
||||
@noDataMessage="There are no new clients for this namespace during this time period."
|
||||
/>
|
||||
@@ -40,22 +40,14 @@
|
||||
<div class="chart-container-right" data-test-chart-container="total-clients">
|
||||
<h2 class="chart-title">Total clients</h2>
|
||||
<p class="chart-description">{{this.chartText.totalCopy}}</p>
|
||||
<Clients::HorizontalBarChart
|
||||
@dataset={{this.barChartTotalClients}}
|
||||
@chartLegend={{@chartLegend}}
|
||||
@totalCounts={{@totalUsageCounts}}
|
||||
/>
|
||||
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
|
||||
</div>
|
||||
{{else}}
|
||||
<div
|
||||
class={{concat (unless this.barChartTotalClients "chart-empty-state ") "chart-container-wide"}}
|
||||
data-test-chart-container="single-chart"
|
||||
>
|
||||
<Clients::HorizontalBarChart
|
||||
@dataset={{this.barChartTotalClients}}
|
||||
@chartLegend={{@chartLegend}}
|
||||
@totalCounts={{@totalUsageCounts}}
|
||||
/>
|
||||
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
|
||||
</div>
|
||||
<div class="chart-subTitle">
|
||||
<p class="chart-subtext" data-test-attribution-subtext>{{this.chartText.totalCopy}}</p>
|
||||
@@ -71,9 +63,10 @@
|
||||
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="legend-center">
|
||||
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
|
||||
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
|
||||
<div class="legend">
|
||||
{{#each this.attributionLegend as |legend idx|}}
|
||||
<span class="legend-colors dot-{{idx}}"></span><span class="legend-label">{{capitalize legend.label}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="chart-empty-state">
|
||||
@@ -96,9 +89,16 @@
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
<p class="has-bottom-margin-s">
|
||||
This export will include the namespace path, authentication method path, and the associated total, entity, and
|
||||
non-entity clients for the below
|
||||
{{if this.formattedEndDate "date range" "month"}}.
|
||||
This export will include the namespace path, mount path and associated total, entity, non-entity and secrets sync
|
||||
clients for the
|
||||
{{if this.formattedEndDate "date range" "month"}}
|
||||
below.
|
||||
</p>
|
||||
<p class="has-bottom-margin-s">
|
||||
The
|
||||
<code>mount_path</code>
|
||||
for secrets sync clients is the KV v2 engine path and for entity/non-entity clients is the corresponding
|
||||
authentication method path.
|
||||
</p>
|
||||
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
|
||||
<p class="has-bottom-margin-s" data-test-export-date-range>
|
||||
@@ -18,7 +18,6 @@ import { format, isSameMonth } from 'date-fns';
|
||||
* @example
|
||||
* ```js
|
||||
* <Clients::Attribution
|
||||
* @chartLegend={{this.chartLegend}}
|
||||
* @totalUsageCounts={{this.totalUsageCounts}}
|
||||
* @newUsageCounts={{this.newUsageCounts}}
|
||||
* @totalClientAttribution={{this.totalClientAttribution}}
|
||||
@@ -31,7 +30,6 @@ import { format, isSameMonth } from 'date-fns';
|
||||
* @upgradeExplanation="We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data."
|
||||
* />
|
||||
* ```
|
||||
* @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked
|
||||
* @param {object} totalUsageCounts - object with total client counts for chart tooltip text
|
||||
* @param {object} newUsageCounts - object with new client counts for chart tooltip text
|
||||
* @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients
|
||||
@@ -47,6 +45,11 @@ import { format, isSameMonth } from 'date-fns';
|
||||
export default class Attribution extends Component {
|
||||
@tracked showCSVDownloadModal = false;
|
||||
@service download;
|
||||
attributionLegend = [
|
||||
{ key: 'entity_clients', label: 'entity clients' },
|
||||
{ key: 'non_entity_clients', label: 'non-entity clients' },
|
||||
{ key: 'secret_syncs', label: 'secrets sync clients' },
|
||||
];
|
||||
|
||||
get formattedStartDate() {
|
||||
if (!this.args.startTimestamp) return null;
|
||||
@@ -123,10 +126,10 @@ export default class Attribution extends Component {
|
||||
}
|
||||
|
||||
destructureCountsToArray(object) {
|
||||
// destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, clients: 191}
|
||||
// destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, secret_syncs: 10, clients: 201}
|
||||
// to get integers for CSV file
|
||||
const { clients, entity_clients, non_entity_clients } = object;
|
||||
return [clients, entity_clients, non_entity_clients];
|
||||
const { clients, entity_clients, non_entity_clients, secret_syncs } = object;
|
||||
return [clients, entity_clients, non_entity_clients, secret_syncs];
|
||||
}
|
||||
|
||||
constructCsvRow(namespaceColumn, mountColumn = null, totalColumns, newColumns = null) {
|
||||
@@ -146,19 +149,25 @@ export default class Attribution extends Component {
|
||||
const csvData = [];
|
||||
// added to clarify that the row of namespace totals without an auth method (blank) are not additional clients
|
||||
// but indicate the total clients for that ns, including its auth methods
|
||||
const upgrade = this.args.upgradeExplanation
|
||||
? `\n **data contains an upgrade, mount summation may not equal namespace totals`
|
||||
: '';
|
||||
const descriptionOfBlanks = this.isSingleNamespace
|
||||
? ''
|
||||
: `\n *namespace totals, inclusive of auth method clients`;
|
||||
: `\n *namespace totals, inclusive of mount clients ${upgrade}`;
|
||||
const csvHeader = [
|
||||
'Namespace path',
|
||||
`"Authentication method ${descriptionOfBlanks}"`,
|
||||
`"Mount path ${descriptionOfBlanks}"`,
|
||||
'Total clients',
|
||||
'Entity clients',
|
||||
'Non-entity clients',
|
||||
'Secrets sync clients',
|
||||
];
|
||||
|
||||
if (newAttribution) {
|
||||
csvHeader.push('Total new clients, New entity clients, New non-entity clients');
|
||||
csvHeader.push(
|
||||
'Total new clients, New entity clients, New non-entity clients, New secrets sync clients'
|
||||
);
|
||||
}
|
||||
|
||||
totalAttribution.forEach((totalClientsObject) => {
|
||||
@@ -199,7 +208,7 @@ export default class Attribution extends Component {
|
||||
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
|
||||
const csvDateRange = this.formattedStartDate + endRange;
|
||||
return this.isSingleNamespace
|
||||
? `clients_by_auth_method_${csvDateRange}`
|
||||
? `clients_by_mount_path_${csvDateRange}`
|
||||
: `clients_by_namespace_${csvDateRange}`;
|
||||
}
|
||||
|
||||
|
||||
54
ui/app/components/clients/chart-container.hbs
Normal file
54
ui/app/components/clients/chart-container.hbs
Normal file
@@ -0,0 +1,54 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! HBS display template for rendering title, description and stat boxes with a chart on the right }}
|
||||
|
||||
<div class="chart-wrapper single-chart-grid" ...attributes>
|
||||
<div class="chart-header has-bottom-margin-xl">
|
||||
<h2 class="chart-title">{{@title}}</h2>
|
||||
<p class="chart-description">
|
||||
{{@description}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{#if @hasChartData}}
|
||||
{{#if (has-block "subTitle")}}
|
||||
<div class="chart-subTitle">
|
||||
{{yield to="subTitle"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (has-block "stats")}}
|
||||
{{yield to="stats"}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (has-block "chart")}}
|
||||
<div class="chart-container-wide">
|
||||
{{yield to="chart"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if @legend}}
|
||||
<div class="legend" data-test-chart-container-legend>
|
||||
{{#each @legend as |legend idx|}}
|
||||
<span class="legend-colors dot-{{idx}}"></span>
|
||||
<span class="legend-label">{{capitalize legend.label}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{else}}
|
||||
<div class="chart-empty-state chart-container-wide">
|
||||
{{yield to="emptyState"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if @timestamp}}
|
||||
<div class="timestamp" data-test-chart-container-timestamp>
|
||||
Updated
|
||||
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
127
ui/app/components/clients/charts/line.hbs
Normal file
127
ui/app/components/clients/charts/line.hbs
Normal file
@@ -0,0 +1,127 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
<div>
|
||||
{{#if this.data}}
|
||||
<div class="lineal-chart" data-test-chart={{@chartTitle}}>
|
||||
<Lineal::Fluid as |width|>
|
||||
{{#let
|
||||
(scale-time domain=this.timeDomain range=(array 0 width) nice=true)
|
||||
(scale-linear domain=this.yDomain range=(array this.chartHeight 0) nice=true)
|
||||
(scale-linear range=(array 0 this.chartHeight))
|
||||
as |xScale yScale tooltipScale|
|
||||
}}
|
||||
<svg width={{width}} height={{this.chartHeight}} class="chart has-grid" data-test-line-chart>
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
<Lineal::Axis
|
||||
@scale={{yScale}}
|
||||
@tickCount="4"
|
||||
@tickPadding={{10}}
|
||||
@tickSizeInner={{concat "-" width}}
|
||||
@tickFormat={{this.formatCount}}
|
||||
@orientation="left"
|
||||
@includeDomain={{false}}
|
||||
class="lineal-axis"
|
||||
data-test-y-axis
|
||||
/>
|
||||
<Lineal::Axis
|
||||
@scale={{xScale}}
|
||||
@orientation="bottom"
|
||||
transform="translate(0,{{yScale.range.min}})"
|
||||
@includeDomain={{false}}
|
||||
@tickSize="0"
|
||||
@tickPadding={{10}}
|
||||
@tickFormat={{this.formatMonth}}
|
||||
@tickCount={{this.data.length}}
|
||||
class="lineal-axis"
|
||||
data-test-x-axis
|
||||
/>
|
||||
{{#each this.upgradedMonths as |d|}}
|
||||
<circle
|
||||
class="upgrade-circle"
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="10"
|
||||
fill="#FDEEBA"
|
||||
stroke="none"
|
||||
data-test-line-chart="upgrade-{{d.month}}"
|
||||
></circle>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
<Lineal::Line
|
||||
@data={{this.data}}
|
||||
@x="x"
|
||||
@y="y"
|
||||
@xScale={{xScale}}
|
||||
@yScale={{yScale}}
|
||||
stroke="#0c56e9"
|
||||
stroke-width="0.5"
|
||||
fill="none"
|
||||
/>
|
||||
{{! this is here to qualify the scales }}
|
||||
<Lineal::Line
|
||||
@data={{this.data}}
|
||||
@x="x"
|
||||
@y="y"
|
||||
@xScale={{xScale}}
|
||||
@yScale={{tooltipScale}}
|
||||
stroke="none"
|
||||
fill="none"
|
||||
/>
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
{{#each this.data as |d|}}
|
||||
{{#if (this.hasValue d.y)}}
|
||||
<circle
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="3.5"
|
||||
fill="#cce3fe"
|
||||
stroke="#0c56e9"
|
||||
stroke-width="1.5"
|
||||
data-test-line-chart="plot-point"
|
||||
></circle>
|
||||
<circle
|
||||
role="button"
|
||||
aria-label="Show exact counts for {{date-format d.x 'MMMM yyyy'}}"
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="10"
|
||||
fill="transparent"
|
||||
{{on "mouseover" (fn (mut this.activeDatum) d)}}
|
||||
{{on "mouseout" (fn (mut this.activeDatum) null)}}
|
||||
data-test-hover-circle={{date-format d.x "M/yy"}}
|
||||
></circle>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
</svg>
|
||||
{{#if this.activeDatum}}
|
||||
<div
|
||||
class="lineal-tooltip-position from-top-left chart-tooltip"
|
||||
role="status"
|
||||
{{style
|
||||
--x=(this.tooltipX (xScale.compute this.activeDatum.x))
|
||||
--y=(this.tooltipY (yScale.compute this.activeDatum.y))
|
||||
}}
|
||||
data-test-tooltip
|
||||
>
|
||||
|
||||
<p class="bold" data-test-tooltip-month>{{date-format this.activeDatum.x "MMMM yyyy"}}</p>
|
||||
<p>{{format-number this.activeDatum.y}} total clients</p>
|
||||
<p>{{format-number (or this.activeDatum.new)}} new clients</p>
|
||||
{{#if this.activeDatum.tooltipUpgrade}}
|
||||
<br />
|
||||
<p class="has-text-highlight">{{this.activeDatum.tooltipUpgrade}}</p>
|
||||
{{/if}}
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</Lineal::Fluid>
|
||||
</div>
|
||||
{{else}}
|
||||
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
144
ui/app/components/clients/charts/line.ts
Normal file
144
ui/app/components/clients/charts/line.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { SVG_DIMENSIONS, formatNumbers } from 'vault/utils/chart-helpers';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { debug } from '@ember/debug';
|
||||
|
||||
import type { Count, MonthlyChartData, Timestamp } from 'vault/vault/charts/client-counts';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
|
||||
interface Args {
|
||||
dataset: MonthlyChartData[];
|
||||
upgradeData: ClientsVersionHistoryModel[];
|
||||
xKey?: string;
|
||||
yKey?: string;
|
||||
chartHeight?: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
x: Date;
|
||||
y: number | null;
|
||||
new: number;
|
||||
tooltipUpgrade: string | null;
|
||||
month: string; // used for test selectors and to match key on upgradeData
|
||||
}
|
||||
|
||||
interface UpgradeByMonth {
|
||||
[key: string]: ClientsVersionHistoryModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @module LineChart
|
||||
* LineChart components are used to display time-based data in a line plot with accompanying tooltip
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
|
||||
* ```
|
||||
* @param {array} dataset - array of objects containing data to be plotted
|
||||
* @param {string} [xKey=clients] - string denoting key for x-axis data of dataset. Should reference a timestamp string.
|
||||
* @param {string} [yKey=timestamp] - string denoting key for y-axis data of dataset. Should reference a number or null.
|
||||
* @param {array} [upgradeData=null] - array of objects containing version history from the /version-history endpoint
|
||||
* @param {number} [chartHeight=190] - height of chart in pixels
|
||||
*/
|
||||
export default class LineChart extends Component<Args> {
|
||||
// Chart settings
|
||||
get yKey() {
|
||||
return this.args.yKey || 'clients';
|
||||
}
|
||||
get xKey() {
|
||||
return this.args.xKey || 'timestamp';
|
||||
}
|
||||
get chartHeight() {
|
||||
return this.args.chartHeight || SVG_DIMENSIONS.height;
|
||||
}
|
||||
// Plot points
|
||||
get data(): ChartData[] {
|
||||
try {
|
||||
return this.args.dataset?.map((datum) => {
|
||||
const timestamp = parseAPITimestamp(datum[this.xKey as keyof Timestamp]) as Date;
|
||||
if (isValid(timestamp) === false)
|
||||
throw new Error(`Unable to parse value "${datum[this.xKey as keyof Timestamp]}" as date`);
|
||||
const upgradeMessage = this.getUpgradeMessage(datum);
|
||||
return {
|
||||
x: timestamp,
|
||||
y: (datum[this.yKey as keyof Count] as number) ?? null,
|
||||
new: this.getNewClients(datum),
|
||||
tooltipUpgrade: upgradeMessage,
|
||||
month: datum.month,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e as string);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
get upgradedMonths() {
|
||||
return this.data.filter((datum) => datum.tooltipUpgrade);
|
||||
}
|
||||
// Domains
|
||||
get yDomain() {
|
||||
const counts: number[] = this.data
|
||||
.map((d) => d.y)
|
||||
.flatMap((num) => (typeof num === 'number' ? [num] : []));
|
||||
const max = Math.max(...counts);
|
||||
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
|
||||
return [0, max <= 4 ? 4 : max];
|
||||
}
|
||||
get timeDomain() {
|
||||
// assume data is sorted by time
|
||||
const firstTime = this.data[0]?.x;
|
||||
const lastTime = this.data[this.data.length - 1]?.x;
|
||||
return [firstTime, lastTime];
|
||||
}
|
||||
|
||||
get upgradeByMonthYear(): UpgradeByMonth {
|
||||
const empty: UpgradeByMonth = {};
|
||||
if (!Array.isArray(this.args.upgradeData)) return empty;
|
||||
return (
|
||||
this.args.upgradeData?.reduce((acc, upgrade) => {
|
||||
if (upgrade.timestampInstalled) {
|
||||
const key = parseAPITimestamp(upgrade.timestampInstalled, 'M/yy');
|
||||
acc[key as string] = upgrade;
|
||||
}
|
||||
return acc;
|
||||
}, empty) || empty
|
||||
);
|
||||
}
|
||||
|
||||
getUpgradeMessage(datum: MonthlyChartData) {
|
||||
const upgradeInfo = this.upgradeByMonthYear[datum.month as string];
|
||||
if (upgradeInfo) {
|
||||
const { version, previousVersion } = upgradeInfo;
|
||||
return `Vault was upgraded
|
||||
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
getNewClients(datum: MonthlyChartData) {
|
||||
if (!datum?.new_clients) return 0;
|
||||
return (datum?.new_clients[this.yKey as keyof Count] as number) || 0;
|
||||
}
|
||||
|
||||
hasValue = (count: number | null) => {
|
||||
return typeof count === 'number' ? true : false;
|
||||
};
|
||||
// These functions are used by the tooltip
|
||||
formatCount = (count: number) => {
|
||||
return formatNumbers([count]);
|
||||
};
|
||||
formatMonth = (date: Date) => {
|
||||
return format(date, 'M/yy');
|
||||
};
|
||||
tooltipX = (original: number) => {
|
||||
return original.toString();
|
||||
};
|
||||
tooltipY = (original: number) => {
|
||||
return `${this.chartHeight - original + 15}`;
|
||||
};
|
||||
}
|
||||
115
ui/app/components/clients/charts/vertical-bar-basic.hbs
Normal file
115
ui/app/components/clients/charts/vertical-bar-basic.hbs
Normal file
@@ -0,0 +1,115 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<div class="lineal-chart" data-test-chart={{@chartTitle}}>
|
||||
<Lineal::Fluid as |width|>
|
||||
{{#let
|
||||
(scale-band domain=this.xDomain range=(array 0 width) padding=0.1)
|
||||
(scale-linear range=(array this.chartHeight 0) domain=this.yDomain)
|
||||
(scale-linear range=(array 0 this.chartHeight) domain=this.yDomain)
|
||||
as |xScale yScale hScale|
|
||||
}}
|
||||
<svg width={{width}} height={{this.chartHeight}} data-test-sync-bar-chart>
|
||||
<title>
|
||||
{{@chartTitle}}
|
||||
</title>
|
||||
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
<Lineal::Axis
|
||||
@scale={{yScale}}
|
||||
@tickCount="4"
|
||||
@tickPadding={{10}}
|
||||
@tickSizeInner={{concat "-" width}}
|
||||
@tickFormat={{this.formatTicksY}}
|
||||
@orientation="left"
|
||||
@includeDomain={{false}}
|
||||
class="lineal-axis"
|
||||
data-test-y-axis
|
||||
/>
|
||||
<Lineal::Axis
|
||||
@scale={{xScale}}
|
||||
@orientation="bottom"
|
||||
transform="translate(0,{{yScale.range.min}})"
|
||||
@includeDomain={{false}}
|
||||
@tickSize="0"
|
||||
@tickPadding={{10}}
|
||||
class="lineal-axis"
|
||||
data-test-x-axis
|
||||
/>
|
||||
{{/if}}
|
||||
<Lineal::Bars
|
||||
@data={{this.chartData}}
|
||||
@x="x"
|
||||
@y="y"
|
||||
@height="y"
|
||||
@width={{this.barWidth}}
|
||||
@xScale={{xScale}}
|
||||
@yScale={{yScale}}
|
||||
@heightScale={{hScale}}
|
||||
transform="translate({{this.barOffset xScale.bandwidth}},0)"
|
||||
fill="transparent"
|
||||
stroke="transparent"
|
||||
class="lineal-chart-bar"
|
||||
data-test-vertical-bar
|
||||
/>
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
{{#each this.chartData as |d|}}
|
||||
<rect
|
||||
role="button"
|
||||
aria-label="Show exact counts for {{d.legendX}}"
|
||||
x="0"
|
||||
y="0"
|
||||
height={{this.chartHeight}}
|
||||
width={{xScale.bandwidth}}
|
||||
fill="transparent"
|
||||
stroke="transparent"
|
||||
transform="translate({{xScale.compute d.x}})"
|
||||
{{on "mouseover" (fn (mut this.activeDatum) d)}}
|
||||
{{on "mouseout" (fn (mut this.activeDatum) null)}}
|
||||
data-test-interactive-area={{d.x}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</svg>
|
||||
{{#if this.activeDatum}}
|
||||
<div
|
||||
class="lineal-tooltip-position chart-tooltip"
|
||||
role="status"
|
||||
{{style
|
||||
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)
|
||||
--y=(this.tooltipY (hScale.compute this.activeDatum.y))
|
||||
}}
|
||||
>
|
||||
<div data-test-tooltip>
|
||||
<p class="bold" data-test-tooltip-month>{{this.activeDatum.legendX}}</p>
|
||||
<p data-test-tooltip-count>{{this.activeDatum.tooltip}}</p>
|
||||
</div>
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</Lineal::Fluid>
|
||||
</div>
|
||||
{{#if @showTable}}
|
||||
<details data-test-underlying-data>
|
||||
<summary>Underlying data</summary>
|
||||
<Hds::Table @caption="Underlying data">
|
||||
<:head as |H|>
|
||||
<H.Tr>
|
||||
<H.Th>Month</H.Th>
|
||||
<H.Th>Count of secret syncs</H.Th>
|
||||
</H.Tr>
|
||||
</:head>
|
||||
<:body as |B|>
|
||||
{{#each this.chartData as |row|}}
|
||||
<B.Tr>
|
||||
<B.Td>{{row.legendX}}</B.Td>
|
||||
<B.Td>{{row.legendY}}</B.Td>
|
||||
</B.Tr>
|
||||
{{/each}}
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
</details>
|
||||
{{/if}}
|
||||
97
ui/app/components/clients/charts/vertical-bar-basic.ts
Normal file
97
ui/app/components/clients/charts/vertical-bar-basic.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { BAR_WIDTH, formatNumbers } from 'vault/utils/chart-helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
||||
import type { Count, MonthlyChartData } from 'vault/vault/charts/client-counts';
|
||||
|
||||
interface Args {
|
||||
data: MonthlyChartData[];
|
||||
dataKey: string;
|
||||
chartTitle: string;
|
||||
chartHeight?: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
x: string;
|
||||
y: number | null;
|
||||
tooltip: string;
|
||||
legendX: string;
|
||||
legendY: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @module VerticalBarBasic
|
||||
* Renders a vertical bar chart of counts fora single data point (@dataKey) over time.
|
||||
*
|
||||
* @example
|
||||
<Clients::Charts::VerticalBarBasic
|
||||
@chartTitle="Secret Sync client counts"
|
||||
@data={{this.model}}
|
||||
@dataKey="secret_syncs"
|
||||
@showTable={{true}}
|
||||
@chartHeight={{200}}
|
||||
/>
|
||||
*/
|
||||
export default class VerticalBarBasic extends Component<Args> {
|
||||
barWidth = BAR_WIDTH;
|
||||
|
||||
@tracked activeDatum: ChartData | null = null;
|
||||
|
||||
get chartHeight() {
|
||||
return this.args.chartHeight || 190;
|
||||
}
|
||||
|
||||
get chartData() {
|
||||
return this.args.data.map((d): ChartData => {
|
||||
const xValue = d.timestamp as string;
|
||||
const yValue = (d[this.args.dataKey as keyof Count] as number) ?? null;
|
||||
return {
|
||||
x: parseAPITimestamp(xValue, 'M/yy') as string,
|
||||
y: yValue,
|
||||
tooltip:
|
||||
yValue === null ? 'No data' : `${formatNumber([yValue])} ${this.args.dataKey.replace(/_/g, ' ')}`,
|
||||
legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string,
|
||||
legendY: (yValue ?? 'No data').toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get yDomain() {
|
||||
const counts: number[] = this.chartData
|
||||
.map((d) => d.y)
|
||||
.flatMap((num) => (typeof num === 'number' ? [num] : []));
|
||||
const max = Math.max(...counts);
|
||||
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
|
||||
return [0, max <= 4 ? 4 : max];
|
||||
}
|
||||
|
||||
get xDomain() {
|
||||
const months = this.chartData.map((d) => d.x);
|
||||
return new Set(months);
|
||||
}
|
||||
|
||||
// TEMPLATE HELPERS
|
||||
barOffset = (bandwidth: number) => {
|
||||
return (bandwidth - this.barWidth) / 2;
|
||||
};
|
||||
|
||||
tooltipX = (original: number, bandwidth: number) => {
|
||||
return (original + bandwidth / 2).toString();
|
||||
};
|
||||
|
||||
tooltipY = (original: number) => {
|
||||
if (!original) return `0`;
|
||||
return `${original}`;
|
||||
};
|
||||
|
||||
formatTicksY = (num: number): string => {
|
||||
return formatNumbers(num) || num.toString();
|
||||
};
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { isAfter, isBefore, isSameMonth, format } from 'date-fns';
|
||||
import getStorage from 'vault/lib/token-storage';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
// my sincere apologies to the next dev who has to refactor/debug this (⇀‸↼‶)
|
||||
export default class Dashboard extends Component {
|
||||
@service store;
|
||||
@service version;
|
||||
|
||||
chartLegend = [
|
||||
{ key: 'entity_clients', label: 'entity clients' },
|
||||
{ key: 'non_entity_clients', label: 'non-entity clients' },
|
||||
];
|
||||
|
||||
// RESPONSE
|
||||
@tracked startMonthTimestamp; // when user queries, updates to first month object of response
|
||||
@tracked endMonthTimestamp; // when user queries, updates to last month object of response
|
||||
@tracked queriedActivityResponse = null;
|
||||
// track params sent to /activity request
|
||||
@tracked activityQueryParams = {
|
||||
start: {}, // updates when user edits billing start month
|
||||
end: {}, // updates when user queries end dates via calendar widget
|
||||
};
|
||||
|
||||
// SEARCH SELECT FILTERS
|
||||
get namespaceArray() {
|
||||
return this.getActivityResponse.byNamespace
|
||||
? this.getActivityResponse.byNamespace.map((namespace) => ({
|
||||
name: namespace.label,
|
||||
id: namespace.label,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
@tracked selectedNamespace = null;
|
||||
@tracked selectedAuthMethod = null;
|
||||
@tracked authMethodOptions = [];
|
||||
|
||||
// TEMPLATE VIEW
|
||||
@tracked noActivityData;
|
||||
@tracked showBillingStartModal = false;
|
||||
@tracked isLoadingQuery = false;
|
||||
@tracked errorObject = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.startMonthTimestamp = this.args.model.licenseStartTimestamp;
|
||||
this.endMonthTimestamp = this.args.model.currentDate;
|
||||
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
|
||||
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
|
||||
this.noActivityData = this.args.model.activity.id === 'no-data' ? true : false;
|
||||
}
|
||||
|
||||
// returns text for empty state message if noActivityData
|
||||
get dateRangeMessage() {
|
||||
if (!this.startMonthTimestamp && !this.endMonthTimestamp) return null;
|
||||
const endMonth = isSameMonth(
|
||||
parseAPITimestamp(this.startMonthTimestamp),
|
||||
parseAPITimestamp(this.endMonthTimestamp)
|
||||
)
|
||||
? ''
|
||||
: ` to ${parseAPITimestamp(this.endMonthTimestamp, 'MMMM yyyy')}`;
|
||||
// completes the message 'No data received from { dateRangeMessage }'
|
||||
return `from ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}` + endMonth;
|
||||
}
|
||||
|
||||
get versionText() {
|
||||
return this.version.isEnterprise
|
||||
? {
|
||||
label: 'Billing start month',
|
||||
description:
|
||||
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
title: 'No billing start date found',
|
||||
message:
|
||||
'In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate.',
|
||||
}
|
||||
: {
|
||||
label: 'Client counting start date',
|
||||
description:
|
||||
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
title: 'No start date found',
|
||||
message:
|
||||
'In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month.',
|
||||
};
|
||||
}
|
||||
|
||||
get isDateRange() {
|
||||
return !isSameMonth(
|
||||
parseAPITimestamp(this.getActivityResponse.startTime),
|
||||
parseAPITimestamp(this.getActivityResponse.endTime)
|
||||
);
|
||||
}
|
||||
|
||||
get isCurrentMonth() {
|
||||
return (
|
||||
isSameMonth(
|
||||
parseAPITimestamp(this.getActivityResponse.startTime),
|
||||
parseAPITimestamp(this.args.model.currentDate)
|
||||
) &&
|
||||
isSameMonth(
|
||||
parseAPITimestamp(this.getActivityResponse.endTime),
|
||||
parseAPITimestamp(this.args.model.currentDate)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get startTimeDiscrepancy() {
|
||||
// show banner if startTime returned from activity log (response) is after the queried startTime
|
||||
const activityStartDateObject = parseAPITimestamp(this.getActivityResponse.startTime);
|
||||
const queryStartDateObject = parseAPITimestamp(this.startMonthTimestamp);
|
||||
let message = 'You requested data from';
|
||||
if (this.startMonthTimestamp === this.args.model.licenseStartTimestamp && this.version.isEnterprise) {
|
||||
// on init, date is automatically pulled from license start date and user hasn't queried anything yet
|
||||
message = 'Your license start date is';
|
||||
}
|
||||
if (
|
||||
isAfter(activityStartDateObject, queryStartDateObject) &&
|
||||
!isSameMonth(activityStartDateObject, queryStartDateObject)
|
||||
) {
|
||||
return `${message} ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}.
|
||||
We only have data from ${parseAPITimestamp(this.getActivityResponse.startTime, 'MMMM yyyy')},
|
||||
and that is what is being shown here.`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get upgradeDuringActivity() {
|
||||
const versionHistory = this.args.model.versionHistory;
|
||||
if (!versionHistory || versionHistory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// filter for upgrade data of noteworthy upgrades (1.9 and/or 1.10)
|
||||
const upgradeVersionHistory = versionHistory.filter(
|
||||
({ version }) => version.match('1.9') || version.match('1.10')
|
||||
);
|
||||
if (!upgradeVersionHistory || upgradeVersionHistory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activityStart = parseAPITimestamp(this.getActivityResponse.startTime);
|
||||
const activityEnd = parseAPITimestamp(this.getActivityResponse.endTime);
|
||||
// filter and return all upgrades that happened within date range of queried activity
|
||||
const upgradesWithinData = upgradeVersionHistory.filter(({ timestampInstalled }) => {
|
||||
const upgradeDate = parseAPITimestamp(timestampInstalled);
|
||||
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
|
||||
});
|
||||
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
|
||||
}
|
||||
|
||||
get upgradeVersionAndDate() {
|
||||
if (!this.upgradeDuringActivity) return null;
|
||||
|
||||
if (this.upgradeDuringActivity.length === 2) {
|
||||
const [firstUpgrade, secondUpgrade] = this.upgradeDuringActivity;
|
||||
const firstDate = parseAPITimestamp(firstUpgrade.timestampInstalled, 'MMM d, yyyy');
|
||||
const secondDate = parseAPITimestamp(secondUpgrade.timestampInstalled, 'MMM d, yyyy');
|
||||
return `Vault was upgraded to ${firstUpgrade.version} (${firstDate}) and ${secondUpgrade.version} (${secondDate}) during this time range.`;
|
||||
} else {
|
||||
const [upgrade] = this.upgradeDuringActivity;
|
||||
return `Vault was upgraded to ${upgrade.version} on ${parseAPITimestamp(
|
||||
upgrade.timestampInstalled,
|
||||
'MMM d, yyyy'
|
||||
)}.`;
|
||||
}
|
||||
}
|
||||
|
||||
get upgradeExplanation() {
|
||||
if (!this.upgradeDuringActivity) return null;
|
||||
if (this.upgradeDuringActivity.length === 1) {
|
||||
const version = this.upgradeDuringActivity[0].version;
|
||||
if (version.match('1.9')) {
|
||||
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data.';
|
||||
}
|
||||
if (version.match('1.10')) {
|
||||
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data.';
|
||||
}
|
||||
}
|
||||
// return combined explanation if spans multiple upgrades
|
||||
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data.';
|
||||
}
|
||||
|
||||
get formattedStartDate() {
|
||||
if (!this.startMonthTimestamp) return null;
|
||||
return parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy');
|
||||
}
|
||||
|
||||
// GETTERS FOR RESPONSE & DATA
|
||||
|
||||
// on init API response uses license start_date, getter updates when user queries dates
|
||||
get getActivityResponse() {
|
||||
return this.queriedActivityResponse || this.args.model.activity;
|
||||
}
|
||||
|
||||
get byMonthActivityData() {
|
||||
if (this.selectedNamespace) {
|
||||
return this.filteredActivityByMonth;
|
||||
} else {
|
||||
return this.getActivityResponse?.byMonth;
|
||||
}
|
||||
}
|
||||
|
||||
get hasAttributionData() {
|
||||
if (this.selectedAuthMethod) return false;
|
||||
if (this.selectedNamespace) {
|
||||
return this.authMethodOptions.length > 0;
|
||||
}
|
||||
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
|
||||
}
|
||||
|
||||
// (object) top level TOTAL client counts for given date range
|
||||
get totalUsageCounts() {
|
||||
return this.selectedNamespace ? this.filteredActivityByNamespace : this.getActivityResponse.total;
|
||||
}
|
||||
|
||||
// (object) single month new client data with total counts + array of namespace breakdown
|
||||
get newClientCounts() {
|
||||
return this.isDateRange ? null : this.byMonthActivityData[0]?.new_clients;
|
||||
}
|
||||
|
||||
// total client data for horizontal bar chart in attribution component
|
||||
get totalClientAttribution() {
|
||||
if (this.selectedNamespace) {
|
||||
return this.filteredActivityByNamespace?.mounts || null;
|
||||
} else {
|
||||
return this.getActivityResponse?.byNamespace || null;
|
||||
}
|
||||
}
|
||||
|
||||
// new client data for horizontal bar chart
|
||||
get newClientAttribution() {
|
||||
// new client attribution only available in a single, historical month (not a date range or current month)
|
||||
if (this.isDateRange || this.isCurrentMonth) return null;
|
||||
|
||||
if (this.selectedNamespace) {
|
||||
return this.newClientCounts?.mounts || null;
|
||||
} else {
|
||||
return this.newClientCounts?.namespaces || null;
|
||||
}
|
||||
}
|
||||
|
||||
get responseTimestamp() {
|
||||
return this.getActivityResponse.responseTimestamp;
|
||||
}
|
||||
|
||||
// FILTERS
|
||||
get filteredActivityByNamespace() {
|
||||
const namespace = this.selectedNamespace;
|
||||
const auth = this.selectedAuthMethod;
|
||||
if (!namespace && !auth) {
|
||||
return this.getActivityResponse;
|
||||
}
|
||||
if (!auth) {
|
||||
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
|
||||
}
|
||||
return this.getActivityResponse.byNamespace
|
||||
.find((ns) => ns.label === namespace)
|
||||
.mounts?.find((mount) => mount.label === auth);
|
||||
}
|
||||
|
||||
get filteredActivityByMonth() {
|
||||
const namespace = this.selectedNamespace;
|
||||
const auth = this.selectedAuthMethod;
|
||||
if (!namespace && !auth) {
|
||||
return this.getActivityResponse?.byMonth;
|
||||
}
|
||||
const namespaceData = this.getActivityResponse?.byMonth
|
||||
.map((m) => m.namespaces_by_key[namespace])
|
||||
.filter((d) => d !== undefined);
|
||||
if (!auth) {
|
||||
return namespaceData.length === 0 ? null : namespaceData;
|
||||
}
|
||||
const mountData = namespaceData
|
||||
.map((namespace) => namespace.mounts_by_key[auth])
|
||||
.filter((d) => d !== undefined);
|
||||
return mountData.length === 0 ? null : mountData;
|
||||
}
|
||||
|
||||
@action
|
||||
async handleClientActivityQuery({ dateType, monthIdx, year }) {
|
||||
this.showBillingStartModal = false;
|
||||
switch (dateType) {
|
||||
case 'cancel':
|
||||
return;
|
||||
case 'reset': // clicked 'Current billing period' in calendar widget -> reset to initial start/end dates
|
||||
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
|
||||
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
|
||||
break;
|
||||
case 'currentMonth': // clicked 'Current month' from calendar widget
|
||||
this.activityQueryParams.start.timestamp = this.args.model.currentDate;
|
||||
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
|
||||
break;
|
||||
case 'startDate': // from "Edit billing start" modal
|
||||
this.activityQueryParams.start = { monthIdx, year };
|
||||
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
|
||||
break;
|
||||
case 'endDate': // selected month and year from calendar widget
|
||||
this.activityQueryParams.end = { monthIdx, year };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
try {
|
||||
this.isLoadingQuery = true;
|
||||
const response = await this.store.queryRecord('clients/activity', {
|
||||
start_time: this.activityQueryParams.start,
|
||||
end_time: this.activityQueryParams.end,
|
||||
});
|
||||
// preference for byMonth timestamps because those correspond to a user's query
|
||||
const { byMonth } = response;
|
||||
this.startMonthTimestamp = byMonth[0]?.timestamp || response.startTime;
|
||||
this.endMonthTimestamp = byMonth[byMonth.length - 1]?.timestamp || response.endTime;
|
||||
if (response.id === 'no-data') {
|
||||
this.noActivityData = true;
|
||||
} else {
|
||||
this.noActivityData = false;
|
||||
getStorage().setItem('vault:ui-inputted-start-date', this.startMonthTimestamp);
|
||||
}
|
||||
this.queriedActivityResponse = response;
|
||||
|
||||
// reset search-select filters
|
||||
this.selectedNamespace = null;
|
||||
this.selectedAuthMethod = null;
|
||||
this.authMethodOptions = [];
|
||||
} catch (e) {
|
||||
this.errorObject = e;
|
||||
return e;
|
||||
} finally {
|
||||
this.isLoadingQuery = false;
|
||||
}
|
||||
}
|
||||
|
||||
get hasMultipleMonthsData() {
|
||||
return this.byMonthActivityData && this.byMonthActivityData.length > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
selectNamespace([value]) {
|
||||
this.selectedNamespace = value;
|
||||
if (!value) {
|
||||
this.authMethodOptions = [];
|
||||
// on clear, also make sure auth method is cleared
|
||||
this.selectedAuthMethod = null;
|
||||
} else {
|
||||
// Side effect: set auth namespaces
|
||||
const mounts = this.filteredActivityByNamespace.mounts?.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
this.authMethodOptions = mounts;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
setAuthMethod([authMount]) {
|
||||
this.selectedAuthMethod = authMount;
|
||||
}
|
||||
|
||||
// validation function sent to <DateDropdown> selecting 'endDate'
|
||||
@action
|
||||
isEndBeforeStart(selection) {
|
||||
let { start } = this.activityQueryParams;
|
||||
start = start?.timestamp ? parseAPITimestamp(start.timestamp) : new Date(start.year, start.monthIdx);
|
||||
return isBefore(selection, start) && !isSameMonth(start, selection)
|
||||
? `End date must be after ${format(start, 'MMMM yyyy')}`
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@
|
||||
{{#modal-dialog
|
||||
tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="35px 0"
|
||||
}}
|
||||
<div class={{concat "chart-tooltip horizontal-chart " (if this.isLabel "is-label-fit-content")}}>
|
||||
<p>{{this.tooltipText}}</p>
|
||||
<div class="chart-tooltip {{if this.isLabel ' is-label-fit-content'}}">
|
||||
{{#each this.tooltipText as |text|}}
|
||||
<p>{{text}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
{{/modal-dialog}}
|
||||
@@ -11,7 +11,7 @@ import { select, event, selectAll } from 'd3-selection';
|
||||
import { scaleLinear, scaleBand } from 'd3-scale';
|
||||
import { axisLeft } from 'd3-axis';
|
||||
import { max, maxIndex } from 'd3-array';
|
||||
import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from 'vault/utils/chart-helpers';
|
||||
import { GREY, BLUE_PALETTE } from 'vault/utils/chart-helpers';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
|
||||
@@ -27,7 +27,6 @@ import { formatNumber } from 'core/helpers/format-number';
|
||||
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
|
||||
* @param {string} labelKey - string of key name for label value in chart data
|
||||
* @param {string} xKey - string of key name for x value in chart data
|
||||
* @param {object} totalCounts - object to calculate percentage for tooltip
|
||||
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
|
||||
*/
|
||||
|
||||
@@ -39,7 +38,7 @@ const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick
|
||||
|
||||
export default class HorizontalBarChart extends Component {
|
||||
@tracked tooltipTarget = '';
|
||||
@tracked tooltipText = '';
|
||||
@tracked tooltipText = [];
|
||||
@tracked isLabel = null;
|
||||
|
||||
get labelKey() {
|
||||
@@ -50,18 +49,10 @@ export default class HorizontalBarChart extends Component {
|
||||
return this.args.xKey || 'clients';
|
||||
}
|
||||
|
||||
get chartLegend() {
|
||||
return this.args.chartLegend;
|
||||
}
|
||||
|
||||
get topNamespace() {
|
||||
return this.args.dataset[maxIndex(this.args.dataset, (d) => d[this.xKey])];
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.args.totalCounts[this.xKey] || null;
|
||||
}
|
||||
|
||||
@action removeTooltip() {
|
||||
this.tooltipTarget = null;
|
||||
}
|
||||
@@ -71,7 +62,7 @@ export default class HorizontalBarChart extends Component {
|
||||
// chart legend tells stackFunction how to stack/organize data
|
||||
// creates an array of data for each key name
|
||||
// each array contains coordinates for each data bar
|
||||
const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
|
||||
const stackFunction = stack().keys(this.args.chartLegend.map((l) => l.key));
|
||||
const dataset = chartData;
|
||||
const stackedData = stackFunction(dataset);
|
||||
const labelKey = this.labelKey;
|
||||
@@ -98,7 +89,7 @@ export default class HorizontalBarChart extends Component {
|
||||
.attr('data-test-group', (d) => `${d.key}`)
|
||||
// shifts chart to accommodate y-axis legend
|
||||
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`)
|
||||
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
|
||||
.style('fill', (d, i) => BLUE_PALETTE[i]);
|
||||
|
||||
const yAxis = axisLeft(yScale).tickSize(0);
|
||||
|
||||
@@ -171,7 +162,6 @@ export default class HorizontalBarChart extends Component {
|
||||
.style('opacity', '0')
|
||||
.style('mix-blend-mode', 'multiply');
|
||||
|
||||
const dataBars = chartSvg.selectAll('rect.data-bar');
|
||||
const actionBarSelection = chartSvg.selectAll('rect.action-bar');
|
||||
|
||||
const compareAttributes = (elementA, elementB, attr) =>
|
||||
@@ -183,28 +173,15 @@ export default class HorizontalBarChart extends Component {
|
||||
const hoveredElement = actionBars.filter((bar) => bar[labelKey] === data[labelKey]).node();
|
||||
this.tooltipTarget = hoveredElement;
|
||||
this.isLabel = false;
|
||||
this.tooltipText = this.total
|
||||
? `${Math.round((data[xKey] * 100) / this.total)}%
|
||||
of total client counts:
|
||||
${formatTooltipNumber(data.entity_clients)} entity clients,
|
||||
${formatTooltipNumber(data.non_entity_clients)} non-entity clients.`
|
||||
: '';
|
||||
this.tooltipText = []; // clear stats
|
||||
this.args.chartLegend.forEach(({ key, label }) => {
|
||||
this.tooltipText.pushObject(`${formatNumber([data[key]])} ${label}`);
|
||||
});
|
||||
|
||||
select(hoveredElement).style('opacity', 1);
|
||||
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, hoveredElement, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
select(this).style('opacity', 0);
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
|
||||
});
|
||||
|
||||
// MOUSE EVENTS FOR Y-AXIS LABELS
|
||||
@@ -214,15 +191,10 @@ export default class HorizontalBarChart extends Component {
|
||||
const hoveredElement = labelActionBar.filter((bar) => bar[labelKey] === data[labelKey]).node();
|
||||
this.tooltipTarget = hoveredElement;
|
||||
this.isLabel = true;
|
||||
this.tooltipText = data[labelKey];
|
||||
this.tooltipText = [data[labelKey]];
|
||||
} else {
|
||||
this.tooltipTarget = null;
|
||||
}
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
|
||||
actionBarSelection
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
@@ -231,11 +203,6 @@ export default class HorizontalBarChart extends Component {
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
this.tooltipTarget = null;
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
|
||||
actionBarSelection
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { max } from 'd3-array';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { select, selectAll, node } from 'd3-selection';
|
||||
import { axisLeft, axisBottom } from 'd3-axis';
|
||||
import { scaleLinear, scalePoint } from 'd3-scale';
|
||||
import { line } from 'd3-shape';
|
||||
import {
|
||||
LIGHT_AND_DARK_BLUE,
|
||||
UPGRADE_WARNING,
|
||||
SVG_DIMENSIONS,
|
||||
formatNumbers,
|
||||
} from 'vault/utils/chart-helpers';
|
||||
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
|
||||
/**
|
||||
* @module LineChart
|
||||
* LineChart components are used to display data in a line plot with accompanying tooltip
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
|
||||
* ```
|
||||
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
|
||||
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
|
||||
* @param {array} upgradeData - array of objects containing version history from the /version-history endpoint
|
||||
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
|
||||
*/
|
||||
|
||||
export default class LineChart extends Component {
|
||||
@tracked tooltipTarget = '';
|
||||
@tracked tooltipMonth = '';
|
||||
@tracked tooltipTotal = '';
|
||||
@tracked tooltipNew = '';
|
||||
@tracked tooltipUpgradeText = '';
|
||||
|
||||
get yKey() {
|
||||
return this.args.yKey || 'clients';
|
||||
}
|
||||
|
||||
get xKey() {
|
||||
return this.args.xKey || 'month';
|
||||
}
|
||||
|
||||
get upgradeData() {
|
||||
const upgradeData = this.args.upgradeData;
|
||||
if (!upgradeData) return null;
|
||||
if (!Array.isArray(upgradeData)) {
|
||||
console.debug('upgradeData must be an array of objects containing upgrade history'); // eslint-disable-line
|
||||
return null;
|
||||
} else if (!Object.keys(upgradeData[0]).includes('timestampInstalled')) {
|
||||
// eslint-disable-next-line
|
||||
console.debug(
|
||||
`upgrade must be an object with the following key names: ['version', 'previousVersion', 'timestampInstalled']`
|
||||
);
|
||||
return null;
|
||||
} else {
|
||||
return upgradeData?.map((versionData) => {
|
||||
return {
|
||||
[this.xKey]: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'),
|
||||
...versionData,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action removeTooltip() {
|
||||
this.tooltipTarget = null;
|
||||
}
|
||||
|
||||
@action
|
||||
renderChart(element, [chartData]) {
|
||||
const dataset = chartData;
|
||||
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
|
||||
const domainMax = max(filteredData.map((d) => d[this.yKey]));
|
||||
const chartSvg = select(element);
|
||||
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
|
||||
// clear out DOM before appending anything
|
||||
chartSvg.selectAll('g').remove().exit().data(filteredData).enter();
|
||||
|
||||
// DEFINE AXES SCALES
|
||||
const yScale = scaleLinear().domain([0, domainMax]).range([0, 100]).nice();
|
||||
const yAxisScale = scaleLinear().domain([0, domainMax]).range([SVG_DIMENSIONS.height, 0]).nice();
|
||||
|
||||
// use full dataset (instead of filteredData) so x-axis spans months with and without data
|
||||
const xScale = scalePoint()
|
||||
.domain(dataset.map((d) => d[this.xKey]))
|
||||
.range([0, SVG_DIMENSIONS.width])
|
||||
.padding(0.2);
|
||||
|
||||
// CUSTOMIZE AND APPEND AXES
|
||||
const yAxis = axisLeft(yAxisScale)
|
||||
.ticks(4)
|
||||
.tickPadding(10)
|
||||
.tickSizeInner(-SVG_DIMENSIONS.width) // makes grid lines length of svg
|
||||
.tickFormat(formatNumbers);
|
||||
|
||||
const xAxis = axisBottom(xScale).tickSize(0);
|
||||
|
||||
yAxis(chartSvg.append('g').attr('data-test-line-chart', 'y-axis-labels'));
|
||||
xAxis(
|
||||
chartSvg
|
||||
.append('g')
|
||||
.attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)
|
||||
.attr('data-test-line-chart', 'x-axis-labels')
|
||||
);
|
||||
|
||||
chartSvg.selectAll('.domain').remove();
|
||||
|
||||
const findUpgradeData = (datum) => {
|
||||
return this.upgradeData
|
||||
? this.upgradeData.find((upgrade) => upgrade[this.xKey] === datum[this.xKey])
|
||||
: null;
|
||||
};
|
||||
|
||||
// VERSION UPGRADE INDICATOR
|
||||
chartSvg
|
||||
.append('g')
|
||||
.selectAll('circle')
|
||||
.data(filteredData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'upgrade-circle')
|
||||
.attr('data-test-line-chart', (d) => `upgrade-${d[this.xKey]}`)
|
||||
.attr('fill', UPGRADE_WARNING)
|
||||
.style('opacity', (d) => (findUpgradeData(d) ? '1' : '0'))
|
||||
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
|
||||
.attr('cx', (d) => xScale(d[this.xKey]))
|
||||
.attr('r', 10);
|
||||
|
||||
// PATH BETWEEN PLOT POINTS
|
||||
const lineGenerator = line()
|
||||
.x((d) => xScale(d[this.xKey]))
|
||||
.y((d) => yAxisScale(d[this.yKey]));
|
||||
|
||||
chartSvg
|
||||
.append('g')
|
||||
.append('path')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', LIGHT_AND_DARK_BLUE[1])
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('d', lineGenerator(filteredData));
|
||||
|
||||
// LINE PLOTS (CIRCLES)
|
||||
chartSvg
|
||||
.append('g')
|
||||
.selectAll('circle')
|
||||
.data(filteredData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('data-test-line-chart', 'plot-point')
|
||||
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
|
||||
.attr('cx', (d) => xScale(d[this.xKey]))
|
||||
.attr('r', 3.5)
|
||||
.attr('fill', LIGHT_AND_DARK_BLUE[0])
|
||||
.attr('stroke', LIGHT_AND_DARK_BLUE[1])
|
||||
.attr('stroke-width', 1.5);
|
||||
|
||||
// LARGER HOVER CIRCLES
|
||||
chartSvg
|
||||
.append('g')
|
||||
.selectAll('circle')
|
||||
.data(filteredData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'hover-circle')
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', '0')
|
||||
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
|
||||
.attr('cx', (d) => xScale(d[this.xKey]))
|
||||
.attr('r', 10);
|
||||
|
||||
const hoverCircles = chartSvg.selectAll('.hover-circle');
|
||||
|
||||
// MOUSE EVENT FOR TOOLTIP
|
||||
hoverCircles.on('mouseover', (data) => {
|
||||
// TODO: how to generalize this?
|
||||
this.tooltipMonth = formatChartDate(data[this.xKey]);
|
||||
this.tooltipTotal = formatNumber([data[this.yKey]]) + ' total clients';
|
||||
this.tooltipNew = (formatNumber([data?.new_clients[this.yKey]]) || '0') + ' new clients';
|
||||
this.tooltipUpgradeText = '';
|
||||
const upgradeInfo = findUpgradeData(data);
|
||||
if (upgradeInfo) {
|
||||
const { version, previousVersion } = upgradeInfo;
|
||||
this.tooltipUpgradeText = `Vault was upgraded
|
||||
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
|
||||
}
|
||||
|
||||
const node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();
|
||||
this.tooltipTarget = node;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
|
||||
/**
|
||||
* @module MonthlyUsage
|
||||
* MonthlyUsage components show how many total clients use Vault each month. Displaying the average totals to the left of a stacked, vertical bar chart.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
<Clients::MonthlyUsage
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@responseTimestamp={{this.responseTimestamp}}
|
||||
@verticalBarChartData={{this.byMonthActivityData}}
|
||||
/>
|
||||
* ```
|
||||
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
|
||||
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
|
||||
* @param {array} verticalBarChartData - array of flattened objects
|
||||
sample object =
|
||||
{
|
||||
month: '1/22',
|
||||
entity_clients: 23,
|
||||
non_entity_clients: 45,
|
||||
clients: 68,
|
||||
namespaces: [],
|
||||
new_clients: {
|
||||
entity_clients: 11,
|
||||
non_entity_clients: 36,
|
||||
clients: 47,
|
||||
namespaces: [],
|
||||
},
|
||||
}
|
||||
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
|
||||
*/
|
||||
export default class MonthlyUsage extends Component {
|
||||
get averageTotalClients() {
|
||||
return calculateAverage(this.args.verticalBarChartData, 'clients') || '0';
|
||||
}
|
||||
|
||||
get averageNewClients() {
|
||||
return (
|
||||
calculateAverage(
|
||||
this.args.verticalBarChartData?.map((d) => d.new_clients),
|
||||
'clients'
|
||||
) || '0'
|
||||
);
|
||||
}
|
||||
}
|
||||
146
ui/app/components/clients/page/counts.hbs
Normal file
146
ui/app/components/clients/page/counts.hbs
Normal file
@@ -0,0 +1,146 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
|
||||
<p class="has-bottom-margin-xl">
|
||||
This dashboard surfaces Vault client usage over time.
|
||||
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
|
||||
Date queries are sent in UTC.
|
||||
</p>
|
||||
|
||||
<h2 class="title is-6 has-bottom-margin-xs" data-test-counts-start-label>
|
||||
{{this.versionText.label}}
|
||||
</h2>
|
||||
|
||||
<div class="is-flex-align-baseline">
|
||||
{{#if this.formattedStartDate}}
|
||||
<p class="is-size-6" data-test-counts-start-month>{{this.formattedStartDate}}</p>
|
||||
<Hds::Button
|
||||
class="has-left-margin-xs"
|
||||
@text="Edit"
|
||||
@color="tertiary"
|
||||
@icon="edit"
|
||||
@iconPosition="trailing"
|
||||
data-test-counts-start-edit
|
||||
{{on "click" (fn (mut this.showBillingStartModal) true)}}
|
||||
/>
|
||||
{{else}}
|
||||
<DateDropdown
|
||||
@handleSubmit={{this.onDateChange}}
|
||||
@dateType="startDate"
|
||||
@submitText="Save"
|
||||
data-test-counts-start-dropdown
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<p class="is-8 has-text-grey has-bottom-margin-l" data-test-counts-description>
|
||||
{{this.versionText.description}}
|
||||
</p>
|
||||
|
||||
{{#if (eq @activity.id "no-data")}}
|
||||
<Clients::NoData @config={{@config}} @dateRangeMessage={{this.dateRangeMessage}} />
|
||||
{{else if @activityError}}
|
||||
<Clients::Error @error={{@activityError}} />
|
||||
{{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 @startTimestamp}}
|
||||
<div class="is-subtitle-gray has-bottom-margin-m">
|
||||
FILTERS
|
||||
<Toolbar data-test-clients-filter-bar>
|
||||
<ToolbarFilters>
|
||||
<CalendarWidget
|
||||
@startTimestamp={{this.startTimestampISO}}
|
||||
@endTimestamp={{this.endTimestampISO}}
|
||||
@selectMonth={{this.onDateChange}}
|
||||
/>
|
||||
{{#if (or @namespace this.namespaces)}}
|
||||
<SearchSelect
|
||||
@id="namespace-search-select"
|
||||
@options={{this.namespaces}}
|
||||
@inputValue={{if @namespace (array @namespace)}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{fn this.setFilterValue "ns"}}
|
||||
@placeholder="Filter by namespace"
|
||||
@displayInherit={{true}}
|
||||
class="is-marginless"
|
||||
data-test-counts-namespaces
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (or @mountPath this.mountPaths)}}
|
||||
<SearchSelect
|
||||
@id="auth-method-search-select"
|
||||
@options={{this.mountPaths}}
|
||||
@inputValue={{if @mountPath (array @mountPath)}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{fn this.setFilterValue "mountPath"}}
|
||||
@placeholder="Filter by mount path"
|
||||
@displayInherit={{true}}
|
||||
data-test-counts-auth-mounts
|
||||
/>
|
||||
{{/if}}
|
||||
</ToolbarFilters>
|
||||
</Toolbar>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.filteredActivity}}
|
||||
{{#if this.startTimeDiscrepancy}}
|
||||
<Hds::Alert data-test-counts-start-discrepancy @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description data-test-counts-start-discrepancy>
|
||||
{{this.startTimeDiscrepancy}}
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
|
||||
{{yield}}
|
||||
|
||||
{{else if (and (not @config.billingStartTimestamp) (not @startTimestamp))}}
|
||||
{{! Empty state for no billing/license start date }}
|
||||
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
|
||||
@message="Update the filter values or click the button to reset them."
|
||||
>
|
||||
<Hds::Button @text="Reset filters" @color="tertiary" @icon="reload" {{on "click" this.resetFilters}} />
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.showBillingStartModal}}
|
||||
<Hds::Modal id="clients-edit-date-modal" @onClose={{fn (mut this.showBillingStartModal) false}} as |M|>
|
||||
<M.Header>
|
||||
Edit start month
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
<p class="has-bottom-margin-s">
|
||||
{{this.versionText.description}}
|
||||
</p>
|
||||
<p><strong>{{this.versionText.label}}</strong></p>
|
||||
<DateDropdown class="has-top-padding-s" @handleSubmit={{this.onDateChange}} @dateType="startDate" @submitText="Save" />
|
||||
</M.Body>
|
||||
<M.Footer as |F|>
|
||||
<Hds::Button data-test-date-dropdown-cancel @text="Cancel" @color="secondary" {{on "click" F.close}} />
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
176
ui/app/components/clients/page/counts.ts
Normal file
176
ui/app/components/clients/page/counts.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { fromUnixTime, getUnixTime, isSameMonth, isAfter } from 'date-fns';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { formatDateObject } from 'core/utils/client-count-utils';
|
||||
|
||||
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 StoreService from 'vault/services/store';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
config: ClientsConfigModel;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
onFilterChange: CallableFunction;
|
||||
}
|
||||
|
||||
export default class ClientsCountsPageComponent extends Component<Args> {
|
||||
@service declare readonly version: VersionService;
|
||||
@service declare readonly store: StoreService;
|
||||
|
||||
get startTimestampISO() {
|
||||
return this.args.startTimestamp ? fromUnixTime(this.args.startTimestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
get endTimestampISO() {
|
||||
return this.args.endTimestamp ? fromUnixTime(this.args.endTimestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
get formattedStartDate() {
|
||||
return this.startTimestampISO ? parseAPITimestamp(this.startTimestampISO, 'MMMM yyyy') : null;
|
||||
}
|
||||
|
||||
// returns text for empty state message if noActivityData
|
||||
get dateRangeMessage() {
|
||||
if (this.startTimestampISO && this.endTimestampISO) {
|
||||
const endMonth = isSameMonth(
|
||||
parseAPITimestamp(this.startTimestampISO) as Date,
|
||||
parseAPITimestamp(this.endTimestampISO) as Date
|
||||
)
|
||||
? ''
|
||||
: `to ${parseAPITimestamp(this.endTimestampISO, 'MMMM yyyy')}`;
|
||||
// completes the message 'No data received from { dateRangeMessage }'
|
||||
return `from ${parseAPITimestamp(this.startTimestampISO, 'MMMM yyyy')} ${endMonth}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get versionText() {
|
||||
return this.version.isEnterprise
|
||||
? {
|
||||
label: 'Billing start month',
|
||||
description:
|
||||
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
title: 'No billing start date found',
|
||||
message:
|
||||
'In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate.',
|
||||
}
|
||||
: {
|
||||
label: 'Client counting start date',
|
||||
description:
|
||||
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
title: 'No start date found',
|
||||
message:
|
||||
'In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month.',
|
||||
};
|
||||
}
|
||||
|
||||
get namespaces() {
|
||||
return this.args.activity.byNamespace
|
||||
? this.args.activity.byNamespace.map((namespace) => ({
|
||||
name: namespace.label,
|
||||
id: namespace.label,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
get mountPaths() {
|
||||
if (this.namespaces.length) {
|
||||
return this.activityForNamespace?.mounts.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
get startTimeDiscrepancy() {
|
||||
// show banner if startTime returned from activity log (response) is after the queried startTime
|
||||
const { activity, config } = this.args;
|
||||
const activityStartDateObject = parseAPITimestamp(activity.startTime) as Date;
|
||||
const queryStartDateObject = parseAPITimestamp(this.startTimestampISO) as Date;
|
||||
const isEnterprise =
|
||||
this.startTimestampISO === config.billingStartTimestamp?.toISOString() && this.version.isEnterprise;
|
||||
const message = isEnterprise ? 'Your license start date is' : 'You requested data from';
|
||||
|
||||
if (
|
||||
isAfter(activityStartDateObject, queryStartDateObject) &&
|
||||
!isSameMonth(activityStartDateObject, queryStartDateObject)
|
||||
) {
|
||||
return `${message} ${this.formattedStartDate}.
|
||||
We only have data from ${parseAPITimestamp(activity.startTime, 'MMMM yyyy')},
|
||||
and that is what is being shown here.`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get activityForNamespace() {
|
||||
const { activity, namespace } = this.args;
|
||||
return namespace ? activity.byNamespace.find((ns) => ns.label === namespace) : null;
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
// return activity counts based on selected namespace and auth mount values
|
||||
const { namespace, mountPath, activity } = this.args;
|
||||
if (namespace) {
|
||||
return mountPath
|
||||
? this.activityForNamespace?.mounts.find((mount) => mount.label === mountPath)
|
||||
: this.activityForNamespace;
|
||||
}
|
||||
return activity.total;
|
||||
}
|
||||
|
||||
@action
|
||||
onDateChange(dateObject: { dateType: string; monthIdx: string; year: string }) {
|
||||
const { dateType, monthIdx, year } = dateObject;
|
||||
const { config } = this.args;
|
||||
const currentTimestamp = getUnixTime(timestamp.now());
|
||||
|
||||
// converts the selectedDate to unix timestamp for activity query
|
||||
const selectedDate = formatDateObject({ monthIdx, year }, dateType === 'endDate');
|
||||
|
||||
if (dateType !== 'cancel') {
|
||||
const start_time = {
|
||||
reset: getUnixTime(config?.billingStartTimestamp) || null, // clicked 'Current billing period' in calendar widget -> resets to billing start date
|
||||
currentMonth: currentTimestamp, // clicked 'Current month' from calendar widget -> defaults to currentTimestamp
|
||||
startDate: selectedDate, // from "Edit billing start" modal
|
||||
}[dateType];
|
||||
// endDate type is selection from calendar widget
|
||||
const end_time = dateType === 'endDate' ? selectedDate : currentTimestamp; // defaults to currentTimestamp
|
||||
const params = start_time !== undefined ? { start_time, end_time } : { end_time };
|
||||
this.args.onFilterChange(params);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
setFilterValue(type: 'ns' | 'mountPath', [value]: [string | undefined]) {
|
||||
const params = { [type]: value };
|
||||
// unset mountPath value when namespace is cleared
|
||||
if (type === 'ns' && !value) {
|
||||
params['mountPath'] = undefined;
|
||||
}
|
||||
this.args.onFilterChange(params);
|
||||
}
|
||||
|
||||
@action resetFilters() {
|
||||
this.args.onFilterChange({
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
ns: undefined,
|
||||
mountPath: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
ui/app/components/clients/page/overview.hbs
Normal file
27
ui/app/components/clients/page/overview.hbs
Normal file
@@ -0,0 +1,27 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::RunningTotal
|
||||
@byMonthActivityData={{this.byMonthActivityData}}
|
||||
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
|
||||
@isCurrentMonth={{this.isCurrentMonth}}
|
||||
@runningTotals={{this.totalUsageCounts}}
|
||||
@upgradeData={{this.upgradeDuringActivity}}
|
||||
@responseTimestamp={{@activity.responseTimestamp}}
|
||||
/>
|
||||
{{#if this.hasAttributionData}}
|
||||
<Clients::Attribution
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@newUsageCounts={{this.newClientCounts}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@newClientAttribution={{this.newClientAttribution}}
|
||||
@selectedNamespace={{@namespace}}
|
||||
@startTimestamp={{this.startTimeISO}}
|
||||
@endTimestamp={{this.endTimeISO}}
|
||||
@responseTimestamp={{@activity.responseTimestamp}}
|
||||
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
|
||||
@upgradeExplanation={{this.upgradeExplanation}}
|
||||
/>
|
||||
{{/if}}
|
||||
8
ui/app/components/clients/page/overview.ts
Normal file
8
ui/app/components/clients/page/overview.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ActivityComponent from '../activity';
|
||||
|
||||
export default ActivityComponent;
|
||||
59
ui/app/components/clients/page/sync.hbs
Normal file
59
ui/app/components/clients/page/sync.hbs
Normal file
@@ -0,0 +1,59 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if this.isDateRange}}
|
||||
<Clients::ChartContainer
|
||||
@title={{this.title}}
|
||||
@description={{this.description}}
|
||||
@timestamp={{@activity.responseTimestamp}}
|
||||
@hasChartData={{this.byMonthActivityData}}
|
||||
class="no-legend"
|
||||
>
|
||||
<:subTitle>
|
||||
<StatText
|
||||
@label="Total sync clients"
|
||||
@subText="The total number of secrets synced from Vault to other destinations during this date range."
|
||||
@value={{this.totalUsageCounts.secret_syncs}}
|
||||
@size="l"
|
||||
data-test-total-sync-clients
|
||||
/>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
<StatText
|
||||
@label="Average sync clients per month"
|
||||
@value={{this.average this.byMonthActivityData "secret_syncs"}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
data-test-average-sync-clients
|
||||
/>
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::VerticalBarBasic
|
||||
@chartTitle={{this.title}}
|
||||
@data={{this.byMonthActivityData}}
|
||||
@dataKey="secret_syncs"
|
||||
@chartHeight={{200}}
|
||||
/>
|
||||
</:chart>
|
||||
|
||||
<:emptyState>
|
||||
<EmptyState
|
||||
@title="No monthly secrets sync clients"
|
||||
@subTitle="There is no secrets sync data available for this date range."
|
||||
@bottomBorder={{true}}
|
||||
/>
|
||||
</:emptyState>
|
||||
</Clients::ChartContainer>
|
||||
{{else}}
|
||||
<div class="chart-wrapper" data-test-usage-stats>
|
||||
<div class="chart-header has-bottom-margin-m">
|
||||
<h2 class="chart-title">{{this.title}}</h2>
|
||||
<p class="chart-description has-bottom-padding-m">{{this.description}}</p>
|
||||
</div>
|
||||
<StatText @label="Total sync clients" @value={{this.totalUsageCounts.secret_syncs}} @size="l" />
|
||||
</div>
|
||||
{{/if}}
|
||||
12
ui/app/components/clients/page/sync.ts
Normal file
12
ui/app/components/clients/page/sync.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ActivityComponent from '../activity';
|
||||
|
||||
export default class SyncComponent extends ActivityComponent {
|
||||
title = 'Secrets sync usage';
|
||||
description =
|
||||
'This data can be used to understand how many secrets sync clients have been used for this date range. A secret with a configured sync destination would qualify as a unique and active client.';
|
||||
}
|
||||
101
ui/app/components/clients/page/token.hbs
Normal file
101
ui/app/components/clients/page/token.hbs
Normal file
@@ -0,0 +1,101 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if (and this.byMonthActivityData this.isDateRange)}}
|
||||
<Clients::ChartContainer
|
||||
@title="Entity/Non-entity clients"
|
||||
@description="This data can be used to understand how many entity and non-entity clients are using Vault each month for this date range."
|
||||
@timestamp={{@activity.responseTimestamp}}
|
||||
@legend={{this.legend}}
|
||||
@hasChartData={{true}}
|
||||
data-test-chart="monthly total"
|
||||
>
|
||||
<:subTitle>
|
||||
<h2 class="chart-title">Total monthly clients</h2>
|
||||
<p class="chart-subtext">
|
||||
Each client is counted once per month. This can help with capacity planning.
|
||||
</p>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
<StatText
|
||||
@label="Average total clients per month"
|
||||
@value={{this.averageTotalClients}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
data-test-chart-stat="monthly total"
|
||||
/>
|
||||
|
||||
<StatText
|
||||
@label="Average new clients per month"
|
||||
@value={{this.averageNewClients}}
|
||||
@size="m"
|
||||
class="data-details-bottom has-top-padding-l"
|
||||
data-test-chart-stat="monthly new"
|
||||
/>
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::VerticalBarChart @dataset={{this.byMonthActivityData}} @chartLegend={{this.legend}} />
|
||||
</:chart>
|
||||
</Clients::ChartContainer>
|
||||
|
||||
<Clients::ChartContainer
|
||||
@title="Monthly new"
|
||||
@description="Entity or non-entity clients which interacted with Vault for the first time during this date range, displayed per month."
|
||||
@timestamp={{@activity.responseTimestamp}}
|
||||
@legend={{this.legend}}
|
||||
@hasChartData={{this.averageNewClients}}
|
||||
class={{unless this.averageNewClients "no-legend"}}
|
||||
data-test-chart="monthly new"
|
||||
>
|
||||
<:stats>
|
||||
<StatText
|
||||
@label="Average new entity clients per month"
|
||||
@value={{this.average this.byMonthNewClients "entity_clients"}}
|
||||
@size="m"
|
||||
class="chart-subTitle has-top-padding-l"
|
||||
data-test-chart-stat="entity"
|
||||
/>
|
||||
|
||||
<StatText
|
||||
@label="Average new non-entity clients per month"
|
||||
@value={{this.average this.byMonthNewClients "non_entity_clients"}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
data-test-chart-stat="nonentity"
|
||||
/>
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::VerticalBarChart @dataset={{this.byMonthNewClients}} @chartLegend={{this.legend}} />
|
||||
</:chart>
|
||||
|
||||
<:emptyState>
|
||||
<EmptyState
|
||||
@title="No new clients"
|
||||
@subTitle="There is no new client data available for this {{if
|
||||
this.countsController.mountPath
|
||||
'auth method'
|
||||
'namespace'
|
||||
}} in this date range."
|
||||
@bottomBorder={{true}}
|
||||
/>
|
||||
</:emptyState>
|
||||
</Clients::ChartContainer>
|
||||
{{else}}
|
||||
{{! UsageStats render when viewing a single, historical month AND activity data predates new client breakdown (< v1.10.0)
|
||||
or viewing the current month filtered down to auth method }}
|
||||
<Clients::UsageStats
|
||||
@title="Total usage"
|
||||
@description="These totals are within
|
||||
{{or @mountPath 'this namespace and all its children'}}.
|
||||
{{if
|
||||
this.isCurrentMonth
|
||||
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
|
||||
}}"
|
||||
@totalUsageCounts={{this.tokenUsageCounts}}
|
||||
/>
|
||||
{{/if}}
|
||||
52
ui/app/components/clients/page/token.ts
Normal file
52
ui/app/components/clients/page/token.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import ActivityComponent from '../activity';
|
||||
|
||||
import type {
|
||||
ClientActivityNewClients,
|
||||
ClientActivityMonthly,
|
||||
ClientActivityResourceByKey,
|
||||
} from 'vault/vault/models/clients/activity';
|
||||
|
||||
export default class ClientsTokenPageComponent extends ActivityComponent {
|
||||
legend = [
|
||||
{ key: 'entity_clients', label: 'entity clients' },
|
||||
{ key: 'non_entity_clients', label: 'non-entity clients' },
|
||||
];
|
||||
|
||||
calculateClientAverages(
|
||||
dataset:
|
||||
| ClientActivityMonthly[]
|
||||
| (ClientActivityResourceByKey | undefined)[]
|
||||
| (ClientActivityNewClients | undefined)[]
|
||||
| undefined
|
||||
) {
|
||||
return ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
|
||||
const average = this.average(dataset, key);
|
||||
return (count += average || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
get averageTotalClients() {
|
||||
return this.calculateClientAverages(this.byMonthActivityData);
|
||||
}
|
||||
|
||||
get averageNewClients() {
|
||||
return this.calculateClientAverages(this.byMonthNewClients);
|
||||
}
|
||||
|
||||
get tokenUsageCounts() {
|
||||
if (this.totalUsageCounts) {
|
||||
const { entity_clients, non_entity_clients } = this.totalUsageCounts;
|
||||
return {
|
||||
clients: entity_clients + non_entity_clients,
|
||||
entity_clients,
|
||||
non_entity_clients,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
119
ui/app/components/clients/running-total.hbs
Normal file
119
ui/app/components/clients/running-total.hbs
Normal file
@@ -0,0 +1,119 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if (gt @byMonthActivityData.length 1)}}
|
||||
<Clients::ChartContainer
|
||||
@title="Vault client counts"
|
||||
@description="The total clients in the specified date range. This includes entity, non-entity, and secrets sync clients. The total client count number is an important consideration for Vault billing."
|
||||
@timestamp={{@responseTimestamp}}
|
||||
@hasChartData={{true}}
|
||||
data-test-chart="running total"
|
||||
>
|
||||
<:subTitle>
|
||||
<StatText
|
||||
@label="Running client total"
|
||||
@subText="The number of clients which interacted with Vault during this date range."
|
||||
@value={{@runningTotals.clients}}
|
||||
@size="l"
|
||||
/>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
<div class="data-details-top has-top-padding-l">
|
||||
<div class="is-flex-row">
|
||||
<StatText
|
||||
@label="Entity clients"
|
||||
@value={{@runningTotals.entity_clients}}
|
||||
@size="m"
|
||||
data-test-chart-stat="running total entity"
|
||||
/>
|
||||
<StatText
|
||||
@label="Secrets sync clients"
|
||||
@value={{@runningTotals.secret_syncs}}
|
||||
@size="m"
|
||||
class="has-left-margin-l"
|
||||
data-test-chart-stat="running total sync"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StatText
|
||||
@label="Non-entity clients"
|
||||
@value={{@runningTotals.non_entity_clients}}
|
||||
@size="m"
|
||||
class="data-details-bottom"
|
||||
data-test-chart-stat="running total sync"
|
||||
/>
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::Line @dataset={{@byMonthActivityData}} @upgradeData={{@upgradeData}} @chartHeight="250" />
|
||||
</:chart>
|
||||
</Clients::ChartContainer>
|
||||
{{else}}
|
||||
{{#let (get @byMonthActivityData "0") as |singleMonthData|}}
|
||||
{{#if (and @isHistoricalMonth singleMonthData.new_clients.clients)}}
|
||||
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
|
||||
<div class="chart-header has-bottom-margin-sm">
|
||||
<h2 class="chart-title">Vault client counts</h2>
|
||||
<p class="chart-description">
|
||||
The total billable clients in the specified date range. This includes entity, non-entity, and secrets sync
|
||||
clients. The total client count number is an important consideration for Vault billing.
|
||||
</p>
|
||||
</div>
|
||||
<div class="single-month-stats" data-test-new>
|
||||
<div class="single-month-section-title">
|
||||
<StatText
|
||||
@label="New clients"
|
||||
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
|
||||
@value={{singleMonthData.new_clients.clients}}
|
||||
@size="l"
|
||||
/>
|
||||
</div>
|
||||
<div class="single-month-breakdown-entity">
|
||||
<StatText @label="Entity clients" @value={{singleMonthData.new_clients.entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-nonentity">
|
||||
<StatText @label="Non-entity clients" @value={{singleMonthData.new_clients.non_entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-sync">
|
||||
<StatText @label="Secrets sync clients" @value={{singleMonthData.new_clients.secret_syncs}} @size="m" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-month-stats" data-test-total>
|
||||
<div class="single-month-section-title">
|
||||
<StatText
|
||||
@label="Total monthly clients"
|
||||
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
|
||||
@value={{singleMonthData.clients}}
|
||||
@size="l"
|
||||
/>
|
||||
</div>
|
||||
<div class="single-month-breakdown-entity">
|
||||
<StatText @label="Entity clients" @value={{singleMonthData.entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-nonentity">
|
||||
<StatText @label="Non-entity clients" @value={{singleMonthData.non_entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-sync">
|
||||
<StatText @label="Secrets sync clients" @value={{singleMonthData.secret_syncs}} @size="m" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{! This renders when either:
|
||||
-> viewing the current month and all namespaces (no filters)
|
||||
-> filtering by a namespace with no month over month data
|
||||
if filtering by a mount with no month over month data <UsageStats> in dashboard.hbs renders }}
|
||||
<Clients::UsageStats
|
||||
@title="Total usage"
|
||||
@description="These totals are within this namespace and all its children. {{if
|
||||
@isCurrentMonth
|
||||
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current billing period' filter."
|
||||
}}"
|
||||
@totalUsageCounts={{@runningTotals}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
|
||||
/**
|
||||
* @module RunningTotal
|
||||
* RunningTotal components display total and new client counts in a given date range by month.
|
||||
* A line chart shows total monthly clients and below a stacked, vertical bar chart shows new clients per month.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
<Clients::RunningTotal
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@byMonthActivityData={{this.byMonth}}
|
||||
@runningTotals={{this.runningTotals}}
|
||||
@upgradeData={{if this.countsIncludeOlderData this.latestUpgradeData}}
|
||||
/>
|
||||
* ```
|
||||
|
||||
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
|
||||
* @param {string} selectedAuthMethod - string of auth method label for empty state message in bar chart
|
||||
* @param {array} byMonthActivityData - array of objects from /activity response, from the 'months' key, includes total and new clients per month
|
||||
object structure: {
|
||||
month: '1/22',
|
||||
entity_clients: 23,
|
||||
non_entity_clients: 45,
|
||||
clients: 68,
|
||||
namespaces: [],
|
||||
new_clients: {
|
||||
entity_clients: 11,
|
||||
non_entity_clients: 36,
|
||||
clients: 47,
|
||||
namespaces: [],
|
||||
},
|
||||
};
|
||||
* @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 }
|
||||
* @param {object} upgradeData - object containing version upgrade data e.g.: {version: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
|
||||
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
|
||||
*
|
||||
*/
|
||||
export default class RunningTotal extends Component {
|
||||
get byMonthNewClients() {
|
||||
if (this.args.byMonthActivityData) {
|
||||
return this.args.byMonthActivityData?.map((m) => m.new_clients);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get entityClientData() {
|
||||
return {
|
||||
runningTotal: this.args.runningTotals.entity_clients,
|
||||
averageNewClients: calculateAverage(this.byMonthNewClients, 'entity_clients'),
|
||||
};
|
||||
}
|
||||
|
||||
get nonEntityClientData() {
|
||||
return {
|
||||
runningTotal: this.args.runningTotals.non_entity_clients,
|
||||
averageNewClients: calculateAverage(this.byMonthNewClients, 'non_entity_clients'),
|
||||
};
|
||||
}
|
||||
|
||||
get hasRunningTotalClients() {
|
||||
return (
|
||||
typeof this.entityClientData.runningTotal === 'number' ||
|
||||
typeof this.nonEntityClientData.runningTotal === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
get hasAverageNewClients() {
|
||||
return (
|
||||
typeof this.entityClientData.averageNewClients === 'number' ||
|
||||
typeof this.nonEntityClientData.averageNewClients === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
get singleMonthData() {
|
||||
return this.args?.byMonthActivityData[0];
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,11 @@
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<StatText
|
||||
class="column"
|
||||
@label="Total clients"
|
||||
@value={{@totalUsageCounts.clients}}
|
||||
@size="l"
|
||||
@subText="The sum of entity clientIDs and non-entity clientIDs; this is Vault’s primary billing metric."
|
||||
@subText="The number of clients which interacted with Vault during this month. This is Vault’s primary billing metric."
|
||||
data-test-stat-text="total-clients"
|
||||
/>
|
||||
</div>
|
||||
@@ -42,9 +43,21 @@
|
||||
@label="Non-entity clients"
|
||||
@value={{@totalUsageCounts.non_entity_clients}}
|
||||
@size="l"
|
||||
@subText="Clients created with a shared set of permissions, but not associated with an entity. "
|
||||
@subText="Clients created with a shared set of permissions, but not associated with an entity."
|
||||
data-test-stat-text="non-entity-clients"
|
||||
/>
|
||||
</div>
|
||||
{{#if (gte @totalUsageCounts.secret_syncs 0)}}
|
||||
<div class="column">
|
||||
<StatText
|
||||
class="column"
|
||||
@label="Secrets sync clients"
|
||||
@value={{@totalUsageCounts.secret_syncs}}
|
||||
@size="l"
|
||||
@subText="A secret with a configured sync destination qualifies as a unique and active client."
|
||||
data-test-stat-text="secret-syncs"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,10 +25,11 @@
|
||||
{{#modal-dialog
|
||||
tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="10px 0"
|
||||
}}
|
||||
<div class="chart-tooltip vertical-chart">
|
||||
<p>{{this.tooltipTotal}}</p>
|
||||
<p>{{this.entityClients}}</p>
|
||||
<p>{{this.nonEntityClients}}</p>
|
||||
<div class="chart-tooltip">
|
||||
<p class="bold">{{this.tooltipTotal}}</p>
|
||||
{{#each this.tooltipStats as |stat|}}
|
||||
<p>{{stat}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
{{/modal-dialog}}
|
||||
@@ -13,10 +13,12 @@ import { axisLeft, axisBottom } from 'd3-axis';
|
||||
import { scaleLinear, scalePoint } from 'd3-scale';
|
||||
import { stack } from 'd3-shape';
|
||||
import {
|
||||
BAR_WIDTH,
|
||||
GREY,
|
||||
LIGHT_AND_DARK_BLUE,
|
||||
BLUE_PALETTE,
|
||||
SVG_DIMENSIONS,
|
||||
TRANSLATE,
|
||||
calculateSum,
|
||||
formatNumbers,
|
||||
} from 'vault/utils/chart-helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
@@ -36,16 +38,10 @@ import { formatNumber } from 'core/helpers/format-number';
|
||||
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
|
||||
*/
|
||||
|
||||
const BAR_WIDTH = 7; // data bar width is 7 pixels
|
||||
export default class VerticalBarChart extends Component {
|
||||
@tracked tooltipTarget = '';
|
||||
@tracked tooltipTotal = '';
|
||||
@tracked entityClients = '';
|
||||
@tracked nonEntityClients = '';
|
||||
|
||||
get chartLegend() {
|
||||
return this.args.chartLegend;
|
||||
}
|
||||
@tracked tooltipStats = [];
|
||||
|
||||
get xKey() {
|
||||
return this.args.xKey || 'month';
|
||||
@@ -59,7 +55,7 @@ export default class VerticalBarChart extends Component {
|
||||
renderChart(element, [chartData]) {
|
||||
const dataset = chartData;
|
||||
const filteredData = dataset.filter((e) => Object.keys(e).includes('clients')); // months with data will contain a 'clients' key (otherwise only a timestamp)
|
||||
const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
|
||||
const stackFunction = stack().keys(this.args.chartLegend.map((l) => l.key));
|
||||
const stackedData = stackFunction(filteredData);
|
||||
const chartSvg = select(element);
|
||||
const domainMax = max(filteredData.map((d) => d[this.yKey]));
|
||||
@@ -81,7 +77,7 @@ export default class VerticalBarChart extends Component {
|
||||
.data(stackedData)
|
||||
.enter()
|
||||
.append('g')
|
||||
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
|
||||
.style('fill', (d, i) => BLUE_PALETTE[i]);
|
||||
|
||||
dataBars
|
||||
.selectAll('rect')
|
||||
@@ -155,9 +151,15 @@ export default class VerticalBarChart extends Component {
|
||||
// MOUSE EVENT FOR TOOLTIP
|
||||
tooltipRect.on('mouseover', (data) => {
|
||||
const hoveredMonth = data[this.xKey];
|
||||
this.tooltipTotal = `${formatNumber([data[this.yKey]])} ${data.new_clients ? 'total' : 'new'} clients`;
|
||||
this.entityClients = `${formatNumber([data.entity_clients])} entity clients`;
|
||||
this.nonEntityClients = `${formatNumber([data.non_entity_clients])} non-entity clients`;
|
||||
const stackedNumbers = []; // accumulates stacked dataset values to calculate total
|
||||
this.tooltipStats = []; // clear stats
|
||||
this.args.chartLegend.forEach(({ key, label }) => {
|
||||
stackedNumbers.push(data[key]);
|
||||
this.tooltipStats.pushObject(`${formatNumber([data[key]])} ${label}`);
|
||||
});
|
||||
this.tooltipTotal = `${formatNumber([calculateSum(stackedNumbers)])} ${
|
||||
data.new_clients ? 'total' : 'new'
|
||||
} clients`;
|
||||
// filter for the tether point that matches the hoveredMonth
|
||||
const hoveredElement = tooltipTether.filter((data) => data.month === hoveredMonth).node();
|
||||
this.tooltipTarget = hoveredElement; // grab the node from the list of rects
|
||||
|
||||
36
ui/app/controllers/vault/cluster/clients/counts.ts
Normal file
36
ui/app/controllers/vault/cluster/clients/counts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Controller from '@ember/controller';
|
||||
import { action, set } from '@ember/object';
|
||||
|
||||
import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';
|
||||
|
||||
const queryParamKeys = ['start_time', 'end_time', 'ns', 'mountPath'];
|
||||
export default class ClientsCountsController extends Controller {
|
||||
queryParams = queryParamKeys;
|
||||
|
||||
start_time: string | number | undefined = undefined;
|
||||
end_time: string | number | undefined = undefined;
|
||||
ns: string | undefined = undefined;
|
||||
mountPath: string | undefined = undefined;
|
||||
|
||||
// using router.transitionTo to update the query params results in the model hook firing each time
|
||||
// this happens when the queryParams object is not added to the route or refreshModel is explicitly set to false
|
||||
// updating the bound properties does however respect the refreshModel settings and functions expectedly
|
||||
@action
|
||||
updateQueryParams(updatedParams: ClientsCountsRouteParams) {
|
||||
if (!updatedParams) {
|
||||
this.queryParams.forEach((key) => (this[key as keyof ClientsCountsRouteParams] = undefined));
|
||||
} else {
|
||||
Object.keys(updatedParams).forEach((key) => {
|
||||
if (queryParamKeys.includes(key)) {
|
||||
const value = updatedParams[key as keyof ClientsCountsRouteParams];
|
||||
set(this, key as keyof ClientsCountsRouteParams, value as keyof ClientsCountsRouteParams);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
14
ui/app/controllers/vault/cluster/clients/counts/overview.ts
Normal file
14
ui/app/controllers/vault/cluster/clients/counts/overview.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Controller, { inject as controller } from '@ember/controller';
|
||||
|
||||
import type ClientsCountsController from '../counts';
|
||||
|
||||
export default class ClientsCountsOverviewController extends Controller {
|
||||
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
|
||||
@controller('vault.cluster.clients.counts' as never)
|
||||
declare readonly countsController: ClientsCountsController;
|
||||
}
|
||||
14
ui/app/controllers/vault/cluster/clients/counts/sync.ts
Normal file
14
ui/app/controllers/vault/cluster/clients/counts/sync.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Controller, { inject as controller } from '@ember/controller';
|
||||
|
||||
import type ClientsCountsController from '../counts';
|
||||
|
||||
export default class ClientsCountsSyncController extends Controller {
|
||||
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
|
||||
@controller('vault.cluster.clients.counts' as never)
|
||||
declare readonly countsController: ClientsCountsController;
|
||||
}
|
||||
14
ui/app/controllers/vault/cluster/clients/counts/token.ts
Normal file
14
ui/app/controllers/vault/cluster/clients/counts/token.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Controller, { inject as controller } from '@ember/controller';
|
||||
|
||||
import type ClientsCountsController from '../counts';
|
||||
|
||||
export default class ClientsCountsTokenController extends Controller {
|
||||
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
|
||||
@controller('vault.cluster.clients.counts' as never)
|
||||
declare readonly countsController: ClientsCountsController;
|
||||
}
|
||||
@@ -27,7 +27,11 @@ Router.map(function () {
|
||||
this.route('license');
|
||||
this.route('mfa-setup');
|
||||
this.route('clients', function () {
|
||||
this.route('dashboard');
|
||||
this.route('counts', function () {
|
||||
this.route('overview');
|
||||
this.route('sync');
|
||||
this.route('token');
|
||||
});
|
||||
this.route('config');
|
||||
this.route('edit');
|
||||
});
|
||||
|
||||
@@ -5,14 +5,21 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
import { action } from '@ember/object';
|
||||
import getStorage from 'vault/lib/token-storage';
|
||||
import { inject as service } from '@ember/service';
|
||||
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
|
||||
|
||||
import type StoreService from 'vault/services/store';
|
||||
import type ClientsConfigModel from 'vault/models/clients/config';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
|
||||
export interface ClientsRouteModel {
|
||||
config: ClientsConfigModel;
|
||||
versionHistory: ClientsVersionHistoryModel;
|
||||
}
|
||||
|
||||
export default class ClientsRoute extends Route {
|
||||
@service store;
|
||||
async getVersionHistory() {
|
||||
@service declare readonly store: StoreService;
|
||||
|
||||
getVersionHistory() {
|
||||
return this.store
|
||||
.findAll('clients/version-history')
|
||||
.then((response) => {
|
||||
@@ -30,14 +37,8 @@ export default class ClientsRoute extends Route {
|
||||
model() {
|
||||
// swallow config error so activity can show if no config permissions
|
||||
return hash({
|
||||
config: this.store.queryRecord('clients/config', {}).catch(() => {}),
|
||||
config: this.store.queryRecord('clients/config', {}).catch(() => ({})),
|
||||
versionHistory: this.getVersionHistory(),
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
deactivate() {
|
||||
// when navigating away from parent route, delete manually inputted license start date
|
||||
getStorage().removeItem(INPUTTED_START_DATE);
|
||||
}
|
||||
}
|
||||
96
ui/app/routes/vault/cluster/clients/counts.ts
Normal file
96
ui/app/routes/vault/cluster/clients/counts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
|
||||
import type StoreService from 'vault/services/store';
|
||||
import type { ClientsRouteModel } from '../clients';
|
||||
import type ClientsConfigModel from 'vault/models/clients/config';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
import type ClientsActivityModel from 'vault/models/clients/activity';
|
||||
import type Controller from '@ember/controller';
|
||||
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
|
||||
|
||||
export interface ClientsCountsRouteParams {
|
||||
start_time?: string | number | undefined;
|
||||
end_time?: string | number | undefined;
|
||||
ns?: string | undefined;
|
||||
mountPath?: string | undefined;
|
||||
}
|
||||
|
||||
interface ClientsCountsRouteModel {
|
||||
config: ClientsConfigModel;
|
||||
versionHistory: ClientsVersionHistoryModel;
|
||||
activity?: ClientsActivityModel;
|
||||
activityError?: AdapterError;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
}
|
||||
interface ClientsCountsController extends Controller {
|
||||
model: ClientsCountsRouteModel;
|
||||
start_time: number | undefined;
|
||||
end_time: number | undefined;
|
||||
ns: string | undefined;
|
||||
mountPath: string | undefined;
|
||||
}
|
||||
|
||||
export default class ClientsCountsRoute extends Route {
|
||||
@service declare readonly store: StoreService;
|
||||
|
||||
queryParams = {
|
||||
start_time: { refreshModel: true, replace: true },
|
||||
end_time: { refreshModel: true, replace: true },
|
||||
ns: { refreshModel: false, replace: true },
|
||||
mountPath: { refreshModel: false, replace: true },
|
||||
};
|
||||
|
||||
async getActivity(start_time: number, end_time: number) {
|
||||
let activity, activityError;
|
||||
// if there is no billingStartTimestamp or selected start date initially we allow the user to manually choose a date
|
||||
// in that case bypass the query so that the user isn't stuck viewing the activity error
|
||||
if (start_time) {
|
||||
try {
|
||||
activity = await this.store.queryRecord('clients/activity', {
|
||||
start_time: { timestamp: start_time },
|
||||
end_time: { timestamp: end_time },
|
||||
});
|
||||
} catch (error) {
|
||||
activityError = error;
|
||||
}
|
||||
return [activity, activityError];
|
||||
}
|
||||
return [{}, null];
|
||||
}
|
||||
|
||||
async model(params: ClientsCountsRouteParams) {
|
||||
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ClientsRouteModel;
|
||||
// we could potentially make an additional request to fetch the license and get the start date from there if the config request fails
|
||||
const startTimestamp = Number(params.start_time) || getUnixTime(config.billingStartTimestamp);
|
||||
const endTimestamp = Number(params.end_time) || getUnixTime(timestamp.now());
|
||||
const [activity, activityError] = await this.getActivity(startTimestamp, endTimestamp);
|
||||
return {
|
||||
config,
|
||||
versionHistory,
|
||||
activity,
|
||||
activityError,
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
resetController(controller: ClientsCountsController, isExiting: boolean) {
|
||||
if (isExiting) {
|
||||
controller.setProperties({
|
||||
start_time: undefined,
|
||||
end_time: undefined,
|
||||
ns: undefined,
|
||||
mountPath: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
17
ui/app/routes/vault/cluster/clients/counts/index.ts
Normal file
17
ui/app/routes/vault/cluster/clients/counts/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
export default class ClientsCountsOverviewRoute extends Route {
|
||||
@service declare readonly router: RouterService;
|
||||
|
||||
redirect() {
|
||||
this.router.transitionTo('vault.cluster.clients.counts.overview');
|
||||
}
|
||||
}
|
||||
8
ui/app/routes/vault/cluster/clients/counts/overview.ts
Normal file
8
ui/app/routes/vault/cluster/clients/counts/overview.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ClientsCountsOverviewRoute extends Route {}
|
||||
8
ui/app/routes/vault/cluster/clients/counts/sync.ts
Normal file
8
ui/app/routes/vault/cluster/clients/counts/sync.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ClientsCountsSyncRoute extends Route {}
|
||||
8
ui/app/routes/vault/cluster/clients/counts/token.ts
Normal file
8
ui/app/routes/vault/cluster/clients/counts/token.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ClientsCountsOverviewRoute extends Route {}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import getStorage from 'vault/lib/token-storage';
|
||||
import { inject as service } from '@ember/service';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service store;
|
||||
currentDate = timestamp.now().toISOString();
|
||||
|
||||
async getActivity(start_time) {
|
||||
// on init ONLY make network request if we have a start_time
|
||||
return start_time
|
||||
? await this.store.queryRecord('clients/activity', {
|
||||
start_time: { timestamp: start_time },
|
||||
end_time: { timestamp: this.currentDate },
|
||||
})
|
||||
: {};
|
||||
}
|
||||
|
||||
async getLicenseStartTime() {
|
||||
try {
|
||||
const license = await this.store.queryRecord('license', {});
|
||||
// if license.startTime is 'undefined' return 'null' for consistency
|
||||
return license.startTime || getStorage().getItem('vault:ui-inputted-start-date') || null;
|
||||
} catch (e) {
|
||||
// return null so user can input date manually
|
||||
// if already inputted manually, will be in localStorage
|
||||
return getStorage().getItem('vault:ui-inputted-start-date') || null;
|
||||
}
|
||||
}
|
||||
|
||||
async model() {
|
||||
const { config, versionHistory } = this.modelFor('vault.cluster.clients');
|
||||
const licenseStart = await this.getLicenseStartTime();
|
||||
const activity = await this.getActivity(licenseStart);
|
||||
return {
|
||||
config,
|
||||
versionHistory,
|
||||
activity,
|
||||
licenseStartTimestamp: licenseStart,
|
||||
currentDate: this.currentDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,6 @@ export default class ClientsIndexRoute extends Route {
|
||||
@service router;
|
||||
|
||||
redirect() {
|
||||
this.router.transitionTo('vault.cluster.clients.dashboard');
|
||||
this.router.transitionTo('vault.cluster.clients.counts.overview');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,8 @@
|
||||
.single-month-breakdown-nonentity {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
.stacked-charts {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
.single-month-breakdown-sync {
|
||||
grid-column-start: 3;
|
||||
}
|
||||
|
||||
.single-chart-grid {
|
||||
@@ -44,6 +43,9 @@
|
||||
grid-template-columns: 1fr 0.3fr 3.7fr;
|
||||
grid-template-rows: 0.5fr 1fr 1fr 1fr 0.25fr;
|
||||
width: 100%;
|
||||
&.no-legend {
|
||||
grid-template-rows: 0.5fr 1fr 1fr 0.25fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dual-chart-grid {
|
||||
@@ -85,6 +87,7 @@
|
||||
justify-self: center;
|
||||
height: 300px;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
|
||||
svg.chart {
|
||||
width: 100%;
|
||||
@@ -128,7 +131,8 @@
|
||||
|
||||
.chart-empty-state {
|
||||
place-self: center stretch;
|
||||
grid-row-end: span 3;
|
||||
grid-row-end: span 2;
|
||||
grid-column-start: 1;
|
||||
grid-column-end: span 3;
|
||||
max-width: none;
|
||||
padding-right: 20px;
|
||||
@@ -165,32 +169,22 @@
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 5;
|
||||
grid-column: 1 / span 2;
|
||||
grid-row-start: -2;
|
||||
color: $ui-gray-500;
|
||||
font-size: $size-9;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.legend-center {
|
||||
.legend {
|
||||
grid-row-start: 5;
|
||||
grid-column-start: 3;
|
||||
grid-column-end: 5;
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 6;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
font-size: $size-9;
|
||||
}
|
||||
|
||||
.legend-right {
|
||||
grid-row-start: 4;
|
||||
grid-column-start: 3;
|
||||
grid-column-end: 3;
|
||||
align-self: end;
|
||||
justify-self: center;
|
||||
font-size: $size-9;
|
||||
}
|
||||
|
||||
// FONT STYLES //
|
||||
|
||||
h2.chart-title {
|
||||
@@ -228,20 +222,21 @@ p.data-details {
|
||||
|
||||
// MISC STYLES
|
||||
|
||||
.light-dot {
|
||||
background-color: $blue-100;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dark-dot {
|
||||
background-color: $blue-500;
|
||||
.legend-colors {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
// numbers are indices because chart legend is iterated over
|
||||
&.dot-0 {
|
||||
background-color: var(--token-color-palette-blue-100);
|
||||
}
|
||||
&.dot-1 {
|
||||
background-color: var(--token-color-palette-blue-300);
|
||||
}
|
||||
&.dot-2 {
|
||||
background-color: var(--token-color-palette-blue-500);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
@@ -255,22 +250,12 @@ p.data-details {
|
||||
font-size: $size-9;
|
||||
padding: 6px;
|
||||
border-radius: $radius-large;
|
||||
width: 140px;
|
||||
flex-wrap: nowrap;
|
||||
width: fit-content;
|
||||
|
||||
.bold {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
.line-chart {
|
||||
width: 117px;
|
||||
}
|
||||
.vertical-chart {
|
||||
text-align: center;
|
||||
flex-wrap: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
.horizontal-chart {
|
||||
padding: $spacing-12;
|
||||
}
|
||||
}
|
||||
|
||||
.is-label-fit-content {
|
||||
@@ -336,8 +321,8 @@ p.data-details {
|
||||
margin-right: $spacing-48;
|
||||
}
|
||||
|
||||
.legend-center {
|
||||
grid-column-start: 1;
|
||||
.legend {
|
||||
grid-column-start: 2;
|
||||
grid-row-start: 4;
|
||||
}
|
||||
|
||||
@@ -346,3 +331,36 @@ p.data-details {
|
||||
grid-row-start: 4;
|
||||
}
|
||||
}
|
||||
|
||||
// LINEAL STYLING //
|
||||
.lineal-chart {
|
||||
position: relative;
|
||||
padding: 10px 10px 20px 50px;
|
||||
width: 100%;
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.lineal-chart-bar {
|
||||
fill: var(--token-color-palette-blue-300);
|
||||
}
|
||||
.lineal-axis {
|
||||
color: $ui-gray-500;
|
||||
text {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
line {
|
||||
color: $ui-gray-300;
|
||||
}
|
||||
}
|
||||
.lineal-tooltip-position {
|
||||
position: absolute;
|
||||
transform-style: preserve-3d;
|
||||
bottom: 30px;
|
||||
left: -20px;
|
||||
pointer-events: none;
|
||||
width: 140px;
|
||||
transform: translate(calc(1px * var(--x, 0)), calc(-1px * var(--y, 0)));
|
||||
transform-origin: bottom left;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
|
||||
<p class="has-bottom-margin-xl">
|
||||
This dashboard will surface Vault client usage over time. Clients represent a user or service that has authenticated to
|
||||
Vault.
|
||||
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
|
||||
Date queries are sent in UTC.
|
||||
</p>
|
||||
<h2 class="title is-6 has-bottom-margin-xs">
|
||||
{{this.versionText.label}}
|
||||
</h2>
|
||||
<div data-test-start-date-editor class="is-flex-align-baseline">
|
||||
{{#if this.formattedStartDate}}
|
||||
<p class="is-size-6" data-test-date-display>{{this.formattedStartDate}}</p>
|
||||
<Hds::Button
|
||||
class="has-left-margin-xs"
|
||||
@text="Edit"
|
||||
@color="tertiary"
|
||||
@icon="edit"
|
||||
@iconPosition="trailing"
|
||||
{{on "click" (fn (mut this.showBillingStartModal) true)}}
|
||||
/>
|
||||
{{else}}
|
||||
<DateDropdown @handleSubmit={{this.handleClientActivityQuery}} @dateType="startDate" @submitText="Save" />
|
||||
{{/if}}
|
||||
</div>
|
||||
<p class="is-8 has-text-grey has-bottom-margin-l">
|
||||
{{this.versionText.description}}
|
||||
</p>
|
||||
{{#if this.noActivityData}}
|
||||
<Clients::NoData @config={{@model.config}} @dateRangeMessage={{this.dateRangeMessage}} />
|
||||
{{else if this.errorObject}}
|
||||
<Clients::Error @error={{this.errorObject}} />
|
||||
{{else}}
|
||||
{{#if (eq @model.config.enabled "Off")}}
|
||||
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
|
||||
<A.Title>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 (or this.totalUsageCounts this.hasAttributionData)}}
|
||||
<div class="is-subtitle-gray has-bottom-margin-m">
|
||||
FILTERS
|
||||
<Toolbar data-test-clients-filter-bar>
|
||||
<ToolbarFilters>
|
||||
<CalendarWidget
|
||||
@startTimestamp={{this.startMonthTimestamp}}
|
||||
@endTimestamp={{this.endMonthTimestamp}}
|
||||
@selectMonth={{this.handleClientActivityQuery}}
|
||||
/>
|
||||
{{#if this.namespaceArray}}
|
||||
<SearchSelect
|
||||
@id="namespace-search-select"
|
||||
@options={{this.namespaceArray}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{this.selectNamespace}}
|
||||
@placeholder={{"Filter by namespace"}}
|
||||
@displayInherit={{true}}
|
||||
class="is-marginless"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (not (is-empty this.authMethodOptions))}}
|
||||
<SearchSelect
|
||||
@id="auth-method-search-select"
|
||||
@options={{this.authMethodOptions}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{this.setAuthMethod}}
|
||||
@placeholder={{"Filter by auth method"}}
|
||||
@displayInherit={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</ToolbarFilters>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
{{#if (or this.upgradeDuringActivity this.startTimeDiscrepancy)}}
|
||||
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
|
||||
<A.Title>Warning</A.Title>
|
||||
<A.Description>
|
||||
<ul class={{if (and this.upgradeDuringActivity this.startTimeDiscrepancy) "bullet"}}>
|
||||
{{#if this.startTimeDiscrepancy}}
|
||||
<li>{{this.startTimeDiscrepancy}}</li>
|
||||
{{/if}}
|
||||
{{#if this.upgradeDuringActivity}}
|
||||
<li>
|
||||
{{this.upgradeVersionAndDate}}
|
||||
{{this.upgradeExplanation}}
|
||||
<DocLink
|
||||
@path="/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
|
||||
>
|
||||
Learn more here.
|
||||
</DocLink>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
{{#if this.isLoadingQuery}}
|
||||
<LayoutLoading />
|
||||
{{else}}
|
||||
{{#if this.totalUsageCounts}}
|
||||
{{#unless this.byMonthActivityData}}
|
||||
{{! UsageStats render when viewing a single, historical month AND activity data predates new client breakdown (< v1.10.0)
|
||||
or viewing the current month filtered down to auth method }}
|
||||
<Clients::UsageStats
|
||||
@title="Total usage"
|
||||
@description="These totals are within
|
||||
{{if this.selectedAuthMethod this.selectedAuthMethod 'this namespace and all its children'}}.
|
||||
{{if
|
||||
this.isCurrentMonth
|
||||
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
|
||||
}}"
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{#if this.byMonthActivityData}}
|
||||
<Clients::RunningTotal
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@selectedAuthMethod={{this.selectedAuthMethod}}
|
||||
@byMonthActivityData={{this.byMonthActivityData}}
|
||||
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
|
||||
@isCurrentMonth={{this.isCurrentMonth}}
|
||||
@runningTotals={{this.totalUsageCounts}}
|
||||
@upgradeData={{this.upgradeDuringActivity}}
|
||||
@responseTimestamp={{this.responseTimestamp}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.hasAttributionData}}
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@newUsageCounts={{this.newClientCounts}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@newClientAttribution={{this.newClientAttribution}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@startTimestamp={{this.startMonthTimestamp}}
|
||||
@endTimestamp={{this.endMonthTimestamp}}
|
||||
@responseTimestamp={{this.responseTimestamp}}
|
||||
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
|
||||
@upgradeExplanation={{this.upgradeExplanation}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.hasMultipleMonthsData}}
|
||||
<Clients::MonthlyUsage
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@verticalBarChartData={{this.byMonthActivityData}}
|
||||
@responseTimestamp={{this.responseTimestamp}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{else if (and (not @model.licenseStartTimestamp) (not this.startMonthTimestamp))}}
|
||||
{{! Empty state for no billing/license start date }}
|
||||
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
|
||||
@message="Select a different start date above, or choose a different end date here:"
|
||||
>
|
||||
<DateDropdown
|
||||
@handleSubmit={{this.handleClientActivityQuery}}
|
||||
@validateDate={{this.isEndBeforeStart}}
|
||||
@dateType="endDate"
|
||||
@submitText="View"
|
||||
/>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{! BILLING START DATE MODAL }}
|
||||
{{#if this.showBillingStartModal}}
|
||||
<Hds::Modal id="clients-edit-date-modal" @onClose={{fn (mut this.showBillingStartModal) false}} as |M|>
|
||||
<M.Header>
|
||||
Edit start month
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
<p class="has-bottom-margin-s">
|
||||
{{this.versionText.description}}
|
||||
</p>
|
||||
<p><strong>{{this.versionText.label}}</strong></p>
|
||||
<DateDropdown
|
||||
class="has-top-padding-s"
|
||||
@handleSubmit={{this.handleClientActivityQuery}}
|
||||
@dateType="startDate"
|
||||
@submitText="Save"
|
||||
/>
|
||||
</M.Body>
|
||||
<M.Footer as |F|>
|
||||
<Hds::Button data-test-date-dropdown-cancel @text="Cancel" @color="secondary" {{on "click" F.close}} />
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
@@ -1,39 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if @dataset}}
|
||||
<svg
|
||||
data-test-line-chart
|
||||
class="chart has-grid"
|
||||
{{on "mouseleave" this.removeTooltip}}
|
||||
{{did-insert this.renderChart @dataset}}
|
||||
{{did-update this.renderChart @dataset}}
|
||||
>
|
||||
</svg>
|
||||
{{else}}
|
||||
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
|
||||
{{/if}}
|
||||
|
||||
{{! TOOLTIP }}
|
||||
|
||||
{{#if this.tooltipTarget}}
|
||||
{{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }}
|
||||
{{! Component must be in curly bracket notation }}
|
||||
{{! template-lint-disable no-curly-component-invocation }}
|
||||
{{#modal-dialog
|
||||
tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="35px 0"
|
||||
}}
|
||||
<div class="chart-tooltip line-chart">
|
||||
<p class="bold">{{this.tooltipMonth}}</p>
|
||||
<p>{{this.tooltipTotal}}</p>
|
||||
<p>{{this.tooltipNew}}</p>
|
||||
{{#if this.tooltipUpgradeText}}
|
||||
<br />
|
||||
<p class="has-text-highlight">{{this.tooltipUpgradeText}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
{{/modal-dialog}}
|
||||
{{/if}}
|
||||
@@ -1,52 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<div class="chart-wrapper single-chart-grid" data-test-monthly-usage>
|
||||
<div class="chart-header has-bottom-margin-xl">
|
||||
<h2 class="chart-title">Vault usage</h2>
|
||||
<p class="chart-description">
|
||||
This data can be used to understand how many total clients are using Vault each month for this date range.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={{concat (unless @verticalBarChartData "chart-empty-state ") "chart-container-wide"}}>
|
||||
<Clients::VerticalBarChart @dataset={{@verticalBarChartData}} @chartLegend={{@chartLegend}} />
|
||||
</div>
|
||||
|
||||
<div class="chart-subTitle">
|
||||
<h2 class="chart-title">Total monthly clients</h2>
|
||||
<p class="chart-subtext">
|
||||
Each client is counted once per month. This can help with capacity planning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-top" data-test-monthly-usage-average-total>
|
||||
<h3 class="data-details">Average total clients per month</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.averageTotalClients}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-bottom" data-test-monthly-usage-average-new>
|
||||
<h3 class="data-details">Average new clients per month</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.averageNewClients}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-test-monthly-usage-timestamp class="timestamp">
|
||||
{{#if @responseTimestamp}}
|
||||
Updated
|
||||
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if @verticalBarChartData}}
|
||||
<div data-test-monthly-usage-legend class="legend-right">
|
||||
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
|
||||
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -1,149 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if (gt @byMonthActivityData.length 1)}}
|
||||
<div class="chart-wrapper stacked-charts" data-test-running-total="monthly-charts">
|
||||
<div class="single-chart-grid">
|
||||
<div class="chart-header has-bottom-margin-xl">
|
||||
<h2 class="chart-title">Vault client counts</h2>
|
||||
<p class="chart-description">
|
||||
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity
|
||||
clients. The total client count number is an important consideration for Vault billing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={{concat (unless @byMonthActivityData "chart-empty-state ") "chart-container-wide"}}>
|
||||
<Clients::LineChart @dataset={{@byMonthActivityData}} @upgradeData={{@upgradeData}} />
|
||||
</div>
|
||||
|
||||
<div class="chart-subTitle">
|
||||
<h2 class="chart-title">Running client total</h2>
|
||||
<p class="chart-subtext">The number of clients which interacted with Vault during this date range. </p>
|
||||
</div>
|
||||
<div class="data-details-top" data-test-running-total-entity>
|
||||
<h3 class="data-details">Entity clients</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.entityClientData.runningTotal}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-bottom" data-test-running-total-nonentity>
|
||||
<h3 class="data-details">Non-entity clients</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.nonEntityClientData.runningTotal}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="single-chart-grid">
|
||||
<div class={{concat (unless this.hasAverageNewClients "chart-empty-state ") "chart-container-wide"}}>
|
||||
<Clients::VerticalBarChart
|
||||
@dataset={{if this.hasAverageNewClients this.byMonthNewClients false}}
|
||||
@chartLegend={{@chartLegend}}
|
||||
@noDataTitle="No new clients"
|
||||
@noDataMessage={{concat
|
||||
"There is no new client data available for this "
|
||||
(if @selectedAuthMethod "auth method" "namespace")
|
||||
" in this date range"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chart-subTitle">
|
||||
<h2 class="chart-title">New monthly clients</h2>
|
||||
<p class="chart-subtext">
|
||||
Clients which interacted with Vault for the first time during this date range, displayed per month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{#if this.hasAverageNewClients}}
|
||||
<div class="data-details-top" data-test-running-new-entity>
|
||||
<h3 class="data-details">Average new entity clients per month</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.entityClientData.averageNewClients}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-bottom" data-test-running-new-nonentity>
|
||||
<h3 class="data-details">Average new non-entity clients per month</h3>
|
||||
<p class="data-details">
|
||||
{{format-number this.nonEntityClientData.averageNewClients}}
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="timestamp" data-test-running-total-timestamp>
|
||||
{{#if @responseTimestamp}}
|
||||
Updated
|
||||
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.hasAverageNewClients}}
|
||||
<div class="legend-right" data-test-running-total-legend>
|
||||
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
|
||||
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if (and @isHistoricalMonth this.singleMonthData.new_clients.clients)}}
|
||||
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
|
||||
<div class="chart-header has-bottom-margin-sm">
|
||||
<h2 class="chart-title">Vault client counts</h2>
|
||||
<p class="chart-description">
|
||||
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity
|
||||
clients. The total client count number is an important consideration for Vault billing.
|
||||
</p>
|
||||
</div>
|
||||
<div class="single-month-stats" data-test-new>
|
||||
<div class="single-month-section-title">
|
||||
<StatText
|
||||
@label="New clients"
|
||||
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
|
||||
@value={{this.singleMonthData.new_clients.clients}}
|
||||
@size="l"
|
||||
/>
|
||||
</div>
|
||||
<div class="single-month-breakdown-entity">
|
||||
<StatText @label="Entity clients" @value={{this.singleMonthData.new_clients.entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-nonentity">
|
||||
<StatText @label="Non-entity clients" @value={{this.singleMonthData.new_clients.non_entity_clients}} @size="m" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-month-stats" data-test-total>
|
||||
<div class="single-month-section-title">
|
||||
<StatText
|
||||
@label="Total monthly clients"
|
||||
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
|
||||
@value={{this.singleMonthData.clients}}
|
||||
@size="l"
|
||||
/>
|
||||
</div>
|
||||
<div class="single-month-breakdown-entity">
|
||||
<StatText @label="Entity clients" @value={{this.singleMonthData.entity_clients}} @size="m" />
|
||||
</div>
|
||||
<div class="single-month-breakdown-nonentity">
|
||||
<StatText @label="Non-entity clients" @value={{this.singleMonthData.non_entity_clients}} @size="m" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{! This renders when either:
|
||||
-> viewing the current month and all namespaces (no filters)
|
||||
-> filtering by a namespace with no month over month data
|
||||
if filtering by a mount with no month over month data <UsageStats> in dashboard.hbs renders }}
|
||||
<Clients::UsageStats
|
||||
@title="Total usage"
|
||||
@description="These totals are within this namespace and all its children. {{if
|
||||
@isCurrentMonth
|
||||
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
|
||||
}}"
|
||||
@totalUsageCounts={{@runningTotals}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -9,7 +9,7 @@
|
||||
Client count
|
||||
</h3>
|
||||
|
||||
<LinkTo @route="vault.cluster.clients.dashboard">Details</LinkTo>
|
||||
<LinkTo @route="vault.cluster.clients">Details</LinkTo>
|
||||
</div>
|
||||
|
||||
<hr class="has-background-gray-100" />
|
||||
|
||||
@@ -14,13 +14,20 @@
|
||||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
|
||||
<nav class="tabs" aria-label="navigation for managing client counts">
|
||||
<ul>
|
||||
<LinkTo @route="vault.cluster.clients.dashboard" data-test-dashboard>
|
||||
Dashboard
|
||||
<LinkTo @route="vault.cluster.clients.counts.overview" data-test-tab="overview">
|
||||
Overview
|
||||
</LinkTo>
|
||||
<LinkTo @route="vault.cluster.clients.counts.token" data-test-tab="token">
|
||||
Entity/Non-entity clients
|
||||
</LinkTo>
|
||||
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
|
||||
Secrets sync clients
|
||||
</LinkTo>
|
||||
{{#if (or @model.config.canRead @model.canRead)}}
|
||||
<LinkTo
|
||||
@route="vault.cluster.clients.config"
|
||||
@current-when="vault.cluster.clients.config vault.cluster.clients.edit"
|
||||
data-test-tab="config"
|
||||
>
|
||||
Configuration
|
||||
</LinkTo>
|
||||
|
||||
17
ui/app/templates/vault/cluster/clients/counts.hbs
Normal file
17
ui/app/templates/vault/cluster/clients/counts.hbs
Normal file
@@ -0,0 +1,17 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::Page::Counts
|
||||
@activity={{this.model.activity}}
|
||||
@activityError={{this.model.activityError}}
|
||||
@config={{this.model.config}}
|
||||
@startTimestamp={{this.model.startTimestamp}}
|
||||
@endTimestamp={{this.model.endTimestamp}}
|
||||
@namespace={{this.ns}}
|
||||
@mountPath={{this.mountPath}}
|
||||
@onFilterChange={{this.updateQueryParams}}
|
||||
>
|
||||
{{outlet}}
|
||||
</Clients::Page::Counts>
|
||||
13
ui/app/templates/vault/cluster/clients/counts/overview.hbs
Normal file
13
ui/app/templates/vault/cluster/clients/counts/overview.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::Page::Overview
|
||||
@activity={{this.model.activity}}
|
||||
@versionHistory={{this.model.versionHistory}}
|
||||
@startTimestamp={{this.model.startTimestamp}}
|
||||
@endTimestamp={{this.model.endTimestamp}}
|
||||
@namespace={{this.countsController.ns}}
|
||||
@mountPath={{this.countsController.mountPath}}
|
||||
/>
|
||||
13
ui/app/templates/vault/cluster/clients/counts/sync.hbs
Normal file
13
ui/app/templates/vault/cluster/clients/counts/sync.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::Page::Sync
|
||||
@activity={{this.model.activity}}
|
||||
@versionHistory={{this.model.versionHistory}}
|
||||
@startTimestamp={{this.model.startTimestamp}}
|
||||
@endTimestamp={{this.model.endTimestamp}}
|
||||
@namespace={{this.countsController.ns}}
|
||||
@mountPath={{this.countsController.mountPath}}
|
||||
/>
|
||||
13
ui/app/templates/vault/cluster/clients/counts/token.hbs
Normal file
13
ui/app/templates/vault/cluster/clients/counts/token.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::Page::Token
|
||||
@activity={{this.model.activity}}
|
||||
@versionHistory={{this.model.versionHistory}}
|
||||
@startTimestamp={{this.model.startTimestamp}}
|
||||
@endTimestamp={{this.model.endTimestamp}}
|
||||
@namespace={{this.countsController.ns}}
|
||||
@mountPath={{this.countsController.mountPath}}
|
||||
/>
|
||||
@@ -1,6 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Clients::Dashboard @model={{@model}} />
|
||||
@@ -7,31 +7,26 @@ import { format } from 'd3-format';
|
||||
import { mean } from 'd3-array';
|
||||
|
||||
// COLOR THEME:
|
||||
export const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#1563FF'];
|
||||
export const BLUE_PALETTE = ['#cce3fe', '#0c56e9', '#1c345f']; // blues from https://helios.hashicorp.design/foundations/colors?tab=palette#core-palette
|
||||
export const UPGRADE_WARNING = '#FDEEBA';
|
||||
export const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
|
||||
export const GREY = '#EBEEF2';
|
||||
|
||||
// TRANSLATIONS:
|
||||
export const TRANSLATE = { left: -11 };
|
||||
export const SVG_DIMENSIONS = { height: 190, width: 500 };
|
||||
|
||||
export const BAR_WIDTH = 7; // data bar width is 7 pixels
|
||||
|
||||
// Reference for tickFormat https://www.youtube.com/watch?v=c3MCROTNN8g
|
||||
export function formatNumbers(number) {
|
||||
if (number < 1000) return number;
|
||||
if (number < 1100) return format('.1s')(number);
|
||||
if (number < 2000) return format('.2s')(number); // between 1k and 2k, show 2 decimals
|
||||
if (number < 10000) return format('.1s')(number);
|
||||
// replace SI prefix of 'G' for billions to 'B'
|
||||
return format('.2s')(number).replace('G', 'B');
|
||||
}
|
||||
|
||||
export function formatTooltipNumber(value) {
|
||||
if (typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
// formats a number according to the locale
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
export function calculateAverage(dataset, objectKey) {
|
||||
// before mapping for values, check that the objectKey exists at least once in the dataset because
|
||||
// map returns 0 when dataset[objectKey] is undefined in order to calculate average
|
||||
@@ -43,3 +38,10 @@ export function calculateAverage(dataset, objectKey) {
|
||||
const checkIntegers = integers.every((n) => Number.isInteger(n)); // decimals will be false
|
||||
return checkIntegers ? Math.round(mean(integers)) : null;
|
||||
}
|
||||
|
||||
export function calculateSum(integerArray) {
|
||||
if (!Array.isArray(integerArray) || integerArray.some((n) => typeof n !== 'number')) {
|
||||
return null;
|
||||
}
|
||||
return integerArray.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
*/
|
||||
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { compareAsc } from 'date-fns';
|
||||
import { compareAsc, getUnixTime } from 'date-fns';
|
||||
|
||||
export const formatDateObject = (dateObj, isEnd) => {
|
||||
if (dateObj) {
|
||||
const { year, monthIdx } = dateObj;
|
||||
// day=0 for Date.UTC() returns the last day of the month before
|
||||
// increase monthIdx by one to get last day of queried month
|
||||
const utc = isEnd ? Date.UTC(year, monthIdx + 1, 0) : Date.UTC(year, monthIdx, 1);
|
||||
return getUnixTime(utc);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatByMonths = (monthsArray) => {
|
||||
// the monthsArray will always include a timestamp of the month and either new/total client data or counts = null
|
||||
@@ -23,7 +33,12 @@ export const formatByMonths = (monthsArray) => {
|
||||
timestamp: m.timestamp,
|
||||
...totalCounts,
|
||||
namespaces: formatByNamespace(m.namespaces) || [],
|
||||
namespaces_by_key: namespaceArrayToObject(totalClientsByNamespace, newClientsByNamespace, month),
|
||||
namespaces_by_key: namespaceArrayToObject(
|
||||
totalClientsByNamespace,
|
||||
newClientsByNamespace,
|
||||
month,
|
||||
m.timestamp
|
||||
),
|
||||
new_clients: {
|
||||
month,
|
||||
timestamp: m.timestamp,
|
||||
@@ -64,11 +79,12 @@ export const formatByNamespace = (namespaceArray) => {
|
||||
export const homogenizeClientNaming = (object) => {
|
||||
// if new key names exist, only return those key/value pairs
|
||||
if (Object.keys(object).includes('entity_clients')) {
|
||||
const { clients, entity_clients, non_entity_clients } = object;
|
||||
const { clients, entity_clients, non_entity_clients, secret_syncs } = object;
|
||||
return {
|
||||
clients,
|
||||
entity_clients,
|
||||
non_entity_clients,
|
||||
secret_syncs,
|
||||
};
|
||||
}
|
||||
// if object only has outdated key names, update naming
|
||||
@@ -99,7 +115,7 @@ export const sortMonthsByTimestamp = (monthsArray) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month) => {
|
||||
export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month, timestamp) => {
|
||||
if (!totalClientsByNamespace) return {}; // return if no data for that month
|
||||
// all 'new_client' data resides within a separate key of each month (see data structure below)
|
||||
// FIRST: iterate and nest respective 'new_clients' data within each namespace and mount object
|
||||
@@ -107,7 +123,7 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
|
||||
const nestNewClientsWithinNamespace = totalClientsByNamespace?.map((ns) => {
|
||||
const newNamespaceCounts = newClientsByNamespace?.find((n) => n.label === ns.label);
|
||||
if (newNamespaceCounts) {
|
||||
const { label, clients, entity_clients, non_entity_clients } = newNamespaceCounts;
|
||||
const { label, clients, entity_clients, non_entity_clients, secret_syncs } = newNamespaceCounts;
|
||||
const newClientsByMount = [...newNamespaceCounts.mounts];
|
||||
const nestNewClientsWithinMounts = ns.mounts?.map((mount) => {
|
||||
const new_clients = newClientsByMount?.find((m) => m.label === mount.label) || {};
|
||||
@@ -123,6 +139,7 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
|
||||
clients,
|
||||
entity_clients,
|
||||
non_entity_clients,
|
||||
secret_syncs,
|
||||
mounts: newClientsByMount,
|
||||
},
|
||||
mounts: [...nestNewClientsWithinMounts],
|
||||
@@ -141,17 +158,20 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
|
||||
namespaceObject.mounts.forEach((mountObject) => {
|
||||
mounts_by_key[mountObject.label] = {
|
||||
month,
|
||||
timestamp,
|
||||
...mountObject,
|
||||
new_clients: { month, ...mountObject.new_clients },
|
||||
};
|
||||
});
|
||||
|
||||
const { label, clients, entity_clients, non_entity_clients, new_clients } = namespaceObject;
|
||||
const { label, clients, entity_clients, non_entity_clients, secret_syncs, new_clients } = namespaceObject;
|
||||
namespaces_by_key[label] = {
|
||||
month,
|
||||
timestamp,
|
||||
clients,
|
||||
entity_clients,
|
||||
non_entity_clients,
|
||||
secret_syncs,
|
||||
new_clients: { month, ...new_clients },
|
||||
mounts_by_key,
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
@cardTitle="Total sync associations"
|
||||
@subText="Total sync associations that count towards client count"
|
||||
@actionText="View billing"
|
||||
@actionTo="clientCountDashboard"
|
||||
@actionTo="clientCountOverview"
|
||||
@actionExternal={{true}}
|
||||
class="is-flex-half"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class SyncEngine extends Engine {
|
||||
Resolver = Resolver;
|
||||
dependencies = {
|
||||
services: ['flash-messages', 'router', 'store', 'version'],
|
||||
externalRoutes: ['kvSecretDetails', 'clientCountDashboard'],
|
||||
externalRoutes: ['kvSecretDetails', 'clientCountOverview'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ function getTotalCounts(array) {
|
||||
entity_clients: getSum(array, 'entity_clients'),
|
||||
non_entity_tokens: getSum(array, 'non_entity_clients'),
|
||||
non_entity_clients: getSum(array, 'non_entity_clients'),
|
||||
secret_syncs: getSum(array, 'secret_syncs'),
|
||||
clients: getSum(array, 'clients'),
|
||||
};
|
||||
}
|
||||
@@ -60,8 +61,27 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns) {
|
||||
namespace_id: ns?.namespace_id || (idx === 0 ? 'root' : Math.random().toString(36).slice(2, 7) + idx),
|
||||
namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns/${idx}`),
|
||||
counts: {},
|
||||
mounts: {},
|
||||
};
|
||||
const mounts = [];
|
||||
|
||||
Array.from(Array(5)).forEach((mount, index) => {
|
||||
const [secretSyncs] = arrayOfCounts(randomBetween(min, max), 1);
|
||||
mounts.push({
|
||||
mount_path: `kvv2-engine-${index}`,
|
||||
counts: {
|
||||
clients: secretSyncs,
|
||||
// TODO test with live backend to confirm entity keys are present (and 0) for kv mounts
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
distinct_entities: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: secretSyncs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// generate auth mounts array
|
||||
Array.from(Array(10)).forEach((mount, index) => {
|
||||
const mountClients = randomBetween(min, max);
|
||||
const [nonEntity, entity] = arrayOfCounts(mountClients, 2);
|
||||
@@ -73,6 +93,8 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns) {
|
||||
non_entity_clients: nonEntity,
|
||||
distinct_entities: entity,
|
||||
non_entity_tokens: nonEntity,
|
||||
// TODO test with live backend to confirm this key is present (and 0) for auth mounts (non-kv mounts)
|
||||
secret_syncs: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -104,7 +126,7 @@ function generateMonths(startDate, endDate, namespaces) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const monthNs = namespaces.map((ns, idx) => generateNamespaceBlock(idx, true, ns));
|
||||
const monthNs = namespaces.map((ns, idx) => generateNamespaceBlock(idx, false, ns));
|
||||
const newClients = namespaces.map((ns, idx) => generateNamespaceBlock(idx, true, ns));
|
||||
months.push({
|
||||
timestamp: formatRFC3339(month),
|
||||
@@ -151,6 +173,7 @@ export default function (server) {
|
||||
enabled: 'default-enable',
|
||||
queries_available: true,
|
||||
retention_months: 24,
|
||||
billing_start_timestamp: formatRFC3339(LICENSE_START),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Response } from 'miragejs';
|
||||
import { camelize } from '@ember/string';
|
||||
import { findDestination } from 'core/helpers/sync-destinations';
|
||||
import clientsHandler from './clients';
|
||||
|
||||
export const associationsResponse = (schema, req) => {
|
||||
const { type, name } = req.params;
|
||||
@@ -201,4 +202,296 @@ export default function (server) {
|
||||
server.get('sys/sync/associations/:mount/*name', (schema, req) => {
|
||||
return syncStatusResponse(schema, req);
|
||||
});
|
||||
|
||||
// SYNC CLIENTS ACTIVITY RESPONSE
|
||||
|
||||
// DYNAMIC RESPONSE (with date querying)
|
||||
clientsHandler(server); // imports all of the endpoints defined in mirage/handlers/clients file
|
||||
|
||||
// STATIC RESPONSE (0 entity/non-entity clients)
|
||||
/*
|
||||
server.get('/sys/internal/counters/activity', (schema, req) => {
|
||||
let { start_time, end_time } = req.queryParams;
|
||||
// backend returns a timestamp if given unix time, so first convert to timestamp string here
|
||||
if (!start_time.includes('T')) start_time = fromUnixTime(start_time).toISOString();
|
||||
if (!end_time.includes('T')) end_time = fromUnixTime(end_time).toISOString();
|
||||
return {
|
||||
request_id: 'some-activity-id',
|
||||
lease_id: '',
|
||||
renewable: false,
|
||||
lease_duration: 0,
|
||||
data: {
|
||||
start_time, // set by query params
|
||||
end_time, // set by query params
|
||||
total: {
|
||||
clients: 15,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 15,
|
||||
},
|
||||
by_namespace: [
|
||||
{
|
||||
counts: {
|
||||
clients: 15,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 15,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 15,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 15,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ counts: null, namespaces: null, new_clients: null, timestamp: '2023-09-01T00:00:00Z' },
|
||||
{
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
new_clients: {
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 10,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 10,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: '2023-10-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
new_clients: {
|
||||
counts: {
|
||||
clients: 3,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 3,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 3,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 3,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 3,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 3,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: '2023-11-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 7,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 7,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
new_clients: {
|
||||
counts: {
|
||||
clients: 2,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 2,
|
||||
},
|
||||
namespaces: [
|
||||
{
|
||||
counts: {
|
||||
clients: 2,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 2,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
counts: {
|
||||
clients: 2,
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
secret_syncs: 2,
|
||||
},
|
||||
mount_path: 'sys/',
|
||||
},
|
||||
],
|
||||
namespace_id: 'root',
|
||||
namespace_path: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: '2023-12-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
wrap_info: null,
|
||||
warnings: null,
|
||||
auth: null,
|
||||
};
|
||||
});
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"@icholy/duration": "^5.1.0",
|
||||
"@lineal-viz/lineal": "^0.5.1",
|
||||
"@tsconfig/ember": "^1.0.1",
|
||||
"@types/ember": "^4.0.2",
|
||||
"@types/ember-data": "^4.4.6",
|
||||
@@ -162,6 +163,7 @@
|
||||
"ember-service-worker": "meirish/ember-service-worker#configurable-scope",
|
||||
"ember-sinon": "^4.0.0",
|
||||
"ember-source": "~4.12.0",
|
||||
"ember-style-modifier": "^4.1.0",
|
||||
"ember-svg-jar": "2.4.0",
|
||||
"ember-template-lint": "5.7.2",
|
||||
"ember-template-lint-plugin-prettier": "4.0.0",
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import sinon from 'sinon';
|
||||
import { visit, currentURL, click, findAll, find, settled } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import authPage from 'vault/tests/pages/auth';
|
||||
import { addMonths, formatRFC3339, startOfMonth, subMonths } from 'date-fns';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import clientsHandlers from 'vault/mirage/handlers/clients';
|
||||
import { SELECTORS, overrideResponse } from '../helpers/clients';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
import { clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
const searchSelect = create(ss);
|
||||
|
||||
const STATIC_NOW = new Date('2023-01-13T14:15:00');
|
||||
// for testing, we're in the middle of a license/billing period
|
||||
const LICENSE_START = startOfMonth(subMonths(STATIC_NOW, 6)); // 2022-07-01
|
||||
// upgrade happened 1 month after license start
|
||||
const UPGRADE_DATE = addMonths(LICENSE_START, 1); // 2022-08-01
|
||||
|
||||
module('Acceptance | client counts dashboard tab', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
|
||||
});
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
clientsHandlers(this.server);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
});
|
||||
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
return authPage.login();
|
||||
});
|
||||
|
||||
test('shows warning when config off, no data', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
|
||||
this.server.get('sys/internal/counters/config', () => {
|
||||
return {
|
||||
request_id: 'some-config-id',
|
||||
data: {
|
||||
default_report_months: 12,
|
||||
enabled: 'default-disable',
|
||||
queries_available: false,
|
||||
retention_months: 24,
|
||||
},
|
||||
};
|
||||
});
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
|
||||
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
|
||||
assert.dom(SELECTORS.emptyStateTitle).hasText('Data tracking is disabled');
|
||||
assert.dom(SELECTORS.filterBar).doesNotExist('Filter bar is hidden when no data available');
|
||||
});
|
||||
|
||||
test('shows empty state when config enabled and no data', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
|
||||
this.server.get('sys/internal/counters/config', () => {
|
||||
return {
|
||||
request_id: 'some-config-id',
|
||||
data: {
|
||||
default_report_months: 12,
|
||||
enabled: 'default-enable',
|
||||
retention_months: 24,
|
||||
},
|
||||
};
|
||||
});
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
|
||||
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
|
||||
assert.dom(SELECTORS.emptyStateTitle).hasTextContaining('No data received');
|
||||
assert.dom(SELECTORS.filterBar).doesNotExist('Does not show filter bar');
|
||||
});
|
||||
|
||||
test('visiting dashboard tab config on and data with mounts', async function (assert) {
|
||||
assert.expect(8);
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
|
||||
assert
|
||||
.dom(SELECTORS.dateDisplay)
|
||||
.hasText('July 2022', 'billing start month is correctly parsed from license');
|
||||
assert
|
||||
.dom(SELECTORS.rangeDropdown)
|
||||
.hasText(`Jul 2022 - Jan 2023`, 'Date range shows dates correctly parsed activity response');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(find('[data-test-line-chart="x-axis-labels"] g.tick text'))
|
||||
.hasText(`7/22`, 'x-axis labels start with billing start date');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
6,
|
||||
`line chart plots 6 points to match query`
|
||||
);
|
||||
});
|
||||
|
||||
test('updates correctly when querying date ranges', async function (assert) {
|
||||
assert.expect(27);
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
|
||||
// query for single, historical month with no new counts
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
if (parseInt(find('[data-test-display-year]').innerText) > LICENSE_START.getFullYear()) {
|
||||
await click('[data-test-previous-year]');
|
||||
}
|
||||
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`));
|
||||
assert.dom('[data-test-usage-stats]').exists('total usage stats show');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthStats)
|
||||
.doesNotExist('running total single month stat boxes do not show');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('does not show monthly usage block');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
|
||||
assert
|
||||
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
|
||||
.exists('new client attribution has empty state');
|
||||
assert
|
||||
.dom('[data-test-empty-state-subtext]')
|
||||
.hasText('There are no new clients for this namespace during this time period. ');
|
||||
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
|
||||
|
||||
// reset to billing period
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-current-billing-period]');
|
||||
|
||||
// change billing start to month/year of first upgrade
|
||||
await click('[data-test-start-date-editor] button');
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
|
||||
await click('[data-test-date-dropdown-submit]');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(find('[data-test-line-chart="x-axis-labels"] g.tick text'))
|
||||
.hasText(`8/22`, 'x-axis labels start with updated billing start month');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
6,
|
||||
`line chart plots 6 points to match query`
|
||||
);
|
||||
|
||||
// query three months ago (Oct 2022)
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
await click('[data-test-previous-year]');
|
||||
await click(find(`[data-test-calendar-month="October"]`));
|
||||
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
3,
|
||||
`line chart plots 3 points to match query`
|
||||
);
|
||||
const xAxisLabels = findAll('[data-test-line-chart="x-axis-labels"] g.tick text');
|
||||
assert
|
||||
.dom(xAxisLabels[xAxisLabels.length - 1])
|
||||
.hasText(`10/22`, 'x-axis labels end with queried end month');
|
||||
|
||||
// query for single, historical month (upgrade month)
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
assert.dom('[data-test-display-year]').hasText('2022');
|
||||
await click(find(`[data-test-calendar-month="August"]`));
|
||||
|
||||
assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('Does not show monthly usage block');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
|
||||
assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows');
|
||||
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
|
||||
|
||||
// reset to billing period
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-current-billing-period]');
|
||||
// query month older than count start date
|
||||
await click('[data-test-start-date-editor] button');
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
|
||||
await click('[data-test-date-dropdown-submit]');
|
||||
assert
|
||||
.dom(SELECTORS.upgradeWarning)
|
||||
.hasTextContaining(
|
||||
`We only have data from January 2022`,
|
||||
'warning banner displays that date queried was prior to count start date'
|
||||
);
|
||||
});
|
||||
|
||||
test('dashboard filters correctly with full data', async function (assert) {
|
||||
assert.expect(21);
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
|
||||
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthlyCharts)
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
|
||||
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
|
||||
|
||||
// FILTER BY NAMESPACE
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
const topNamespace = response.byNamespace[0];
|
||||
const topMount = topNamespace.mounts[0];
|
||||
assert.ok(true, 'Filter by first namespace');
|
||||
assert.strictEqual(
|
||||
find(SELECTORS.selectedNs).innerText.toLowerCase(),
|
||||
topNamespace.label,
|
||||
'selects top namespace'
|
||||
);
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
|
||||
assert
|
||||
.dom('[data-test-running-total-entity] p')
|
||||
.includesText(`${formatNumber([topNamespace.entity_clients])}`, 'total entity clients is accurate');
|
||||
assert
|
||||
.dom('[data-test-running-total-nonentity] p')
|
||||
.includesText(
|
||||
`${formatNumber([topNamespace.non_entity_clients])}`,
|
||||
'total non-entity clients is accurate'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-attribution-clients] p')
|
||||
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
|
||||
|
||||
// FILTER BY AUTH METHOD
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
assert.ok(true, 'Filter by first auth method');
|
||||
assert.strictEqual(
|
||||
find(SELECTORS.selectedAuthMount).innerText.toLowerCase(),
|
||||
topMount.label,
|
||||
'selects top mount'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-running-total-entity] p')
|
||||
.includesText(`${formatNumber([topMount.entity_clients])}`, 'total entity clients is accurate');
|
||||
assert
|
||||
.dom('[data-test-running-total-nonentity] p')
|
||||
.includesText(`${formatNumber([topMount.non_entity_clients])}`, 'total non-entity clients is accurate');
|
||||
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
|
||||
|
||||
await click('#namespace-search-select [data-test-selected-list-button="delete"]');
|
||||
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
|
||||
assert
|
||||
.dom('[data-test-running-total-entity]')
|
||||
.hasTextContaining(
|
||||
`${formatNumber([response.total.entity_clients])}`,
|
||||
'total entity clients is back to unfiltered value'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-running-total-nonentity]')
|
||||
.hasTextContaining(
|
||||
`${formatNumber([formatNumber([response.total.non_entity_clients])])}`,
|
||||
'total non-entity clients is back to unfiltered value'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-attribution-clients]')
|
||||
.hasTextContaining(
|
||||
`${formatNumber([topNamespace.clients])}`,
|
||||
'top attribution clients back to unfiltered value'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows warning if upgrade happened within license period', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.get('sys/version-history', function () {
|
||||
return {
|
||||
data: {
|
||||
keys: ['1.9.0', '1.9.1', '1.9.2', '1.10.1'],
|
||||
key_info: {
|
||||
'1.9.0': {
|
||||
previous_version: null,
|
||||
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 4)),
|
||||
},
|
||||
'1.9.1': {
|
||||
previous_version: '1.9.0',
|
||||
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 3)),
|
||||
},
|
||||
'1.9.2': {
|
||||
previous_version: '1.9.1',
|
||||
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 2)),
|
||||
},
|
||||
'1.10.1': {
|
||||
previous_version: '1.9.2',
|
||||
timestamp_installed: formatRFC3339(UPGRADE_DATE),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
|
||||
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
|
||||
assert
|
||||
.dom(SELECTORS.upgradeWarning)
|
||||
.hasTextContaining(
|
||||
`Warning Vault was upgraded to 1.10.1 on Aug 1, 2022. We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data. Learn more here.`
|
||||
);
|
||||
|
||||
await click('[data-test-start-date-editor] button');
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
|
||||
await click('[data-test-date-dropdown-submit]');
|
||||
assert.dom(`${SELECTORS.upgradeWarning} ul`).hasClass('bullet', 'renders bullets when multiple warnings');
|
||||
});
|
||||
|
||||
test('Shows empty if license start date is current month', async function (assert) {
|
||||
// TODO cmb update to reflect new behavior
|
||||
const licenseStart = STATIC_NOW;
|
||||
const licenseEnd = addMonths(licenseStart, 12);
|
||||
this.server.get('sys/license/status', function () {
|
||||
return {
|
||||
request_id: 'my-license-request-id',
|
||||
data: {
|
||||
autoloaded: {
|
||||
license_id: 'my-license-id',
|
||||
start_time: formatRFC3339(licenseStart),
|
||||
expiration_time: formatRFC3339(licenseEnd),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
|
||||
assert.dom(SELECTORS.emptyStateTitle).doesNotExist('No data for this billing period');
|
||||
});
|
||||
|
||||
test('shows correct interface if no permissions on license', async function (assert) {
|
||||
this.server.get('/sys/license/status', () => overrideResponse(403));
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
|
||||
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
|
||||
// Message changes depending on ent or OSS
|
||||
assert.dom(SELECTORS.emptyStateTitle).exists('Empty state exists');
|
||||
assert.dom(SELECTORS.monthDropdown).exists('Dropdown exists to select month');
|
||||
assert.dom(SELECTORS.yearDropdown).exists('Dropdown exists to select year');
|
||||
});
|
||||
|
||||
test('shows error template if permissions denied querying activity response with no data', async function (assert) {
|
||||
this.server.get('sys/license/status', () => overrideResponse(403));
|
||||
this.server.get('sys/version-history', () => overrideResponse(403));
|
||||
this.server.get('sys/internal/counters/config', () => overrideResponse(403));
|
||||
this.server.get('sys/internal/counters/activity', () => overrideResponse(403));
|
||||
|
||||
await visit('/vault/clients/dashboard');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
|
||||
assert
|
||||
.dom(SELECTORS.emptyStateTitle)
|
||||
.includesText('start date found', 'Empty state shows no billing start date');
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(this.element.querySelector('[data-test-dropdown-month]:not([disabled])'));
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(this.element.querySelector('[data-test-dropdown-year]:not([disabled])'));
|
||||
await click(SELECTORS.dateDropdownSubmit);
|
||||
assert
|
||||
.dom(SELECTORS.emptyStateTitle)
|
||||
.hasText('You are not authorized', 'Empty state displays not authorized message');
|
||||
});
|
||||
});
|
||||
60
ui/tests/acceptance/clients/counts-test.js
Normal file
60
ui/tests/acceptance/clients/counts-test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import sinon from 'sinon';
|
||||
import { visit, click, currentURL } from '@ember/test-helpers';
|
||||
import authPage from 'vault/tests/pages/auth';
|
||||
import { SELECTORS as ts, STATIC_NOW } from 'vault/tests/helpers/clients';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
module('Acceptance | clients | counts', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
|
||||
});
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
clientsHandler(this.server);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
return authPage.login();
|
||||
});
|
||||
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
test('it should redirect to counts overview route for transitions to parent', async function (assert) {
|
||||
await visit('/vault/clients');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Redirects to counts overview route');
|
||||
});
|
||||
|
||||
test('it should persist filter query params between child routes', async function (assert) {
|
||||
await visit('/vault/clients/counts/overview');
|
||||
await click(ts.rangeDropdown);
|
||||
await click(ts.currentBillingPeriod);
|
||||
const timeQueryRegex = /end_time=\d+&start_time=\d+/g;
|
||||
assert.ok(currentURL().match(timeQueryRegex).length, 'Start and end times added as query params');
|
||||
|
||||
await click(ts.tab('token'));
|
||||
assert.ok(
|
||||
currentURL().match(timeQueryRegex).length,
|
||||
'Start and end times persist through child route change'
|
||||
);
|
||||
|
||||
await click(ts.navLink('Dashboard'));
|
||||
await click(ts.navLink('Client Count'));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/vault/clients/counts/overview',
|
||||
'Query params are reset when exiting route'
|
||||
);
|
||||
});
|
||||
});
|
||||
240
ui/tests/acceptance/clients/counts/overview-test.js
Normal file
240
ui/tests/acceptance/clients/counts/overview-test.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import sinon from 'sinon';
|
||||
import { visit, click, findAll, find, settled } from '@ember/test-helpers';
|
||||
import authPage from 'vault/tests/pages/auth';
|
||||
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
|
||||
import { SELECTORS, STATIC_NOW, LICENSE_START, UPGRADE_DATE } from 'vault/tests/helpers/clients';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import { clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
|
||||
const searchSelect = create(ss);
|
||||
|
||||
module('Acceptance | clients | overview', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
|
||||
});
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
clientsHandler(this.server);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
await authPage.login();
|
||||
return visit('/vault/clients/counts/overview');
|
||||
});
|
||||
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
test('it should render charts', async function (assert) {
|
||||
assert
|
||||
.dom(SELECTORS.counts.startMonth)
|
||||
.hasText('July 2022', 'billing start month is correctly parsed from license');
|
||||
assert
|
||||
.dom(SELECTORS.rangeDropdown)
|
||||
.hasText(`Jul 2022 - Jan 2023`, 'Date range shows dates correctly parsed activity response');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(SELECTORS.charts.line.xAxisLabel)
|
||||
.hasText(`7/22`, 'x-axis labels start with billing start date');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
6,
|
||||
`line chart plots 6 points to match query`
|
||||
);
|
||||
});
|
||||
|
||||
test('it should update charts when querying date ranges', async function (assert) {
|
||||
// query for single, historical month with no new counts
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
if (parseInt(find('[data-test-display-year]').innerText) > LICENSE_START.getFullYear()) {
|
||||
await click('[data-test-previous-year]');
|
||||
}
|
||||
await click(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`);
|
||||
assert
|
||||
.dom(SELECTORS.runningTotalMonthStats)
|
||||
.doesNotExist('running total single month stat boxes do not show');
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
|
||||
assert
|
||||
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
|
||||
.exists('new client attribution has empty state');
|
||||
assert
|
||||
.dom('[data-test-empty-state-subtext]')
|
||||
.hasText('There are no new clients for this namespace during this time period. ');
|
||||
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
|
||||
|
||||
// reset to billing period
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-current-billing-period]');
|
||||
|
||||
// change billing start to month/year of first upgrade
|
||||
await click(SELECTORS.counts.startEdit);
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
|
||||
await click('[data-test-date-dropdown-submit]');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(SELECTORS.charts.line.xAxisLabel)
|
||||
.hasText(`8/22`, 'x-axis labels start with updated billing start month');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
6,
|
||||
`line chart plots 6 points to match query`
|
||||
);
|
||||
|
||||
// query three months ago (Oct 2022)
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
await click('[data-test-previous-year]');
|
||||
await click('[data-test-calendar-month="October"]');
|
||||
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.strictEqual(
|
||||
findAll('[data-test-line-chart="plot-point"]').length,
|
||||
3,
|
||||
`line chart plots 3 points to match query`
|
||||
);
|
||||
const xAxisLabels = findAll(SELECTORS.charts.line.xAxisLabel);
|
||||
assert
|
||||
.dom(xAxisLabels[xAxisLabels.length - 1])
|
||||
.hasText(`10/22`, 'x-axis labels end with queried end month');
|
||||
|
||||
// query for single, historical month (upgrade month)
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-show-calendar]');
|
||||
assert.dom('[data-test-display-year]').hasText('2022');
|
||||
await click('[data-test-calendar-month="August"]');
|
||||
|
||||
assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show');
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
|
||||
assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows');
|
||||
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
|
||||
|
||||
// reset to billing period
|
||||
await click(SELECTORS.rangeDropdown);
|
||||
await click('[data-test-current-billing-period]');
|
||||
// query month older than count start date
|
||||
await click(SELECTORS.counts.startEdit);
|
||||
await click(SELECTORS.monthDropdown);
|
||||
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
|
||||
await click(SELECTORS.yearDropdown);
|
||||
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
|
||||
await click('[data-test-date-dropdown-submit]');
|
||||
assert
|
||||
.dom(SELECTORS.counts.startDiscrepancy)
|
||||
.hasTextContaining(
|
||||
`We only have data from January 2022`,
|
||||
'warning banner displays that date queried was prior to count start date'
|
||||
);
|
||||
});
|
||||
|
||||
test('totals filter correctly with full data', async function (assert) {
|
||||
assert
|
||||
.dom(SELECTORS.charts.chart('running total'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
|
||||
|
||||
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
|
||||
// FILTER BY NAMESPACE
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
const topNamespace = response.byNamespace[0];
|
||||
const topMount = topNamespace.mounts[0];
|
||||
|
||||
assert.dom(SELECTORS.selectedNs).hasText(topNamespace.label, 'selects top namespace');
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Entity clients'))
|
||||
.includesText(`${formatNumber([topNamespace.entity_clients])}`, 'total entity clients is accurate');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
|
||||
.includesText(
|
||||
`${formatNumber([topNamespace.non_entity_clients])}`,
|
||||
'total non-entity clients is accurate'
|
||||
);
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
|
||||
.includesText(`${formatNumber([topNamespace.secret_syncs])}`, 'total sync clients is accurate');
|
||||
assert
|
||||
.dom('[data-test-attribution-clients] p')
|
||||
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
|
||||
|
||||
// FILTER BY AUTH METHOD
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
|
||||
assert.ok(true, 'Filter by first auth method');
|
||||
assert.dom(SELECTORS.selectedAuthMount).hasText(topMount.label, 'selects top mount');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Entity clients'))
|
||||
.includesText(`${formatNumber([topMount.entity_clients])}`, 'total entity clients is accurate');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
|
||||
.includesText(`${formatNumber([topMount.non_entity_clients])}`, 'total non-entity clients is accurate');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
|
||||
.includesText(`${formatNumber([topMount.secret_syncs])}`, 'total sync clients is accurate');
|
||||
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
|
||||
|
||||
await click('#namespace-search-select [data-test-selected-list-button="delete"]');
|
||||
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Entity clients'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([response.total.entity_clients])}`,
|
||||
'total entity clients is back to unfiltered value'
|
||||
);
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([formatNumber([response.total.non_entity_clients])])}`,
|
||||
'total non-entity clients is back to unfiltered value'
|
||||
);
|
||||
assert
|
||||
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([formatNumber([response.total.secret_syncs])])}`,
|
||||
'total sync clients is back to unfiltered value'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-attribution-clients]')
|
||||
.hasTextContaining(
|
||||
`${formatNumber([topNamespace.clients])}`,
|
||||
'top attribution clients back to unfiltered value'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
|
||||
assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication dr route renders');
|
||||
|
||||
await click(link('Client Count'));
|
||||
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'Client counts route renders');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Client counts route renders');
|
||||
|
||||
await click(link('License'));
|
||||
assert.strictEqual(currentURL(), '/vault/license', 'License route renders');
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import { Response } from 'miragejs';
|
||||
import { SELECTORS as GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { click } from '@ember/test-helpers';
|
||||
import { addMonths, startOfMonth, subMonths } from 'date-fns';
|
||||
|
||||
/** Scenarios
|
||||
Config off, no data
|
||||
@@ -22,7 +25,48 @@ import { Response } from 'miragejs';
|
||||
License start date this month
|
||||
*/
|
||||
export const SELECTORS = {
|
||||
dashboardActiveTab: '.active[data-test-dashboard]',
|
||||
...GENERAL,
|
||||
counts: {
|
||||
startLabel: '[data-test-counts-start-label]',
|
||||
description: '[data-test-counts-description]',
|
||||
startMonth: '[data-test-counts-start-month]',
|
||||
startEdit: '[data-test-counts-start-edit]',
|
||||
startDropdown: '[data-test-counts-start-dropdown]',
|
||||
configDisabled: '[data-test-counts-disabled]',
|
||||
namespaces: '[data-test-counts-namespaces]',
|
||||
mountPaths: '[data-test-counts-auth-mounts]',
|
||||
startDiscrepancy: '[data-test-counts-start-discrepancy]',
|
||||
},
|
||||
tokenTab: {
|
||||
entity: '[data-test-monthly-new-entity]',
|
||||
nonentity: '[data-test-monthly-new-nonentity]',
|
||||
legend: '[data-test-monthly-new-legend]',
|
||||
},
|
||||
syncTab: {
|
||||
total: '[data-test-total-sync-clients]',
|
||||
average: '[data-test-average-sync-clients]',
|
||||
},
|
||||
charts: {
|
||||
chart: (title) => `[data-test-chart="${title}"]`, // newer lineal charts
|
||||
statTextValue: (label) =>
|
||||
label ? `[data-test-stat-text-container="${label}"] .stat-value` : '[data-test-stat-text-container]',
|
||||
legend: '[data-test-chart-container-legend]',
|
||||
legendLabel: (nth) => `.legend-label:nth-child(${nth * 2})`, // nth * 2 accounts for dots in legend
|
||||
timestamp: '[data-test-chart-container-timestamp]',
|
||||
dataBar: '[data-test-vertical-bar]',
|
||||
xAxisLabel: '[data-test-x-axis] text',
|
||||
// selectors for old d3 charts
|
||||
verticalBar: '[data-test-vertical-bar-chart]',
|
||||
lineChart: '[data-test-line-chart]',
|
||||
bar: {
|
||||
xAxisLabel: '[data-test-vertical-chart="x-axis-labels"] text',
|
||||
dataBar: '[data-test-vertical-chart="data-bar"]',
|
||||
},
|
||||
line: {
|
||||
xAxisLabel: '[data-test-line-chart] [data-test-x-axis] text',
|
||||
plotPoint: '[data-test-line-chart="plot-point"]',
|
||||
},
|
||||
},
|
||||
emptyStateTitle: '[data-test-empty-state-title]',
|
||||
usageStats: '[data-test-usage-stats]',
|
||||
dateDisplay: '[data-test-date-display]',
|
||||
@@ -31,10 +75,10 @@ export const SELECTORS = {
|
||||
rangeDropdown: '[data-test-calendar-widget-trigger]',
|
||||
monthDropdown: '[data-test-toggle-month]',
|
||||
yearDropdown: '[data-test-toggle-year]',
|
||||
currentBillingPeriod: '[data-test-current-billing-period]',
|
||||
dateDropdownSubmit: '[data-test-date-dropdown-submit]',
|
||||
runningTotalMonthStats: '[data-test-running-total="single-month-stats"]',
|
||||
runningTotalMonthlyCharts: '[data-test-running-total="monthly-charts"]',
|
||||
monthlyUsageBlock: '[data-test-monthly-usage]',
|
||||
selectedAuthMount: 'div#auth-method-search-select [data-test-selected-option] div',
|
||||
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
|
||||
upgradeWarning: '[data-test-clients-upgrade-warning]',
|
||||
@@ -80,3 +124,19 @@ export function overrideResponse(httpStatus, data) {
|
||||
}
|
||||
return new Response(200, { 'Content-Type': 'application/json' }, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function dateDropdownSelect(month, year) {
|
||||
const { dateDropdown, counts } = SELECTORS;
|
||||
await click(counts.startEdit);
|
||||
await click(dateDropdown.toggleMonth);
|
||||
await click(dateDropdown.selectMonth(month));
|
||||
await click(dateDropdown.toggleYear);
|
||||
await click(dateDropdown.selectYear(year));
|
||||
await click(dateDropdown.submit);
|
||||
}
|
||||
|
||||
export const STATIC_NOW = new Date('2023-01-13T14:15:00');
|
||||
// for testing, we're in the middle of a license/billing period
|
||||
export const LICENSE_START = startOfMonth(subMonths(STATIC_NOW, 6)); // 2022-07-01
|
||||
// upgrade happened 1 month after license start
|
||||
export const UPGRADE_DATE = addMonths(LICENSE_START, 1); // 2022-08-01
|
||||
|
||||
@@ -22,6 +22,22 @@ export const SELECTORS = {
|
||||
emptyStateActions: '[data-test-empty-state-actions]',
|
||||
menuTrigger: '[data-test-popup-menu-trigger]',
|
||||
listItem: '[data-test-list-item-link]',
|
||||
calendarWidget: {
|
||||
trigger: '[data-test-calendar-widget-trigger]',
|
||||
currentMonth: '[data-test-current-month]',
|
||||
currentBillingPeriod: '[data-test-current-billing-period]',
|
||||
customEndMonth: '[data-test-show-calendar]',
|
||||
previousYear: '[data-test-previous-year]',
|
||||
nextYear: '[data-test-next-year]',
|
||||
calendarMonth: (month) => `[data-test-calendar-month="${month}"]`,
|
||||
},
|
||||
dateDropdown: {
|
||||
toggleMonth: '[data-test-toggle-month]',
|
||||
toggleYear: '[data-test-toggle-year]',
|
||||
selectMonth: (month) => `[data-test-dropdown-month="${month}"]`,
|
||||
selectYear: (year) => `[data-test-dropdown-year="${year}"]`,
|
||||
submit: '[data-test-date-dropdown-submit]',
|
||||
},
|
||||
// FORMS
|
||||
infoRowValue: (label) => `[data-test-value-div="${label}"]`,
|
||||
inputByAttr: (attr) => `[data-test-input="${attr}"]`,
|
||||
@@ -54,4 +70,5 @@ export const SELECTORS = {
|
||||
input: '[data-test-kv-suggestion-input]',
|
||||
select: '[data-test-kv-suggestion-select]',
|
||||
},
|
||||
navLink: (label) => `[data-test-sidebar-nav-link="${label}"]`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { render, triggerEvent } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
const EXAMPLE = [
|
||||
{
|
||||
month: '7/22',
|
||||
timestamp: '2022-07-01T00:00:00-07:00',
|
||||
clients: null,
|
||||
entity_clients: null,
|
||||
non_entity_clients: null,
|
||||
secret_syncs: null,
|
||||
},
|
||||
{
|
||||
month: '8/22',
|
||||
timestamp: '2022-08-01T00:00:00-07:00',
|
||||
clients: 6440,
|
||||
entity_clients: 1471,
|
||||
non_entity_clients: 4389,
|
||||
secret_syncs: 4207,
|
||||
},
|
||||
{
|
||||
month: '9/22',
|
||||
timestamp: '2022-09-01T00:00:00-07:00',
|
||||
clients: 9583,
|
||||
entity_clients: 149,
|
||||
non_entity_clients: 20,
|
||||
secret_syncs: 5802,
|
||||
},
|
||||
];
|
||||
|
||||
module('Integration | Component | clients/charts/vertical-bar-basic', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.data = EXAMPLE;
|
||||
});
|
||||
|
||||
test('it renders when some months have no data', async function (assert) {
|
||||
assert.expect(10);
|
||||
await render(hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" />`);
|
||||
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
|
||||
assert.dom('[data-test-vertical-bar]').exists({ count: 3 }, 'renders 3 vertical bars');
|
||||
|
||||
// Tooltips
|
||||
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseover');
|
||||
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
|
||||
assert.dom('[data-test-tooltip-count]').hasText('5,802 secret syncs', 'tooltip has exact count');
|
||||
assert.dom('[data-test-tooltip-month]').hasText('September 2022', 'tooltip has humanized month and year');
|
||||
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseout');
|
||||
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
|
||||
await triggerEvent('[data-test-interactive-area="7/22"]', 'mouseover');
|
||||
assert
|
||||
.dom('[data-test-tooltip-count]')
|
||||
.hasText('No data', 'renders tooltip with no data message when no data is available');
|
||||
// Axis
|
||||
assert.dom('[data-test-x-axis]').hasText('7/22 8/22 9/22', 'renders x-axis labels');
|
||||
assert.dom('[data-test-y-axis]').hasText('0 2k 4k', 'renders y-axis labels');
|
||||
// Table
|
||||
assert.dom('[data-test-underlying-data]').doesNotExist('does not render underlying data by default');
|
||||
});
|
||||
|
||||
// 0 is different than null (no data)
|
||||
test('it renders when all months have 0 clients', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
this.data = [
|
||||
{
|
||||
month: '6/22',
|
||||
timestamp: '2022-06-01T00:00:00-07:00',
|
||||
clients: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
month: '7/22',
|
||||
timestamp: '2022-07-01T00:00:00-07:00',
|
||||
clients: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
];
|
||||
await render(hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" />`);
|
||||
|
||||
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
|
||||
assert.dom('[data-test-vertical-bar]').exists({ count: 2 }, 'renders 2 vertical bars');
|
||||
assert.dom('[data-test-vertical-bar]').hasAttribute('height', '0', 'rectangles have 0 height');
|
||||
// Tooltips
|
||||
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseover');
|
||||
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
|
||||
assert.dom('[data-test-tooltip-count]').hasText('0 secret syncs', 'tooltip has exact count');
|
||||
assert.dom('[data-test-tooltip-month]').hasText('June 2022', 'tooltip has humanized month and year');
|
||||
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseout');
|
||||
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
|
||||
// Axis
|
||||
assert.dom('[data-test-x-axis]').hasText('6/22 7/22', 'renders x-axis labels');
|
||||
assert.dom('[data-test-y-axis]').hasText('0 1 2 3 4', 'renders y-axis labels');
|
||||
});
|
||||
|
||||
test('it renders underlying data', async function (assert) {
|
||||
assert.expect(3);
|
||||
await render(
|
||||
hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" @showTable={{true}} />`
|
||||
);
|
||||
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
|
||||
assert.dom('[data-test-underlying-data]').exists('renders underlying data when showTable=true');
|
||||
assert
|
||||
.dom('[data-test-underlying-data] thead')
|
||||
.hasText('Month Count of secret syncs', 'renders correct table headers');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
|
||||
test('it renders empty state with no data', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution @chartLegend={{this.chartLegend}} />
|
||||
<Clients::Attribution />
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-component="empty-state"]').exists();
|
||||
@@ -59,7 +59,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
test('it renders with data for namespaces', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@@ -92,7 +91,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
this.end = formatRFC3339(subMonths(endOfMonth(this.mockNow), 1));
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@@ -145,7 +143,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
test('it renders single chart for current month', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@@ -166,7 +163,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
test('it renders single chart and correct text for for date range', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@@ -189,7 +185,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
this.set('selectedNamespace', 'second');
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.namespaceMountsData}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@@ -220,7 +215,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
||||
test('it renders modal', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@chartLegend={{this.chartLegend}}
|
||||
@totalClientAttribution={{this.namespaceMountsData}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp="2022-06-01T23:00:11.050Z"
|
||||
|
||||
@@ -9,7 +9,6 @@ import { setupRenderingTest } from 'ember-qunit';
|
||||
import { find, render, findAll } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { format, formatRFC3339, subMonths } from 'date-fns';
|
||||
// import { formatChartDate } from 'core/utils/date-formatters';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
|
||||
module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
@@ -20,6 +19,181 @@ module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.set('xKey', 'foo');
|
||||
this.set('yKey', 'bar');
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: '2018-04-03T14:15:30',
|
||||
bar: 4,
|
||||
expectedLabel: '4/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-05-03T14:15:30',
|
||||
bar: 8,
|
||||
expectedLabel: '5/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-06-03T14:15:30',
|
||||
bar: 14,
|
||||
expectedLabel: '6/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-07-03T14:15:30',
|
||||
bar: 10,
|
||||
expectedLabel: '7/18',
|
||||
},
|
||||
]);
|
||||
});
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line @dataset={{this.dataset}} @xKey={{this.xKey}} @yKey={{this.yKey}} />
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
|
||||
assert
|
||||
.dom('[data-test-line-chart="plot-point"]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
findAll('[data-test-x-axis] text').forEach((e, i) => {
|
||||
// For some reason the first axis label is not rendered
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.dataset[i].expectedLabel}`,
|
||||
`renders x-axis label: ${this.dataset[i].expectedLabel}`
|
||||
);
|
||||
});
|
||||
assert.dom('[data-test-y-axis] text').hasText('0', `y-axis starts at 0`);
|
||||
});
|
||||
|
||||
test('it renders upgrade data', async function (assert) {
|
||||
const now = timestamp.now();
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 4)),
|
||||
bar: 4,
|
||||
month: format(subMonths(now, 4), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 3)),
|
||||
bar: 8,
|
||||
month: format(subMonths(now, 3), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 2)),
|
||||
bar: 14,
|
||||
month: format(subMonths(now, 2), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 1)),
|
||||
bar: 10,
|
||||
month: format(subMonths(now, 1), 'M/yy'),
|
||||
},
|
||||
]);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
|
||||
assert
|
||||
.dom('[data-test-line-chart="plot-point"]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
assert
|
||||
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2].month}"]`))
|
||||
.hasStyle(
|
||||
{ fill: 'rgb(253, 238, 186)' },
|
||||
`upgrade data point ${this.dataset[2].month} has yellow highlight`
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders tooltip', async function (assert) {
|
||||
assert.expect(1);
|
||||
const now = timestamp.now();
|
||||
const tooltipData = [
|
||||
{
|
||||
month: format(subMonths(now, 4), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 4)),
|
||||
clients: 4,
|
||||
new_clients: {
|
||||
clients: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 3), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 3)),
|
||||
clients: 8,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 2), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 2)),
|
||||
clients: 14,
|
||||
new_clients: {
|
||||
clients: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 1), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 1)),
|
||||
clients: 20,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
this.set('dataset', tooltipData);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const tooltipHoverCircles = findAll('[data-test-hover-circle]');
|
||||
assert.strictEqual(tooltipHoverCircles.length, tooltipData.length, 'all data circles are rendered');
|
||||
|
||||
// FLAKY after adding a11y testing, skip for now
|
||||
// for (const [i, bar] of tooltipHoverCircles.entries()) {
|
||||
// await triggerEvent(bar, 'mouseover');
|
||||
// const tooltip = document.querySelector('.ember-modal-dialog');
|
||||
// const { month, clients, new_clients } = tooltipData[i];
|
||||
// assert
|
||||
// .dom(tooltip)
|
||||
// .includesText(
|
||||
// `${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
|
||||
// `tooltip text is correct for ${month}`
|
||||
// );
|
||||
// }
|
||||
});
|
||||
|
||||
test('it fails gracefully when data is not formatted correctly', async function (assert) {
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: 1,
|
||||
@@ -38,149 +212,27 @@ module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
bar: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart @dataset={{this.dataset}} @xKey={{this.xKey}} @yKey={{this.yKey}} />
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
|
||||
assert
|
||||
.dom('[data-test-line-chart="plot-point"]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
|
||||
findAll('[data-test-line-chart="x-axis-labels"] text').forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(`${this.dataset[i][this.xKey]}`, `renders x-axis label: ${this.dataset[i][this.xKey]}`);
|
||||
});
|
||||
assert.dom(find('[data-test-line-chart="y-axis-labels"] text')).hasText('0', `y-axis starts at 0`);
|
||||
});
|
||||
|
||||
test('it renders upgrade data', async function (assert) {
|
||||
const now = timestamp.now();
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: format(subMonths(now, 4), 'M/yy'),
|
||||
bar: 4,
|
||||
},
|
||||
{
|
||||
foo: format(subMonths(now, 3), 'M/yy'),
|
||||
bar: 8,
|
||||
},
|
||||
{
|
||||
foo: format(subMonths(now, 2), 'M/yy'),
|
||||
bar: 14,
|
||||
},
|
||||
{
|
||||
foo: format(subMonths(now, 1), 'M/yy'),
|
||||
bar: 10,
|
||||
},
|
||||
]);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
|
||||
|
||||
assert.dom('[data-test-line-chart]').doesNotExist('Chart is not rendered');
|
||||
assert
|
||||
.dom('[data-test-line-chart="plot-point"]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
assert
|
||||
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2][this.xKey]}"]`))
|
||||
.hasStyle({ opacity: '1' }, `upgrade data point ${this.dataset[2][this.xKey]} has yellow highlight`);
|
||||
});
|
||||
|
||||
test('it renders tooltip', async function (assert) {
|
||||
assert.expect(1);
|
||||
const now = timestamp.now();
|
||||
const tooltipData = [
|
||||
{
|
||||
month: format(subMonths(now, 4), 'M/yy'),
|
||||
clients: 4,
|
||||
new_clients: {
|
||||
clients: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 3), 'M/yy'),
|
||||
clients: 8,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 2), 'M/yy'),
|
||||
clients: 14,
|
||||
new_clients: {
|
||||
clients: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 1), 'M/yy'),
|
||||
clients: 20,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
this.set('dataset', tooltipData);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const tooltipHoverCircles = findAll('[data-test-line-chart] circle.hover-circle');
|
||||
assert.strictEqual(tooltipHoverCircles.length, tooltipData.length, 'all data circles are rendered');
|
||||
|
||||
// FLAKY after adding a11y testing, skip for now
|
||||
// for (const [i, bar] of tooltipHoverCircles.entries()) {
|
||||
// await triggerEvent(bar, 'mouseover');
|
||||
// const tooltip = document.querySelector('.ember-modal-dialog');
|
||||
// const { month, clients, new_clients } = tooltipData[i];
|
||||
// assert
|
||||
// .dom(tooltip)
|
||||
// .includesText(
|
||||
// `${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
|
||||
// `tooltip text is correct for ${month}`
|
||||
// );
|
||||
// }
|
||||
.dom('[data-test-component="empty-state"]')
|
||||
.hasText('No data to display', 'Shows empty state when time date is not formatted correctly');
|
||||
});
|
||||
|
||||
test('it fails gracefully when upgradeData is an object', async function (assert) {
|
||||
this.set('upgradeData', { some: 'object' });
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@@ -198,7 +250,7 @@ module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
this.set('upgradeData', [{ incorrect: 'key names' }]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@@ -215,7 +267,7 @@ module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
test('it renders empty state when no dataset', async function (assert) {
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::LineChart @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
|
||||
<Clients::Charts::Line @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -227,4 +279,121 @@ module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
'custom message renders'
|
||||
);
|
||||
});
|
||||
|
||||
test('it updates axis when dataset updates', async function (assert) {
|
||||
const datasets = {
|
||||
small: [
|
||||
{
|
||||
foo: '2020-04-01',
|
||||
bar: 4,
|
||||
month: '4/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-05-01',
|
||||
bar: 8,
|
||||
month: '5/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-06-01',
|
||||
bar: 1,
|
||||
},
|
||||
{
|
||||
foo: '2020-07-01',
|
||||
bar: 10,
|
||||
},
|
||||
],
|
||||
large: [
|
||||
{
|
||||
foo: '2020-08-01',
|
||||
bar: 4586,
|
||||
month: '8/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-09-01',
|
||||
bar: 8928,
|
||||
month: '9/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-10-01',
|
||||
bar: 11948,
|
||||
month: '10/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-11-01',
|
||||
bar: 16943,
|
||||
month: '11/20',
|
||||
},
|
||||
],
|
||||
broken: [
|
||||
{
|
||||
foo: '2020-01-01',
|
||||
bar: null,
|
||||
month: '1/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-02-01',
|
||||
bar: 0,
|
||||
month: '2/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-03-01',
|
||||
bar: 22,
|
||||
month: '3/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-04-01',
|
||||
bar: null,
|
||||
month: '4/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-05-01',
|
||||
bar: 70,
|
||||
month: '5/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-06-01',
|
||||
bar: 50,
|
||||
month: '6/20',
|
||||
},
|
||||
],
|
||||
};
|
||||
this.set('dataset', datasets.small);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
assert.dom('[data-test-y-axis]').hasText('0 2 4 6 8 10', 'y-axis renders correctly for small values');
|
||||
assert
|
||||
.dom('[data-test-x-axis]')
|
||||
.hasText('4/20 5/20 6/20 7/20', 'x-axis renders correctly for small values');
|
||||
|
||||
// Update to large dataset
|
||||
this.set('dataset', datasets.large);
|
||||
assert.dom('[data-test-y-axis]').hasText('0 5k 10k 15k', 'y-axis renders correctly for new large values');
|
||||
assert
|
||||
.dom('[data-test-x-axis]')
|
||||
.hasText('8/20 9/20 10/20 11/20', 'x-axis renders correctly for small values');
|
||||
|
||||
// Update to broken dataset
|
||||
this.set('dataset', datasets.broken);
|
||||
assert.dom('[data-test-y-axis]').hasText('0 20 40 60', 'y-axis renders correctly for new broken values');
|
||||
assert
|
||||
.dom('[data-test-x-axis]')
|
||||
.hasText('1/20 2/20 3/20 4/20 5/20 6/20', 'x-axis renders correctly for small values');
|
||||
assert.dom('[data-test-hover-circle]').exists({ count: 4 }, 'only render circles for non-null values');
|
||||
|
||||
assert
|
||||
.dom('[data-test-hover-circle="1/20"]')
|
||||
.doesNotExist('first month dot does not exist because value is null');
|
||||
assert
|
||||
.dom('[data-test-hover-circle="4/20"]')
|
||||
.doesNotExist('other null count month dot also does not render');
|
||||
// Note: the line should also show a gap, but this is difficult to test for
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
219
ui/tests/integration/components/clients/page/counts-test.js
Normal file
219
ui/tests/integration/components/clients/page/counts-test.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { render, click, settled } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { SELECTORS as ts, dateDropdownSelect } from 'vault/tests/helpers/clients';
|
||||
import { selectChoose } from 'ember-power-select/test-support/helpers';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const STATIC_NOW = new Date('2024-01-25T23:59:59Z');
|
||||
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
|
||||
const END_TIME = getUnixTime(STATIC_NOW);
|
||||
|
||||
module('Integration | Component | clients | Page::Counts', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.before(function () {
|
||||
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
|
||||
});
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
clientsHandler(this.server);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const activityQuery = {
|
||||
start_time: { timestamp: START_TIME },
|
||||
end_time: { timestamp: END_TIME },
|
||||
};
|
||||
this.activity = await store.queryRecord('clients/activity', activityQuery);
|
||||
this.config = await store.queryRecord('clients/config', {});
|
||||
this.startTimestamp = START_TIME;
|
||||
this.endTimestamp = END_TIME;
|
||||
this.renderComponent = () =>
|
||||
render(hbs`
|
||||
<Clients::Page::Counts
|
||||
@activity={{this.activity}}
|
||||
@activityError={{this.activityError}}
|
||||
@config={{this.config}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@namespace={{this.namespace}}
|
||||
@mountPath={{this.mountPath}}
|
||||
@onFilterChange={{this.onFilterChange}}
|
||||
>
|
||||
<div data-test-yield>Yield block</div>
|
||||
</Clients::Page::Counts>
|
||||
`);
|
||||
});
|
||||
hooks.after(function () {
|
||||
timestamp.now.restore();
|
||||
});
|
||||
|
||||
test('it should render start date label and description based on version', async function (assert) {
|
||||
const versionService = this.owner.lookup('service:version');
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(ts.counts.startLabel).hasText('Client counting start date', 'Label renders for OSS');
|
||||
assert
|
||||
.dom(ts.counts.description)
|
||||
.hasText(
|
||||
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
'Description renders for OSS'
|
||||
);
|
||||
|
||||
versionService.set('type', 'enterprise');
|
||||
await settled();
|
||||
|
||||
assert.dom(ts.counts.startLabel).hasText('Billing start month', 'Label renders for Enterprise');
|
||||
assert
|
||||
.dom(ts.counts.description)
|
||||
.hasText(
|
||||
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
|
||||
'Description renders for Enterprise'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should populate start and end month displays', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(ts.counts.startMonth).hasText('October 2023', 'Start month renders');
|
||||
assert
|
||||
.dom(ts.calendarWidget.trigger)
|
||||
.hasText('Oct 2023 - Jan 2024', 'Start and end months render in filter bar');
|
||||
});
|
||||
|
||||
test('it should render no data empty state', async function (assert) {
|
||||
this.activity = { id: 'no-data' };
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom(ts.emptyStateTitle)
|
||||
.hasText('No data received from October 2023 to January 2024', '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(ts.emptyStateTitle).hasText('You are not authorized', 'Activity error empty state renders');
|
||||
});
|
||||
|
||||
test('it should render config disabled alert', async function (assert) {
|
||||
this.config.enabled = 'Off';
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(ts.counts.configDisabled).hasText('Tracking is disabled', 'Config disabled alert renders');
|
||||
});
|
||||
|
||||
test('it should send correct values on start and end date change', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
let expected = { start_time: getUnixTime(new Date('2023-01-01T00:00:00Z')), end_time: END_TIME };
|
||||
this.onFilterChange = (params) => {
|
||||
assert.deepEqual(params, expected, 'Correct values sent on filter change');
|
||||
this.startTimestamp = params.start_time || START_TIME;
|
||||
this.endTimestamp = params.end_time || END_TIME;
|
||||
};
|
||||
|
||||
await this.renderComponent();
|
||||
await dateDropdownSelect('January', '2023');
|
||||
|
||||
expected.start_time = END_TIME;
|
||||
await click(ts.calendarWidget.trigger);
|
||||
await click(ts.calendarWidget.currentMonth);
|
||||
|
||||
expected.start_time = getUnixTime(this.config.billingStartTimestamp);
|
||||
await click(ts.calendarWidget.trigger);
|
||||
await click(ts.calendarWidget.currentBillingPeriod);
|
||||
|
||||
expected = { end_time: getUnixTime(new Date('2023-12-31T00:00:00Z')) };
|
||||
await click(ts.calendarWidget.trigger);
|
||||
await click(ts.calendarWidget.customEndMonth);
|
||||
await click(ts.calendarWidget.previousYear);
|
||||
await click(ts.calendarWidget.calendarMonth('December'));
|
||||
});
|
||||
|
||||
test('it should render namespace and auth mount filters', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
this.namespace = 'root';
|
||||
this.mountPath = 'auth/authid0';
|
||||
|
||||
let assertion = (params) =>
|
||||
assert.deepEqual(params, { ns: undefined, mountPath: undefined }, 'Auth mount cleared with namespace');
|
||||
this.onFilterChange = (params) => {
|
||||
if (assertion) {
|
||||
assertion(params);
|
||||
}
|
||||
const keys = Object.keys(params);
|
||||
this.namespace = keys.includes('ns') ? params.ns : this.namespace;
|
||||
this.mountPath = keys.includes('mountPath') ? params.mountPath : this.mountPath;
|
||||
};
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(ts.counts.namespaces).includesText(this.namespace, 'Selected namespace renders');
|
||||
assert.dom(ts.counts.mountPaths).includesText(this.mountPath, 'Selected auth mount renders');
|
||||
|
||||
await click(`${ts.counts.namespaces} button`);
|
||||
// this is only necessary in tests since SearchSelect does not respond to initialValue changes
|
||||
// in the app the component is rerender on query param change
|
||||
assertion = null;
|
||||
await click(`${ts.counts.mountPaths} button`);
|
||||
|
||||
assertion = (params) => assert.true(params.ns.includes('ns/'), 'Namespace value sent on change');
|
||||
await selectChoose(ts.counts.namespaces, '.ember-power-select-option', 0);
|
||||
|
||||
assertion = (params) =>
|
||||
assert.true(params.mountPath.includes('auth/'), 'Auth mount value sent on change');
|
||||
await selectChoose(ts.counts.mountPaths, '.ember-power-select-option', 0);
|
||||
});
|
||||
|
||||
test('it should render start time discrepancy alert', async function (assert) {
|
||||
this.startTimestamp = getUnixTime(new Date('2022-06-01T00:00:00Z'));
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom(ts.counts.startDiscrepancy)
|
||||
.hasText(
|
||||
'Warning You requested data from June 2022. We only have data from October 2023, and that is what is being shown here.',
|
||||
'Start discrepancy alert renders'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should render empty state for no start or license start time', async function (assert) {
|
||||
this.startTimestamp = null;
|
||||
this.config.billingStartTimestamp = null;
|
||||
this.activity = {};
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(ts.emptyStateTitle).hasText('No start date found', 'Empty state renders');
|
||||
assert.dom(ts.counts.startDropdown).exists('Date dropdown renders when start time is not provided');
|
||||
});
|
||||
|
||||
test('it should render catch all empty state', async function (assert) {
|
||||
this.activity.total = null;
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom(ts.emptyStateTitle)
|
||||
.hasText('No data received from October 2023 to January 2024', 'Empty state renders');
|
||||
});
|
||||
});
|
||||
122
ui/tests/integration/components/clients/page/sync-test.js
Normal file
122
ui/tests/integration/components/clients/page/sync-test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { render, findAll } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { SELECTORS } from 'vault/tests/helpers/clients';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
import { dateFormat } from 'core/helpers/date-format';
|
||||
|
||||
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
|
||||
const END_TIME = getUnixTime(new Date('2024-01-31T23:59:59Z'));
|
||||
const { syncTab, charts, usageStats } = SELECTORS;
|
||||
|
||||
module('Integration | Component | clients | Clients::Page::Sync', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
clientsHandler(this.server);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
const activityQuery = {
|
||||
start_time: { timestamp: START_TIME },
|
||||
end_time: { timestamp: END_TIME },
|
||||
};
|
||||
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
|
||||
this.startTimestamp = START_TIME;
|
||||
this.endTimestamp = END_TIME;
|
||||
this.renderComponent = () =>
|
||||
render(hbs`
|
||||
<Clients::Page::Sync
|
||||
@activity={{this.activity}}
|
||||
@versionHistory={{this.versionHistory}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@namespace={{this.countsController.ns}}
|
||||
@mountPath={{this.countsController.mountPath}}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
test('it should render with full month activity data', async function (assert) {
|
||||
assert.expect(4 + this.activity.byMonth.length);
|
||||
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
|
||||
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
|
||||
await this.renderComponent();
|
||||
assert
|
||||
.dom(syncTab.total)
|
||||
.hasText(
|
||||
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
|
||||
`renders correct total sync stat ${expectedTotal}`
|
||||
);
|
||||
assert
|
||||
.dom(syncTab.average)
|
||||
.hasText(
|
||||
`Average sync clients per month ${expectedAvg}`,
|
||||
`renders correct average sync stat ${expectedAvg}`
|
||||
);
|
||||
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
withTimeZone: true,
|
||||
});
|
||||
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders response timestamp');
|
||||
|
||||
// assert bar chart is correct
|
||||
findAll(`${charts.chart('Secrets sync usage')} ${charts.xAxisLabel}`).forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.activity.byMonth[i].month}`,
|
||||
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
|
||||
);
|
||||
});
|
||||
assert
|
||||
.dom(charts.dataBar)
|
||||
.exists(
|
||||
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length },
|
||||
'renders correct number of data bars'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should render empty state for no monthly data', async function (assert) {
|
||||
assert.expect(5);
|
||||
this.activity.set('byMonth', []);
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(charts.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
|
||||
assert.dom(SELECTORS.emptyStateTitle).hasText('No monthly secrets sync clients');
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
withTimeZone: true,
|
||||
});
|
||||
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
|
||||
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
|
||||
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
|
||||
});
|
||||
|
||||
test('it should render stats without chart for a single month', async function (assert) {
|
||||
assert.expect(4);
|
||||
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
|
||||
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
|
||||
const total = formatNumber([this.activity.total.secret_syncs]);
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(charts.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
|
||||
assert
|
||||
.dom(usageStats)
|
||||
.hasText(
|
||||
`Secrets sync usage This data can be used to understand how many secrets sync clients have been used for this date range. A secret with a configured sync destination would qualify as a unique and active client. Total sync clients ${total}`,
|
||||
'renders sync stats instead of chart'
|
||||
);
|
||||
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
|
||||
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
|
||||
});
|
||||
});
|
||||
188
ui/tests/integration/components/clients/page/token-test.js
Normal file
188
ui/tests/integration/components/clients/page/token-test.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { render, findAll } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import { dateFormat } from 'core/helpers/date-format';
|
||||
import { SELECTORS as ts } from 'vault/tests/helpers/clients';
|
||||
|
||||
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
|
||||
const END_TIME = getUnixTime(new Date('2024-01-31T23:59:59Z'));
|
||||
|
||||
module('Integration | Component | clients | Page::Token', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
clientsHandler(this.server);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const activityQuery = {
|
||||
start_time: { timestamp: START_TIME },
|
||||
end_time: { timestamp: END_TIME },
|
||||
};
|
||||
this.activity = await store.queryRecord('clients/activity', activityQuery);
|
||||
this.newActivity = this.activity.byMonth.map((d) => d.new_clients);
|
||||
this.versionHistory = await store
|
||||
.findAll('clients/version-history')
|
||||
.then((response) => {
|
||||
return response.map(({ version, previousVersion, timestampInstalled }) => {
|
||||
return {
|
||||
version,
|
||||
previousVersion,
|
||||
timestampInstalled,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => []);
|
||||
this.startTimestamp = START_TIME;
|
||||
this.endTimestamp = END_TIME;
|
||||
this.renderComponent = () =>
|
||||
render(hbs`
|
||||
<Clients::Page::Token
|
||||
@activity={{this.activity}}
|
||||
@versionHistory={{this.versionHistory}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@namespace={{this.ns}}
|
||||
@mountPath={{this.mountPath}}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
test('it should render monthly total chart', async function (assert) {
|
||||
const getAverage = (data) => {
|
||||
const average = ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
|
||||
return (count += calculateAverage(data, key) || 0);
|
||||
}, 0);
|
||||
return formatNumber([average]);
|
||||
};
|
||||
const expectedTotal = getAverage(this.activity.byMonth);
|
||||
const expectedNew = getAverage(this.newActivity);
|
||||
const chart = ts.charts.chart('monthly total');
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Average total clients per month'))
|
||||
.hasText(expectedTotal, 'renders correct total clients');
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Average new clients per month'))
|
||||
.hasText(expectedNew, 'renders correct new clients');
|
||||
// assert bar chart is correct
|
||||
findAll(`${chart} ${ts.charts.bar.xAxisLabel}`).forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.activity.byMonth[i].month}`,
|
||||
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
|
||||
);
|
||||
});
|
||||
assert
|
||||
.dom(`${chart} ${ts.charts.bar.dataBar}`)
|
||||
.exists(
|
||||
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length * 2 },
|
||||
'renders correct number of data bars'
|
||||
);
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
withTimeZone: true,
|
||||
});
|
||||
assert
|
||||
.dom(`${chart} ${ts.charts.timestamp}`)
|
||||
.hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
|
||||
assert.dom(`${chart} ${ts.charts.legendLabel(1)}`).hasText('Entity clients', 'Legend label renders');
|
||||
assert.dom(`${chart} ${ts.charts.legendLabel(2)}`).hasText('Non-entity clients', 'Legend label renders');
|
||||
});
|
||||
|
||||
test('it should render monthly new chart', async function (assert) {
|
||||
const expectedNewEntity = formatNumber([calculateAverage(this.newActivity, 'entity_clients')]);
|
||||
const expectedNewNonEntity = formatNumber([calculateAverage(this.newActivity, 'non_entity_clients')]);
|
||||
const chart = ts.charts.chart('monthly new');
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Average new entity clients per month'))
|
||||
.hasText(expectedNewEntity, 'renders correct new entity clients');
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Average new non-entity clients per month'))
|
||||
.hasText(expectedNewNonEntity, 'renders correct new nonentity clients');
|
||||
// assert bar chart is correct
|
||||
findAll(`${chart} ${ts.charts.bar.xAxisLabel}`).forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.activity.byMonth[i].month}`,
|
||||
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
|
||||
);
|
||||
});
|
||||
assert
|
||||
.dom(`${chart} ${ts.charts.bar.dataBar}`)
|
||||
.exists(
|
||||
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length * 2 },
|
||||
'renders correct number of data bars'
|
||||
);
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
withTimeZone: true,
|
||||
});
|
||||
assert
|
||||
.dom(`${chart} ${ts.charts.timestamp}`)
|
||||
.hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
|
||||
assert.dom(`${chart} ${ts.charts.legendLabel(1)}`).hasText('Entity clients', 'Legend label renders');
|
||||
assert.dom(`${chart} ${ts.charts.legendLabel(2)}`).hasText('Non-entity clients', 'Legend label renders');
|
||||
});
|
||||
|
||||
test('it should render empty state for no new monthly data', async function (assert) {
|
||||
this.activity.byMonth = this.activity.byMonth.map((d) => ({
|
||||
...d,
|
||||
new_clients: { month: d.month },
|
||||
}));
|
||||
const chart = ts.charts.chart('monthly-new');
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(`${chart} ${ts.charts.verticalBar}`).doesNotExist('Chart does not render');
|
||||
assert.dom(`${chart} ${ts.charts.legend}`).doesNotExist('Legend does not render');
|
||||
assert.dom(ts.emptyStateTitle).hasText('No new clients');
|
||||
assert.dom(ts.tokenTab.entity).doesNotExist('New client counts does not exist');
|
||||
assert.dom(ts.tokenTab.nonentity).doesNotExist('Average new client counts does not exist');
|
||||
});
|
||||
|
||||
test('it should render usage stats', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
this.activity.endTime = this.activity.startTime;
|
||||
const {
|
||||
total: { entity_clients, non_entity_clients },
|
||||
} = this.activity;
|
||||
|
||||
const checkUsage = () => {
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Total clients'))
|
||||
.hasText(formatNumber([entity_clients + non_entity_clients]), 'Total clients value renders');
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Entity clients'))
|
||||
.hasText(formatNumber([entity_clients]), 'Entity clients value renders');
|
||||
assert
|
||||
.dom(ts.charts.statTextValue('Non-entity clients'))
|
||||
.hasText(formatNumber([non_entity_clients]), 'Non-entity clients value renders');
|
||||
};
|
||||
|
||||
// total usage should display for single month query
|
||||
await this.renderComponent();
|
||||
checkUsage();
|
||||
|
||||
// total usage should display when there is no monthly data
|
||||
this.activity.byMonth = null;
|
||||
await this.renderComponent();
|
||||
checkUsage();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
|
||||
.hasAttribute('href', 'https://developer.hashicorp.com/vault/tutorials/monitoring/usage-metrics');
|
||||
});
|
||||
|
||||
test('it renders with data', async function (assert) {
|
||||
test('it renders with token data', async function (assert) {
|
||||
this.set('counts', {
|
||||
clients: 17,
|
||||
entity_clients: 7,
|
||||
@@ -38,18 +38,40 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
|
||||
});
|
||||
await render(hbs`<Clients::UsageStats @totalUsageCounts={{this.counts}} />`);
|
||||
|
||||
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts even with no data passed');
|
||||
assert.dom('[data-test-stat-text="total-clients"]').exists('Total clients exists');
|
||||
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts');
|
||||
assert
|
||||
.dom('[data-test-stat-text="total-clients"] .stat-value')
|
||||
.hasText('17', 'Total clients shows passed value');
|
||||
assert.dom('[data-test-stat-text="entity-clients"]').exists('Entity clients exists');
|
||||
assert
|
||||
.dom('[data-test-stat-text="entity-clients"] .stat-value')
|
||||
.hasText('7', 'entity clients shows passed value');
|
||||
assert.dom('[data-test-stat-text="non-entity-clients"]').exists('Non entity clients exists');
|
||||
assert
|
||||
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
|
||||
.hasText('10', 'non entity clients shows passed value');
|
||||
});
|
||||
|
||||
test('it renders with full totals data', async function (assert) {
|
||||
this.set('counts', {
|
||||
clients: 22,
|
||||
entity_clients: 7,
|
||||
non_entity_clients: 10,
|
||||
secret_syncs: 5,
|
||||
});
|
||||
|
||||
await render(hbs`<Clients::UsageStats @totalUsageCounts={{this.counts}} />`);
|
||||
|
||||
assert.dom('[data-test-stat-text]').exists({ count: 4 }, 'Renders 4 Stat texts');
|
||||
assert
|
||||
.dom('[data-test-stat-text="total-clients"] .stat-value')
|
||||
.hasText('22', 'Total clients shows passed value');
|
||||
assert
|
||||
.dom('[data-test-stat-text="entity-clients"] .stat-value')
|
||||
.hasText('7', 'entity clients shows passed value');
|
||||
assert
|
||||
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
|
||||
.hasText('10', 'non entity clients shows passed value');
|
||||
assert
|
||||
.dom('[data-test-stat-text="secret-syncs"] .stat-value')
|
||||
.hasText('5', 'secrets sync clients shows passed value');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -882,7 +882,8 @@ module('Integration | Util | client count utils', function (hooks) {
|
||||
const byNamespaceKeyObject = namespaceArrayToObject(
|
||||
totalClientsByNamespace,
|
||||
newClientsByNamespace,
|
||||
'10/21'
|
||||
'10/21',
|
||||
'2021-10-01T00:00:00Z'
|
||||
);
|
||||
|
||||
assert.propEqual(
|
||||
@@ -923,7 +924,7 @@ module('Integration | Util | client count utils', function (hooks) {
|
||||
|
||||
assert.propEqual(
|
||||
{},
|
||||
namespaceArrayToObject(null, null, '10/21'),
|
||||
namespaceArrayToObject(null, null, '10/21', 'timestamp-here'),
|
||||
'returns an empty object when totalClientsByNamespace = null'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { formatNumbers, formatTooltipNumber, calculateAverage } from 'vault/utils/chart-helpers';
|
||||
import { formatNumbers, calculateAverage, calculateSum } from 'vault/utils/chart-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
const SMALL_NUMBERS = [0, 7, 27, 103, 999];
|
||||
const LARGE_NUMBERS = {
|
||||
1001: '1k',
|
||||
1245: '1.2k',
|
||||
33777: '34k',
|
||||
532543: '530k',
|
||||
2100100: '2.1M',
|
||||
@@ -17,7 +18,7 @@ const LARGE_NUMBERS = {
|
||||
|
||||
module('Unit | Utility | chart-helpers', function () {
|
||||
test('formatNumbers renders number correctly', function (assert) {
|
||||
assert.expect(11);
|
||||
assert.expect(12);
|
||||
const method = formatNumbers();
|
||||
assert.ok(method);
|
||||
SMALL_NUMBERS.forEach(function (num) {
|
||||
@@ -29,11 +30,6 @@ module('Unit | Utility | chart-helpers', function () {
|
||||
});
|
||||
});
|
||||
|
||||
test('formatTooltipNumber renders number correctly', function (assert) {
|
||||
const formatted = formatTooltipNumber(120300200100);
|
||||
assert.strictEqual(formatted.length, 15, 'adds punctuation at proper place for large numbers');
|
||||
});
|
||||
|
||||
test('calculateAverage is accurate', function (assert) {
|
||||
const testArray1 = [
|
||||
{ label: 'foo', value: 10 },
|
||||
@@ -63,4 +59,10 @@ module('Unit | Utility | chart-helpers', function () {
|
||||
'returns null when object key does not exist at all'
|
||||
);
|
||||
});
|
||||
|
||||
test('calculateSum adds array of numbers', function (assert) {
|
||||
assert.strictEqual(calculateSum([2, 3]), 5, 'it sums array');
|
||||
assert.strictEqual(calculateSum(['one', 2]), null, 'returns null if array contains non-integers');
|
||||
assert.strictEqual(calculateSum('not an array'), null, 'returns null if an array is not passed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import KvSecretDataModel from 'vault/models/kv/data';
|
||||
import KvSecretMetadataModel from 'vault/models/kv/metadata';
|
||||
import PkiActionModel from 'vault/models/pki/action';
|
||||
import PkiCertificateGenerateModel from 'vault/models/pki/certificate/generate';
|
||||
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 {
|
||||
@@ -15,6 +18,9 @@ declare module 'ember-data/types/registries/model' {
|
||||
'pki/certificate/generate': PkiCertificateGenerateModel;
|
||||
'kv/data': KvSecretDataModel;
|
||||
'kv/metadata': KvSecretMetadataModel;
|
||||
'clients/activity': ClientsActivityModel;
|
||||
'clients/config': ClientsConfigModel;
|
||||
'clients/version-history': ClientsVersionHistoryModel;
|
||||
// Catchall for any other models
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
25
ui/types/vault/charts/client-counts.d.ts
vendored
Normal file
25
ui/types/vault/charts/client-counts.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// Count and EmptyCount are mutually exclusive
|
||||
// but that's hard to represent in an interface
|
||||
// so for now we just have both
|
||||
interface Count {
|
||||
clients?: number;
|
||||
entity_clients?: number;
|
||||
non_entity_clients?: number;
|
||||
secret_syncs?: number;
|
||||
}
|
||||
interface EmptyCount {
|
||||
count?: null;
|
||||
}
|
||||
interface Timestamp {
|
||||
month: string; // eg. 12/22
|
||||
timestamp: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface MonthlyChartData extends Count, EmptyCount, Timestamp {
|
||||
new_clients?: Count;
|
||||
}
|
||||
50
ui/types/vault/models/clients/activity.d.ts
vendored
Normal file
50
ui/types/vault/models/clients/activity.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import type { Model } from 'vault/app-types';
|
||||
|
||||
interface ClientActivityTotals {
|
||||
clients: number;
|
||||
entity_clients: number;
|
||||
non_entity_clients: number;
|
||||
secret_syncs: number;
|
||||
}
|
||||
|
||||
interface ClientActivityNestedCount extends ClientActivityTotals {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ClientActivityNewClients extends ClientActivityTotals {
|
||||
month: string;
|
||||
mounts?: ClientActivityNestedCount[];
|
||||
namespaces?: ClientActivityNestedCount[];
|
||||
}
|
||||
|
||||
interface ClientActivityNamespace extends ClientActivityNestedCount {
|
||||
mounts: ClientActivityNestedCount[];
|
||||
}
|
||||
|
||||
interface ClientActivityResourceByKey extends ClientActivityTotals {
|
||||
month: 'string';
|
||||
mounts_by_key: { [key: string]: ClientActivityResourceByKey };
|
||||
new_clients: ClientActivityNewClients;
|
||||
}
|
||||
|
||||
interface ClientActivityMonthly extends ClientActivityTotals {
|
||||
month: string;
|
||||
timestamp: string;
|
||||
namespaces: ClientActivityNamespace[];
|
||||
namespaces_by_key: { [key: string]: ClientActivityResourceByKey };
|
||||
new_clients: ClientActivityNewClients;
|
||||
}
|
||||
|
||||
export default interface ClientsActivityModel extends Model {
|
||||
byMonth: ClientActivityMonthly[];
|
||||
byNamespace: ClientActivityNamespace[];
|
||||
total: ClientActivityTotals;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
responseTimestamp: string;
|
||||
}
|
||||
15
ui/types/vault/models/clients/config.d.ts
vendored
Normal file
15
ui/types/vault/models/clients/config.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* 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;
|
||||
}
|
||||
12
ui/types/vault/models/clients/version-history.d.ts
vendored
Normal file
12
ui/types/vault/models/clients/version-history.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* 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;
|
||||
}
|
||||
139
ui/yarn.lock
139
ui/yarn.lock
@@ -6429,6 +6429,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lineal-viz/lineal@npm:^0.5.1":
|
||||
version: 0.5.1
|
||||
resolution: "@lineal-viz/lineal@npm:0.5.1"
|
||||
dependencies:
|
||||
"@embroider/addon-shim": ^1.0.0
|
||||
d3-array: ^3.2.0
|
||||
d3-scale: ^4.0.2
|
||||
d3-shape: ^3.1.0
|
||||
ember-cached-decorator-polyfill: ^1.0.1
|
||||
ember-modifier: ^3.2.7
|
||||
ember-resize-modifier: ^0.4.1
|
||||
checksum: be47dbac62d65f01121d78b8ed393c7c5aa7cb520755a504d912b44054a478a16b9834208f031cb3cd1710c3f04008a91aa41b36ed544d0a738a09607d3c29ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lint-todo/utils@npm:^13.0.3":
|
||||
version: 13.0.3
|
||||
resolution: "@lint-todo/utils@npm:13.0.3"
|
||||
@@ -12736,6 +12751,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"csstype@npm:^3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "csstype@npm:3.1.3"
|
||||
checksum: 8db785cc92d259102725b3c694ec0c823f5619a84741b5c7991b8ad135dfaa66093038a1cc63e03361a6cd28d122be48f2106ae72334e067dd619a51f49eddf7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cyclist@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "cyclist@npm:1.0.1"
|
||||
@@ -12750,6 +12772,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.2.0":
|
||||
version: 3.2.4
|
||||
resolution: "d3-array@npm:3.2.4"
|
||||
dependencies:
|
||||
internmap: 1 - 2
|
||||
checksum: a5976a6d6205f69208478bb44920dd7ce3e788c9dceb86b304dbe401a4bfb42ecc8b04c20facde486e9adcb488b5d1800d49393a3f81a23902b68158e12cddd0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-axis@npm:1, d3-axis@npm:^1.0.8":
|
||||
version: 1.0.12
|
||||
resolution: "d3-axis@npm:1.0.12"
|
||||
@@ -12794,6 +12825,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-color@npm:1 - 3":
|
||||
version: 3.1.0
|
||||
resolution: "d3-color@npm:3.1.0"
|
||||
checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-contour@npm:1":
|
||||
version: 1.3.2
|
||||
resolution: "d3-contour@npm:1.3.2"
|
||||
@@ -12876,6 +12914,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-format@npm:1 - 3":
|
||||
version: 3.1.0
|
||||
resolution: "d3-format@npm:3.1.0"
|
||||
checksum: f345ec3b8ad3cab19bff5dead395bd9f5590628eb97a389b1dd89f0b204c7c4fc1d9520f13231c2c7cf14b7c9a8cf10f8ef15bde2befbab41454a569bd706ca2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-geo@npm:1":
|
||||
version: 1.12.1
|
||||
resolution: "d3-geo@npm:1.12.1"
|
||||
@@ -12901,6 +12946,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-interpolate@npm:1.2.0 - 3":
|
||||
version: 3.0.1
|
||||
resolution: "d3-interpolate@npm:3.0.1"
|
||||
dependencies:
|
||||
d3-color: 1 - 3
|
||||
checksum: a42ba314e295e95e5365eff0f604834e67e4a3b3c7102458781c477bd67e9b24b6bb9d8e41ff5521050a3f2c7c0c4bbbb6e187fd586daa3980943095b267e78b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-path@npm:1":
|
||||
version: 1.0.9
|
||||
resolution: "d3-path@npm:1.0.9"
|
||||
@@ -12908,6 +12962,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-path@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "d3-path@npm:3.1.0"
|
||||
checksum: 2306f1bd9191e1eac895ec13e3064f732a85f243d6e627d242a313f9777756838a2215ea11562f0c7630c7c3b16a19ec1fe0948b1c82f3317fac55882f6ee5d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-polygon@npm:1":
|
||||
version: 1.0.6
|
||||
resolution: "d3-polygon@npm:1.0.6"
|
||||
@@ -12968,6 +13029,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-scale@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "d3-scale@npm:4.0.2"
|
||||
dependencies:
|
||||
d3-array: 2.10.0 - 3
|
||||
d3-format: 1 - 3
|
||||
d3-interpolate: 1.2.0 - 3
|
||||
d3-time: 2.1.1 - 3
|
||||
d3-time-format: 2 - 4
|
||||
checksum: a9c770d283162c3bd11477c3d9d485d07f8db2071665f1a4ad23eec3e515e2cefbd369059ec677c9ac849877d1a765494e90e92051d4f21111aa56791c98729e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-selection-multi@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "d3-selection-multi@npm:1.0.1"
|
||||
@@ -12994,6 +13068,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-shape@npm:^3.1.0":
|
||||
version: 3.2.0
|
||||
resolution: "d3-shape@npm:3.2.0"
|
||||
dependencies:
|
||||
d3-path: ^3.1.0
|
||||
checksum: de2af5fc9a93036a7b68581ca0bfc4aca2d5a328aa7ba7064c11aedd44d24f310c20c40157cb654359d4c15c3ef369f95ee53d71221017276e34172c7b719cfa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time-format@npm:2 - 4":
|
||||
version: 4.1.0
|
||||
resolution: "d3-time-format@npm:4.1.0"
|
||||
dependencies:
|
||||
d3-time: 1 - 3
|
||||
checksum: 7342bce28355378152bbd4db4e275405439cabba082d9cd01946d40581140481c8328456d91740b0fe513c51ec4a467f4471ffa390c7e0e30ea30e9ec98fcdf4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time-format@npm:2, d3-time-format@npm:^2.1.1":
|
||||
version: 2.3.0
|
||||
resolution: "d3-time-format@npm:2.3.0"
|
||||
@@ -13010,6 +13102,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3":
|
||||
version: 3.1.0
|
||||
resolution: "d3-time@npm:3.1.0"
|
||||
dependencies:
|
||||
d3-array: 2 - 3
|
||||
checksum: 613b435352a78d9f31b7f68540788186d8c331b63feca60ad21c88e9db1989fe888f97f242322ebd6365e45ec3fb206a4324cd4ca0dfffa1d9b5feb856ba00a7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-timer@npm:1":
|
||||
version: 1.0.10
|
||||
resolution: "d3-timer@npm:1.0.10"
|
||||
@@ -15340,7 +15441,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ember-modifier@npm:^3.2.7":
|
||||
"ember-modifier@npm:^3.2.0, ember-modifier@npm:^3.2.7":
|
||||
version: 3.2.7
|
||||
resolution: "ember-modifier@npm:3.2.7"
|
||||
dependencies:
|
||||
@@ -15422,6 +15523,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ember-resize-modifier@npm:^0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "ember-resize-modifier@npm:0.4.1"
|
||||
dependencies:
|
||||
ember-cli-babel: ^7.26.11
|
||||
ember-cli-htmlbars: ^6.0.1
|
||||
ember-modifier: ^3.2.0
|
||||
checksum: 6363c9bc240678ecbe538f655d91a1fd79b7ac2e72f2fe7db70141f45e846bb685d4a10640ed1e59e2f6fae39ad3ca09fb2d60f02493f2db43dd5c38c7823c90
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ember-resolver@npm:^10.0.0":
|
||||
version: 10.1.1
|
||||
resolution: "ember-resolver@npm:10.1.1"
|
||||
@@ -15609,6 +15721,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ember-style-modifier@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "ember-style-modifier@npm:4.1.0"
|
||||
dependencies:
|
||||
"@babel/core": ^7.23.6
|
||||
csstype: ^3.1.3
|
||||
ember-auto-import: ^2.7.0
|
||||
ember-cli-babel: ^8.2.0
|
||||
ember-modifier: ^3.2.7 || ^4.0.0
|
||||
peerDependencies:
|
||||
"@ember/string": ^3.0.1
|
||||
ember-source: ">= 4.12.0"
|
||||
checksum: a83a0328210d7ec6e12995ded1b8ace876245fcb025ba380cb034770bc717628d6ba142c947087c6500b9bf6993225b32d57f1e1371e009e4396a5fd3c65b190
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ember-svg-jar@npm:2.4.0":
|
||||
version: 2.4.0
|
||||
resolution: "ember-svg-jar@npm:2.4.0"
|
||||
@@ -19150,6 +19278,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"internmap@npm:1 - 2":
|
||||
version: 2.0.3
|
||||
resolution: "internmap@npm:2.0.3"
|
||||
checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"invariant@npm:^2.2.2":
|
||||
version: 2.2.4
|
||||
resolution: "invariant@npm:2.2.4"
|
||||
@@ -27845,6 +27980,7 @@ __metadata:
|
||||
"@hashicorp/design-system-components": ^3.4.0
|
||||
"@hashicorp/ember-flight-icons": ^4.0.5
|
||||
"@icholy/duration": ^5.1.0
|
||||
"@lineal-viz/lineal": ^0.5.1
|
||||
"@tsconfig/ember": ^1.0.1
|
||||
"@types/ember": ^4.0.2
|
||||
"@types/ember-data": ^4.4.6
|
||||
@@ -27939,6 +28075,7 @@ __metadata:
|
||||
ember-service-worker: "meirish/ember-service-worker#configurable-scope"
|
||||
ember-sinon: ^4.0.0
|
||||
ember-source: ~4.12.0
|
||||
ember-style-modifier: ^4.1.0
|
||||
ember-svg-jar: 2.4.0
|
||||
ember-template-lint: 5.7.2
|
||||
ember-template-lint-plugin-prettier: 4.0.0
|
||||
|
||||
Reference in New Issue
Block a user