UI: Convert client count utils to typescript (#26262)

* cleanup namespaceArrayToObject method

* WIP typescript conversion

* WIP typescripted destructured block

* slowly making progress....

* WIP move all types to util type file, separate returns in formatByMonths

* namespaceArrayToObject is working?!?

* fix mirage handler not generating months when queries are after upgrade

* wow, the types are actually working omg

* add comments and update client-count-utils test

* delete old js file

* remove types from activity model

* remove comment

* reuse totalclients type to minimize places we add types

* commit file with type changes for git diff

* delete util file again

* address PR feedback and move type declarations to util file

* remove unused types

* update tests, use client helper in dashboard clients test

* remove typo

* make modifications with updated combined activity response from the backend
This commit is contained in:
claire bontempo
2024-04-09 13:53:16 -07:00
committed by GitHub
parent 1e3efed2fa
commit 009702cae0
13 changed files with 788 additions and 460 deletions

View File

@@ -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<Args> {
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<Args> {
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<Args> {
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<Args> {
// 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() {

View File

@@ -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<Args> {
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<Args> {
}
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) => {

View File

@@ -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<Args> {
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,

View File

@@ -162,7 +162,7 @@ export default class ClientsCountsPageComponent extends Component<Args> {
}
@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());

View File

@@ -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);

View File

@@ -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,
},
},
},
},
};
*/
};

View File

@@ -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<ClientTypes, Counts[ClientTypes]>, clientType: ClientTypes) => {
newObj[clientType] = verboseObject[clientType];
return newObj;
}, {} as Record<ClientTypes, Counts[ClientTypes]>);
};
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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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');
});
});

View File

@@ -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'
);
});
});

View File

@@ -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;
}

View File

@@ -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;