UI: Fix client counts bug when no new clients (#27352)

This commit is contained in:
Chelsea Shaw
2024-06-06 13:15:12 -05:00
committed by GitHub
parent 61b27d0cb4
commit 61a3885eb6
5 changed files with 640 additions and 86 deletions

3
changelog/27352.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: fix issue where a month without new clients breaks the client count dashboard
```

View File

@@ -86,7 +86,9 @@ export const formatDateObject = (dateObj: { monthIdx: number; year: number }, is
return getUnixTime(utc);
};
export const formatByMonths = (monthsArray: ActivityMonthBlock[] | EmptyActivityMonthBlock[]) => {
export const formatByMonths = (
monthsArray: (ActivityMonthBlock | EmptyActivityMonthBlock | NoNewClientsActivityMonthBlock)[]
) => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const month = parseAPITimestamp(m.timestamp, 'M/yy') as string;
@@ -95,23 +97,28 @@ export const formatByMonths = (monthsArray: ActivityMonthBlock[] | EmptyActivity
if (m.counts) {
const totalClientsByNamespace = formatByNamespace(m.namespaces);
const newClientsByNamespace = formatByNamespace(m.new_clients?.namespaces);
let newClients: ByMonthNewClients = { month, timestamp, namespaces: [] };
if (m.new_clients?.counts) {
newClients = {
month,
timestamp,
...destructureClientCounts(m?.new_clients?.counts),
namespaces: formatByNamespace(m.new_clients?.namespaces),
};
}
return {
month,
timestamp,
...destructureClientCounts(m.counts),
namespaces: formatByNamespace(m.namespaces) || [],
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) || [],
},
new_clients: newClients,
};
}
// empty month
@@ -125,7 +132,8 @@ export const formatByMonths = (monthsArray: ActivityMonthBlock[] | EmptyActivity
});
};
export const formatByNamespace = (namespaceArray: NamespaceObject[]) => {
export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByNamespaceClients[] => {
if (!Array.isArray(namespaceArray)) return [];
return namespaceArray.map((ns) => {
// i.e. 'namespace_path' is an empty string for 'root', so use namespace_id
const label = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path;
@@ -158,7 +166,9 @@ export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClien
);
};
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[] | EmptyActivityMonthBlock[]) => {
export const sortMonthsByTimestamp = (
monthsArray: (ActivityMonthBlock | EmptyActivityMonthBlock | NoNewClientsActivityMonthBlock)[]
) => {
const sortedPayload = [...monthsArray];
return sortedPayload.sort((a, b) =>
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
@@ -168,7 +178,7 @@ export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[] | EmptyA
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'],
monthNew: ByMonthClients['new_clients']['namespaces'] | null,
month: string,
timestamp: string
) => {
@@ -176,36 +186,45 @@ export const namespaceArrayToObject = (
// 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 keyedNs: NamespaceByKey = {
...destructureClientCounts(ns),
timestamp,
month,
mounts_by_key: {},
new_clients: {
month,
timestamp,
label: ns.label,
mounts: [],
},
};
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(
keyedNs.mounts_by_key = ns.mounts.reduce(
(mountObj: { [key: string]: MountByKey }, mount) => {
const newMountClients = newNsClients.mounts.find((m) => m.label === mount.label);
if (newMountClients) {
const mountNewClients = newNsClients ? newNsClients.mounts.find((m) => m.label === mount.label) : {};
mountObj[mount.label] = {
...mount,
timestamp,
month,
new_clients: { month, timestamp, ...newMountClients },
new_clients: {
timestamp,
month,
label: mount.label,
...mountNewClients,
},
};
}
return mountObj;
},
{} as { [key: string]: MountByKey }
);
nsObject[ns.label] = {
...destructureClientCounts(ns),
timestamp,
month,
new_clients: { month, timestamp, ...newNsClients },
mounts_by_key,
};
if (newNsClients) {
keyedNs.new_clients = { month, timestamp, ...newNsClients };
}
nsObject[ns.label] = keyedNs;
return nsObject;
}, {});
@@ -239,6 +258,15 @@ export interface TotalClients {
acme_clients: number;
}
// extend this type when the counts are optional (eg for new clients)
interface TotalClientsSometimes {
clients?: number;
entity_clients?: number;
non_entity_clients?: number;
secret_syncs?: number;
acme_clients?: number;
}
export interface ByNamespaceClients extends TotalClients {
label: string;
mounts: MountClients[];
@@ -255,7 +283,9 @@ export interface ByMonthClients extends TotalClients {
namespaces_by_key: { [key: string]: NamespaceByKey };
new_clients: ByMonthNewClients;
}
export interface ByMonthNewClients extends TotalClients {
// clients numbers are only returned if month is of type ActivityMonthBlock
export interface ByMonthNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
namespaces: ByNamespaceClients[];
@@ -268,7 +298,7 @@ export interface NamespaceByKey extends TotalClients {
new_clients: NamespaceNewClients;
}
export interface NamespaceNewClients extends TotalClients {
export interface NamespaceNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
label: string;
@@ -282,7 +312,7 @@ export interface MountByKey extends TotalClients {
new_clients: MountNewClients;
}
export interface MountNewClients extends TotalClients {
export interface MountNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
label: string;
@@ -308,6 +338,16 @@ export interface ActivityMonthBlock {
};
}
export interface NoNewClientsActivityMonthBlock {
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
counts: Counts;
namespaces: NamespaceObject[];
new_clients: {
counts: null;
namespaces: null;
};
}
export interface EmptyActivityMonthBlock {
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
counts: null;

View File

@@ -40,7 +40,7 @@ export function assertBarChart(assert, chartName, byMonthData, isStacked = false
}
export const ACTIVITY_RESPONSE_STUB = {
start_time: '2023-08-01T00:00:00Z',
start_time: '2023-06-01T00:00:00Z',
end_time: '2023-09-30T23:59:59Z', // is always the last day and hour of the month queried
by_namespace: [
{
@@ -148,11 +148,209 @@ export const ACTIVITY_RESPONSE_STUB = {
],
months: [
{
timestamp: '2023-08-01T00:00:00Z',
timestamp: '2023-06-01T00:00:00Z',
counts: null,
namespaces: null,
new_clients: null,
},
{
timestamp: '2023-07-01T00:00:00Z',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
mounts: [
{
mount_path: 'pki-engine-0',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'auth/authid/0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'kvv2-engine-0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
],
},
],
new_clients: {
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
mounts: [
{
mount_path: 'pki-engine-0',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'auth/authid/0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'kvv2-engine-0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
],
},
],
},
},
{
timestamp: '2023-08-01T00:00:00Z',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
distinct_entities: 100,
non_entity_tokens: 100,
},
mounts: [
{
mount_path: 'pki-engine-0',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'auth/authid/0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
{
mount_path: 'kvv2-engine-0',
counts: {
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
distinct_entities: 0,
non_entity_tokens: 0,
},
},
],
},
],
new_clients: {
counts: null,
namespaces: null,
},
},
{
timestamp: '2023-09-01T00:00:00Z',
counts: {
@@ -646,10 +844,323 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
],
by_month: [
{
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
namespaces_by_key: {},
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
},
},
{
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
namespaces: [
{
label: 'root',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
},
],
},
],
namespaces_by_key: {
root: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
timestamp: '2023-07-01T00:00:00Z',
month: '7/23',
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
label: 'root',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
},
],
},
mounts_by_key: {
'pki-engine-0': {
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
timestamp: '2023-07-01T00:00:00Z',
month: '7/23',
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
'auth/authid/0': {
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
timestamp: '2023-07-01T00:00:00Z',
month: '7/23',
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
},
},
'kvv2-engine-0': {
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
timestamp: '2023-07-01T00:00:00Z',
month: '7/23',
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
},
},
},
},
},
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
namespaces: [
{
label: 'root',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
},
],
},
],
},
},
{
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
namespaces: [
{
label: 'root',
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
},
],
},
],
namespaces_by_key: {
root: {
acme_clients: 100,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 100,
timestamp: '2023-08-01T00:00:00Z',
month: '8/23',
new_clients: {
label: 'root',
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
mounts: [],
},
mounts_by_key: {
'pki-engine-0': {
label: 'pki-engine-0',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
timestamp: '2023-08-01T00:00:00Z',
month: '8/23',
new_clients: {
label: 'pki-engine-0',
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
},
},
'auth/authid/0': {
label: 'auth/authid/0',
acme_clients: 0,
clients: 100,
entity_clients: 100,
non_entity_clients: 100,
secret_syncs: 0,
timestamp: '2023-08-01T00:00:00Z',
month: '8/23',
new_clients: {
label: 'auth/authid/0',
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
},
},
'kvv2-engine-0': {
label: 'kvv2-engine-0',
acme_clients: 0,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 100,
timestamp: '2023-08-01T00:00:00Z',
month: '8/23',
new_clients: {
label: 'kvv2-engine-0',
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
},
},
},
},
},
new_clients: {
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',

View File

@@ -43,7 +43,7 @@ module('Integration | Component | dashboard/client-count-card', function (hooks)
assert
.dom(CLIENT_COUNT.statText('Total'))
.hasText(
`Total The number of clients in this billing period (Aug 2023 - Sep 2023). ${formatNumber([
`Total The number of clients in this billing period (Jun 2023 - Sep 2023). ${formatNumber([
total.clients,
])}`
);

View File

@@ -129,27 +129,29 @@ module('Integration | Util | client count utils', function (hooks) {
});
test('formatByMonths: it formats the months array', async function (assert) {
assert.expect(5);
assert.expect(9);
const original = [...RESPONSE.months];
const [formattedNoData, formattedWithActivity] = formatByMonths(RESPONSE.months);
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
// instead of asserting the whole expected response, broken up so tests are easier to debug
// but kept whole above to copy/paste updated response expectations in the future
const [expectedNoData, expectedWithActivity] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
const { namespaces, new_clients } = expectedWithActivity;
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
['namespaces', 'new_clients', 'namespaces_by_key'].forEach((key) => {
assert.propEqual(
formattedWithActivity.namespaces,
namespaces,
'it formats namespaces array for months with data'
formattedWithActivity[key],
expectedWithActivity[key],
`it formats ${key} array for months with data`
);
assert.propEqual(
formattedWithActivity.new_clients,
new_clients,
'it formats new_clients block for months with data'
formattedNoNew[key],
expectedNoNew[key],
`it formats the ${key} array for months with no new clients`
);
});
assert.propEqual(RESPONSE.months, original, 'it does not modify original months array');
assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty');
});
@@ -187,7 +189,7 @@ module('Integration | Util | client count utils', function (hooks) {
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
assert.expect(2);
// API returns them in order so this test is extra extra
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0]]; // mixup order
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0], RESPONSE.months[3], RESPONSE.months[2]]; // mixup order
const original = [...RESPONSE.months];
const expected = RESPONSE.months;
assert.propEqual(sortMonthsByTimestamp(unOrdered), expected);
@@ -195,32 +197,30 @@ module('Integration | Util | client count utils', function (hooks) {
});
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, empty month format covered by formatByMonths test above
const original = { ...RESPONSE.months[1] };
const expectedObject = SERIALIZED_ACTIVITY_RESPONSE.by_month[1].namespaces_by_key;
const formattedTotal = formatByNamespace(RESPONSE.months[1].namespaces);
// namespaceArrayToObject only called when there are counts, so skip month 0 which has no counts
for (let i = 1; i < RESPONSE.months.length; i++) {
const original = { ...RESPONSE.months[i] };
const expectedObject = SERIALIZED_ACTIVITY_RESPONSE.by_month[i].namespaces_by_key;
const formattedTotal = formatByNamespace(RESPONSE.months[i].namespaces);
const testObject = namespaceArrayToObject(
formattedTotal,
formatByNamespace(RESPONSE.months[1].new_clients.namespaces),
'9/23',
'2023-09-01T00:00:00Z'
formatByNamespace(RESPONSE.months[i].new_clients.namespaces),
`${i + 6}/23`,
original.timestamp
);
const { root } = testObject;
const { root: expectedRoot } = expectedObject;
assert.propEqual(root.new_clients, expectedRoot.new_clients, 'it formats namespaces new_clients');
assert.propEqual(
root?.new_clients,
expectedRoot?.new_clients,
`it formats namespaces new_clients for ${original.timestamp}`
);
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(
namespaceArrayToObject(formattedTotal, formatByNamespace([]), '9/23', '2023-09-01T00:00:00Z'),
{},
'returns an empty object when there are no new clients '
);
assert.propEqual(RESPONSE.months[1], original, 'it does not modify original month data');
assert.propEqual(RESPONSE.months[i], original, 'it does not modify original month data');
}
});
// TESTS FOR COMBINED ACTIVITY DATA - no mount attribution < 1.10