[Fleet] Show Count of Agent Policies on Integration Details (#86916)

* component to show count of agent policies for integration
* API route and service to return stats of package usage
This commit is contained in:
Paul Tavares 2021-01-12 07:56:05 -05:00 committed by GitHub
parent 31e66979b9
commit 02695ef5ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 420 additions and 39 deletions

View file

@ -29,6 +29,7 @@ export const EPM_API_ROUTES = {
DELETE_PATTERN: EPM_PACKAGES_ONE,
FILEPATH_PATTERN: `${EPM_PACKAGES_FILE}/{filePath*}`,
CATEGORIES_PATTERN: `${EPM_API_ROOT}/categories`,
STATS_PATTERN: `${EPM_PACKAGES_MANY}/{pkgName}/stats`,
};
// Data stream API routes

View file

@ -35,6 +35,10 @@ export const epmRouteService = {
return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgkey}', pkgkey);
},
getStatsPath: (pkgName: string) => {
return EPM_API_ROUTES.STATS_PATTERN.replace('{pkgName}', pkgName);
},
getFilePath: (filePath: string) => {
return `${EPM_API_ROOT}${filePath.replace('/package', '/packages')}`;
},

View file

@ -279,6 +279,10 @@ export interface Installation extends SavedObjectAttributes {
install_source: InstallSource;
}
export interface PackageUsageStats {
agent_policy_count: number;
}
export type Installable<T> = Installed<T> | NotInstalled<T>;
export type Installed<T = {}> = T & {

View file

@ -10,6 +10,7 @@ import {
Installable,
RegistrySearchResult,
PackageInfo,
PackageUsageStats,
} from '../models/epm';
export interface GetCategoriesRequest {
@ -54,6 +55,16 @@ export interface GetInfoResponse {
response: PackageInfo;
}
export interface GetStatsRequest {
params: {
pkgname: string;
};
}
export interface GetStatsResponse {
response: PackageUsageStats;
}
export interface InstallPackageRequest {
params: {
pkgkey: string;

View file

@ -16,6 +16,7 @@ import {
InstallPackageResponse,
DeletePackageResponse,
} from '../../types';
import { GetStatsResponse } from '../../../../../common';
export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return useRequest<GetCategoriesResponse>({
@ -47,6 +48,13 @@ export const useGetPackageInfoByKey = (pkgkey: string) => {
});
};
export const useGetPackageStats = (pkgName: string) => {
return useRequest<GetStatsResponse>({
path: epmRouteService.getStatsPath(pkgName),
method: 'get',
});
};
export const sendGetPackageInfoByKey = (pkgkey: string) => {
return sendRequest<GetInfoResponse>({
path: epmRouteService.getInfoPath(pkgkey),

View file

@ -14,6 +14,7 @@ import {
GetFleetStatusResponse,
GetInfoResponse,
GetPackagePoliciesResponse,
GetStatsResponse,
} from '../../../../../../../common/types/rest_spec';
import { DetailViewPanelName, KibanaAssetType } from '../../../../../../../common/types/models';
import {
@ -29,7 +30,7 @@ describe('when on integration detail', () => {
const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey });
let testRenderer: TestRenderer;
let renderResult: ReturnType<typeof testRenderer.render>;
let mockedApi: MockedApi;
let mockedApi: MockedApi<EpmPackageDetailsResponseProvidersMock>;
const render = () =>
(renderResult = testRenderer.render(
<Route path={PAGE_ROUTING_PATHS.integration_details}>
@ -48,6 +49,39 @@ describe('when on integration detail', () => {
window.location.hash = '#/';
});
describe('and the package is installed', () => {
beforeEach(() => render());
it('should display agent policy usage count', async () => {
await mockedApi.waitForApi();
expect(renderResult.queryByTestId('agentPolicyCount')).not.toBeNull();
});
it('should show the Policies tab', async () => {
await mockedApi.waitForApi();
expect(renderResult.queryByTestId('tab-policies')).not.toBeNull();
});
});
describe('and the package is not installed', () => {
beforeEach(() => {
const unInstalledPackage = mockedApi.responseProvider.epmGetInfo();
unInstalledPackage.response.status = 'not_installed';
mockedApi.responseProvider.epmGetInfo.mockReturnValue(unInstalledPackage);
render();
});
it('should NOT display agent policy usage count', async () => {
await mockedApi.waitForApi();
expect(renderResult.queryByTestId('agentPolicyCount')).toBeNull();
});
it('should NOT the Policies tab', async () => {
await mockedApi.waitForApi();
expect(renderResult.queryByTestId('tab-policies')).toBeNull();
});
});
describe('and a custom UI extension is NOT registered', () => {
beforeEach(() => render());
@ -190,12 +224,27 @@ describe('when on integration detail', () => {
});
});
interface MockedApi {
interface MockedApi<
R extends Record<string, jest.MockedFunction<any>> = Record<string, jest.MockedFunction<any>>
> {
/** Will return a promise that resolves when triggered APIs are complete */
waitForApi: () => Promise<void>;
/** A object containing the list of API response provider functions that are used by the mocked API */
responseProvider: R;
}
const mockApiCalls = (http: MockedFleetStartServices['http']): MockedApi => {
interface EpmPackageDetailsResponseProvidersMock {
epmGetInfo: jest.MockedFunction<() => GetInfoResponse>;
epmGetFile: jest.MockedFunction<() => string>;
epmGetStats: jest.MockedFunction<() => GetStatsResponse>;
fleetSetup: jest.MockedFunction<() => GetFleetStatusResponse>;
packagePolicyList: jest.MockedFunction<() => GetPackagePoliciesResponse>;
agentPolicyList: jest.MockedFunction<() => GetAgentPoliciesResponse>;
}
const mockApiCalls = (
http: MockedFleetStartServices['http']
): MockedApi<EpmPackageDetailsResponseProvidersMock> => {
let inflightApiCalls = 0;
const apiDoneListeners: Array<() => void> = [];
const markApiCallAsHandled = async () => {
@ -663,41 +712,13 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos
perPage: 100,
};
http.get.mockImplementation(async (path) => {
if (typeof path === 'string') {
if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) {
markApiCallAsHandled();
return epmPackageResponse;
}
const epmGetStatsResponse: GetStatsResponse = {
response: {
agent_policy_count: 2,
},
};
if (path === epmRouteService.getFilePath('/package/nginx/0.3.7/docs/README.md')) {
markApiCallAsHandled();
return packageReadMe;
}
if (path === fleetSetupRouteService.getFleetSetupPath()) {
markApiCallAsHandled();
return agentsSetupResponse;
}
if (path === packagePolicyRouteService.getListPath()) {
markApiCallAsHandled();
return packagePoliciesResponse;
}
if (path === agentPolicyRouteService.getListPath()) {
markApiCallAsHandled();
return agentPoliciesResponse;
}
const err = new Error(`API [GET ${path}] is not MOCKED!`);
// eslint-disable-next-line no-console
console.error(err);
throw err;
}
});
return {
const mockedApiInterface: MockedApi<EpmPackageDetailsResponseProvidersMock> = {
waitForApi() {
return new Promise((resolve) => {
if (inflightApiCalls > 0) {
@ -707,5 +728,54 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos
}
});
},
responseProvider: {
epmGetInfo: jest.fn().mockReturnValue(epmPackageResponse),
epmGetFile: jest.fn().mockReturnValue(packageReadMe),
epmGetStats: jest.fn().mockReturnValue(epmGetStatsResponse),
fleetSetup: jest.fn().mockReturnValue(agentsSetupResponse),
packagePolicyList: jest.fn().mockReturnValue(packagePoliciesResponse),
agentPolicyList: jest.fn().mockReturnValue(agentPoliciesResponse),
},
};
http.get.mockImplementation(async (path) => {
if (typeof path === 'string') {
if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.epmGetInfo();
}
if (path === epmRouteService.getFilePath('/package/nginx/0.3.7/docs/README.md')) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.epmGetFile();
}
if (path === fleetSetupRouteService.getFleetSetupPath()) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.fleetSetup();
}
if (path === packagePolicyRouteService.getListPath()) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.packagePolicyList();
}
if (path === agentPolicyRouteService.getListPath()) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.agentPolicyList();
}
if (path === epmRouteService.getStatsPath('nginx')) {
markApiCallAsHandled();
return mockedApiInterface.responseProvider.epmGetStats();
}
const err = new Error(`API [GET ${path}] is not MOCKED!`);
// eslint-disable-next-line no-console
console.error(err);
throw err;
}
});
return mockedApiInterface;
};

View file

@ -44,6 +44,7 @@ import './index.scss';
import { useUIExtension } from '../../../../hooks/use_ui_extension';
import { PLUGIN_ID } from '../../../../../../../common/constants';
import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info';
import { IntegrationAgentPolicyCount } from './integration_agent_policy_count';
export const DEFAULT_PANEL: DetailViewPanelName = 'overview';
@ -239,6 +240,18 @@ export function Detail() {
</EuiFlexGroup>
),
},
...(packageInstallStatus === 'installed'
? [
{ isDivider: true },
{
label: i18n.translate('xpack.fleet.epm.usedByLabel', {
defaultMessage: 'Agent Policies',
}),
'data-test-subj': 'agentPolicyCount',
content: <IntegrationAgentPolicyCount packageName={packageInfo.name} />,
},
]
: []),
{ isDivider: true },
{
content: (
@ -264,7 +277,7 @@ export function Detail() {
),
},
].map((item, index) => (
<EuiFlexItem grow={false} key={index}>
<EuiFlexItem grow={false} key={index} data-test-subj={item['data-test-subj']}>
{item.isDivider ?? false ? (
<Divider />
) : item.label ? (
@ -285,6 +298,7 @@ export function Detail() {
handleAddIntegrationPolicyClick,
hasWriteCapabilites,
packageInfo,
packageInstallStatus,
pkgkey,
updateAvailable,
]

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import { useGetPackageStats } from '../../../../hooks';
/**
* Displays a count of Agent Policies that are using the given integration
*/
export const IntegrationAgentPolicyCount = memo<{ packageName: string }>(({ packageName }) => {
const { data } = useGetPackageStats(packageName);
return <>{data?.response.agent_policy_count ?? 0}</>;
});

View file

@ -17,6 +17,7 @@ import {
BulkInstallPackageInfo,
BulkInstallPackagesResponse,
IBulkInstallPackageHTTPError,
GetStatsResponse,
} from '../../../common';
import {
GetCategoriesRequestSchema,
@ -27,6 +28,7 @@ import {
InstallPackageByUploadRequestSchema,
DeletePackageRequestSchema,
BulkUpgradePackagesFromRegistryRequestSchema,
GetStatsRequestSchema,
} from '../../types';
import {
BulkInstallResponse,
@ -48,6 +50,7 @@ import { splitPkgKey } from '../../services/epm/registry';
import { licenseService } from '../../services';
import { getArchiveEntry } from '../../services/epm/archive/cache';
import { getAsset } from '../../services/epm/archive/storage';
import { getPackageUsageStats } from '../../services/epm/packages/get';
export const getCategoriesHandler: RequestHandler<
undefined,
@ -196,6 +199,23 @@ export const getInfoHandler: RequestHandler<TypeOf<typeof GetInfoRequestSchema.p
}
};
export const getStatsHandler: RequestHandler<TypeOf<typeof GetStatsRequestSchema.params>> = async (
context,
request,
response
) => {
try {
const { pkgName } = request.params;
const savedObjectsClient = context.core.savedObjects.client;
const body: GetStatsResponse = {
response: await getPackageUsageStats({ savedObjectsClient, pkgName }),
};
return response.ok({ body });
} catch (error) {
return defaultIngestErrorHandler({ error, response });
}
};
export const installPackageFromRegistryHandler: RequestHandler<
TypeOf<typeof InstallPackageFromRegistryRequestSchema.params>,
undefined,

View file

@ -15,6 +15,7 @@ import {
installPackageByUploadHandler,
deletePackageHandler,
bulkInstallPackagesFromRegistryHandler,
getStatsHandler,
} from './handlers';
import {
GetCategoriesRequestSchema,
@ -25,6 +26,7 @@ import {
InstallPackageByUploadRequestSchema,
DeletePackageRequestSchema,
BulkUpgradePackagesFromRegistryRequestSchema,
GetStatsRequestSchema,
} from '../../types';
const MAX_FILE_SIZE_BYTES = 104857600; // 100MB
@ -57,6 +59,15 @@ export const registerRoutes = (router: IRouter) => {
getLimitedListHandler
);
router.get(
{
path: EPM_API_ROUTES.STATS_PATTERN,
validate: GetStatsRequestSchema,
options: { tags: [`access:${PLUGIN_ID}`] },
},
getStatsHandler
);
router.get(
{
path: EPM_API_ROUTES.FILEPATH_PATTERN,

View file

@ -0,0 +1,171 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsClientContract, SavedObjectsFindResult } from 'kibana/server';
import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, PackagePolicySOAttributes } from '../../../../common';
import { getPackageUsageStats } from './get';
describe('When using EPM `get` services', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
beforeEach(() => {
soClient = savedObjectsClientMock.create();
});
describe('and invoking getPackageUsageStats()', () => {
beforeEach(() => {
const savedObjects: Array<SavedObjectsFindResult<PackagePolicySOAttributes>> = [
{
type: 'ingest-package-policies',
id: 'dcf83172-c38e-4501-b236-9f479da8a7d6',
attributes: {
name: 'system-3',
description: '',
namespace: 'default',
policy_id: '22222-22222-2222-2222',
enabled: true,
output_id: '',
inputs: [],
package: { name: 'system', title: 'System', version: '0.10.4' },
revision: 1,
created_at: '2020-12-22T21:28:05.380Z',
created_by: 'elastic',
updated_at: '2020-12-22T21:28:05.380Z',
updated_by: 'elastic',
},
references: [],
migrationVersion: { 'ingest-package-policies': '7.11.0' },
updated_at: '2020-12-22T21:28:05.383Z',
version: 'WzE1NTAsMV0=',
score: 0,
},
{
type: 'ingest-package-policies',
id: '5b61eb5c-d94c-48a6-a17c-b0d1f7c65336',
attributes: {
name: 'system-1',
namespace: 'default',
package: { name: 'system', title: 'System', version: '0.10.4' },
enabled: true,
policy_id: '11111-111111-11111-11111', // << duplicate id with plicy below
output_id: 'ca111b80-43c1-11eb-84bf-7177b74381c5',
inputs: [],
revision: 1,
created_at: '2020-12-21T19:22:04.902Z',
created_by: 'system',
updated_at: '2020-12-21T19:22:04.902Z',
updated_by: 'system',
},
references: [],
migrationVersion: { 'ingest-package-policies': '7.11.0' },
updated_at: '2020-12-21T19:22:04.905Z',
version: 'WzIxNSwxXQ==',
score: 0,
},
{
type: 'ingest-package-policies',
id: 'dcf83172-c38e-4501-b236-9f479da8a7d6',
attributes: {
name: 'system-2',
description: '',
namespace: 'default',
policy_id: '11111-111111-11111-11111',
enabled: true,
output_id: '',
inputs: [],
package: { name: 'system', title: 'System', version: '0.10.4' },
revision: 1,
created_at: '2020-12-22T21:28:05.380Z',
created_by: 'elastic',
updated_at: '2020-12-22T21:28:05.380Z',
updated_by: 'elastic',
},
references: [],
migrationVersion: { 'ingest-package-policies': '7.11.0' },
updated_at: '2020-12-22T21:28:05.383Z',
version: 'WzE1NTAsMV0=',
score: 0,
},
{
type: 'ingest-package-policies',
id: 'dcf83172-c38e-4501-b236-9f479da8a7d6',
attributes: {
name: 'system-4',
description: '',
namespace: 'default',
policy_id: '33333-33333-333333-333333',
enabled: true,
output_id: '',
inputs: [],
package: { name: 'system', title: 'System', version: '0.10.4' },
revision: 1,
created_at: '2020-12-22T21:28:05.380Z',
created_by: 'elastic',
updated_at: '2020-12-22T21:28:05.380Z',
updated_by: 'elastic',
},
references: [],
migrationVersion: { 'ingest-package-policies': '7.11.0' },
updated_at: '2020-12-22T21:28:05.383Z',
version: 'WzE1NTAsMV0=',
score: 0,
},
];
soClient.find.mockImplementation(async ({ page = 1, perPage = 20 }) => {
let savedObjectsResponse: typeof savedObjects;
switch (page) {
case 1:
savedObjectsResponse = [savedObjects[0]];
break;
case 2:
savedObjectsResponse = savedObjects.slice(1);
break;
default:
savedObjectsResponse = [];
}
return {
page,
per_page: perPage,
total: 1500,
saved_objects: savedObjectsResponse,
};
});
});
it('should query and paginate SO using package name as filter', async () => {
await getPackageUsageStats({ savedObjectsClient: soClient, pkgName: 'system' });
expect(soClient.find).toHaveBeenNthCalledWith(1, {
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
perPage: 1000,
page: 1,
filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: system`,
});
expect(soClient.find).toHaveBeenNthCalledWith(2, {
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
perPage: 1000,
page: 2,
filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: system`,
});
expect(soClient.find).toHaveBeenNthCalledWith(3, {
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
perPage: 1000,
page: 3,
filter: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: system`,
});
});
it('should return count of unique agent policies', async () => {
expect(
await getPackageUsageStats({ savedObjectsClient: soClient, pkgName: 'system' })
).toEqual({
agent_policy_count: 3,
});
});
});
});

View file

@ -5,7 +5,13 @@
*/
import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'src/core/server';
import { isPackageLimited, installationStatuses } from '../../../../common';
import {
isPackageLimited,
installationStatuses,
PackageUsageStats,
PackagePolicySOAttributes,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
} from '../../../../common';
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants';
import { ArchivePackage, RegistryPackage, EpmPackageAdditions } from '../../../../common/types';
import { Installation, PackageInfo, KibanaAssetType } from '../../../types';
@ -13,6 +19,7 @@ import * as Registry from '../registry';
import { createInstallableFrom, isRequiredPackage } from './index';
import { getEsPackage } from '../archive/storage';
import { getArchivePackage } from '../archive';
import { normalizeKuery } from '../../saved_object';
export { getFile, SearchParams } from '../registry';
@ -116,6 +123,43 @@ export async function getPackageInfo(options: {
return createInstallableFrom(updated, savedObject);
}
export const getPackageUsageStats = async ({
savedObjectsClient,
pkgName,
}: {
savedObjectsClient: SavedObjectsClientContract;
pkgName: string;
}): Promise<PackageUsageStats> => {
const filter = normalizeKuery(
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
`${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${pkgName}`
);
const agentPolicyCount = new Set<string>();
let page = 1;
let hasMore = true;
while (hasMore) {
// using saved Objects client directly, instead of the `list()` method of `package_policy` service
// in order to not cause a circular dependency (package policy service imports from this module)
const packagePolicies = await savedObjectsClient.find<PackagePolicySOAttributes>({
type: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
perPage: 1000,
page: page++,
filter,
});
for (let index = 0, total = packagePolicies.saved_objects.length; index < total; index++) {
agentPolicyCount.add(packagePolicies.saved_objects[index].attributes.policy_id);
}
hasMore = packagePolicies.saved_objects.length > 0;
}
return {
agent_policy_count: agentPolicyCount.size,
};
};
interface PackageResponse {
paths: string[];
packageInfo: ArchivePackage | RegistryPackage;

View file

@ -32,6 +32,12 @@ export const GetInfoRequestSchema = {
}),
};
export const GetStatsRequestSchema = {
params: schema.object({
pkgName: schema.string(),
}),
};
export const InstallPackageFromRegistryRequestSchema = {
params: schema.object({
pkgkey: schema.string(),