vault/ui/lib/core/addon/utils/client-count-utils.ts
Vault Automation 0c6c13dd38
license: update headers to IBM Corp. (#10229) (#10233)
* license: update headers to IBM Corp.
* `make proto`
* update offset because source file changed

Signed-off-by: Ryan Cragun <me@ryan.ec>
Co-authored-by: Ryan Cragun <me@ryan.ec>
2025-10-21 15:20:20 -06:00

250 lines
9.1 KiB
TypeScript

/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc, isWithinInterval } from 'date-fns';
import { ROOT_NAMESPACE } from 'vault/services/namespace';
import { sanitizePath } from './sanitize-path';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
import type {
ActivityExportData,
ActivityMonthBlock,
ActivityMonthEmpty,
ActivityMonthStandard,
ByMonthNewClients,
ByNamespaceClients,
ClientFilterTypes,
ClientTypes,
Counts,
MountClients,
MountNewClients,
NamespaceNewClients,
NamespaceObject,
} from 'vault/vault/client-counts/activity-api';
/*
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 sys/activity/counters client count types here
export const CLIENT_TYPES = [
'acme_clients',
'clients', // summation of total clients
'entity_clients',
'non_entity_clients',
'secret_syncs',
] as const;
// map to dropdowns for filtering client count tables
export enum ClientFilters {
NAMESPACE = 'namespace_path',
MOUNT_PATH = 'mount_path',
MOUNT_TYPE = 'mount_type',
// this filter/query param does not map to a key in either API response and is handled ~special~
MONTH = 'month',
}
// client_type in the exported activity data differs slightly from the types of client keys
// returned by sys/internal/counters/activity endpoint (:
export const EXPORT_CLIENT_TYPES = ['non-entity-token', 'pki-acme', 'secret-sync', 'entity'] as const;
// 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 && start && end) {
const upgrades = versionHistory.reduce((array: ClientsVersionHistoryModel[], upgradeData) => {
const isRelevantHistory = (v: string) => {
return (
upgradeData.version.match(v) &&
// only add if there is a previous version, otherwise this upgrade is the users' first version
upgradeData.previousVersion &&
// only add first match, disregard subsequent patch releases of the same version
!array.some((d: ClientsVersionHistoryModel) => d.version.match(v))
);
};
['1.9', '1.10', '1.17'].forEach((v) => {
if (isRelevantHistory(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 [];
};
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const { timestamp } = m;
if (monthIsEmpty(m)) {
// empty month
return {
timestamp,
namespaces: [],
new_clients: { timestamp, namespaces: [] },
};
}
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
if (monthWithAllCounts(m)) {
newClients = {
timestamp,
...destructureClientCounts(m?.new_clients.counts),
namespaces: formatByNamespace(m.new_clients.namespaces),
};
}
return {
timestamp,
...destructureClientCounts(m.counts),
namespaces: formatByNamespace(m.namespaces),
new_clients: newClients,
};
});
};
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 nsLabel = 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,
namespace_path: nsLabel,
mount_path: m.mount_path,
// sanitized so it matches activity export data because mount_type there does NOT have a trailing slash
mount_type: sanitizePath(m.mount_type),
...destructureClientCounts(m.counts),
}));
}
return {
label: nsLabel,
...destructureClientCounts(ns.counts),
mounts,
};
});
};
// This method returns only client types from the passed object, excluding other keys such as "label".
// 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[]) => {
const sortedPayload = [...monthsArray];
return sortedPayload.sort((a, b) =>
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
);
};
// *Performance note*
// The client dashboard renders dropdown lists that specify filters. When the user selects a dropdown item (filter)
// it updates the query param and this method is called to filter the data passed to the displayed table.
// This method is not doing anything computationally expensive so it should be fine for filtering up to 50K rows of data.
// If activity data (either the by_namespace list or rows of data in the activity export API) grow past that, then we
// will want to look at converting this to a restartable task or do something else :)
export function filterTableData(
data: MountClients[] | ActivityExportData[],
filters: Record<ClientFilterTypes, string>
): MountClients[] | ActivityExportData[] {
// Return original data if no filters are specified
if (!filters || Object.values(filters).every((v) => !v)) {
return data;
}
return data.filter((datum) => {
// Datum must satisfy every filter
return Object.entries(filters).every(([filterKey, filterValue]) => {
// If no filter is specified for that key, return true
if (!filterValue) return true;
// Otherwise only return true if the datum matches the filter
return matchesFilter(datum, filterKey as ClientFilterTypes, filterValue);
});
}) as typeof data;
}
const matchesFilter = (
datum: ActivityExportData | MountClients,
filterKey: ClientFilterTypes,
filterValue: string
) => {
// Only ActivityExportData data is ever filtered by 'client_first_used_time' (not MountClients)
if (filterKey === ClientFilters.MONTH) {
return 'client_first_used_time' in datum
? isSameMonthUTC(datum.client_first_used_time, filterValue)
: false;
}
const datumValue = datum[filterKey];
// The API returns and empty string as the namespace_path for the "root" namespace.
// When a user selects "root" as a namespace filter we need to match the datum value
// as either an empty string (for the activity export data) OR as "root"
// (the by_namespace data is serialized to make "root" the namespace_path).
if (filterKey === ClientFilters.NAMESPACE && filterValue === 'root') {
return datumValue === ROOT_NAMESPACE || datumValue === filterValue;
}
return datumValue === filterValue;
};
export const flattenMounts = (namespaceArray: ByNamespaceClients[]) =>
namespaceArray.map((n) => n.mounts).flat();
// TYPE GUARDS FOR CONDITIONALS
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
return !month || month?.counts === null;
}
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
return month?.counts !== null && month?.new_clients?.counts !== null;
}
export function filterIsSupported(f: string): f is ClientFilterTypes {
return Object.values(ClientFilters).includes(f as ClientFilterTypes);
}
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;
}