diff --git a/changelog/24752.txt b/changelog/24752.txt new file mode 100644 index 0000000000..736684af28 --- /dev/null +++ b/changelog/24752.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Separates out client counts dashboard to overview and entity/non-entity tabs +``` \ No newline at end of file diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js index fae8e503d6..3a3b2b9986 100644 --- a/ui/app/adapters/clients/activity.js +++ b/ui/app/adapters/clients/activity.js @@ -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 }; } diff --git a/ui/app/app.js b/ui/app/app.js index 5564cb86e9..38b93d6b04 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -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', }, }, }, diff --git a/ui/app/components/clients/activity.ts b/ui/app/components/clients/activity.ts new file mode 100644 index 0000000000..7e57618cc7 --- /dev/null +++ b/ui/app/components/clients/activity.ts @@ -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 { + 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; + } +} diff --git a/ui/app/templates/components/clients/attribution.hbs b/ui/app/components/clients/attribution.hbs similarity index 82% rename from ui/app/templates/components/clients/attribution.hbs rename to ui/app/components/clients/attribution.hbs index 5418b26b5b..a88f4f74fb 100644 --- a/ui/app/templates/components/clients/attribution.hbs +++ b/ui/app/components/clients/attribution.hbs @@ -31,7 +31,7 @@

{{this.chartText.newCopy}}

@@ -40,22 +40,14 @@

Total clients

{{this.chartText.totalCopy}}

- +
{{else}}
- +

{{this.chartText.totalCopy}}

@@ -71,9 +63,10 @@

{{format-number this.topClientCounts.clients}}

{{/if}} -
- {{capitalize (get @chartLegend "0.label")}} - {{capitalize (get @chartLegend "1.label")}} +
+ {{#each this.attributionLegend as |legend idx|}} + {{capitalize legend.label}} + {{/each}}
{{else}}
@@ -96,9 +89,16 @@

- 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. +

+

+ The + mount_path + for secrets sync clients is the KV v2 engine path and for entity/non-entity clients is the corresponding + authentication method path.

SELECTED DATE {{if this.formattedEndDate " RANGE"}}

diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index 5c6e58b9a7..611679f8aa 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -18,7 +18,6 @@ import { format, isSameMonth } from 'date-fns'; * @example * ```js * * ``` - * @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}`; } diff --git a/ui/app/components/clients/chart-container.hbs b/ui/app/components/clients/chart-container.hbs new file mode 100644 index 0000000000..7a2e6ce229 --- /dev/null +++ b/ui/app/components/clients/chart-container.hbs @@ -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 }} + +

+
+

{{@title}}

+

+ {{@description}} +

+
+ + {{#if @hasChartData}} + {{#if (has-block "subTitle")}} +
+ {{yield to="subTitle"}} +
+ {{/if}} + + {{#if (has-block "stats")}} + {{yield to="stats"}} + {{/if}} + + {{#if (has-block "chart")}} +
+ {{yield to="chart"}} +
+ {{/if}} + + {{#if @legend}} +
+ {{#each @legend as |legend idx|}} + + {{capitalize legend.label}} + {{/each}} +
+ {{/if}} + + {{else}} +
+ {{yield to="emptyState"}} +
+ {{/if}} + + {{#if @timestamp}} +
+ Updated + {{date-format @timestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} +
+ {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/clients/charts/line.hbs b/ui/app/components/clients/charts/line.hbs new file mode 100644 index 0000000000..2acb916d27 --- /dev/null +++ b/ui/app/components/clients/charts/line.hbs @@ -0,0 +1,127 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} +
+ {{#if this.data}} +
+ + {{#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| + }} + + {{#if (and xScale.isValid yScale.isValid)}} + + + {{#each this.upgradedMonths as |d|}} + + {{/each}} + {{/if}} + + {{! this is here to qualify the scales }} + + {{#if (and xScale.isValid yScale.isValid)}} + {{#each this.data as |d|}} + {{#if (this.hasValue d.y)}} + + + {{/if}} + {{/each}} + {{/if}} + + + {{#if this.activeDatum}} +
+ +

{{date-format this.activeDatum.x "MMMM yyyy"}}

+

{{format-number this.activeDatum.y}} total clients

+

{{format-number (or this.activeDatum.new)}} new clients

+ {{#if this.activeDatum.tooltipUpgrade}} +
+

{{this.activeDatum.tooltipUpgrade}}

+ {{/if}} +
+
+ {{/if}} + {{/let}} +
+
+ {{else}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/clients/charts/line.ts b/ui/app/components/clients/charts/line.ts new file mode 100644 index 0000000000..d10484e020 --- /dev/null +++ b/ui/app/components/clients/charts/line.ts @@ -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 + * + * ``` + * @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 { + // 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}`; + }; +} diff --git a/ui/app/components/clients/charts/vertical-bar-basic.hbs b/ui/app/components/clients/charts/vertical-bar-basic.hbs new file mode 100644 index 0000000000..e2119a2f7c --- /dev/null +++ b/ui/app/components/clients/charts/vertical-bar-basic.hbs @@ -0,0 +1,115 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + {{#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| + }} + + + {{@chartTitle}} + + + {{#if (and xScale.isValid yScale.isValid)}} + + + {{/if}} + + {{#if (and xScale.isValid yScale.isValid)}} + {{#each this.chartData as |d|}} + + {{/each}} + {{/if}} + + {{#if this.activeDatum}} +
+
+

{{this.activeDatum.legendX}}

+

{{this.activeDatum.tooltip}}

+
+
+
+ {{/if}} + {{/let}} +
+
+{{#if @showTable}} +
+ Underlying data + + <:head as |H|> + + Month + Count of secret syncs + + + <:body as |B|> + {{#each this.chartData as |row|}} + + {{row.legendX}} + {{row.legendY}} + + {{/each}} + + +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/charts/vertical-bar-basic.ts b/ui/app/components/clients/charts/vertical-bar-basic.ts new file mode 100644 index 0000000000..e81ad07a86 --- /dev/null +++ b/ui/app/components/clients/charts/vertical-bar-basic.ts @@ -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 + + */ +export default class VerticalBarBasic extends Component { + 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(); + }; +} diff --git a/ui/app/templates/components/clients/config.hbs b/ui/app/components/clients/config.hbs similarity index 100% rename from ui/app/templates/components/clients/config.hbs rename to ui/app/components/clients/config.hbs diff --git a/ui/app/components/clients/dashboard.js b/ui/app/components/clients/dashboard.js deleted file mode 100644 index 03106cd263..0000000000 --- a/ui/app/components/clients/dashboard.js +++ /dev/null @@ -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 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; - } -} diff --git a/ui/app/templates/components/clients/error.hbs b/ui/app/components/clients/error.hbs similarity index 100% rename from ui/app/templates/components/clients/error.hbs rename to ui/app/components/clients/error.hbs diff --git a/ui/app/templates/components/clients/horizontal-bar-chart.hbs b/ui/app/components/clients/horizontal-bar-chart.hbs similarity index 84% rename from ui/app/templates/components/clients/horizontal-bar-chart.hbs rename to ui/app/components/clients/horizontal-bar-chart.hbs index 31e1f7a781..c1e6fec64c 100644 --- a/ui/app/templates/components/clients/horizontal-bar-chart.hbs +++ b/ui/app/components/clients/horizontal-bar-chart.hbs @@ -22,8 +22,10 @@ {{#modal-dialog tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="35px 0" }} -
-

{{this.tooltipText}}

+
+ {{#each this.tooltipText as |text|}} +

{{text}}

+ {{/each}}
{{/modal-dialog}} diff --git a/ui/app/components/clients/horizontal-bar-chart.js b/ui/app/components/clients/horizontal-bar-chart.js index 224ca22676..b21d651610 100644 --- a/ui/app/components/clients/horizontal-bar-chart.js +++ b/ui/app/components/clients/horizontal-bar-chart.js @@ -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'); diff --git a/ui/app/components/clients/line-chart.js b/ui/app/components/clients/line-chart.js deleted file mode 100644 index fe4e291975..0000000000 --- a/ui/app/components/clients/line-chart.js +++ /dev/null @@ -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 - * - * ``` - * @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; - }); - } -} diff --git a/ui/app/components/clients/monthly-usage.js b/ui/app/components/clients/monthly-usage.js deleted file mode 100644 index be0aebaee1..0000000000 --- a/ui/app/components/clients/monthly-usage.js +++ /dev/null @@ -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 - - * ``` - * @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' - ); - } -} diff --git a/ui/app/templates/components/clients/no-data.hbs b/ui/app/components/clients/no-data.hbs similarity index 100% rename from ui/app/templates/components/clients/no-data.hbs rename to ui/app/components/clients/no-data.hbs diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs new file mode 100644 index 0000000000..b6140df84e --- /dev/null +++ b/ui/app/components/clients/page/counts.hbs @@ -0,0 +1,146 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+

+ This dashboard surfaces Vault client usage over time. + Documentation is available here. + Date queries are sent in UTC. +

+ +

+ {{this.versionText.label}} +

+ +
+ {{#if this.formattedStartDate}} +

{{this.formattedStartDate}}

+ + {{else}} + + {{/if}} +
+ +

+ {{this.versionText.description}} +

+ + {{#if (eq @activity.id "no-data")}} + + {{else if @activityError}} + + {{else}} + {{#if (eq @config.enabled "Off")}} + + Tracking is disabled + + Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need + to + edit the configuration + to enable tracking again. + + + {{/if}} + + {{#if @startTimestamp}} +
+ FILTERS + + + + {{#if (or @namespace this.namespaces)}} + + {{/if}} + {{#if (or @mountPath this.mountPaths)}} + + {{/if}} + + +
+ {{/if}} + + {{#if this.filteredActivity}} + {{#if this.startTimeDiscrepancy}} + + Warning + + {{this.startTimeDiscrepancy}} + + + {{/if}} + + {{yield}} + + {{else if (and (not @config.billingStartTimestamp) (not @startTimestamp))}} + {{! Empty state for no billing/license start date }} + + {{else}} + + + + {{/if}} + {{/if}} +
+ +{{#if this.showBillingStartModal}} + + + Edit start month + + +

+ {{this.versionText.description}} +

+

{{this.versionText.label}}

+ +
+ + + +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts new file mode 100644 index 0000000000..55ccb21e7e --- /dev/null +++ b/ui/app/components/clients/page/counts.ts @@ -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 { + @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, + }); + } +} diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs new file mode 100644 index 0000000000..8ab73f4343 --- /dev/null +++ b/ui/app/components/clients/page/overview.hbs @@ -0,0 +1,27 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + +{{#if this.hasAttributionData}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/overview.ts b/ui/app/components/clients/page/overview.ts new file mode 100644 index 0000000000..ceaf8870d1 --- /dev/null +++ b/ui/app/components/clients/page/overview.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ActivityComponent from '../activity'; + +export default ActivityComponent; diff --git a/ui/app/components/clients/page/sync.hbs b/ui/app/components/clients/page/sync.hbs new file mode 100644 index 0000000000..6f31a37229 --- /dev/null +++ b/ui/app/components/clients/page/sync.hbs @@ -0,0 +1,59 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.isDateRange}} + + <:subTitle> + + + + <:stats> + + + + <:chart> + + + + <:emptyState> + + + +{{else}} +
+
+

{{this.title}}

+

{{this.description}}

+
+ +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/sync.ts b/ui/app/components/clients/page/sync.ts new file mode 100644 index 0000000000..e7238bcd70 --- /dev/null +++ b/ui/app/components/clients/page/sync.ts @@ -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.'; +} diff --git a/ui/app/components/clients/page/token.hbs b/ui/app/components/clients/page/token.hbs new file mode 100644 index 0000000000..f7e68c2f02 --- /dev/null +++ b/ui/app/components/clients/page/token.hbs @@ -0,0 +1,101 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if (and this.byMonthActivityData this.isDateRange)}} + + <:subTitle> +

Total monthly clients

+

+ Each client is counted once per month. This can help with capacity planning. +

+ + + <:stats> + + + + + + <:chart> + + +
+ + + <:stats> + + + + + + <:chart> + + + + <:emptyState> + + + +{{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 }} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/token.ts b/ui/app/components/clients/page/token.ts new file mode 100644 index 0000000000..fd00b32038 --- /dev/null +++ b/ui/app/components/clients/page/token.ts @@ -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; + } +} diff --git a/ui/app/components/clients/running-total.hbs b/ui/app/components/clients/running-total.hbs new file mode 100644 index 0000000000..8f5a77c984 --- /dev/null +++ b/ui/app/components/clients/running-total.hbs @@ -0,0 +1,119 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if (gt @byMonthActivityData.length 1)}} + + <:subTitle> + + + + <:stats> +
+
+ + +
+
+ + + + <:chart> + + +
+{{else}} + {{#let (get @byMonthActivityData "0") as |singleMonthData|}} + {{#if (and @isHistoricalMonth singleMonthData.new_clients.clients)}} +
+
+

Vault client counts

+

+ 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. +

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ {{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 in dashboard.hbs renders }} + + {{/if}} + {{/let}} +{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/running-total.js b/ui/app/components/clients/running-total.js deleted file mode 100644 index d3121a27b0..0000000000 --- a/ui/app/components/clients/running-total.js +++ /dev/null @@ -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 - - * ``` - - * @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]; - } -} diff --git a/ui/app/templates/components/clients/usage-stats.hbs b/ui/app/components/clients/usage-stats.hbs similarity index 69% rename from ui/app/templates/components/clients/usage-stats.hbs rename to ui/app/components/clients/usage-stats.hbs index 4dfbef264a..edfb473bfe 100644 --- a/ui/app/templates/components/clients/usage-stats.hbs +++ b/ui/app/components/clients/usage-stats.hbs @@ -19,10 +19,11 @@
@@ -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" />
+ {{#if (gte @totalUsageCounts.secret_syncs 0)}} +
+ +
+ {{/if}}
\ No newline at end of file diff --git a/ui/app/templates/components/clients/vertical-bar-chart.hbs b/ui/app/components/clients/vertical-bar-chart.hbs similarity index 84% rename from ui/app/templates/components/clients/vertical-bar-chart.hbs rename to ui/app/components/clients/vertical-bar-chart.hbs index 5359c0b23b..50256e585e 100644 --- a/ui/app/templates/components/clients/vertical-bar-chart.hbs +++ b/ui/app/components/clients/vertical-bar-chart.hbs @@ -25,10 +25,11 @@ {{#modal-dialog tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="10px 0" }} -
-

{{this.tooltipTotal}}

-

{{this.entityClients}}

-

{{this.nonEntityClients}}

+
+

{{this.tooltipTotal}}

+ {{#each this.tooltipStats as |stat|}} +

{{stat}}

+ {{/each}}
{{/modal-dialog}} diff --git a/ui/app/components/clients/vertical-bar-chart.js b/ui/app/components/clients/vertical-bar-chart.js index cb417e7e79..ac6af2dd86 100644 --- a/ui/app/components/clients/vertical-bar-chart.js +++ b/ui/app/components/clients/vertical-bar-chart.js @@ -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 diff --git a/ui/app/controllers/vault/cluster/clients/counts.ts b/ui/app/controllers/vault/cluster/clients/counts.ts new file mode 100644 index 0000000000..f9125de2f1 --- /dev/null +++ b/ui/app/controllers/vault/cluster/clients/counts.ts @@ -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); + } + }); + } + } +} diff --git a/ui/app/controllers/vault/cluster/clients/counts/overview.ts b/ui/app/controllers/vault/cluster/clients/counts/overview.ts new file mode 100644 index 0000000000..b5c60816ce --- /dev/null +++ b/ui/app/controllers/vault/cluster/clients/counts/overview.ts @@ -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; +} diff --git a/ui/app/controllers/vault/cluster/clients/counts/sync.ts b/ui/app/controllers/vault/cluster/clients/counts/sync.ts new file mode 100644 index 0000000000..a5acc1037d --- /dev/null +++ b/ui/app/controllers/vault/cluster/clients/counts/sync.ts @@ -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; +} diff --git a/ui/app/controllers/vault/cluster/clients/counts/token.ts b/ui/app/controllers/vault/cluster/clients/counts/token.ts new file mode 100644 index 0000000000..eaa3b3cbc0 --- /dev/null +++ b/ui/app/controllers/vault/cluster/clients/counts/token.ts @@ -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; +} diff --git a/ui/app/router.js b/ui/app/router.js index 4bfdf02cb9..b8c60245f5 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -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'); }); diff --git a/ui/app/routes/vault/cluster/clients.js b/ui/app/routes/vault/cluster/clients.ts similarity index 66% rename from ui/app/routes/vault/cluster/clients.js rename to ui/app/routes/vault/cluster/clients.ts index 35567d48a6..4b496a0371 100644 --- a/ui/app/routes/vault/cluster/clients.js +++ b/ui/app/routes/vault/cluster/clients.ts @@ -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); - } } diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts new file mode 100644 index 0000000000..9632162085 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -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, + }); + } + } +} diff --git a/ui/app/routes/vault/cluster/clients/counts/index.ts b/ui/app/routes/vault/cluster/clients/counts/index.ts new file mode 100644 index 0000000000..04b17de3f1 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/counts/index.ts @@ -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'); + } +} diff --git a/ui/app/routes/vault/cluster/clients/counts/overview.ts b/ui/app/routes/vault/cluster/clients/counts/overview.ts new file mode 100644 index 0000000000..44a3f92f38 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/counts/overview.ts @@ -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 {} diff --git a/ui/app/routes/vault/cluster/clients/counts/sync.ts b/ui/app/routes/vault/cluster/clients/counts/sync.ts new file mode 100644 index 0000000000..ef99d44bba --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/counts/sync.ts @@ -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 {} diff --git a/ui/app/routes/vault/cluster/clients/counts/token.ts b/ui/app/routes/vault/cluster/clients/counts/token.ts new file mode 100644 index 0000000000..44a3f92f38 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/counts/token.ts @@ -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 {} diff --git a/ui/app/routes/vault/cluster/clients/dashboard.js b/ui/app/routes/vault/cluster/clients/dashboard.js deleted file mode 100644 index 01069e01be..0000000000 --- a/ui/app/routes/vault/cluster/clients/dashboard.js +++ /dev/null @@ -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, - }; - } -} diff --git a/ui/app/routes/vault/cluster/clients/index.js b/ui/app/routes/vault/cluster/clients/index.js index 9a05c8d50c..4029dd4395 100644 --- a/ui/app/routes/vault/cluster/clients/index.js +++ b/ui/app/routes/vault/cluster/clients/index.js @@ -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'); } } diff --git a/ui/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss index 167d9f1125..f42a046d1e 100644 --- a/ui/app/styles/core/charts.scss +++ b/ui/app/styles/core/charts.scss @@ -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; +} diff --git a/ui/app/templates/components/clients/dashboard.hbs b/ui/app/templates/components/clients/dashboard.hbs deleted file mode 100644 index 4f5e3301dd..0000000000 --- a/ui/app/templates/components/clients/dashboard.hbs +++ /dev/null @@ -1,207 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -
-

- This dashboard will surface Vault client usage over time. Clients represent a user or service that has authenticated to - Vault. - Documentation is available here. - Date queries are sent in UTC. -

-

- {{this.versionText.label}} -

-
- {{#if this.formattedStartDate}} -

{{this.formattedStartDate}}

- - {{else}} - - {{/if}} -
-

- {{this.versionText.description}} -

- {{#if this.noActivityData}} - - {{else if this.errorObject}} - - {{else}} - {{#if (eq @model.config.enabled "Off")}} - - Tracking is disabled - - Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need - to - edit the configuration - to enable tracking again. - - - {{/if}} - {{#if (or this.totalUsageCounts this.hasAttributionData)}} -
- FILTERS - - - - {{#if this.namespaceArray}} - - {{/if}} - {{#if (not (is-empty this.authMethodOptions))}} - - {{/if}} - - -
- - {{#if (or this.upgradeDuringActivity this.startTimeDiscrepancy)}} - - Warning - -
    - {{#if this.startTimeDiscrepancy}} -
  • {{this.startTimeDiscrepancy}}
  • - {{/if}} - {{#if this.upgradeDuringActivity}} -
  • - {{this.upgradeVersionAndDate}} - {{this.upgradeExplanation}} - - Learn more here. - -
  • - {{/if}} -
-
-
- {{/if}} - {{#if this.isLoadingQuery}} - - {{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 }} - - {{/unless}} - {{#if this.byMonthActivityData}} - - {{/if}} - {{#if this.hasAttributionData}} - - {{/if}} - {{#if this.hasMultipleMonthsData}} - - {{/if}} - {{/if}} - {{/if}} - {{else if (and (not @model.licenseStartTimestamp) (not this.startMonthTimestamp))}} - {{! Empty state for no billing/license start date }} - - {{else}} - - - - {{/if}} - {{/if}} -
- -{{! BILLING START DATE MODAL }} -{{#if this.showBillingStartModal}} - - - Edit start month - - -

- {{this.versionText.description}} -

-

{{this.versionText.label}}

- -
- - - -
-{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/clients/line-chart.hbs b/ui/app/templates/components/clients/line-chart.hbs deleted file mode 100644 index c00e62026a..0000000000 --- a/ui/app/templates/components/clients/line-chart.hbs +++ /dev/null @@ -1,39 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{#if @dataset}} - - -{{else}} - -{{/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" - }} -
-

{{this.tooltipMonth}}

-

{{this.tooltipTotal}}

-

{{this.tooltipNew}}

- {{#if this.tooltipUpgradeText}} -
-

{{this.tooltipUpgradeText}}

- {{/if}} -
-
- {{/modal-dialog}} -{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/clients/monthly-usage.hbs b/ui/app/templates/components/clients/monthly-usage.hbs deleted file mode 100644 index 6610a43866..0000000000 --- a/ui/app/templates/components/clients/monthly-usage.hbs +++ /dev/null @@ -1,52 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -
-
-

Vault usage

-

- This data can be used to understand how many total clients are using Vault each month for this date range. -

-
- -
- -
- -
-

Total monthly clients

-

- Each client is counted once per month. This can help with capacity planning. -

-
- -
-

Average total clients per month

-

- {{format-number this.averageTotalClients}} -

-
- -
-

Average new clients per month

-

- {{format-number this.averageNewClients}} -

-
- -
- {{#if @responseTimestamp}} - Updated - {{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} - {{/if}} -
- - {{#if @verticalBarChartData}} -
- {{capitalize (get @chartLegend "0.label")}} - {{capitalize (get @chartLegend "1.label")}} -
- {{/if}} -
\ No newline at end of file diff --git a/ui/app/templates/components/clients/running-total.hbs b/ui/app/templates/components/clients/running-total.hbs deleted file mode 100644 index 7ff4ee7b8c..0000000000 --- a/ui/app/templates/components/clients/running-total.hbs +++ /dev/null @@ -1,149 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{#if (gt @byMonthActivityData.length 1)}} -
-
-
-

Vault client counts

-

- 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. -

-
- -
- -
- -
-

Running client total

-

The number of clients which interacted with Vault during this date range.

-
-
-

Entity clients

-

- {{format-number this.entityClientData.runningTotal}} -

-
- -
-

Non-entity clients

-

- {{format-number this.nonEntityClientData.runningTotal}} -

-
-
- -
-
- -
- -
-

New monthly clients

-

- Clients which interacted with Vault for the first time during this date range, displayed per month. -

-
- - {{#if this.hasAverageNewClients}} -
-

Average new entity clients per month

-

- {{format-number this.entityClientData.averageNewClients}} -

-
- -
-

Average new non-entity clients per month

-

- {{format-number this.nonEntityClientData.averageNewClients}} -

-
- {{/if}} - -
- {{#if @responseTimestamp}} - Updated - {{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} - {{/if}} -
- - {{#if this.hasAverageNewClients}} -
- {{capitalize (get @chartLegend "0.label")}} - {{capitalize (get @chartLegend "1.label")}} -
- {{/if}} -
-
-{{else}} - {{#if (and @isHistoricalMonth this.singleMonthData.new_clients.clients)}} -
-
-

Vault client counts

-

- 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. -

-
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
- -
-
-
- {{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 in dashboard.hbs renders }} - - {{/if}} -{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/dashboard/client-count-card.hbs b/ui/app/templates/components/dashboard/client-count-card.hbs index 6742455a5b..bdaea93f5b 100644 --- a/ui/app/templates/components/dashboard/client-count-card.hbs +++ b/ui/app/templates/components/dashboard/client-count-card.hbs @@ -9,7 +9,7 @@ Client count - Details + Details

diff --git a/ui/app/templates/vault/cluster/clients.hbs b/ui/app/templates/vault/cluster/clients.hbs index 40225fca7c..2a4942b6d5 100644 --- a/ui/app/templates/vault/cluster/clients.hbs +++ b/ui/app/templates/vault/cluster/clients.hbs @@ -14,13 +14,20 @@
+ `); + + 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` +
+ +
+ `); + 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` +
+ +
+ `); + + 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`
- -
- `); - - 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` -
-
`); - 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` -
- -
- `); - - 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`
- - - +
`); @@ -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` +
+ +
+ `); + 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 + }); }); diff --git a/ui/tests/integration/components/clients/monthly-usage-test.js b/ui/tests/integration/components/clients/monthly-usage-test.js deleted file mode 100644 index 6dc31979a0..0000000000 --- a/ui/tests/integration/components/clients/monthly-usage-test.js +++ /dev/null @@ -1,1485 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import sinon from 'sinon'; -import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; -import { formatRFC3339 } from 'date-fns'; -import { findAll } from '@ember/test-helpers'; -import { calculateAverage } from 'vault/utils/chart-helpers'; -import { formatNumber } from 'core/helpers/format-number'; -import timestamp from 'core/utils/timestamp'; - -module('Integration | Component | clients/monthly-usage', function (hooks) { - setupRenderingTest(hooks); - const DATASET = [ - { - month: '8/21', - timestamp: '2021-08-01T00:00:00Z', - counts: null, - namespaces: [], - new_clients: { - month: '8/21', - namespaces: [], - }, - namespaces_by_key: {}, - }, - { - month: '9/21', - clients: 19251, - entity_clients: 10713, - non_entity_clients: 8538, - namespaces: [ - { - label: 'root', - clients: 4852, - entity_clients: 3108, - non_entity_clients: 1744, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1598, - entity_clients: 687, - non_entity_clients: 911, - }, - { - label: 'path-1', - clients: 1429, - entity_clients: 981, - non_entity_clients: 448, - }, - { - label: 'path-4-with-over-18-characters', - clients: 965, - entity_clients: 720, - non_entity_clients: 245, - }, - { - label: 'path-2', - clients: 860, - entity_clients: 720, - non_entity_clients: 140, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 4702, - entity_clients: 3057, - non_entity_clients: 1645, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1686, - entity_clients: 926, - non_entity_clients: 760, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1525, - entity_clients: 789, - non_entity_clients: 736, - }, - { - label: 'path-2', - clients: 905, - entity_clients: 849, - non_entity_clients: 56, - }, - { - label: 'path-1', - clients: 586, - entity_clients: 493, - non_entity_clients: 93, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 4569, - entity_clients: 1871, - non_entity_clients: 2698, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 1534, - entity_clients: 619, - non_entity_clients: 915, - }, - { - label: 'path-3-with-over-18-characters', - clients: 1528, - entity_clients: 589, - non_entity_clients: 939, - }, - { - label: 'path-1', - clients: 828, - entity_clients: 612, - non_entity_clients: 216, - }, - { - label: 'path-2', - clients: 679, - entity_clients: 51, - non_entity_clients: 628, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 3771, - entity_clients: 2029, - non_entity_clients: 1742, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1249, - entity_clients: 793, - non_entity_clients: 456, - }, - { - label: 'path-1', - clients: 1046, - entity_clients: 444, - non_entity_clients: 602, - }, - { - label: 'path-2', - clients: 930, - entity_clients: 277, - non_entity_clients: 653, - }, - { - label: 'path-4-with-over-18-characters', - clients: 546, - entity_clients: 515, - non_entity_clients: 31, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1357, - entity_clients: 648, - non_entity_clients: 709, - mounts: [ - { - label: 'path-1', - clients: 613, - entity_clients: 23, - non_entity_clients: 590, - }, - { - label: 'path-3-with-over-18-characters', - clients: 543, - entity_clients: 465, - non_entity_clients: 78, - }, - { - label: 'path-2', - clients: 146, - entity_clients: 141, - non_entity_clients: 5, - }, - { - label: 'path-4-with-over-18-characters', - clients: 55, - entity_clients: 19, - non_entity_clients: 36, - }, - ], - }, - ], - namespaces_by_key: { - root: { - month: '9/21', - clients: 4852, - entity_clients: 3108, - non_entity_clients: 1744, - new_clients: { - month: '9/21', - label: 'root', - clients: 2525, - entity_clients: 1315, - non_entity_clients: 1210, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1598, - entity_clients: 687, - non_entity_clients: 911, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1055, - entity_clients: 257, - non_entity_clients: 798, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 1429, - entity_clients: 981, - non_entity_clients: 448, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 543, - entity_clients: 340, - non_entity_clients: 203, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 965, - entity_clients: 720, - non_entity_clients: 245, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 136, - entity_clients: 7, - non_entity_clients: 129, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 860, - entity_clients: 720, - non_entity_clients: 140, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 791, - entity_clients: 711, - non_entity_clients: 80, - }, - }, - }, - }, - 'test-ns-2/': { - month: '9/21', - clients: 4702, - entity_clients: 3057, - non_entity_clients: 1645, - new_clients: { - month: '9/21', - label: 'test-ns-2/', - clients: 1537, - entity_clients: 662, - non_entity_clients: 875, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1686, - entity_clients: 926, - non_entity_clients: 760, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 520, - entity_clients: 13, - non_entity_clients: 507, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 1525, - entity_clients: 789, - non_entity_clients: 736, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 499, - entity_clients: 197, - non_entity_clients: 302, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 905, - entity_clients: 849, - non_entity_clients: 56, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 398, - entity_clients: 370, - non_entity_clients: 28, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 586, - entity_clients: 493, - non_entity_clients: 93, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 120, - entity_clients: 82, - non_entity_clients: 38, - }, - }, - }, - }, - 'test-ns-1/': { - month: '9/21', - clients: 4569, - entity_clients: 1871, - non_entity_clients: 2698, - new_clients: { - month: '9/21', - label: 'test-ns-1/', - clients: 2712, - entity_clients: 879, - non_entity_clients: 1833, - }, - mounts_by_key: { - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 1534, - entity_clients: 619, - non_entity_clients: 915, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 740, - entity_clients: 39, - non_entity_clients: 701, - }, - }, - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1528, - entity_clients: 589, - non_entity_clients: 939, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1250, - entity_clients: 536, - non_entity_clients: 714, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 828, - entity_clients: 612, - non_entity_clients: 216, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 463, - entity_clients: 283, - non_entity_clients: 180, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 679, - entity_clients: 51, - non_entity_clients: 628, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 259, - entity_clients: 21, - non_entity_clients: 238, - }, - }, - }, - }, - 'test-ns-2-with-namespace-length-over-18-characters/': { - month: '9/21', - clients: 3771, - entity_clients: 2029, - non_entity_clients: 1742, - new_clients: { - month: '9/21', - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 2087, - entity_clients: 902, - non_entity_clients: 1185, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1249, - entity_clients: 793, - non_entity_clients: 456, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 472, - entity_clients: 260, - non_entity_clients: 212, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 1046, - entity_clients: 444, - non_entity_clients: 602, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 775, - entity_clients: 349, - non_entity_clients: 426, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 930, - entity_clients: 277, - non_entity_clients: 653, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 632, - entity_clients: 90, - non_entity_clients: 542, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 546, - entity_clients: 515, - non_entity_clients: 31, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 208, - entity_clients: 203, - non_entity_clients: 5, - }, - }, - }, - }, - 'test-ns-1-with-namespace-length-over-18-characters/': { - month: '9/21', - clients: 1357, - entity_clients: 648, - non_entity_clients: 709, - new_clients: { - month: '9/21', - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 560, - entity_clients: 189, - non_entity_clients: 371, - }, - mounts_by_key: { - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 613, - entity_clients: 23, - non_entity_clients: 590, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 318, - entity_clients: 12, - non_entity_clients: 306, - }, - }, - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 543, - entity_clients: 465, - non_entity_clients: 78, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 126, - entity_clients: 89, - non_entity_clients: 37, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 146, - entity_clients: 141, - non_entity_clients: 5, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 76, - entity_clients: 75, - non_entity_clients: 1, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 55, - entity_clients: 19, - non_entity_clients: 36, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 40, - entity_clients: 13, - non_entity_clients: 27, - }, - }, - }, - }, - }, - new_clients: { - month: '9/21', - clients: 9421, - entity_clients: 3947, - non_entity_clients: 5474, - namespaces: [ - { - label: 'test-ns-1/', - clients: 2712, - entity_clients: 879, - non_entity_clients: 1833, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1250, - entity_clients: 536, - non_entity_clients: 714, - }, - { - label: 'path-4-with-over-18-characters', - clients: 740, - entity_clients: 39, - non_entity_clients: 701, - }, - { - label: 'path-1', - clients: 463, - entity_clients: 283, - non_entity_clients: 180, - }, - { - label: 'path-2', - clients: 259, - entity_clients: 21, - non_entity_clients: 238, - }, - ], - }, - { - label: 'root', - clients: 2525, - entity_clients: 1315, - non_entity_clients: 1210, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1055, - entity_clients: 257, - non_entity_clients: 798, - }, - { - label: 'path-2', - clients: 791, - entity_clients: 711, - non_entity_clients: 80, - }, - { - label: 'path-1', - clients: 543, - entity_clients: 340, - non_entity_clients: 203, - }, - { - label: 'path-4-with-over-18-characters', - clients: 136, - entity_clients: 7, - non_entity_clients: 129, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 2087, - entity_clients: 902, - non_entity_clients: 1185, - mounts: [ - { - label: 'path-1', - clients: 775, - entity_clients: 349, - non_entity_clients: 426, - }, - { - label: 'path-2', - clients: 632, - entity_clients: 90, - non_entity_clients: 542, - }, - { - label: 'path-3-with-over-18-characters', - clients: 472, - entity_clients: 260, - non_entity_clients: 212, - }, - { - label: 'path-4-with-over-18-characters', - clients: 208, - entity_clients: 203, - non_entity_clients: 5, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 1537, - entity_clients: 662, - non_entity_clients: 875, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 520, - entity_clients: 13, - non_entity_clients: 507, - }, - { - label: 'path-4-with-over-18-characters', - clients: 499, - entity_clients: 197, - non_entity_clients: 302, - }, - { - label: 'path-2', - clients: 398, - entity_clients: 370, - non_entity_clients: 28, - }, - { - label: 'path-1', - clients: 120, - entity_clients: 82, - non_entity_clients: 38, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 560, - entity_clients: 189, - non_entity_clients: 371, - mounts: [ - { - label: 'path-1', - clients: 318, - entity_clients: 12, - non_entity_clients: 306, - }, - { - label: 'path-3-with-over-18-characters', - clients: 126, - entity_clients: 89, - non_entity_clients: 37, - }, - { - label: 'path-2', - clients: 76, - entity_clients: 75, - non_entity_clients: 1, - }, - { - label: 'path-4-with-over-18-characters', - clients: 40, - entity_clients: 13, - non_entity_clients: 27, - }, - ], - }, - ], - }, - }, - { - month: '10/21', - clients: 19417, - entity_clients: 10105, - non_entity_clients: 9312, - namespaces: [ - { - label: 'root', - clients: 4835, - entity_clients: 2364, - non_entity_clients: 2471, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1797, - entity_clients: 883, - non_entity_clients: 914, - }, - { - label: 'path-1', - clients: 1501, - entity_clients: 663, - non_entity_clients: 838, - }, - { - label: 'path-2', - clients: 1461, - entity_clients: 800, - non_entity_clients: 661, - }, - { - label: 'path-4-with-over-18-characters', - clients: 76, - entity_clients: 18, - non_entity_clients: 58, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 4027, - entity_clients: 1692, - non_entity_clients: 2335, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 1223, - entity_clients: 820, - non_entity_clients: 403, - }, - { - label: 'path-3-with-over-18-characters', - clients: 1110, - entity_clients: 111, - non_entity_clients: 999, - }, - { - label: 'path-1', - clients: 1034, - entity_clients: 462, - non_entity_clients: 572, - }, - { - label: 'path-2', - clients: 660, - entity_clients: 299, - non_entity_clients: 361, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 3924, - entity_clients: 2132, - non_entity_clients: 1792, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1411, - entity_clients: 765, - non_entity_clients: 646, - }, - { - label: 'path-2', - clients: 1205, - entity_clients: 382, - non_entity_clients: 823, - }, - { - label: 'path-1', - clients: 884, - entity_clients: 850, - non_entity_clients: 34, - }, - { - label: 'path-4-with-over-18-characters', - clients: 424, - entity_clients: 135, - non_entity_clients: 289, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 3639, - entity_clients: 2314, - non_entity_clients: 1325, - mounts: [ - { - label: 'path-1', - clients: 1062, - entity_clients: 781, - non_entity_clients: 281, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1021, - entity_clients: 609, - non_entity_clients: 412, - }, - { - label: 'path-2', - clients: 849, - entity_clients: 426, - non_entity_clients: 423, - }, - { - label: 'path-3-with-over-18-characters', - clients: 707, - entity_clients: 498, - non_entity_clients: 209, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 2992, - entity_clients: 1603, - non_entity_clients: 1389, - mounts: [ - { - label: 'path-1', - clients: 1140, - entity_clients: 480, - non_entity_clients: 660, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1058, - entity_clients: 651, - non_entity_clients: 407, - }, - { - label: 'path-2', - clients: 575, - entity_clients: 416, - non_entity_clients: 159, - }, - { - label: 'path-3-with-over-18-characters', - clients: 219, - entity_clients: 56, - non_entity_clients: 163, - }, - ], - }, - ], - namespaces_by_key: { - root: { - month: '10/21', - clients: 4835, - entity_clients: 2364, - non_entity_clients: 2471, - new_clients: { - month: '10/21', - label: 'root', - clients: 1732, - entity_clients: 586, - non_entity_clients: 1146, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1797, - entity_clients: 883, - non_entity_clients: 914, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 907, - entity_clients: 192, - non_entity_clients: 715, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1501, - entity_clients: 663, - non_entity_clients: 838, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 276, - entity_clients: 202, - non_entity_clients: 74, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 1461, - entity_clients: 800, - non_entity_clients: 661, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 502, - entity_clients: 189, - non_entity_clients: 313, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 76, - entity_clients: 18, - non_entity_clients: 58, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 47, - entity_clients: 3, - non_entity_clients: 44, - }, - }, - }, - }, - 'test-ns-2/': { - month: '10/21', - clients: 4027, - entity_clients: 1692, - non_entity_clients: 2335, - new_clients: { - month: '10/21', - label: 'test-ns-2/', - clients: 2301, - entity_clients: 678, - non_entity_clients: 1623, - }, - mounts_by_key: { - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1223, - entity_clients: 820, - non_entity_clients: 403, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 602, - entity_clients: 212, - non_entity_clients: 390, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1110, - entity_clients: 111, - non_entity_clients: 999, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 440, - entity_clients: 7, - non_entity_clients: 433, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1034, - entity_clients: 462, - non_entity_clients: 572, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 980, - entity_clients: 454, - non_entity_clients: 526, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 660, - entity_clients: 299, - non_entity_clients: 361, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 279, - entity_clients: 5, - non_entity_clients: 274, - }, - }, - }, - }, - 'test-ns-2-with-namespace-length-over-18-characters/': { - month: '10/21', - clients: 3924, - entity_clients: 2132, - non_entity_clients: 1792, - new_clients: { - month: '10/21', - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 1561, - entity_clients: 1225, - non_entity_clients: 336, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1411, - entity_clients: 765, - non_entity_clients: 646, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 948, - entity_clients: 660, - non_entity_clients: 288, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 1205, - entity_clients: 382, - non_entity_clients: 823, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 305, - entity_clients: 289, - non_entity_clients: 16, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 884, - entity_clients: 850, - non_entity_clients: 34, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 230, - entity_clients: 207, - non_entity_clients: 23, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 424, - entity_clients: 135, - non_entity_clients: 289, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 78, - entity_clients: 69, - non_entity_clients: 9, - }, - }, - }, - }, - 'test-ns-1-with-namespace-length-over-18-characters/': { - month: '10/21', - clients: 3639, - entity_clients: 2314, - non_entity_clients: 1325, - new_clients: { - month: '10/21', - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1245, - entity_clients: 710, - non_entity_clients: 535, - }, - mounts_by_key: { - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1062, - entity_clients: 781, - non_entity_clients: 281, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 288, - entity_clients: 63, - non_entity_clients: 225, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1021, - entity_clients: 609, - non_entity_clients: 412, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 440, - entity_clients: 323, - non_entity_clients: 117, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 849, - entity_clients: 426, - non_entity_clients: 423, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 339, - entity_clients: 308, - non_entity_clients: 31, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 707, - entity_clients: 498, - non_entity_clients: 209, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 178, - entity_clients: 16, - non_entity_clients: 162, - }, - }, - }, - }, - 'test-ns-1/': { - month: '10/21', - clients: 2992, - entity_clients: 1603, - non_entity_clients: 1389, - new_clients: { - month: '10/21', - label: 'test-ns-1/', - clients: 820, - entity_clients: 356, - non_entity_clients: 464, - }, - mounts_by_key: { - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1140, - entity_clients: 480, - non_entity_clients: 660, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 239, - entity_clients: 30, - non_entity_clients: 209, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1058, - entity_clients: 651, - non_entity_clients: 407, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 256, - entity_clients: 63, - non_entity_clients: 193, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 575, - entity_clients: 416, - non_entity_clients: 159, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 259, - entity_clients: 245, - non_entity_clients: 14, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 219, - entity_clients: 56, - non_entity_clients: 163, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 66, - entity_clients: 18, - non_entity_clients: 48, - }, - }, - }, - }, - }, - new_clients: { - month: '10/21', - clients: 7659, - entity_clients: 3555, - non_entity_clients: 4104, - namespaces: [ - { - label: 'test-ns-2/', - clients: 2301, - entity_clients: 678, - non_entity_clients: 1623, - mounts: [ - { - label: 'path-1', - clients: 980, - entity_clients: 454, - non_entity_clients: 526, - }, - { - label: 'path-4-with-over-18-characters', - clients: 602, - entity_clients: 212, - non_entity_clients: 390, - }, - { - label: 'path-3-with-over-18-characters', - clients: 440, - entity_clients: 7, - non_entity_clients: 433, - }, - { - label: 'path-2', - clients: 279, - entity_clients: 5, - non_entity_clients: 274, - }, - ], - }, - { - label: 'root', - clients: 1732, - entity_clients: 586, - non_entity_clients: 1146, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 907, - entity_clients: 192, - non_entity_clients: 715, - }, - { - label: 'path-2', - clients: 502, - entity_clients: 189, - non_entity_clients: 313, - }, - { - label: 'path-1', - clients: 276, - entity_clients: 202, - non_entity_clients: 74, - }, - { - label: 'path-4-with-over-18-characters', - clients: 47, - entity_clients: 3, - non_entity_clients: 44, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 1561, - entity_clients: 1225, - non_entity_clients: 336, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 948, - entity_clients: 660, - non_entity_clients: 288, - }, - { - label: 'path-2', - clients: 305, - entity_clients: 289, - non_entity_clients: 16, - }, - { - label: 'path-1', - clients: 230, - entity_clients: 207, - non_entity_clients: 23, - }, - { - label: 'path-4-with-over-18-characters', - clients: 78, - entity_clients: 69, - non_entity_clients: 9, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1245, - entity_clients: 710, - non_entity_clients: 535, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 440, - entity_clients: 323, - non_entity_clients: 117, - }, - { - label: 'path-2', - clients: 339, - entity_clients: 308, - non_entity_clients: 31, - }, - { - label: 'path-1', - clients: 288, - entity_clients: 63, - non_entity_clients: 225, - }, - { - label: 'path-3-with-over-18-characters', - clients: 178, - entity_clients: 16, - non_entity_clients: 162, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 820, - entity_clients: 356, - non_entity_clients: 464, - mounts: [ - { - label: 'path-2', - clients: 259, - entity_clients: 245, - non_entity_clients: 14, - }, - { - label: 'path-4-with-over-18-characters', - clients: 256, - entity_clients: 63, - non_entity_clients: 193, - }, - { - label: 'path-1', - clients: 239, - entity_clients: 30, - non_entity_clients: 209, - }, - { - label: 'path-3-with-over-18-characters', - clients: 66, - entity_clients: 18, - non_entity_clients: 48, - }, - ], - }, - ], - }, - }, - ]; - hooks.before(function () { - sinon.stub(timestamp, 'now').callsFake(() => new Date('2018-04-03T14:15:30')); - }); - hooks.beforeEach(function () { - this.set('timestamp', formatRFC3339(timestamp.now())); - this.set('isDateRange', true); - this.set('chartLegend', [ - { label: 'entity clients', key: 'entity_clients' }, - { label: 'non-entity clients', key: 'non_entity_clients' }, - ]); - this.set('byMonthActivityData', DATASET); - }); - hooks.after(function () { - timestamp.now.restore(); - }); - - test('it renders empty state with no data', async function (assert) { - await render(hbs` - - `); - assert.dom('[data-test-monthly-usage]').exists('monthly usage component renders'); - assert.dom('[data-test-component="empty-state"]').exists(); - assert.dom('[data-test-empty-state-subtext]').hasText('No data to display'); - assert.dom('[data-test-monthly-usage-average-total] p.data-details').hasText('0', 'average total is 0'); - assert.dom('[data-test-monthly-usage-average-new] p.data-details').hasText('0', 'average new is 0'); - assert.dom('[data-test-vertical-bar-chart]').doesNotExist('vertical bar chart does not render'); - assert.dom('[data-test-monthly-usage-legend]').doesNotExist('legend does not exist'); - assert.dom('[data-test-monthly-usage-timestamp]').exists('renders timestamp'); - }); - - test('it renders with month over month activity data', async function (assert) { - const expectedTotal = formatNumber([calculateAverage(DATASET, 'clients')]); - const expectedNew = formatNumber([ - calculateAverage( - DATASET?.map((d) => d.new_clients), - 'clients' - ), - ]); - await render(hbs` - - `); - assert.dom('[data-test-monthly-usage]').exists('monthly usage component renders'); - assert.dom('[data-test-component="empty-state"]').doesNotExist(); - assert.dom('[data-test-vertical-bar-chart]').exists('vertical bar chart displays'); - assert.dom('[data-test-monthly-usage-legend]').exists('renders vertical bar chart legend'); - assert.dom('[data-test-monthly-usage-timestamp]').exists('renders timestamp'); - - findAll('[data-test-vertical-chart="x-axis-labels"] text').forEach((e, i) => { - assert.dom(e).hasText(`${DATASET[i].month}`, `renders x-axis label: ${DATASET[i].month}`); - }); - assert - .dom('[data-test-vertical-chart="data-bar"]') - .exists( - { count: DATASET.filter((m) => m.counts !== null).length * 2 }, - 'renders correct number of data bars' - ); - assert - .dom('[data-test-monthly-usage-average-total] p.data-details') - .hasText(`${expectedTotal}`, `renders correct total average ${expectedTotal}`); - assert - .dom('[data-test-monthly-usage-average-new] p.data-details') - .hasText(`${expectedNew}`, `renders correct new average ${expectedNew}`); - }); -}); diff --git a/ui/tests/integration/components/clients/page/counts-test.js b/ui/tests/integration/components/clients/page/counts-test.js new file mode 100644 index 0000000000..f919df8b82 --- /dev/null +++ b/ui/tests/integration/components/clients/page/counts-test.js @@ -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` + +
Yield block
+
+ `); + }); + 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'); + }); +}); diff --git a/ui/tests/integration/components/clients/page/sync-test.js b/ui/tests/integration/components/clients/page/sync-test.js new file mode 100644 index 0000000000..f992ff41d6 --- /dev/null +++ b/ui/tests/integration/components/clients/page/sync-test.js @@ -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` + + `); + }); + + 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'); + }); +}); diff --git a/ui/tests/integration/components/clients/page/token-test.js b/ui/tests/integration/components/clients/page/token-test.js new file mode 100644 index 0000000000..15368fd1dd --- /dev/null +++ b/ui/tests/integration/components/clients/page/token-test.js @@ -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` + + `); + }); + + 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(); + }); +}); diff --git a/ui/tests/integration/components/clients/running-total-test.js b/ui/tests/integration/components/clients/running-total-test.js index c39b20f024..53a98eaf63 100644 --- a/ui/tests/integration/components/clients/running-total-test.js +++ b/ui/tests/integration/components/clients/running-total-test.js @@ -4,1428 +4,39 @@ */ import { module, test } from 'qunit'; -import sinon from 'sinon'; import { setupRenderingTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { formatRFC3339 } from 'date-fns'; +import clientsHandler from 'vault/mirage/handlers/clients'; +import sinon from 'sinon'; +import { formatRFC3339, getUnixTime } from 'date-fns'; import { findAll } from '@ember/test-helpers'; -import { calculateAverage } from 'vault/utils/chart-helpers'; import { formatNumber } from 'core/helpers/format-number'; import timestamp from 'core/utils/timestamp'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { SELECTORS as ts } from 'vault/tests/helpers/clients'; + +const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z')); module('Integration | Component | clients/running-total', function (hooks) { setupRenderingTest(hooks); - const MONTHLY_ACTIVITY = [ - { - month: '8/21', - timestamp: '2021-08-01T00:00:00Z', - counts: null, - namespaces: [], - new_clients: { - month: '8/21', - namespaces: [], - }, - namespaces_by_key: {}, - }, - { - month: '9/21', - clients: 19251, - entity_clients: 10713, - non_entity_clients: 8538, - namespaces: [ - { - label: 'root', - clients: 4852, - entity_clients: 3108, - non_entity_clients: 1744, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1598, - entity_clients: 687, - non_entity_clients: 911, - }, - { - label: 'path-1', - clients: 1429, - entity_clients: 981, - non_entity_clients: 448, - }, - { - label: 'path-4-with-over-18-characters', - clients: 965, - entity_clients: 720, - non_entity_clients: 245, - }, - { - label: 'path-2', - clients: 860, - entity_clients: 720, - non_entity_clients: 140, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 4702, - entity_clients: 3057, - non_entity_clients: 1645, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1686, - entity_clients: 926, - non_entity_clients: 760, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1525, - entity_clients: 789, - non_entity_clients: 736, - }, - { - label: 'path-2', - clients: 905, - entity_clients: 849, - non_entity_clients: 56, - }, - { - label: 'path-1', - clients: 586, - entity_clients: 493, - non_entity_clients: 93, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 4569, - entity_clients: 1871, - non_entity_clients: 2698, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 1534, - entity_clients: 619, - non_entity_clients: 915, - }, - { - label: 'path-3-with-over-18-characters', - clients: 1528, - entity_clients: 589, - non_entity_clients: 939, - }, - { - label: 'path-1', - clients: 828, - entity_clients: 612, - non_entity_clients: 216, - }, - { - label: 'path-2', - clients: 679, - entity_clients: 51, - non_entity_clients: 628, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 3771, - entity_clients: 2029, - non_entity_clients: 1742, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1249, - entity_clients: 793, - non_entity_clients: 456, - }, - { - label: 'path-1', - clients: 1046, - entity_clients: 444, - non_entity_clients: 602, - }, - { - label: 'path-2', - clients: 930, - entity_clients: 277, - non_entity_clients: 653, - }, - { - label: 'path-4-with-over-18-characters', - clients: 546, - entity_clients: 515, - non_entity_clients: 31, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1357, - entity_clients: 648, - non_entity_clients: 709, - mounts: [ - { - label: 'path-1', - clients: 613, - entity_clients: 23, - non_entity_clients: 590, - }, - { - label: 'path-3-with-over-18-characters', - clients: 543, - entity_clients: 465, - non_entity_clients: 78, - }, - { - label: 'path-2', - clients: 146, - entity_clients: 141, - non_entity_clients: 5, - }, - { - label: 'path-4-with-over-18-characters', - clients: 55, - entity_clients: 19, - non_entity_clients: 36, - }, - ], - }, - ], - namespaces_by_key: { - root: { - month: '9/21', - clients: 4852, - entity_clients: 3108, - non_entity_clients: 1744, - new_clients: { - month: '9/21', - label: 'root', - clients: 2525, - entity_clients: 1315, - non_entity_clients: 1210, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1598, - entity_clients: 687, - non_entity_clients: 911, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1055, - entity_clients: 257, - non_entity_clients: 798, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 1429, - entity_clients: 981, - non_entity_clients: 448, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 543, - entity_clients: 340, - non_entity_clients: 203, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 965, - entity_clients: 720, - non_entity_clients: 245, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 136, - entity_clients: 7, - non_entity_clients: 129, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 860, - entity_clients: 720, - non_entity_clients: 140, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 791, - entity_clients: 711, - non_entity_clients: 80, - }, - }, - }, - }, - 'test-ns-2/': { - month: '9/21', - clients: 4702, - entity_clients: 3057, - non_entity_clients: 1645, - new_clients: { - month: '9/21', - label: 'test-ns-2/', - clients: 1537, - entity_clients: 662, - non_entity_clients: 875, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1686, - entity_clients: 926, - non_entity_clients: 760, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 520, - entity_clients: 13, - non_entity_clients: 507, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 1525, - entity_clients: 789, - non_entity_clients: 736, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 499, - entity_clients: 197, - non_entity_clients: 302, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 905, - entity_clients: 849, - non_entity_clients: 56, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 398, - entity_clients: 370, - non_entity_clients: 28, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 586, - entity_clients: 493, - non_entity_clients: 93, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 120, - entity_clients: 82, - non_entity_clients: 38, - }, - }, - }, - }, - 'test-ns-1/': { - month: '9/21', - clients: 4569, - entity_clients: 1871, - non_entity_clients: 2698, - new_clients: { - month: '9/21', - label: 'test-ns-1/', - clients: 2712, - entity_clients: 879, - non_entity_clients: 1833, - }, - mounts_by_key: { - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 1534, - entity_clients: 619, - non_entity_clients: 915, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 740, - entity_clients: 39, - non_entity_clients: 701, - }, - }, - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1528, - entity_clients: 589, - non_entity_clients: 939, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1250, - entity_clients: 536, - non_entity_clients: 714, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 828, - entity_clients: 612, - non_entity_clients: 216, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 463, - entity_clients: 283, - non_entity_clients: 180, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 679, - entity_clients: 51, - non_entity_clients: 628, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 259, - entity_clients: 21, - non_entity_clients: 238, - }, - }, - }, - }, - 'test-ns-2-with-namespace-length-over-18-characters/': { - month: '9/21', - clients: 3771, - entity_clients: 2029, - non_entity_clients: 1742, - new_clients: { - month: '9/21', - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 2087, - entity_clients: 902, - non_entity_clients: 1185, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 1249, - entity_clients: 793, - non_entity_clients: 456, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 472, - entity_clients: 260, - non_entity_clients: 212, - }, - }, - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 1046, - entity_clients: 444, - non_entity_clients: 602, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 775, - entity_clients: 349, - non_entity_clients: 426, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 930, - entity_clients: 277, - non_entity_clients: 653, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 632, - entity_clients: 90, - non_entity_clients: 542, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 546, - entity_clients: 515, - non_entity_clients: 31, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 208, - entity_clients: 203, - non_entity_clients: 5, - }, - }, - }, - }, - 'test-ns-1-with-namespace-length-over-18-characters/': { - month: '9/21', - clients: 1357, - entity_clients: 648, - non_entity_clients: 709, - new_clients: { - month: '9/21', - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 560, - entity_clients: 189, - non_entity_clients: 371, - }, - mounts_by_key: { - 'path-1': { - month: '9/21', - label: 'path-1', - clients: 613, - entity_clients: 23, - non_entity_clients: 590, - new_clients: { - month: '9/21', - label: 'path-1', - clients: 318, - entity_clients: 12, - non_entity_clients: 306, - }, - }, - 'path-3-with-over-18-characters': { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 543, - entity_clients: 465, - non_entity_clients: 78, - new_clients: { - month: '9/21', - label: 'path-3-with-over-18-characters', - clients: 126, - entity_clients: 89, - non_entity_clients: 37, - }, - }, - 'path-2': { - month: '9/21', - label: 'path-2', - clients: 146, - entity_clients: 141, - non_entity_clients: 5, - new_clients: { - month: '9/21', - label: 'path-2', - clients: 76, - entity_clients: 75, - non_entity_clients: 1, - }, - }, - 'path-4-with-over-18-characters': { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 55, - entity_clients: 19, - non_entity_clients: 36, - new_clients: { - month: '9/21', - label: 'path-4-with-over-18-characters', - clients: 40, - entity_clients: 13, - non_entity_clients: 27, - }, - }, - }, - }, - }, - new_clients: { - month: '9/21', - clients: 9421, - entity_clients: 3947, - non_entity_clients: 5474, - namespaces: [ - { - label: 'test-ns-1/', - clients: 2712, - entity_clients: 879, - non_entity_clients: 1833, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1250, - entity_clients: 536, - non_entity_clients: 714, - }, - { - label: 'path-4-with-over-18-characters', - clients: 740, - entity_clients: 39, - non_entity_clients: 701, - }, - { - label: 'path-1', - clients: 463, - entity_clients: 283, - non_entity_clients: 180, - }, - { - label: 'path-2', - clients: 259, - entity_clients: 21, - non_entity_clients: 238, - }, - ], - }, - { - label: 'root', - clients: 2525, - entity_clients: 1315, - non_entity_clients: 1210, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1055, - entity_clients: 257, - non_entity_clients: 798, - }, - { - label: 'path-2', - clients: 791, - entity_clients: 711, - non_entity_clients: 80, - }, - { - label: 'path-1', - clients: 543, - entity_clients: 340, - non_entity_clients: 203, - }, - { - label: 'path-4-with-over-18-characters', - clients: 136, - entity_clients: 7, - non_entity_clients: 129, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 2087, - entity_clients: 902, - non_entity_clients: 1185, - mounts: [ - { - label: 'path-1', - clients: 775, - entity_clients: 349, - non_entity_clients: 426, - }, - { - label: 'path-2', - clients: 632, - entity_clients: 90, - non_entity_clients: 542, - }, - { - label: 'path-3-with-over-18-characters', - clients: 472, - entity_clients: 260, - non_entity_clients: 212, - }, - { - label: 'path-4-with-over-18-characters', - clients: 208, - entity_clients: 203, - non_entity_clients: 5, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 1537, - entity_clients: 662, - non_entity_clients: 875, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 520, - entity_clients: 13, - non_entity_clients: 507, - }, - { - label: 'path-4-with-over-18-characters', - clients: 499, - entity_clients: 197, - non_entity_clients: 302, - }, - { - label: 'path-2', - clients: 398, - entity_clients: 370, - non_entity_clients: 28, - }, - { - label: 'path-1', - clients: 120, - entity_clients: 82, - non_entity_clients: 38, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 560, - entity_clients: 189, - non_entity_clients: 371, - mounts: [ - { - label: 'path-1', - clients: 318, - entity_clients: 12, - non_entity_clients: 306, - }, - { - label: 'path-3-with-over-18-characters', - clients: 126, - entity_clients: 89, - non_entity_clients: 37, - }, - { - label: 'path-2', - clients: 76, - entity_clients: 75, - non_entity_clients: 1, - }, - { - label: 'path-4-with-over-18-characters', - clients: 40, - entity_clients: 13, - non_entity_clients: 27, - }, - ], - }, - ], - }, - }, - { - month: '10/21', - clients: 19417, - entity_clients: 10105, - non_entity_clients: 9312, - namespaces: [ - { - label: 'root', - clients: 4835, - entity_clients: 2364, - non_entity_clients: 2471, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1797, - entity_clients: 883, - non_entity_clients: 914, - }, - { - label: 'path-1', - clients: 1501, - entity_clients: 663, - non_entity_clients: 838, - }, - { - label: 'path-2', - clients: 1461, - entity_clients: 800, - non_entity_clients: 661, - }, - { - label: 'path-4-with-over-18-characters', - clients: 76, - entity_clients: 18, - non_entity_clients: 58, - }, - ], - }, - { - label: 'test-ns-2/', - clients: 4027, - entity_clients: 1692, - non_entity_clients: 2335, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 1223, - entity_clients: 820, - non_entity_clients: 403, - }, - { - label: 'path-3-with-over-18-characters', - clients: 1110, - entity_clients: 111, - non_entity_clients: 999, - }, - { - label: 'path-1', - clients: 1034, - entity_clients: 462, - non_entity_clients: 572, - }, - { - label: 'path-2', - clients: 660, - entity_clients: 299, - non_entity_clients: 361, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 3924, - entity_clients: 2132, - non_entity_clients: 1792, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 1411, - entity_clients: 765, - non_entity_clients: 646, - }, - { - label: 'path-2', - clients: 1205, - entity_clients: 382, - non_entity_clients: 823, - }, - { - label: 'path-1', - clients: 884, - entity_clients: 850, - non_entity_clients: 34, - }, - { - label: 'path-4-with-over-18-characters', - clients: 424, - entity_clients: 135, - non_entity_clients: 289, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 3639, - entity_clients: 2314, - non_entity_clients: 1325, - mounts: [ - { - label: 'path-1', - clients: 1062, - entity_clients: 781, - non_entity_clients: 281, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1021, - entity_clients: 609, - non_entity_clients: 412, - }, - { - label: 'path-2', - clients: 849, - entity_clients: 426, - non_entity_clients: 423, - }, - { - label: 'path-3-with-over-18-characters', - clients: 707, - entity_clients: 498, - non_entity_clients: 209, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 2992, - entity_clients: 1603, - non_entity_clients: 1389, - mounts: [ - { - label: 'path-1', - clients: 1140, - entity_clients: 480, - non_entity_clients: 660, - }, - { - label: 'path-4-with-over-18-characters', - clients: 1058, - entity_clients: 651, - non_entity_clients: 407, - }, - { - label: 'path-2', - clients: 575, - entity_clients: 416, - non_entity_clients: 159, - }, - { - label: 'path-3-with-over-18-characters', - clients: 219, - entity_clients: 56, - non_entity_clients: 163, - }, - ], - }, - ], - namespaces_by_key: { - root: { - month: '10/21', - clients: 4835, - entity_clients: 2364, - non_entity_clients: 2471, - new_clients: { - month: '10/21', - label: 'root', - clients: 1732, - entity_clients: 586, - non_entity_clients: 1146, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1797, - entity_clients: 883, - non_entity_clients: 914, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 907, - entity_clients: 192, - non_entity_clients: 715, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1501, - entity_clients: 663, - non_entity_clients: 838, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 276, - entity_clients: 202, - non_entity_clients: 74, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 1461, - entity_clients: 800, - non_entity_clients: 661, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 502, - entity_clients: 189, - non_entity_clients: 313, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 76, - entity_clients: 18, - non_entity_clients: 58, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 47, - entity_clients: 3, - non_entity_clients: 44, - }, - }, - }, - }, - 'test-ns-2/': { - month: '10/21', - clients: 4027, - entity_clients: 1692, - non_entity_clients: 2335, - new_clients: { - month: '10/21', - label: 'test-ns-2/', - clients: 2301, - entity_clients: 678, - non_entity_clients: 1623, - }, - mounts_by_key: { - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1223, - entity_clients: 820, - non_entity_clients: 403, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 602, - entity_clients: 212, - non_entity_clients: 390, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1110, - entity_clients: 111, - non_entity_clients: 999, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 440, - entity_clients: 7, - non_entity_clients: 433, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1034, - entity_clients: 462, - non_entity_clients: 572, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 980, - entity_clients: 454, - non_entity_clients: 526, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 660, - entity_clients: 299, - non_entity_clients: 361, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 279, - entity_clients: 5, - non_entity_clients: 274, - }, - }, - }, - }, - 'test-ns-2-with-namespace-length-over-18-characters/': { - month: '10/21', - clients: 3924, - entity_clients: 2132, - non_entity_clients: 1792, - new_clients: { - month: '10/21', - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 1561, - entity_clients: 1225, - non_entity_clients: 336, - }, - mounts_by_key: { - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 1411, - entity_clients: 765, - non_entity_clients: 646, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 948, - entity_clients: 660, - non_entity_clients: 288, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 1205, - entity_clients: 382, - non_entity_clients: 823, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 305, - entity_clients: 289, - non_entity_clients: 16, - }, - }, - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 884, - entity_clients: 850, - non_entity_clients: 34, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 230, - entity_clients: 207, - non_entity_clients: 23, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 424, - entity_clients: 135, - non_entity_clients: 289, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 78, - entity_clients: 69, - non_entity_clients: 9, - }, - }, - }, - }, - 'test-ns-1-with-namespace-length-over-18-characters/': { - month: '10/21', - clients: 3639, - entity_clients: 2314, - non_entity_clients: 1325, - new_clients: { - month: '10/21', - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1245, - entity_clients: 710, - non_entity_clients: 535, - }, - mounts_by_key: { - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1062, - entity_clients: 781, - non_entity_clients: 281, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 288, - entity_clients: 63, - non_entity_clients: 225, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1021, - entity_clients: 609, - non_entity_clients: 412, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 440, - entity_clients: 323, - non_entity_clients: 117, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 849, - entity_clients: 426, - non_entity_clients: 423, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 339, - entity_clients: 308, - non_entity_clients: 31, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 707, - entity_clients: 498, - non_entity_clients: 209, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 178, - entity_clients: 16, - non_entity_clients: 162, - }, - }, - }, - }, - 'test-ns-1/': { - month: '10/21', - clients: 2992, - entity_clients: 1603, - non_entity_clients: 1389, - new_clients: { - month: '10/21', - label: 'test-ns-1/', - clients: 820, - entity_clients: 356, - non_entity_clients: 464, - }, - mounts_by_key: { - 'path-1': { - month: '10/21', - label: 'path-1', - clients: 1140, - entity_clients: 480, - non_entity_clients: 660, - new_clients: { - month: '10/21', - label: 'path-1', - clients: 239, - entity_clients: 30, - non_entity_clients: 209, - }, - }, - 'path-4-with-over-18-characters': { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 1058, - entity_clients: 651, - non_entity_clients: 407, - new_clients: { - month: '10/21', - label: 'path-4-with-over-18-characters', - clients: 256, - entity_clients: 63, - non_entity_clients: 193, - }, - }, - 'path-2': { - month: '10/21', - label: 'path-2', - clients: 575, - entity_clients: 416, - non_entity_clients: 159, - new_clients: { - month: '10/21', - label: 'path-2', - clients: 259, - entity_clients: 245, - non_entity_clients: 14, - }, - }, - 'path-3-with-over-18-characters': { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 219, - entity_clients: 56, - non_entity_clients: 163, - new_clients: { - month: '10/21', - label: 'path-3-with-over-18-characters', - clients: 66, - entity_clients: 18, - non_entity_clients: 48, - }, - }, - }, - }, - }, - new_clients: { - month: '10/21', - clients: 7659, - entity_clients: 3555, - non_entity_clients: 4104, - namespaces: [ - { - label: 'test-ns-2/', - clients: 2301, - entity_clients: 678, - non_entity_clients: 1623, - mounts: [ - { - label: 'path-1', - clients: 980, - entity_clients: 454, - non_entity_clients: 526, - }, - { - label: 'path-4-with-over-18-characters', - clients: 602, - entity_clients: 212, - non_entity_clients: 390, - }, - { - label: 'path-3-with-over-18-characters', - clients: 440, - entity_clients: 7, - non_entity_clients: 433, - }, - { - label: 'path-2', - clients: 279, - entity_clients: 5, - non_entity_clients: 274, - }, - ], - }, - { - label: 'root', - clients: 1732, - entity_clients: 586, - non_entity_clients: 1146, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 907, - entity_clients: 192, - non_entity_clients: 715, - }, - { - label: 'path-2', - clients: 502, - entity_clients: 189, - non_entity_clients: 313, - }, - { - label: 'path-1', - clients: 276, - entity_clients: 202, - non_entity_clients: 74, - }, - { - label: 'path-4-with-over-18-characters', - clients: 47, - entity_clients: 3, - non_entity_clients: 44, - }, - ], - }, - { - label: 'test-ns-2-with-namespace-length-over-18-characters/', - clients: 1561, - entity_clients: 1225, - non_entity_clients: 336, - mounts: [ - { - label: 'path-3-with-over-18-characters', - clients: 948, - entity_clients: 660, - non_entity_clients: 288, - }, - { - label: 'path-2', - clients: 305, - entity_clients: 289, - non_entity_clients: 16, - }, - { - label: 'path-1', - clients: 230, - entity_clients: 207, - non_entity_clients: 23, - }, - { - label: 'path-4-with-over-18-characters', - clients: 78, - entity_clients: 69, - non_entity_clients: 9, - }, - ], - }, - { - label: 'test-ns-1-with-namespace-length-over-18-characters/', - clients: 1245, - entity_clients: 710, - non_entity_clients: 535, - mounts: [ - { - label: 'path-4-with-over-18-characters', - clients: 440, - entity_clients: 323, - non_entity_clients: 117, - }, - { - label: 'path-2', - clients: 339, - entity_clients: 308, - non_entity_clients: 31, - }, - { - label: 'path-1', - clients: 288, - entity_clients: 63, - non_entity_clients: 225, - }, - { - label: 'path-3-with-over-18-characters', - clients: 178, - entity_clients: 16, - non_entity_clients: 162, - }, - ], - }, - { - label: 'test-ns-1/', - clients: 820, - entity_clients: 356, - non_entity_clients: 464, - mounts: [ - { - label: 'path-2', - clients: 259, - entity_clients: 245, - non_entity_clients: 14, - }, - { - label: 'path-4-with-over-18-characters', - clients: 256, - entity_clients: 63, - non_entity_clients: 193, - }, - { - label: 'path-1', - clients: 239, - entity_clients: 30, - non_entity_clients: 209, - }, - { - label: 'path-3-with-over-18-characters', - clients: 66, - entity_clients: 18, - non_entity_clients: 48, - }, - ], - }, - ], - }, - }, - ]; - const NEW_ACTIVITY = MONTHLY_ACTIVITY.map((d) => d.new_clients); - const TOTAL_USAGE_COUNTS = { - clients: 38668, - entity_clients: 20818, - non_entity_clients: 17850, - }; + setupMirage(hooks); + hooks.before(function () { - sinon.stub(timestamp, 'now').callsFake(() => new Date('2018-04-03T14:15:30')); + sinon.stub(timestamp, 'now').callsFake(() => new Date('2024-01-31T23:59:59Z')); }); - hooks.beforeEach(function () { + + hooks.beforeEach(async function () { + clientsHandler(this.server); + const store = this.owner.lookup('service:store'); + const activityQuery = { + start_time: { timestamp: START_TIME }, + end_time: { timestamp: getUnixTime(timestamp.now()) }, + }; + this.activity = await store.queryRecord('clients/activity', activityQuery); + this.newActivity = this.activity.byMonth.map((d) => d.new_clients); + this.totalUsageCounts = this.activity.total; this.set('timestamp', formatRFC3339(timestamp.now())); this.set('chartLegend', [ { label: 'entity clients', key: 'entity_clients' }, @@ -1438,163 +49,142 @@ module('Integration | Component | clients/running-total', function (hooks) { }, }); }); + hooks.after(function () { timestamp.now.restore(); }); test('it renders with full monthly activity data', async function (assert) { - this.set('byMonthActivityData', MONTHLY_ACTIVITY); - this.set('totalUsageCounts', TOTAL_USAGE_COUNTS); - const expectedTotalEntity = formatNumber([TOTAL_USAGE_COUNTS.entity_clients]); - const expectedTotalNonEntity = formatNumber([TOTAL_USAGE_COUNTS.non_entity_clients]); - const expectedNewEntity = formatNumber([calculateAverage(NEW_ACTIVITY, 'entity_clients')]); - const expectedNewNonEntity = formatNumber([calculateAverage(NEW_ACTIVITY, 'non_entity_clients')]); + const expectedTotalEntity = formatNumber([this.totalUsageCounts.entity_clients]); + const expectedTotalNonEntity = formatNumber([this.totalUsageCounts.non_entity_clients]); + const expectedTotalSync = formatNumber([this.totalUsageCounts.secret_syncs]); await render(hbs` - + `); - assert.dom('[data-test-running-total]').exists('running total component renders'); - assert.dom('[data-test-line-chart]').exists('line chart renders'); - assert.dom('[data-test-vertical-bar-chart]').exists('vertical bar chart renders'); - assert.dom('[data-test-running-total-legend]').exists('legend renders'); - assert.dom('[data-test-running-total-timestamp]').exists('renders timestamp'); + assert.dom(ts.charts.chart('running total')).exists('running total component renders'); + assert.dom(ts.charts.lineChart).exists('line chart renders'); assert - .dom('[data-test-running-total-entity] p.data-details') - .hasText(`${expectedTotalEntity}`, `renders correct total average ${expectedTotalEntity}`); + .dom(ts.charts.statTextValue('Entity clients')) + .hasText(`${expectedTotalEntity}`, `renders correct total entity average ${expectedTotalEntity}`); assert - .dom('[data-test-running-total-nonentity] p.data-details') - .hasText(`${expectedTotalNonEntity}`, `renders correct new average ${expectedTotalNonEntity}`); + .dom(ts.charts.statTextValue('Non-entity clients')) + .hasText( + `${expectedTotalNonEntity}`, + `renders correct total nonentity average ${expectedTotalNonEntity}` + ); assert - .dom('[data-test-running-new-entity] p.data-details') - .hasText(`${expectedNewEntity}`, `renders correct total average ${expectedNewEntity}`); - assert - .dom('[data-test-running-new-nonentity] p.data-details') - .hasText(`${expectedNewNonEntity}`, `renders correct new average ${expectedNewNonEntity}`); + .dom(ts.charts.statTextValue('Secrets sync clients')) + .hasText(`${expectedTotalSync}`, `renders correct total sync ${expectedTotalSync}`); // assert line chart is correct - findAll('[data-test-line-chart="x-axis-labels"] text').forEach((e, i) => { + findAll(ts.charts.line.xAxisLabel).forEach((e, i) => { assert .dom(e) .hasText( - `${MONTHLY_ACTIVITY[i].month}`, - `renders x-axis labels for line chart: ${MONTHLY_ACTIVITY[i].month}` + `${this.activity.byMonth[i].month}`, + `renders x-axis labels for line chart: ${this.activity.byMonth[i].month}` ); }); assert - .dom('[data-test-line-chart="plot-point"]') + .dom(ts.charts.line.plotPoint) .exists( - { count: MONTHLY_ACTIVITY.filter((m) => m.counts !== null).length }, + { count: this.activity.byMonth.filter((m) => m.counts !== null).length }, 'renders correct number of plot points' ); - - // assert bar chart is correct - findAll('[data-test-vertical-chart="x-axis-labels"] text').forEach((e, i) => { - assert - .dom(e) - .hasText( - `${MONTHLY_ACTIVITY[i].month}`, - `renders x-axis labels for bar chart: ${MONTHLY_ACTIVITY[i].month}` - ); - }); - assert - .dom('[data-test-vertical-chart="data-bar"]') - .exists( - { count: MONTHLY_ACTIVITY.filter((m) => m.counts !== null).length * 2 }, - 'renders correct number of data bars' - ); }); test('it renders with no new monthly data', async function (assert) { - const monthlyWithoutNew = MONTHLY_ACTIVITY.map((d) => ({ ...d, new_clients: { month: d.month } })); - this.set('byMonthActivityData', monthlyWithoutNew); - this.set('totalUsageCounts', TOTAL_USAGE_COUNTS); - const expectedTotalEntity = formatNumber([TOTAL_USAGE_COUNTS.entity_clients]); - const expectedTotalNonEntity = formatNumber([TOTAL_USAGE_COUNTS.non_entity_clients]); + this.set( + 'monthlyWithoutNew', + this.activity.byMonth.map((d) => ({ + ...d, + new_clients: { month: d.month }, + })) + ); + const expectedTotalEntity = formatNumber([this.totalUsageCounts.entity_clients]); + const expectedTotalNonEntity = formatNumber([this.totalUsageCounts.non_entity_clients]); + const expectedTotalSync = formatNumber([this.totalUsageCounts.secret_syncs]); await render(hbs` - + `); - assert.dom('[data-test-running-total]').exists('running total component renders'); - assert.dom('[data-test-line-chart]').exists('line chart renders'); - assert.dom('[data-test-vertical-bar-chart]').doesNotExist('vertical bar chart does not render'); - assert.dom('[data-test-running-total-legend]').doesNotExist('legend does not render'); - assert.dom('[data-test-component="empty-state"]').exists('renders empty state'); - assert.dom('[data-test-empty-state-title]').hasText('No new clients'); - assert.dom('[data-test-running-total-timestamp]').exists('renders timestamp'); + assert.dom(ts.charts.chart('running total')).exists('running total component renders'); + assert.dom(ts.charts.lineChart).exists('line chart renders'); + assert - .dom('[data-test-running-total-entity] p.data-details') - .hasText(`${expectedTotalEntity}`, `renders correct total average ${expectedTotalEntity}`); + .dom(ts.charts.statTextValue('Entity clients')) + .hasText(`${expectedTotalEntity}`, `renders correct total entity average ${expectedTotalEntity}`); assert - .dom('[data-test-running-total-nonentity] p.data-details') - .hasText(`${expectedTotalNonEntity}`, `renders correct new average ${expectedTotalNonEntity}`); + .dom(ts.charts.statTextValue('Non-entity clients')) + .hasText( + `${expectedTotalNonEntity}`, + `renders correct total nonentity average ${expectedTotalNonEntity}` + ); assert - .dom('[data-test-running-new-entity] p.data-details') - .doesNotExist('new client counts does not exist'); - assert - .dom('[data-test-running-new-nonentity] p.data-details') - .doesNotExist('average new client counts does not exist'); + .dom(ts.charts.statTextValue('Secrets sync clients')) + .hasText(`${expectedTotalSync}`, `renders correct total sync ${expectedTotalSync}`); }); test('it renders with single historical month data', async function (assert) { - const singleMonth = MONTHLY_ACTIVITY[MONTHLY_ACTIVITY.length - 1]; - const singleMonthNew = NEW_ACTIVITY[NEW_ACTIVITY.length - 1]; + const singleMonth = this.activity.byMonth[this.activity.byMonth.length - 1]; + const singleMonthNew = this.newActivity[this.newActivity.length - 1]; this.set('singleMonth', [singleMonth]); const expectedTotalClients = formatNumber([singleMonth.clients]); const expectedTotalEntity = formatNumber([singleMonth.entity_clients]); const expectedTotalNonEntity = formatNumber([singleMonth.non_entity_clients]); + const expectedTotalSync = formatNumber([singleMonth.secret_syncs]); const expectedNewClients = formatNumber([singleMonthNew.clients]); const expectedNewEntity = formatNumber([singleMonthNew.entity_clients]); const expectedNewNonEntity = formatNumber([singleMonthNew.non_entity_clients]); + const expectedNewSyncs = formatNumber([singleMonthNew.secret_syncs]); + const { statTextValue } = ts.charts; await render(hbs` - + `); - assert.dom('[data-test-running-total]').exists('running total component renders'); - assert.dom('[data-test-line-chart]').doesNotExist('line chart does not render'); - assert.dom('[data-test-vertical-bar-chart]').doesNotExist('vertical bar chart does not render'); - assert.dom('[data-test-running-total-legend]').doesNotExist('legend does not render'); - assert.dom('[data-test-running-total-timestamp]').doesNotExist('renders timestamp'); - assert.dom('[data-test-stat-text-container]').exists({ count: 6 }, 'renders stat text containers'); + assert.dom(ts.charts.lineChart).doesNotExist('line chart does not render'); + assert.dom(statTextValue()).exists({ count: 8 }, 'renders stat text containers'); assert - .dom('[data-test-new] [data-test-stat-text-container="New clients"] div.stat-value') + .dom(`[data-test-new] ${statTextValue('New clients')}`) .hasText(`${expectedNewClients}`, `renders correct total new clients: ${expectedNewClients}`); assert - .dom('[data-test-new] [data-test-stat-text-container="Entity clients"] div.stat-value') + .dom(`[data-test-new] ${statTextValue('Entity clients')}`) .hasText(`${expectedNewEntity}`, `renders correct total new entity: ${expectedNewEntity}`); assert - .dom('[data-test-new] [data-test-stat-text-container="Non-entity clients"] div.stat-value') + .dom(`[data-test-new] ${statTextValue('Non-entity clients')}`) .hasText(`${expectedNewNonEntity}`, `renders correct total new non-entity: ${expectedNewNonEntity}`); assert - .dom('[data-test-total] [data-test-stat-text-container="Total monthly clients"] div.stat-value') + .dom(`[data-test-new] ${statTextValue('Secrets sync clients')}`) + .hasText(`${expectedNewSyncs}`, `renders correct total new non-entity: ${expectedNewSyncs}`); + assert + .dom(`[data-test-total] ${statTextValue('Total monthly clients')}`) .hasText(`${expectedTotalClients}`, `renders correct total clients: ${expectedTotalClients}`); assert - .dom('[data-test-total] [data-test-stat-text-container="Entity clients"] div.stat-value') + .dom(`[data-test-total] ${statTextValue('Entity clients')}`) .hasText(`${expectedTotalEntity}`, `renders correct total entity: ${expectedTotalEntity}`); assert - .dom('[data-test-total] [data-test-stat-text-container="Non-entity clients"] div.stat-value') + .dom(`[data-test-total] ${statTextValue('Non-entity clients')}`) .hasText(`${expectedTotalNonEntity}`, `renders correct total non-entity: ${expectedTotalNonEntity}`); + assert + .dom(`[data-test-total] ${statTextValue('Secrets sync clients')}`) + .hasText(`${expectedTotalSync}`, `renders correct total sync: ${expectedTotalSync}`); }); }); diff --git a/ui/tests/integration/components/clients/usage-stats-test.js b/ui/tests/integration/components/clients/usage-stats-test.js index 8f3c9ececc..5f347c7afb 100644 --- a/ui/tests/integration/components/clients/usage-stats-test.js +++ b/ui/tests/integration/components/clients/usage-stats-test.js @@ -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``); - 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``); + + 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'); + }); }); diff --git a/ui/tests/integration/utils/client-count-utils-test.js b/ui/tests/integration/utils/client-count-utils-test.js index 5f17fc4ec9..9a15309eeb 100644 --- a/ui/tests/integration/utils/client-count-utils-test.js +++ b/ui/tests/integration/utils/client-count-utils-test.js @@ -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' ); }); diff --git a/ui/tests/unit/utils/chart-helpers-test.js b/ui/tests/unit/utils/chart-helpers-test.js index 881a286d10..14dad1eb2d 100644 --- a/ui/tests/unit/utils/chart-helpers-test.js +++ b/ui/tests/unit/utils/chart-helpers-test.js @@ -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'); + }); }); diff --git a/ui/types/ember-data/types/registries/model.d.ts b/ui/types/ember-data/types/registries/model.d.ts index fc4ba54164..eb5485c757 100644 --- a/ui/types/ember-data/types/registries/model.d.ts +++ b/ui/types/ember-data/types/registries/model.d.ts @@ -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; } diff --git a/ui/types/vault/charts/client-counts.d.ts b/ui/types/vault/charts/client-counts.d.ts new file mode 100644 index 0000000000..7edb8b1b6d --- /dev/null +++ b/ui/types/vault/charts/client-counts.d.ts @@ -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; +} diff --git a/ui/types/vault/models/clients/activity.d.ts b/ui/types/vault/models/clients/activity.d.ts new file mode 100644 index 0000000000..4c1f43862a --- /dev/null +++ b/ui/types/vault/models/clients/activity.d.ts @@ -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; +} diff --git a/ui/types/vault/models/clients/config.d.ts b/ui/types/vault/models/clients/config.d.ts new file mode 100644 index 0000000000..86c1fa2b55 --- /dev/null +++ b/ui/types/vault/models/clients/config.d.ts @@ -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; +} diff --git a/ui/types/vault/models/clients/version-history.d.ts b/ui/types/vault/models/clients/version-history.d.ts new file mode 100644 index 0000000000..0e7ec41b7e --- /dev/null +++ b/ui/types/vault/models/clients/version-history.d.ts @@ -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; +} diff --git a/ui/yarn.lock b/ui/yarn.lock index 5cd3f478a1..fe567232d3 100644 --- a/ui/yarn.lock +++ b/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