diff --git a/ui/app/components/clients/activity.ts b/ui/app/components/clients/activity.ts index f2bf3fac6f..ea25b3b0bc 100644 --- a/ui/app/components/clients/activity.ts +++ b/ui/app/components/clients/activity.ts @@ -10,15 +10,16 @@ import Component from '@glimmer/component'; import { isSameMonth, fromUnixTime } from 'date-fns'; import { parseAPITimestamp } from 'core/utils/date-formatters'; import { calculateAverage } from 'vault/utils/chart-helpers'; -import { filterVersionHistory } from 'core/utils/client-count-utils'; +import { filterVersionHistory, hasMountsKey, hasNamespacesKey } from 'core/utils/client-count-utils'; 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'; +import type { + ByMonthNewClients, + MountNewClients, + NamespaceByKey, + NamespaceNewClients, +} from 'core/utils/client-count-utils'; interface Args { isSecretsSyncActivated?: boolean; @@ -33,10 +34,8 @@ interface Args { export default class ClientsActivityComponent extends Component { average = ( data: - | ClientActivityMonthly[] - | (ClientActivityResourceByKey | undefined)[] - | (ClientActivityNewClients | undefined)[] - | undefined, + | (ByMonthNewClients | NamespaceNewClients | MountNewClients | undefined)[] + | (NamespaceByKey | undefined)[], key: string ) => { return calculateAverage(data, key); @@ -65,18 +64,18 @@ export default class ClientsActivityComponent extends Component { return activity.byMonth; } const namespaceData = activity.byMonth - .map((m) => m.namespaces_by_key[namespace as keyof typeof m.namespaces_by_key]) + ?.map((m) => m.namespaces_by_key[namespace]) .filter((d) => d !== undefined); if (!mountPath) { - return namespaceData.length === 0 ? undefined : namespaceData; + return namespaceData || []; } - const mountData = mountPath - ? namespaceData.map((namespace) => namespace?.mounts_by_key[mountPath]).filter((d) => d !== undefined) - : namespaceData; + const mountData = namespaceData + ?.map((namespace) => namespace?.mounts_by_key[mountPath]) + .filter((d) => d !== undefined); - return mountData.length === 0 ? undefined : mountData; + return mountData || []; } get filteredActivityByNamespace() { @@ -119,11 +118,13 @@ export default class ClientsActivityComponent extends Component { return filterVersionHistory(versionHistory, activity.startTime, activity.endTime); } - // (object) single month new client data with total counts + array of namespace breakdown + // (object) single month new client data with total counts and array of + // either namespaces or mounts get newClientCounts() { - if (this.isDateRange || !this.byMonthActivityData) { + if (this.isDateRange || this.byMonthActivityData.length === 0) { return null; } + return this.byMonthActivityData[0]?.new_clients; } @@ -140,13 +141,14 @@ export default class ClientsActivityComponent extends Component { // 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.isDateRange || this.isCurrentMonth || !this.newClientCounts) return null; - if (this.args.namespace) { - return this.newClientCounts?.mounts || null; - } else { - return this.newClientCounts?.namespaces || null; - } + const newCounts = this.newClientCounts; + if (this.args.namespace && hasMountsKey(newCounts)) return newCounts?.mounts; + + if (hasNamespacesKey(newCounts)) return newCounts?.namespaces; + + return null; } get hasAttributionData() { diff --git a/ui/app/components/clients/charts/line.ts b/ui/app/components/clients/charts/line.ts index da1dda11ab..3c737a85e2 100644 --- a/ui/app/components/clients/charts/line.ts +++ b/ui/app/components/clients/charts/line.ts @@ -9,8 +9,9 @@ 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'; +import type { MonthlyChartData, Timestamp } from 'vault/vault/charts/client-counts'; +import type { TotalClients } from 'core/utils/client-count-utils'; interface Args { dataset: MonthlyChartData[]; @@ -67,7 +68,7 @@ export default class LineChart extends Component { const upgradeMessage = this.getUpgradeMessage(datum); return { x: timestamp, - y: (datum[this.yKey as keyof Count] as number) ?? null, + y: (datum[this.yKey as keyof TotalClients] as number) ?? null, new: this.getNewClients(datum), tooltipUpgrade: upgradeMessage, month: datum.month, @@ -123,7 +124,7 @@ export default class LineChart extends Component { } getNewClients(datum: MonthlyChartData) { if (!datum?.new_clients) return 0; - return (datum?.new_clients[this.yKey as keyof Count] as number) || 0; + return (datum?.new_clients[this.yKey as keyof TotalClients] as number) || 0; } hasValue = (count: number | null) => { diff --git a/ui/app/components/clients/charts/vertical-bar-basic.ts b/ui/app/components/clients/charts/vertical-bar-basic.ts index e81ad07a86..867a5d5b74 100644 --- a/ui/app/components/clients/charts/vertical-bar-basic.ts +++ b/ui/app/components/clients/charts/vertical-bar-basic.ts @@ -9,7 +9,8 @@ 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'; +import type { MonthlyChartData } from 'vault/vault/charts/client-counts'; +import type { TotalClients } from 'core/utils/client-count-utils'; interface Args { data: MonthlyChartData[]; @@ -51,7 +52,7 @@ export default class VerticalBarBasic extends Component { 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; + const yValue = (d[this.args.dataKey as keyof TotalClients] as number) ?? null; return { x: parseAPITimestamp(xValue, 'M/yy') as string, y: yValue, diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts index 23da4c52f0..59bf06a5e3 100644 --- a/ui/app/components/clients/page/counts.ts +++ b/ui/app/components/clients/page/counts.ts @@ -162,7 +162,7 @@ export default class ClientsCountsPageComponent extends Component { } @action - onDateChange(dateObject: { dateType: string; monthIdx: string; year: string }) { + onDateChange(dateObject: { dateType: string; monthIdx: number; year: number }) { const { dateType, monthIdx, year } = dateObject; const { config } = this.args; const currentTimestamp = getUnixTime(timestamp.now()); diff --git a/ui/app/components/clients/page/token.ts b/ui/app/components/clients/page/token.ts index fd00b32038..4d653ec3ef 100644 --- a/ui/app/components/clients/page/token.ts +++ b/ui/app/components/clients/page/token.ts @@ -6,10 +6,11 @@ import ActivityComponent from '../activity'; import type { - ClientActivityNewClients, - ClientActivityMonthly, - ClientActivityResourceByKey, -} from 'vault/vault/models/clients/activity'; + ByMonthNewClients, + MountNewClients, + NamespaceByKey, + NamespaceNewClients, +} from 'core/utils/client-count-utils'; export default class ClientsTokenPageComponent extends ActivityComponent { legend = [ @@ -19,10 +20,8 @@ export default class ClientsTokenPageComponent extends ActivityComponent { calculateClientAverages( dataset: - | ClientActivityMonthly[] - | (ClientActivityResourceByKey | undefined)[] - | (ClientActivityNewClients | undefined)[] - | undefined + | (NamespaceByKey | undefined)[] + | (ByMonthNewClients | NamespaceNewClients | MountNewClients | undefined)[] ) { return ['entity_clients', 'non_entity_clients'].reduce((count, key) => { const average = this.average(dataset, key); diff --git a/ui/lib/core/addon/utils/client-count-utils.js b/ui/lib/core/addon/utils/client-count-utils.js deleted file mode 100644 index 39fd01aa6c..0000000000 --- a/ui/lib/core/addon/utils/client-count-utils.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { parseAPITimestamp } from 'core/utils/date-formatters'; -import { compareAsc, getUnixTime, isWithinInterval } from 'date-fns'; - -// add new types here -export const CLIENT_TYPES = [ - 'acme_clients', - 'clients', // summation of total clients - 'entity_clients', - 'non_entity_clients', - 'secret_syncs', -]; - -// returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10 -// that occurred between timestamps (i.e. queried activity data) -export const filterVersionHistory = (versionHistory, start, end) => { - if (versionHistory) { - const upgrades = versionHistory.reduce((array, upgradeData) => { - const includesVersion = (v) => - // only add first match, disregard subsequent patch releases of the same version - upgradeData.version.match(v) && !array.some((d) => d.version.match(v)); - - ['1.9', '1.10'].forEach((v) => { - if (includesVersion(v)) array.push(upgradeData); - }); - - return array; - }, []); - - // if there are noteworthy upgrades, only return those during queried date range - if (upgrades.length) { - const startDate = parseAPITimestamp(start); - const endDate = parseAPITimestamp(end); - return upgrades.filter(({ timestampInstalled }) => { - const upgradeDate = parseAPITimestamp(timestampInstalled); - return isWithinInterval(upgradeDate, { start: startDate, end: endDate }); - }); - } - } - return []; -}; - -export const formatDateObject = (dateObj, isEnd) => { - if (dateObj) { - const { year, monthIdx } = dateObj; - // day=0 for Date.UTC() returns the last day of the month before - // increase monthIdx by one to get last day of queried month - const utc = isEnd ? Date.UTC(year, monthIdx + 1, 0) : Date.UTC(year, monthIdx, 1); - return getUnixTime(utc); - } -}; - -export const formatByMonths = (monthsArray) => { - // the monthsArray will always include a timestamp of the month and either new/total client data or counts = null - if (!Array.isArray(monthsArray)) return monthsArray; - - const sortedPayload = sortMonthsByTimestamp(monthsArray); - return sortedPayload?.map((m) => { - const month = parseAPITimestamp(m.timestamp, 'M/yy'); - const totalClientsByNamespace = formatByNamespace(m.namespaces); - const newClientsByNamespace = formatByNamespace(m.new_clients?.namespaces); - return { - month, - timestamp: m.timestamp, - ...destructureClientCounts(m?.counts), - namespaces: formatByNamespace(m.namespaces) || [], - namespaces_by_key: namespaceArrayToObject( - totalClientsByNamespace, - newClientsByNamespace, - month, - m.timestamp - ), - new_clients: { - month, - timestamp: m.timestamp, - ...destructureClientCounts(m?.new_clients?.counts), - namespaces: formatByNamespace(m.new_clients?.namespaces) || [], - }, - }; - }); -}; - -export const formatByNamespace = (namespaceArray) => { - if (!Array.isArray(namespaceArray)) return namespaceArray; - return namespaceArray?.map((ns) => { - // i.e. 'namespace_path' is an empty string for 'root', so use namespace_id - const label = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path; - // data prior to adding mount granularity will still have a mounts key, - // but with the value: "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go) - // transform to an empty array for type consistency - let mounts = []; - if (Array.isArray(ns.mounts)) { - mounts = ns.mounts.map((m) => ({ label: m['mount_path'], ...destructureClientCounts(m?.counts) })); - } - return { - label, - ...destructureClientCounts(ns.counts), - mounts, - }; - }); -}; - -// In 1.10 'distinct_entities' changed to 'entity_clients' and 'non_entity_tokens' to 'non_entity_clients' -// these deprecated keys still exist on the response, so only return relevant keys here -// when querying historical data the response will always contain the latest client type keys because the activity log is -// constructed based on the version of Vault the user is on (key values will be 0) -export const destructureClientCounts = (verboseObject) => { - if (!verboseObject) return; - return CLIENT_TYPES.reduce((newObj, clientType) => { - newObj[clientType] = verboseObject[clientType]; - return newObj; - }, {}); -}; - -export const sortMonthsByTimestamp = (monthsArray) => { - const sortedPayload = [...monthsArray]; - return sortedPayload.sort((a, b) => - compareAsc(parseAPITimestamp(a.timestamp), parseAPITimestamp(b.timestamp)) - ); -}; - -export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month, timestamp) => { - if (!totalClientsByNamespace) return {}; // return if no data for that month - // all 'new_client' data resides within a separate key of each month (see data structure below) - // FIRST: iterate and nest respective 'new_clients' data within each namespace and mount object - // note: this is happening within the month object - const nestNewClientsWithinNamespace = totalClientsByNamespace?.map((ns) => { - const newNamespaceCounts = newClientsByNamespace?.find((n) => n.label === ns.label); - if (newNamespaceCounts) { - const newClientsByMount = [...newNamespaceCounts.mounts]; - const nestNewClientsWithinMounts = ns.mounts?.map((mount) => { - const new_clients = newClientsByMount?.find((m) => m.label === mount.label) || {}; - return { - ...mount, - new_clients, - }; - }); - return { - ...ns, - new_clients: { - label: ns.label, - ...destructureClientCounts(newNamespaceCounts), - mounts: newClientsByMount, - }, - mounts: [...nestNewClientsWithinMounts], - }; - } - return { - ...ns, - new_clients: {}, - }; - }); - // SECOND: create a new object (namespace_by_key) in which each namespace label is a key - const namespaces_by_key = {}; - nestNewClientsWithinNamespace?.forEach((namespaceObject) => { - // THIRD: make another object within the namespace where each mount label is a key - const mounts_by_key = {}; - namespaceObject.mounts.forEach((mountObject) => { - mounts_by_key[mountObject.label] = { - month, - timestamp, - ...mountObject, - new_clients: { month, ...mountObject.new_clients }, - }; - }); - - const { label, new_clients } = namespaceObject; - namespaces_by_key[label] = { - month, - timestamp, - ...destructureClientCounts(namespaceObject), - new_clients: { month, ...new_clients }, - mounts_by_key, - }; - }); - return namespaces_by_key; - /* - structure of object returned - namespace_by_key: { - "namespace_label": { - month: "3/22", - clients: 32, - entity_clients: 16, - non_entity_clients: 16, - new_clients: { - month: "3/22", - clients: 5, - entity_clients: 2, - non_entity_clients: 3, - mounts: [...array of this namespace's mounts and their new client counts], - }, - mounts_by_key: { - "mount_label": { - month: "3/22", - clients: 3, - entity_clients: 2, - non_entity_clients: 1, - new_clients: { - month: "3/22", - clients: 5, - entity_clients: 2, - non_entity_clients: 3, - }, - }, - }, - }, - }; - */ -}; diff --git a/ui/lib/core/addon/utils/client-count-utils.ts b/ui/lib/core/addon/utils/client-count-utils.ts new file mode 100644 index 0000000000..fe5dc06908 --- /dev/null +++ b/ui/lib/core/addon/utils/client-count-utils.ts @@ -0,0 +1,295 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { compareAsc, getUnixTime, isWithinInterval } from 'date-fns'; + +import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history'; + +/* +The client count utils are responsible for serializing the sys/internal/counters/activity API response +The initial API response shape and serialized types are defined below. + +To help visualize there are sample responses in ui/tests/helpers/clients.js +*/ + +// add new types here +export const CLIENT_TYPES = [ + 'acme_clients', + 'clients', // summation of total clients + 'entity_clients', + 'non_entity_clients', + 'secret_syncs', +] as const; + +type ClientTypes = (typeof CLIENT_TYPES)[number]; + +// returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10 +// that occurred between timestamps (i.e. queried activity data) +export const filterVersionHistory = ( + versionHistory: ClientsVersionHistoryModel[], + start: string, + end: string +) => { + if (versionHistory) { + const upgrades = versionHistory.reduce((array: ClientsVersionHistoryModel[], upgradeData) => { + const includesVersion = (v: string) => + // only add first match, disregard subsequent patch releases of the same version + upgradeData.version.match(v) && !array.some((d: ClientsVersionHistoryModel) => d.version.match(v)); + + ['1.9', '1.10'].forEach((v) => { + if (includesVersion(v)) array.push(upgradeData); + }); + + return array; + }, []); + + // if there are noteworthy upgrades, only return those during queried date range + if (upgrades.length) { + const startDate = parseAPITimestamp(start) as Date; + const endDate = parseAPITimestamp(end) as Date; + return upgrades.filter(({ timestampInstalled }) => { + const upgradeDate = parseAPITimestamp(timestampInstalled) as Date; + return isWithinInterval(upgradeDate, { start: startDate, end: endDate }); + }); + } + } + return []; +}; + +export const formatDateObject = (dateObj: { monthIdx: number; year: number }, isEnd: boolean) => { + const { year, monthIdx } = dateObj; + // day=0 for Date.UTC() returns the last day of the month before + // increase monthIdx by one to get last day of queried month + const utc = isEnd ? Date.UTC(year, monthIdx + 1, 0) : Date.UTC(year, monthIdx, 1); + return getUnixTime(utc); +}; + +export const formatByMonths = (monthsArray: ActivityMonthBlock[] | EmptyActivityMonthBlock[]) => { + const sortedPayload = sortMonthsByTimestamp(monthsArray); + return sortedPayload?.map((m) => { + const month = parseAPITimestamp(m.timestamp, 'M/yy') as string; + const { timestamp } = m; + // counts are null if there is no monthly data + if (m.counts) { + const totalClientsByNamespace = formatByNamespace(m.namespaces); + const newClientsByNamespace = formatByNamespace(m.new_clients?.namespaces); + return { + month, + timestamp, + ...destructureClientCounts(m.counts), + namespaces: formatByNamespace(m.namespaces) || [], + namespaces_by_key: namespaceArrayToObject( + totalClientsByNamespace, + newClientsByNamespace, + month, + m.timestamp + ), + new_clients: { + month, + timestamp, + ...destructureClientCounts(m?.new_clients?.counts), + namespaces: formatByNamespace(m.new_clients?.namespaces) || [], + }, + }; + } + // empty month + return { + month, + timestamp, + namespaces: [], + namespaces_by_key: {}, + new_clients: { month, timestamp, namespaces: [] }, + }; + }); +}; + +export const formatByNamespace = (namespaceArray: NamespaceObject[]) => { + return namespaceArray.map((ns) => { + // i.e. 'namespace_path' is an empty string for 'root', so use namespace_id + const label = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path; + // data prior to adding mount granularity will still have a mounts array, + // but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go) + // transform to an empty array for type consistency + let mounts: MountClients[] | [] = []; + if (Array.isArray(ns.mounts)) { + mounts = ns.mounts.map((m) => ({ label: m.mount_path, ...destructureClientCounts(m.counts) })); + } + return { + label, + ...destructureClientCounts(ns.counts), + mounts, + }; + }); +}; + +// In 1.10 'distinct_entities' changed to 'entity_clients' and 'non_entity_tokens' to 'non_entity_clients' +// these deprecated keys still exist on the response, so only return relevant keys here +// when querying historical data the response will always contain the latest client type keys because the activity log is +// constructed based on the version of Vault the user is on (key values will be 0) +export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClients) => { + return CLIENT_TYPES.reduce((newObj: Record, clientType: ClientTypes) => { + newObj[clientType] = verboseObject[clientType]; + return newObj; + }, {} as Record); +}; + +export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[] | EmptyActivityMonthBlock[]) => { + const sortedPayload = [...monthsArray]; + return sortedPayload.sort((a, b) => + compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date) + ); +}; + +export const namespaceArrayToObject = ( + monthTotals: ByNamespaceClients[], + // technically this arg (monthNew) is the same type as above, just nested inside monthly new clients + monthNew: ByMonthClients['new_clients']['namespaces'], + month: string, + timestamp: string +) => { + // namespaces_by_key is used to filter monthly activity data by namespace + // it's an object in each month data block where the keys are namespace paths + // and values include new and total client counts for that namespace in that month + const namespaces_by_key = monthTotals.reduce((nsObject: { [key: string]: NamespaceByKey }, ns) => { + const newNsClients = monthNew?.find((n) => n.label === ns.label); + if (newNsClients) { + // mounts_by_key is is used to filter further in a namespace and get monthly activity by mount + // it's an object inside the namespace block where the keys are mount paths + // and the values include new and total client counts for that mount in that month + const mounts_by_key = ns.mounts.reduce((mountObj: { [key: string]: MountByKey }, mount) => { + const newMountClients = newNsClients.mounts.find((m) => m.label === mount.label); + + if (newMountClients) { + mountObj[mount.label] = { + ...mount, + timestamp, + month, + new_clients: { month, ...newMountClients }, + }; + } + return mountObj; + }, {} as { [key: string]: MountByKey }); + + nsObject[ns.label] = { + ...destructureClientCounts(ns), + timestamp, + month, + new_clients: { month, ...newNsClients }, + mounts_by_key, + }; + } + return nsObject; + }, {}); + + return namespaces_by_key; +}; + +// type guards for conditionals +export function hasMountsKey( + obj: ByMonthNewClients | NamespaceNewClients | MountNewClients +): obj is NamespaceNewClients { + return 'mounts' in obj; +} + +export function hasNamespacesKey( + obj: ByMonthNewClients | NamespaceNewClients | MountNewClients +): obj is ByMonthNewClients { + return 'namespaces' in obj; +} + +// TYPES RETURNED BY UTILS (serialized) + +export interface TotalClients { + clients: number; + entity_clients: number; + non_entity_clients: number; + secret_syncs: number; + acme_clients: number; +} + +export interface ByNamespaceClients extends TotalClients { + label: string; + mounts: MountClients[]; +} + +export interface MountClients extends TotalClients { + label: string; +} + +export interface ByMonthClients extends TotalClients { + month: string; + timestamp: string; + namespaces: ByNamespaceClients[]; + namespaces_by_key: { [key: string]: NamespaceByKey }; + new_clients: ByMonthNewClients; +} +export interface ByMonthNewClients extends TotalClients { + month: string; + timestamp: string; + namespaces: ByNamespaceClients[]; +} + +export interface NamespaceByKey extends TotalClients { + month: string; + timestamp: string; + mounts_by_key: { [key: string]: MountByKey }; + new_clients: NamespaceNewClients; +} + +export interface NamespaceNewClients extends TotalClients { + month: string; + label: string; + mounts: MountClients[]; +} + +export interface MountByKey extends TotalClients { + month: string; + timestamp: string; + label: string; + new_clients: MountNewClients; +} + +export interface MountNewClients extends TotalClients { + month: string; + label: string; +} + +// API RESPONSE SHAPE (prior to serialization) + +export interface NamespaceObject { + namespace_id: string; + namespace_path: string; + counts: Counts; + mounts: { mount_path: string; counts: Counts }[]; +} + +export interface ActivityMonthBlock { + timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month) + counts: Counts; + namespaces: NamespaceObject[]; + new_clients: { + counts: Counts; + namespaces: NamespaceObject[]; + timestamp: string; + }; +} + +export interface EmptyActivityMonthBlock { + timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month) + counts: null; + namespaces: null; + new_clients: null; +} + +export interface Counts { + acme_clients: number; + clients: number; + distinct_entities: number; + entity_clients: number; + non_entity_clients: number; + non_entity_tokens: number; + secret_syncs: number; +} diff --git a/ui/mirage/handlers/clients.js b/ui/mirage/handlers/clients.js index 6293542bd1..2e9d27e982 100644 --- a/ui/mirage/handlers/clients.js +++ b/ui/mirage/handlers/clients.js @@ -110,8 +110,10 @@ function generateMonths(startDate, endDate, namespaces) { const numberOfMonths = differenceInCalendarMonths(endDateObject, startDateObject) + 1; const months = []; - // only generate monthly block if queried dates span an upgrade - if (isWithinInterval(UPGRADE_DATE, { start: startDateObject, end: endDateObject })) { + // only generate monthly block if queried dates span or follow upgrade to 1.10 + const upgradeWithin = isWithinInterval(UPGRADE_DATE, { start: startDateObject, end: endDateObject }); + const upgradeAfter = isAfter(startDateObject, UPGRADE_DATE); + if (upgradeWithin || upgradeAfter) { for (let i = 0; i < numberOfMonths; i++) { const month = addMonths(startOfMonth(startDateObject), i); const hasNoData = isBefore(month, UPGRADE_DATE) && !isSameMonth(month, UPGRADE_DATE); diff --git a/ui/tests/helpers/clients.js b/ui/tests/helpers/clients.js index 38dea95fd7..20874b41cd 100644 --- a/ui/tests/helpers/clients.js +++ b/ui/tests/helpers/clients.js @@ -193,7 +193,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { namespace_id: '81ry61', - namespace_path: 'ns/1', + namespace_path: 'ns1', counts: { distinct_entities: 783, entity_clients: 783, @@ -315,7 +315,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { namespace_id: '81ry61', - namespace_path: 'ns/1', + namespace_path: 'ns1', counts: { distinct_entities: 50, entity_clients: 50, @@ -378,7 +378,7 @@ export const ACTIVITY_RESPONSE_STUB = { namespaces: [ { namespace_id: '81ry61', - namespace_path: 'ns/1', + namespace_path: 'ns1', counts: { distinct_entities: 30, entity_clients: 30, @@ -493,6 +493,174 @@ export const ACTIVITY_RESPONSE_STUB = { }, }; +// combined activity data before and after 1.10 upgrade when Vault added mount attribution +export const MIXED_ACTIVITY_RESPONSE_STUB = { + start_time: '2024-03-01T00:00:00Z', + end_time: '2024-04-30T23:59:59Z', + total: { + acme_clients: 0, + clients: 3, + distinct_entities: 3, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + by_namespace: [ + { + counts: { + acme_clients: 0, + clients: 3, + distinct_entities: 3, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mounts: [ + { + counts: { + acme_clients: 0, + clients: 2, + distinct_entities: 2, + entity_clients: 2, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'no mount accessor (pre-1.10 upgrade?)', + }, + { + counts: { + acme_clients: 0, + clients: 1, + distinct_entities: 1, + entity_clients: 1, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'auth/u/', + }, + ], + namespace_id: 'root', + namespace_path: '', + }, + ], + months: [ + { + counts: null, + namespaces: null, + new_clients: null, + timestamp: '2024-03-01T00:00:00Z', + }, + { + counts: { + acme_clients: 0, + clients: 3, + distinct_entities: 0, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + namespaces: [ + { + counts: { + acme_clients: 0, + clients: 3, + distinct_entities: 0, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mounts: [ + { + counts: { + acme_clients: 0, + clients: 2, + distinct_entities: 0, + entity_clients: 2, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'no mount accessor (pre-1.10 upgrade?)', + }, + { + counts: { + acme_clients: 0, + clients: 1, + distinct_entities: 0, + entity_clients: 1, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'auth/u/', + }, + ], + namespace_id: 'root', + namespace_path: '', + }, + ], + new_clients: { + counts: { + acme_clients: 0, + clients: 3, + distinct_entities: 0, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + namespaces: [ + { + counts: { + acme_clients: 0, + clients: 3, + distinct_entities: 0, + entity_clients: 3, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mounts: [ + { + counts: { + acme_clients: 0, + clients: 2, + distinct_entities: 0, + entity_clients: 2, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'no mount accessor (pre-1.10 upgrade?)', + }, + { + counts: { + acme_clients: 0, + clients: 1, + distinct_entities: 0, + entity_clients: 1, + non_entity_clients: 0, + non_entity_tokens: 0, + secret_syncs: 0, + }, + mount_path: 'auth/u/', + }, + ], + namespace_id: 'root', + namespace_path: '', + }, + ], + }, + timestamp: '2024-04-01T00:00:00Z', + }, + ], +}; // format returned by model hook in routes/vault/cluster/clients.ts export const VERSION_HISTORY = [ { @@ -560,7 +728,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { ], }, { - label: 'ns/1', + label: 'ns1', clients: 2376, entity_clients: 783, non_entity_clients: 1193, @@ -649,7 +817,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { ], }, { - label: 'ns/1', + label: 'ns1', clients: 3085, entity_clients: 50, non_entity_clients: 140, @@ -787,7 +955,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, }, }, - 'ns/1': { + ns1: { month: '9/23', timestamp: '2023-09-01T00:00:00Z', clients: 3085, @@ -797,7 +965,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { acme_clients: 125, new_clients: { month: '9/23', - label: 'ns/1', + label: 'ns1', clients: 222, entity_clients: 30, non_entity_clients: 62, @@ -901,7 +1069,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { acme_clients: 50, namespaces: [ { - label: 'ns/1', + label: 'ns1', clients: 222, entity_clients: 30, non_entity_clients: 62, diff --git a/ui/tests/integration/components/dashboard/client-count-card-test.js b/ui/tests/integration/components/dashboard/client-count-card-test.js index 8bed582d63..936295c332 100644 --- a/ui/tests/integration/components/dashboard/client-count-card-test.js +++ b/ui/tests/integration/components/dashboard/client-count-card-test.js @@ -8,59 +8,37 @@ import { setupRenderingTest } from 'vault/tests/helpers'; import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; +import sinon from 'sinon'; +import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients'; import timestamp from 'core/utils/timestamp'; -import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients'; module('Integration | Component | dashboard/client-count-card', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); + hooks.before(function () { + sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW); + }); + hooks.beforeEach(function () { this.license = { - startTime: '2018-04-03T14:15:30', + startTime: LICENSE_START.toISOString(), }; }); + hooks.after(function () { + timestamp.now.restore(); + }); + test('it should display client count information', async function (assert) { + assert.expect(9); this.server.get('sys/internal/counters/activity', () => { + // this assertion should be hit twice, once initially and then again clicking 'refresh' + assert.true(true, 'makes request to sys/internal/counters/activity'); return { request_id: 'some-activity-id', - data: { - months: [ - { - timestamp: '2023-08-01T00:00:00-07:00', - counts: {}, - namespaces: [ - { - namespace_id: 'root', - namespace_path: '', - counts: {}, - mounts: [{ mount_path: 'auth/up2/', counts: {} }], - }, - ], - new_clients: { - counts: { - clients: 12, - }, - namespaces: [ - { - namespace_id: 'root', - namespace_path: '', - counts: { - clients: 12, - }, - mounts: [{ mount_path: 'auth/up2/', counts: {} }], - }, - ], - }, - }, - ], - total: { - clients: 300417, - entity_clients: 73150, - non_entity_clients: 227267, - }, - }, + data: ACTIVITY_RESPONSE_STUB, }; }); @@ -69,74 +47,15 @@ module('Integration | Component | dashboard/client-count-card', function (hooks) assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total'); assert .dom('[data-test-stat-text="total-clients"] .stat-text') - .hasText( - `The number of clients in this billing period (Apr 2018 - ${parseAPITimestamp( - timestamp.now().toISOString(), - 'MMM yyyy' - )}).` - ); - assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('300,417'); + .hasText('The number of clients in this billing period (Jul 2023 - Jan 2024).'); + assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('7,805'); assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New'); assert .dom('[data-test-stat-text="new-clients"] .stat-text') .hasText('The number of clients new to Vault in the current month.'); - assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('12'); - this.server.get('sys/internal/counters/activity', () => { - return { - request_id: 'some-activity-id', - data: { - months: [ - { - timestamp: '2023-09-01T00:00:00-07:00', - counts: {}, - namespaces: [ - { - namespace_id: 'root', - namespace_path: '', - counts: {}, - mounts: [{ mount_path: 'auth/up2/', counts: {} }], - }, - ], - new_clients: { - counts: { - clients: 5, - }, - namespaces: [ - { - namespace_id: 'root', - namespace_path: '', - counts: { - clients: 12, - }, - mounts: [{ mount_path: 'auth/up2/', counts: {} }], - }, - ], - }, - }, - ], - total: { - clients: 120, - entity_clients: 100, - non_entity_clients: 100, - }, - }, - }; - }); + assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('336'); + + // fires second request to /activity await click('[data-test-refresh]'); - assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total'); - assert - .dom('[data-test-stat-text="total-clients"] .stat-text') - .hasText( - `The number of clients in this billing period (Apr 2018 - ${parseAPITimestamp( - timestamp.now().toISOString(), - 'MMM yyyy' - )}).` - ); - assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('120'); - assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New'); - assert - .dom('[data-test-stat-text="new-clients"] .stat-text') - .hasText('The number of clients new to Vault in the current month.'); - assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('5'); }); }); diff --git a/ui/tests/integration/utils/client-count-utils-test.js b/ui/tests/integration/utils/client-count-utils-test.js index 907f0be10f..710adddb24 100644 --- a/ui/tests/integration/utils/client-count-utils-test.js +++ b/ui/tests/integration/utils/client-count-utils-test.js @@ -16,6 +16,7 @@ import { import { LICENSE_START } from 'vault/mirage/handlers/clients'; import { ACTIVITY_RESPONSE_STUB as RESPONSE, + MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE, VERSION_HISTORY, SERIALIZED_ACTIVITY_RESPONSE, } from 'vault/tests/helpers/clients'; @@ -29,7 +30,7 @@ in a serializer test for easier debugging module('Integration | Util | client count utils', function (hooks) { setupTest(hooks); - test('filterVersionHistory: returns version data for relevant upgrades that occurred during date range', async function (assert) { + test('filterVersionHistory: it returns version data for relevant upgrades that occurred during date range', async function (assert) { assert.expect(2); // LICENSE_START is '2023-07-02T00:00:00Z' const original = [...VERSION_HISTORY]; @@ -56,8 +57,8 @@ module('Integration | Util | client count utils', function (hooks) { assert.propEqual(VERSION_HISTORY, original, 'it does not modify original array'); }); - test('formatByMonths: formats the months array', async function (assert) { - assert.expect(4); + test('formatByMonths: it formats the months array', async function (assert) { + assert.expect(5); const original = [...RESPONSE.months]; const [formattedNoData, formattedWithActivity] = formatByMonths(RESPONSE.months); @@ -79,9 +80,10 @@ module('Integration | Util | client count utils', function (hooks) { 'it formats new_clients block for months with data' ); assert.propEqual(RESPONSE.months, original, 'it does not modify original months array'); + assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty'); }); - test('formatByNamespace: formats namespace array with mounts', async function (assert) { + test('formatByNamespace: it formats namespace array with mounts', async function (assert) { assert.expect(3); const original = [...RESPONSE.by_namespace]; const [formattedRoot, formattedNs1] = formatByNamespace(RESPONSE.by_namespace); @@ -92,39 +94,7 @@ module('Integration | Util | client count utils', function (hooks) { assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array'); }); - test('formatByNamespace: formats namespace array with no mounts (activity log data < 1.10)', async function (assert) { - assert.expect(1); - const noMounts = [ - { - namespace_id: 'root', - namespace_path: '', - counts: { - distinct_entities: 10, - entity_clients: 10, - non_entity_tokens: 20, - non_entity_clients: 20, - secret_syncs: 0, - acme_clients: 0, - clients: 30, - }, - mounts: 'no mount accessor (pre-1.10 upgrade?)', - }, - ]; - const expected = [ - { - acme_clients: 0, - clients: 30, - entity_clients: 10, - label: 'root', - mounts: [], - non_entity_clients: 20, - secret_syncs: 0, - }, - ]; - assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts'); - }); - - test('destructureClientCounts: homogenizes key names when both old and new keys exist, or just old key names', async function (assert) { + test('destructureClientCounts: it returns relevant key names when both old and new keys exist', async function (assert) { assert.expect(2); const original = { ...RESPONSE.total }; const expected = { @@ -148,29 +118,250 @@ module('Integration | Util | client count utils', function (hooks) { assert.propEqual(RESPONSE.months, original, 'it does not modify original array'); }); - test('namespaceArrayToObject: it generates namespaces_by_key without modifying original', async function (assert) { - assert.expect(3); + test('namespaceArrayToObject: it returns namespaces_by_key and mounts_by_key', async function (assert) { + assert.expect(5); - // month at 0-index has no data so use second month in array - const { namespaces, new_clients } = RESPONSE.months[1]; + // month at 0-index has no data so use second month in array, empty month format covered by formatByMonths test above const original = { ...RESPONSE.months[1] }; - const byNamespaceKeyObject = namespaceArrayToObject( - formatByNamespace(namespaces), - formatByNamespace(new_clients.namespaces), + const expectedObject = SERIALIZED_ACTIVITY_RESPONSE.by_month[1].namespaces_by_key; + const formattedTotal = formatByNamespace(RESPONSE.months[1].namespaces); + + const testObject = namespaceArrayToObject( + formattedTotal, + formatByNamespace(RESPONSE.months[1].new_clients.namespaces), '9/23', '2023-09-01T00:00:00Z' ); + const { root } = testObject; + const { root: expectedRoot } = expectedObject; + assert.propEqual(root.new_clients, expectedRoot.new_clients, 'it formats namespaces new_clients'); + assert.propEqual(root.mounts_by_key, expectedRoot.mounts_by_key, 'it formats namespaces mounts_by_key'); + assert.propContains(root, expectedRoot, 'namespace has correct keys'); + assert.propEqual( - byNamespaceKeyObject, - SERIALIZED_ACTIVITY_RESPONSE.by_month[1].namespaces_by_key, - 'it returns object with namespaces by key and includes mounts_by_key' - ); - assert.propEqual( - namespaceArrayToObject(null, null, '10/21', 'timestamp-here'), + namespaceArrayToObject(formattedTotal, formatByNamespace([]), '9/23', '2023-09-01T00:00:00Z'), {}, - 'returns an empty object when monthByNamespace = null' + 'returns an empty object when there are no new clients ' ); assert.propEqual(RESPONSE.months[1], original, 'it does not modify original month data'); }); + + // TESTS FOR COMBINED ACTIVITY DATA - no mount attribution < 1.10 + test('it formats the namespaces array with no mount attribution (activity log data < 1.10)', async function (assert) { + assert.expect(2); + const noMounts = [ + { + namespace_id: 'root', + namespace_path: '', + counts: { + distinct_entities: 10, + entity_clients: 10, + non_entity_tokens: 20, + non_entity_clients: 20, + secret_syncs: 0, + acme_clients: 0, + clients: 30, + }, + mounts: [ + { + counts: { + distinct_entities: 10, + entity_clients: 10, + non_entity_tokens: 20, + non_entity_clients: 20, + secret_syncs: 0, + acme_clients: 0, + clients: 30, + }, + mount_path: 'no mount accessor (pre-1.10 upgrade?)', + }, + ], + }, + ]; + const expected = [ + { + acme_clients: 0, + clients: 30, + entity_clients: 10, + label: 'root', + mounts: [ + { + acme_clients: 0, + clients: 30, + entity_clients: 10, + label: 'no mount accessor (pre-1.10 upgrade?)', + non_entity_clients: 20, + secret_syncs: 0, + }, + ], + non_entity_clients: 20, + secret_syncs: 0, + }, + ]; + assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts'); + assert.propEqual(formatByNamespace([]), [], 'it returns an empty array if the by_namespace key is empty'); + }); + + test('it formats the months array with mixed activity data', async function (assert) { + assert.expect(3); + + const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months); + // mirage isn't set up to generate mixed data, so hardcoding the expected responses here + assert.propEqual( + formattedWithActivity.namespaces, + [ + { + acme_clients: 0, + clients: 3, + entity_clients: 3, + label: 'root', + mounts: [ + { + acme_clients: 0, + clients: 2, + entity_clients: 2, + label: 'no mount accessor (pre-1.10 upgrade?)', + non_entity_clients: 0, + secret_syncs: 0, + }, + { + acme_clients: 0, + clients: 1, + entity_clients: 1, + label: 'auth/u/', + non_entity_clients: 0, + secret_syncs: 0, + }, + ], + non_entity_clients: 0, + secret_syncs: 0, + }, + ], + 'it formats combined data for monthly namespaces spanning upgrade to 1.10' + ); + assert.propEqual( + formattedWithActivity.new_clients, + { + acme_clients: 0, + clients: 3, + entity_clients: 3, + month: '4/24', + namespaces: [ + { + acme_clients: 0, + clients: 3, + entity_clients: 3, + label: 'root', + mounts: [ + { + acme_clients: 0, + clients: 2, + entity_clients: 2, + label: 'no mount accessor (pre-1.10 upgrade?)', + non_entity_clients: 0, + secret_syncs: 0, + }, + { + acme_clients: 0, + clients: 1, + entity_clients: 1, + label: 'auth/u/', + non_entity_clients: 0, + secret_syncs: 0, + }, + ], + non_entity_clients: 0, + secret_syncs: 0, + }, + ], + non_entity_clients: 0, + secret_syncs: 0, + timestamp: '2024-04-01T00:00:00Z', + }, + 'it formats combined data for monthly new_clients spanning upgrade to 1.10' + ); + assert.propEqual( + formattedWithActivity.namespaces_by_key, + { + root: { + acme_clients: 0, + clients: 3, + entity_clients: 3, + month: '4/24', + mounts_by_key: { + 'auth/u/': { + acme_clients: 0, + clients: 1, + entity_clients: 1, + label: 'auth/u/', + month: '4/24', + new_clients: { + acme_clients: 0, + clients: 1, + entity_clients: 1, + label: 'auth/u/', + month: '4/24', + non_entity_clients: 0, + secret_syncs: 0, + }, + non_entity_clients: 0, + secret_syncs: 0, + timestamp: '2024-04-01T00:00:00Z', + }, + 'no mount accessor (pre-1.10 upgrade?)': { + acme_clients: 0, + clients: 2, + entity_clients: 2, + label: 'no mount accessor (pre-1.10 upgrade?)', + month: '4/24', + new_clients: { + acme_clients: 0, + clients: 2, + entity_clients: 2, + label: 'no mount accessor (pre-1.10 upgrade?)', + month: '4/24', + non_entity_clients: 0, + secret_syncs: 0, + }, + non_entity_clients: 0, + secret_syncs: 0, + timestamp: '2024-04-01T00:00:00Z', + }, + }, + new_clients: { + acme_clients: 0, + clients: 3, + entity_clients: 3, + label: 'root', + month: '4/24', + mounts: [ + { + acme_clients: 0, + clients: 2, + entity_clients: 2, + label: 'no mount accessor (pre-1.10 upgrade?)', + non_entity_clients: 0, + secret_syncs: 0, + }, + { + acme_clients: 0, + clients: 1, + entity_clients: 1, + label: 'auth/u/', + non_entity_clients: 0, + secret_syncs: 0, + }, + ], + non_entity_clients: 0, + secret_syncs: 0, + }, + non_entity_clients: 0, + secret_syncs: 0, + timestamp: '2024-04-01T00:00:00Z', + }, + }, + 'it formats combined data for monthly namespaces_by_key spanning upgrade to 1.10' + ); + }); }); diff --git a/ui/types/vault/charts/client-counts.d.ts b/ui/types/vault/charts/client-counts.d.ts index 7edb8b1b6d..c3ca2a922c 100644 --- a/ui/types/vault/charts/client-counts.d.ts +++ b/ui/types/vault/charts/client-counts.d.ts @@ -3,15 +3,11 @@ * SPDX-License-Identifier: BUSL-1.1 */ -// Count and EmptyCount are mutually exclusive +import type { TotalClients } from 'core/utils/client-count-utils'; + +// TotalClients 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; } @@ -20,6 +16,6 @@ interface Timestamp { timestamp: string; // ISO 8601 } -export interface MonthlyChartData extends Count, EmptyCount, Timestamp { - new_clients?: Count; +export interface MonthlyChartData extends TotalClients, EmptyCount, Timestamp { + new_clients?: TotalClients; } diff --git a/ui/types/vault/models/clients/activity.d.ts b/ui/types/vault/models/clients/activity.d.ts index 4c1f43862a..58e7123777 100644 --- a/ui/types/vault/models/clients/activity.d.ts +++ b/ui/types/vault/models/clients/activity.d.ts @@ -5,45 +5,12 @@ 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; -} +import type { ByMonthClients, ByNamespaceClients, TotalClients } from 'core/utils/client-count-utils'; export default interface ClientsActivityModel extends Model { - byMonth: ClientActivityMonthly[]; - byNamespace: ClientActivityNamespace[]; - total: ClientActivityTotals; + byMonth: ByMonthClients[]; + byNamespace: ByNamespaceClients[]; + total: TotalClients; startTime: string; endTime: string; responseTimestamp: string;