mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* delete activity component, convert date-formatters to ts * add "month" filter to overview tab * add test coverage for date range dropdown * add month filtering to client-list * remove old comment * wire up clients to route filters for client-list * adds changelog * only link to client-list for enterprise versions * add refresh page link * render all tabs, add custom empty state for secret sycn clients * cleanup unused service imports * revert billing periods as first of the month * first round of test updates * update client count utils test * fix comment typo * organize tests Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* 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';
|
|
|
|
/*
|
|
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;
|
|
|
|
export type ClientTypes = (typeof CLIENT_TYPES)[number];
|
|
|
|
// 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',
|
|
}
|
|
|
|
export type ClientFilterTypes = (typeof ClientFilters)[keyof typeof ClientFilters];
|
|
|
|
// 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;
|
|
|
|
export type ActivityExportClientTypes = (typeof EXPORT_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 && 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;
|
|
}
|
|
|
|
// TYPES RETURNED BY UTILS (serialized)
|
|
export interface TotalClients {
|
|
clients: number;
|
|
entity_clients: number;
|
|
non_entity_clients: number;
|
|
secret_syncs: number;
|
|
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[];
|
|
}
|
|
|
|
export interface MountClients extends TotalClients {
|
|
label: string;
|
|
mount_path: string;
|
|
mount_type: string;
|
|
namespace_path: string;
|
|
}
|
|
|
|
export interface ByMonthClients extends TotalClients {
|
|
timestamp: string;
|
|
namespaces: ByNamespaceClients[];
|
|
new_clients: ByMonthNewClients;
|
|
}
|
|
|
|
export interface ByMonthNewClients extends TotalClientsSometimes {
|
|
timestamp: string;
|
|
namespaces: ByNamespaceClients[];
|
|
}
|
|
|
|
export interface NamespaceByKey extends TotalClients {
|
|
timestamp: string;
|
|
new_clients: NamespaceNewClients;
|
|
}
|
|
|
|
export interface NamespaceNewClients extends TotalClientsSometimes {
|
|
timestamp: string;
|
|
label: string;
|
|
mounts: MountClients[];
|
|
}
|
|
|
|
export interface MountByKey extends TotalClients {
|
|
timestamp: string;
|
|
label: string;
|
|
new_clients: MountNewClients;
|
|
}
|
|
|
|
export interface MountNewClients extends TotalClientsSometimes {
|
|
timestamp: string;
|
|
label: string;
|
|
}
|
|
|
|
// Serialized data from activity/export API
|
|
export interface ActivityExportData {
|
|
client_id: string;
|
|
client_type: ActivityExportClientTypes;
|
|
namespace_id: string;
|
|
namespace_path: string;
|
|
mount_accessor: string;
|
|
mount_type: string;
|
|
mount_path: string;
|
|
token_creation_time: string;
|
|
client_first_used_time: string;
|
|
}
|
|
export interface EntityClients extends ActivityExportData {
|
|
entity_name: string;
|
|
entity_alias_name: string;
|
|
local_entity_alias: boolean;
|
|
policies: string[];
|
|
entity_metadata: Record<string, any>;
|
|
entity_alias_metadata: Record<string, any>;
|
|
entity_alias_custom_metadata: Record<string, any>;
|
|
entity_group_ids: string[];
|
|
}
|
|
|
|
// API RESPONSE SHAPE (prior to serialization)
|
|
|
|
export interface NamespaceObject {
|
|
namespace_id: string;
|
|
namespace_path: string;
|
|
counts: Counts;
|
|
mounts: { mount_path: string; counts: Counts; mount_type: string }[];
|
|
}
|
|
|
|
type ActivityMonthStandard = {
|
|
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;
|
|
};
|
|
};
|
|
type ActivityMonthNoNewClients = {
|
|
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
|
|
counts: Counts;
|
|
namespaces: NamespaceObject[];
|
|
new_clients: {
|
|
counts: null;
|
|
namespaces: null;
|
|
};
|
|
};
|
|
type ActivityMonthEmpty = {
|
|
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
|
|
counts: null;
|
|
namespaces: null;
|
|
new_clients: null;
|
|
};
|
|
export type ActivityMonthBlock = ActivityMonthEmpty | ActivityMonthNoNewClients | ActivityMonthStandard;
|
|
|
|
export interface Counts {
|
|
acme_clients: number;
|
|
clients: number;
|
|
entity_clients: number;
|
|
non_entity_clients: number;
|
|
secret_syncs: number;
|
|
}
|