From 169d197d1a219ced2ca95a0de639f3b6d465b2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Fri, 11 Aug 2023 16:29:28 +0200 Subject: [PATCH] [Security Solution] Allow users to sort Endpoint list by every column (#162142) ## Summary This PR adds sorting to Endpoint list, it works with all columns. Details: - by default it sorts as before: by enrollment date descending - the sorting is persisted in the URL - the `GET api/endpoint/metadata` route now accepts `sortField` and `sortDirection` query params, but is quite strict: it only accepts the fields shown in the table for sorting, otherwise returns `400`. reason: feels better to fail on schema validation than allowing any string to go through and returning an internal error - the API expects the `sortField` to harmonise with the returned `HostInfoInterface` type, so looking from the outside it is consistent. internally, it maps the incoming `sortField` to the internal structure (e.g. `metadata.host.ip` -> `united.endpoint.host.ip`) - ~**update:** moves `last_checkin` to be a runtime field, so it can be used for sorting~ - ~**update:** adds `Enrolled at` as a column to the table, and `enrolled_at` as a new field in the response~ Todo: - [x] fix typescript type errors - [x] add tests ![image](https://github-production-user-asset-6210df.s3.amazonaws.com/39014407/254272727-fe1d69d6-b006-4ad2-9649-966d4131ca9e.png) ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../endpoint/metadata/list_metadata_route.ts | 16 +- .../common/endpoint/constants.ts | 3 + .../common/endpoint/types/index.ts | 35 ++-- .../cypress/e2e/mocked_data/endpoints.cy.ts | 69 +++++++- .../pages/endpoint_hosts/store/builders.ts | 6 + .../pages/endpoint_hosts/store/index.test.ts | 6 + .../pages/endpoint_hosts/store/middleware.ts | 9 +- .../store/mock_endpoint_result_list.ts | 6 + .../pages/endpoint_hosts/store/reducer.ts | 4 +- .../pages/endpoint_hosts/store/selectors.ts | 20 ++- .../management/pages/endpoint_hosts/types.ts | 9 + .../pages/endpoint_hosts/view/index.tsx | 157 ++++++++++++------ .../endpoint/routes/metadata/handlers.ts | 10 +- .../endpoint/routes/metadata/metadata.test.ts | 4 + .../routes/metadata/query_builders.test.ts | 84 ++++++++-- .../routes/metadata/query_builders.ts | 44 ++++- .../metadata/endpoint_metadata_service.ts | 14 +- .../apis/metadata.fixtures.ts | 14 +- .../apis/metadata.ts | 103 +++++++++++- 19 files changed, 514 insertions(+), 99 deletions(-) diff --git a/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata_route.ts b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata_route.ts index 44b946faf8623..2a0ea7c0fbac1 100644 --- a/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata_route.ts +++ b/x-pack/plugins/security_solution/common/api/endpoint/metadata/list_metadata_route.ts @@ -8,7 +8,7 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { ENDPOINT_DEFAULT_PAGE, ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../endpoint/constants'; -import { HostStatus } from '../../../endpoint/types'; +import { HostStatus, EndpointSortableField } from '../../../endpoint/types'; export const GetMetadataListRequestSchema = { query: schema.object( @@ -16,6 +16,20 @@ export const GetMetadataListRequestSchema = { page: schema.number({ defaultValue: ENDPOINT_DEFAULT_PAGE, min: 0 }), pageSize: schema.number({ defaultValue: ENDPOINT_DEFAULT_PAGE_SIZE, min: 1, max: 10000 }), kuery: schema.maybe(schema.string()), + sortField: schema.maybe( + schema.oneOf([ + schema.literal(EndpointSortableField.ENROLLED_AT.toString()), + schema.literal(EndpointSortableField.HOSTNAME.toString()), + schema.literal(EndpointSortableField.HOST_STATUS.toString()), + schema.literal(EndpointSortableField.POLICY_NAME.toString()), + schema.literal(EndpointSortableField.POLICY_STATUS.toString()), + schema.literal(EndpointSortableField.HOST_OS_NAME.toString()), + schema.literal(EndpointSortableField.HOST_IP.toString()), + schema.literal(EndpointSortableField.AGENT_VERSION.toString()), + schema.literal(EndpointSortableField.LAST_SEEN.toString()), + ]) + ), + sortDirection: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), hostStatuses: schema.maybe( schema.arrayOf( schema.oneOf([ diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 25380c79cf6ed..19c77f230eea5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -7,6 +7,7 @@ /** endpoint data streams that are used for host isolation */ import { getFileDataIndexName, getFileMetadataIndexName } from '@kbn/fleet-plugin/common'; +import { EndpointSortableField } from './types'; /** for index patterns `.logs-endpoint.actions-* and .logs-endpoint.action.responses-*`*/ export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions'; @@ -99,6 +100,8 @@ export const failedFleetActionErrorCode = '424'; export const ENDPOINT_DEFAULT_PAGE = 0; export const ENDPOINT_DEFAULT_PAGE_SIZE = 10; +export const ENDPOINT_DEFAULT_SORT_FIELD = EndpointSortableField.ENROLLED_AT; +export const ENDPOINT_DEFAULT_SORT_DIRECTION = 'desc'; export const ENDPOINT_ERROR_CODES: Record = { ES_CONNECTION_ERROR: -272, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index eaafc26878070..5702f14f2a37a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -1325,26 +1325,39 @@ export interface ListPageRouteState { backButtonLabel?: string; } -/** - * REST API standard base response for list types - */ -interface BaseListResponse { - data: D[]; - page: number; - pageSize: number; - total: number; -} - export interface AdditionalOnSwitchChangeParams { value: boolean; policyConfigData: UIPolicyConfig; protectionOsList: ImmutableArray>; } +/** Allowed fields for sorting in the EndpointList table. + * These are the column fields in the EndpointList table, based on the + * returned `HostInfoInterface` data type (and not on the internal data structure). + */ +export enum EndpointSortableField { + ENROLLED_AT = 'enrolled_at', + HOSTNAME = 'metadata.host.hostname', + HOST_STATUS = 'host_status', + POLICY_NAME = 'metadata.Endpoint.policy.applied.name', + POLICY_STATUS = 'metadata.Endpoint.policy.applied.status', + HOST_OS_NAME = 'metadata.host.os.name', + HOST_IP = 'metadata.host.ip', + AGENT_VERSION = 'metadata.agent.version', + LAST_SEEN = 'last_checkin', +} + /** * Returned by the server via GET /api/endpoint/metadata */ -export type MetadataListResponse = BaseListResponse; +export interface MetadataListResponse { + data: HostInfo[]; + page: number; + pageSize: number; + total: number; + sortField: EndpointSortableField; + sortDirection: 'asc' | 'desc'; +} export type { EndpointPrivileges } from './authz'; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/endpoints.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/endpoints.cy.ts index 6dd4b6664d6a3..ce9533d857677 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/endpoints.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/endpoints.cy.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { MetadataListResponse } from '../../../../../common/endpoint/types'; +import { EndpointSortableField } from '../../../../../common/endpoint/types'; import { APP_ENDPOINTS_PATH } from '../../../../../common/constants'; import type { ReturnTypeFromChainable } from '../../types'; import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; @@ -15,7 +17,7 @@ describe('Endpoints page', () => { let endpointData: ReturnTypeFromChainable; before(() => { - indexEndpointHosts().then((indexEndpoints) => { + indexEndpointHosts({ count: 3 }).then((indexEndpoints) => { endpointData = indexEndpoints; }); }); @@ -36,4 +38,69 @@ describe('Endpoints page', () => { loadPage(APP_ENDPOINTS_PATH); cy.contains('Hosts running Elastic Defend').should('exist'); }); + + describe('Sorting', () => { + it('Sorts by enrollment date descending order by default', () => { + cy.intercept('api/endpoint/metadata*').as('getEndpointMetadataRequest'); + + loadPage(APP_ENDPOINTS_PATH); + + cy.wait('@getEndpointMetadataRequest').then((subject) => { + const body = subject.response?.body as MetadataListResponse; + + expect(body.sortField).to.equal(EndpointSortableField.ENROLLED_AT); + expect(body.sortDirection).to.equal('desc'); + }); + + // no sorting indicator is present on the screen + cy.get('.euiTableSortIcon').should('not.exist'); + }); + + it('User can sort by any field', () => { + loadPage(APP_ENDPOINTS_PATH); + + const fields = Object.values(EndpointSortableField).filter( + // enrolled_at is not present in the table, it's just the default sorting + (value) => value !== EndpointSortableField.ENROLLED_AT + ); + + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + cy.intercept(`api/endpoint/metadata*${encodeURIComponent(field)}*`).as(`request.${field}`); + + cy.getByTestSubj(`tableHeaderCell_${field}_${i}`).as('header').click(); + validateSortingInResponse(field, 'asc'); + cy.get('@header').should('have.attr', 'aria-sort', 'ascending'); + cy.get('.euiTableSortIcon').should('exist'); + + cy.get('@header').click(); + validateSortingInResponse(field, 'desc'); + cy.get('@header').should('have.attr', 'aria-sort', 'descending'); + cy.get('.euiTableSortIcon').should('exist'); + } + }); + + it('Sorting can be passed via URL', () => { + cy.intercept('api/endpoint/metadata*').as(`request.host_status`); + + loadPage(`${APP_ENDPOINTS_PATH}?sort_field=host_status&sort_direction=desc`); + + validateSortingInResponse('host_status', 'desc'); + cy.get('[data-test-subj^=tableHeaderCell_host_status').should( + 'have.attr', + 'aria-sort', + 'descending' + ); + }); + + const validateSortingInResponse = (field: string, direction: 'asc' | 'desc') => + cy.wait(`@request.${field}`).then((subject) => { + expect(subject.response?.statusCode).to.equal(200); + + const body = subject.response?.body as MetadataListResponse; + expect(body.total).to.equal(3); + expect(body.sortField).to.equal(field); + expect(body.sortDirection).to.equal(direction); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts index 69baec5732693..260cb90ed172f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, +} from '../../../../../common/endpoint/constants'; import type { Immutable } from '../../../../../common/endpoint/types'; import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createLoadedResourceState, createUninitialisedResourceState } from '../../../state'; @@ -16,6 +20,8 @@ export const initialEndpointPageState = (): Immutable => { pageSize: 10, pageIndex: 0, total: 0, + sortDirection: ENDPOINT_DEFAULT_SORT_DIRECTION, + sortField: ENDPOINT_DEFAULT_SORT_FIELD, loading: false, error: undefined, location: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 44165211f7b41..5cefbe2fa5588 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -14,6 +14,10 @@ import type { EndpointAction } from './action'; import { endpointListReducer } from './reducer'; import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createUninitialisedResourceState } from '../../../state'; +import { + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, +} from '../../../../../common/endpoint/constants'; describe('EndpointList store concerns', () => { let store: Store; @@ -40,6 +44,8 @@ describe('EndpointList store concerns', () => { hosts: [], pageSize: 10, pageIndex: 0, + sortField: ENDPOINT_DEFAULT_SORT_FIELD, + sortDirection: ENDPOINT_DEFAULT_SORT_DIRECTION, total: 0, loading: false, error: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 6caf5b221b996..c3572b38d40ea 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -353,7 +353,12 @@ async function endpointListMiddleware({ }) { const { getState, dispatch } = store; - const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(getState()); + const { + page_index: pageIndex, + page_size: pageSize, + sort_field: sortField, + sort_direction: sortDirection, + } = uiQueryParams(getState()); let endpointResponse: MetadataListResponse | undefined; try { @@ -365,6 +370,8 @@ async function endpointListMiddleware({ page: pageIndex, pageSize, kuery: decodedQuery.query as string, + sortField, + sortDirection, }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index f6c5c144f529b..1bc156d0c5a37 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -32,6 +32,8 @@ import type { GetPolicyListResponse } from '../../policy/types'; import { pendingActionsResponseMock } from '../../../../common/lib/endpoint_pending_actions/mocks'; import { ACTION_STATUS_ROUTE, + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, HOST_METADATA_LIST_ROUTE, METADATA_TRANSFORMS_STATUS_ROUTE, } from '../../../../../common/endpoint/constants'; @@ -67,6 +69,8 @@ export const mockEndpointResultList: (options?: { total, page, pageSize, + sortDirection: ENDPOINT_DEFAULT_SORT_DIRECTION, + sortField: ENDPOINT_DEFAULT_SORT_FIELD, }; return mock; }; @@ -121,6 +125,8 @@ const endpointListApiPathHandlerMocks = ({ total: endpointsResults?.length || 0, page: 0, pageSize: 10, + sortDirection: ENDPOINT_DEFAULT_SORT_DIRECTION, + sortField: ENDPOINT_DEFAULT_SORT_FIELD, }; }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 227ffebea8dec..de1bb7b834e0f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -63,13 +63,15 @@ const handleMetadataTransformStatsChanged: CaseReducer { if (action.type === 'serverReturnedEndpointList') { - const { data, total, page, pageSize } = action.payload; + const { data, total, page, pageSize, sortDirection, sortField } = action.payload; return { ...state, hosts: data, total, pageIndex: page, pageSize, + sortField, + sortDirection, loading: false, error: undefined, }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 7f6e464536412..c33407b36515d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -11,7 +11,11 @@ import { createSelector } from 'reselect'; import { matchPath } from 'react-router-dom'; import { decode } from '@kbn/rison'; import type { Query } from '@kbn/es-query'; -import type { EndpointPendingActions, Immutable } from '../../../../../common/endpoint/types'; +import type { + EndpointPendingActions, + EndpointSortableField, + Immutable, +} from '../../../../../common/endpoint/types'; import type { EndpointIndexUIQueryParams, EndpointState } from '../types'; import { extractListPaginationParams } from '../../../common/routing'; import { @@ -35,6 +39,12 @@ export const pageIndex = (state: Immutable): number => state.page export const pageSize = (state: Immutable): number => state.pageSize; +export const sortField = (state: Immutable): EndpointSortableField => + state.sortField; + +export const sortDirection = (state: Immutable): 'asc' | 'desc' => + state.sortDirection; + export const totalHits = (state: Immutable): number => state.total; export const listLoading = (state: Immutable): boolean => state.loading; @@ -94,6 +104,8 @@ export const uiQueryParams: ( 'selected_endpoint', 'show', 'admin_query', + 'sort_field', + 'sort_direction', ]; const allowedShowValues: Array = [ @@ -117,6 +129,12 @@ export const uiQueryParams: ( if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) { data[key] = value as EndpointIndexUIQueryParams['show']; } + } else if (key === 'sort_direction') { + if (['asc', 'desc'].includes(value)) { + data[key] = value as EndpointIndexUIQueryParams['sort_direction']; + } + } else if (key === 'sort_field') { + data[key] = value as EndpointSortableField; } else { data[key] = value; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index f0408b4537333..0528c8a5ad572 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -10,6 +10,7 @@ import type { GetInfoResponse } from '@kbn/fleet-plugin/common'; import type { AppLocation, EndpointPendingActions, + EndpointSortableField, HostInfo, Immutable, PolicyData, @@ -26,6 +27,10 @@ export interface EndpointState { pageSize: number; /** which page to show */ pageIndex: number; + /** field used for sorting */ + sortField: EndpointSortableField; + /** direction of sorting */ + sortDirection: 'asc' | 'desc'; /** total number of hosts returned */ total: number; /** list page is retrieving data */ @@ -97,6 +102,10 @@ export interface EndpointIndexUIQueryParams { page_size?: string; /** Which page to show */ page_index?: string; + /** Field used for sorting */ + sort_field?: EndpointSortableField; + /** Direction of sorting */ + sort_direction?: 'asc' | 'desc'; /** show the policy response or host details */ show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate'; /** Query text from search bar*/ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 0bd26b0dddd34..b1e8b4925a2c2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -7,6 +7,7 @@ import React, { type CSSProperties, useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import type { CriteriaWithPagination } from '@elastic/eui'; import { EuiBasicTable, type EuiBasicTableColumn, @@ -42,9 +43,11 @@ import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_con import type { CreateStructuredSelector } from '../../../../common/store'; import type { HostInfo, + HostInfoInterface, Immutable, PolicyDetailsRouteState, } from '../../../../../common/endpoint/types'; +import { EndpointSortableField } from '../../../../../common/endpoint/types'; import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { HostsEmptyState, PolicyEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; @@ -81,6 +84,21 @@ interface GetEndpointListColumnsProps { getAppUrl: ReturnType['getAppUrl']; } +const columnWidths: Record< + Exclude | 'actions', + string +> = { + [EndpointSortableField.HOSTNAME]: '18%', + [EndpointSortableField.HOST_STATUS]: '15%', + [EndpointSortableField.POLICY_NAME]: '20%', + [EndpointSortableField.POLICY_STATUS]: '150px', + [EndpointSortableField.HOST_OS_NAME]: '90px', + [EndpointSortableField.HOST_IP]: '22%', + [EndpointSortableField.AGENT_VERSION]: '10%', + [EndpointSortableField.LAST_SEEN]: '15%', + actions: '65px', +}; + const getEndpointListColumns = ({ canReadPolicyManagement, backToEndpointList, @@ -96,17 +114,18 @@ const getEndpointListColumns = ({ return [ { - field: 'metadata', - width: '15%', + field: EndpointSortableField.HOSTNAME, + width: columnWidths[EndpointSortableField.HOSTNAME], name: i18n.translate('xpack.securitySolution.endpoint.list.hostname', { defaultMessage: 'Endpoint', }), - render: ({ host: { hostname }, agent: { id } }: HostInfo['metadata']) => { + sortable: true, + render: (hostname: HostInfo['metadata']['host']['hostname'], item: HostInfo) => { const toRoutePath = getEndpointDetailsPath( { ...queryParams, name: 'endpointDetails', - selected_endpoint: id, + selected_endpoint: item.metadata.agent.id, }, search ); @@ -124,11 +143,12 @@ const getEndpointListColumns = ({ }, }, { - field: 'host_status', - width: '14%', + field: EndpointSortableField.HOST_STATUS, + width: columnWidths[EndpointSortableField.HOST_STATUS], name: i18n.translate('xpack.securitySolution.endpoint.list.hostStatus', { defaultMessage: 'Agent status', }), + sortable: true, render: (hostStatus: HostInfo['host_status'], endpointInfo) => { return ( { + render: ( + policyName: HostInfo['metadata']['Endpoint']['policy']['applied']['name'], + item: HostInfo + ) => { + const policy = item.metadata.Endpoint.policy.applied; + return ( <> - + {canReadPolicyManagement ? ( - {policy.name} + {policyName} ) : ( - <>{policy.name} + <>{policyName} )} {policy.endpoint_policy_version && ( @@ -186,12 +212,16 @@ const getEndpointListColumns = ({ }, }, { - field: 'metadata.Endpoint.policy.applied', - width: '9%', + field: EndpointSortableField.POLICY_STATUS, + width: columnWidths[EndpointSortableField.POLICY_STATUS], name: i18n.translate('xpack.securitySolution.endpoint.list.policyStatus', { defaultMessage: 'Policy status', }), - render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { + sortable: true, + render: ( + status: HostInfo['metadata']['Endpoint']['policy']['applied']['status'], + item: HostInfo + ) => { const toRoutePath = getEndpointDetailsPath({ name: 'endpointPolicyResponse', ...queryParams, @@ -199,17 +229,14 @@ const getEndpointListColumns = ({ }); const toRouteUrl = getAppUrl({ path: toRoutePath }); return ( - + { return ( @@ -236,11 +264,12 @@ const getEndpointListColumns = ({ }, }, { - field: 'metadata.host.ip', - width: '12%', + field: EndpointSortableField.HOST_IP, + width: columnWidths[EndpointSortableField.HOST_IP], name: i18n.translate('xpack.securitySolution.endpoint.list.ip', { defaultMessage: 'IP address', }), + sortable: true, render: (ip: string[]) => { return ( @@ -254,11 +283,12 @@ const getEndpointListColumns = ({ }, }, { - field: 'metadata.agent.version', - width: '9%', + field: EndpointSortableField.AGENT_VERSION, + width: columnWidths[EndpointSortableField.AGENT_VERSION], name: i18n.translate('xpack.securitySolution.endpoint.list.endpointVersion', { defaultMessage: 'Version', }), + sortable: true, render: (version: string) => { return ( @@ -270,10 +300,11 @@ const getEndpointListColumns = ({ }, }, { - field: 'metadata.@timestamp', + field: EndpointSortableField.LAST_SEEN, + width: columnWidths[EndpointSortableField.LAST_SEEN], name: lastActiveColumnName, - width: '9%', - render(dateValue: HostInfo['metadata']['@timestamp']) { + sortable: true, + render(dateValue: HostInfo['last_checkin']) { return ( { const history = useHistory(); const { listData, pageIndex, pageSize, + sortField, + sortDirection, totalHits: totalItemCount, listLoading: loading, listError, @@ -369,7 +406,7 @@ export const EndpointList = () => { }, [pageIndex, pageSize, maxPageCount]); const onTableChange = useCallback( - ({ page }: { page: { index: number; size: number } }) => { + ({ page, sort }: CriteriaWithPagination) => { const { index, size } = page; // FIXME: PT: if endpoint details is open, table is not displaying correct number of rows history.push( @@ -378,33 +415,40 @@ export const EndpointList = () => { ...queryParams, page_index: JSON.stringify(index), page_size: JSON.stringify(size), + sort_direction: sort?.direction, + sort_field: sort?.field as EndpointSortableField, }) ); }, [history, queryParams] ); + const stateHandleCreatePolicyClick: CreatePackagePolicyRouteState = useMemo( + () => ({ + onCancelNavigateTo: [ + APP_UI_ID, + { + path: getEndpointListPath({ name: 'endpointList' }), + }, + ], + onCancelUrl: getAppUrl({ path: getEndpointListPath({ name: 'endpointList' }) }), + onSaveNavigateTo: [ + APP_UI_ID, + { + path: getEndpointListPath({ name: 'endpointList' }), + }, + ], + }), + [getAppUrl] + ); + const handleCreatePolicyClick = useNavigateToAppEventHandler( 'fleet', { path: `/integrations/${ endpointPackageVersion ? `/endpoint-${endpointPackageVersion}` : '' }/add-integration`, - state: { - onCancelNavigateTo: [ - APP_UI_ID, - { - path: getEndpointListPath({ name: 'endpointList' }), - }, - ], - onCancelUrl: getAppUrl({ path: getEndpointListPath({ name: 'endpointList' }) }), - onSaveNavigateTo: [ - APP_UI_ID, - { - path: getEndpointListPath({ name: 'endpointList' }), - }, - ], - }, + state: stateHandleCreatePolicyClick, } ); @@ -450,9 +494,7 @@ export const EndpointList = () => { const handleDeployEndpointsClick = useNavigateToAppEventHandler('fleet', { path: `/policies/${selectedPolicyId}?openEnrollmentFlyout=true`, - state: { - onDoneNavigateTo: [APP_UI_ID, { path: getEndpointListPath({ name: 'endpointList' }) }], - }, + state: stateHandleDeployEndpointsClick, }); const handleSelectableOnChange = useCallback<(o: EuiSelectableProps['options']) => void>( @@ -500,18 +542,28 @@ export const EndpointList = () => { ] ); + const sorting = useMemo( + () => ({ + sort: { field: sortField as keyof HostInfoInterface, direction: sortDirection }, + }), + [sortDirection, sortField] + ); + + const mutableListData = useMemo(() => [...listData], [listData]); + const renderTableOrEmptyState = useMemo(() => { if (endpointsExist) { return ( ); } else if (canReadEndpointList && !canAccessFleet) { @@ -554,15 +606,16 @@ export const EndpointList = () => { handleDeployEndpointsClick, handleSelectableOnChange, hasPolicyData, - listData, listError?.message, loading, + mutableListData, onTableChange, paginationSetup, policyItemsLoading, policyItems, selectedPolicyId, setTableRowProps, + sorting, ]); return ( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 123eab09255c6..9131304d529a3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -7,7 +7,10 @@ import type { TypeOf } from '@kbn/config-schema'; import type { Logger, RequestHandler } from '@kbn/core/server'; -import type { MetadataListResponse } from '../../../../common/endpoint/types'; +import type { + MetadataListResponse, + EndpointSortableField, +} from '../../../../common/endpoint/types'; import { errorHandler } from '../error_handler'; import type { SecuritySolutionRequestHandlerContext } from '../../../types'; @@ -19,6 +22,8 @@ import type { import { ENDPOINT_DEFAULT_PAGE, ENDPOINT_DEFAULT_PAGE_SIZE, + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, METADATA_TRANSFORMS_PATTERN, } from '../../../../common/endpoint/constants'; @@ -54,6 +59,9 @@ export function getMetadataListRequestHandler( total, page: request.query.page || ENDPOINT_DEFAULT_PAGE, pageSize: request.query.pageSize || ENDPOINT_DEFAULT_PAGE_SIZE, + sortField: + (request.query.sortField as EndpointSortableField) || ENDPOINT_DEFAULT_SORT_FIELD, + sortDirection: request.query.sortDirection || ENDPOINT_DEFAULT_SORT_DIRECTION, }; return response.ok({ body }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 061c090f2df28..236879a4e4164 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -41,6 +41,8 @@ import type { PackagePolicyClient, } from '@kbn/fleet-plugin/server'; import { + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, HOST_METADATA_GET_ROUTE, HOST_METADATA_LIST_ROUTE, METADATA_TRANSFORMS_STATUS_ROUTE, @@ -226,6 +228,8 @@ describe('test endpoint routes', () => { expect(endpointResultList.total).toEqual(1); expect(endpointResultList.page).toEqual(0); expect(endpointResultList.pageSize).toEqual(10); + expect(endpointResultList.sortField).toEqual(ENDPOINT_DEFAULT_SORT_FIELD); + expect(endpointResultList.sortDirection).toEqual(ENDPOINT_DEFAULT_SORT_DIRECTION); }); it('should get forbidden if no security solution access', async () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts index bfdc8022e5ca2..7efe718b458a9 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts @@ -10,6 +10,8 @@ import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constan import { get } from 'lodash'; import { expectedCompleteUnitedIndexQuery } from './query_builders.fixtures'; import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { EndpointSortableField } from '../../../../common/endpoint/types'; describe('query builder', () => { describe('MetadataGetQuery', () => { @@ -38,15 +40,19 @@ describe('query builder', () => { }); describe('buildUnitedIndexQuery', () => { - it('correctly builds empty query', async () => { - const soClient = savedObjectsClientMock.create(); + let soClient: jest.Mocked; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); soClient.find.mockResolvedValue({ saved_objects: [], total: 0, per_page: 0, page: 0, }); + }); + it('correctly builds empty query', async () => { const query = await buildUnitedIndexQuery( soClient, { page: 1, pageSize: 10, hostStatuses: [], kuery: '' }, @@ -91,15 +97,27 @@ describe('query builder', () => { expect(query.body.query).toEqual(expected); }); - it('correctly builds query', async () => { - const soClient = savedObjectsClientMock.create(); - soClient.find.mockResolvedValue({ - saved_objects: [], - total: 0, - per_page: 0, - page: 0, - }); + it('adds `status` runtime field', async () => { + const query = await buildUnitedIndexQuery( + soClient, + { page: 1, pageSize: 10, hostStatuses: [], kuery: '' }, + [] + ); + expect(query.body.runtime_mappings).toHaveProperty('status'); + }); + + it('adds `last_checkin` runtime field', async () => { + const query = await buildUnitedIndexQuery( + soClient, + { page: 1, pageSize: 10, hostStatuses: [], kuery: '' }, + [] + ); + + expect(query.body.runtime_mappings).toHaveProperty('last_checkin'); + }); + + it('correctly builds query', async () => { const query = await buildUnitedIndexQuery( soClient, { @@ -113,5 +131,51 @@ describe('query builder', () => { const expected = expectedCompleteUnitedIndexQuery; expect(query.body.query).toEqual(expected); }); + + describe('sorting', () => { + it('uses default sort field if none passed', async () => { + const query = await buildUnitedIndexQuery(soClient, { + page: 1, + pageSize: 10, + }); + + expect(query.body.sort).toEqual([ + { 'united.agent.enrolled_at': { order: 'desc', unmapped_type: 'date' } }, + ]); + }); + + it.each` + inputField | mappedField + ${'host_status'} | ${'status'} + ${'metadata.host.hostname'} | ${'united.endpoint.host.hostname'} + ${'metadata.Endpoint.policy.applied.name'} | ${'united.endpoint.Endpoint.policy.applied.name'} + `('correctly maps field $inputField', async ({ inputField, mappedField }) => { + const query = await buildUnitedIndexQuery(soClient, { + page: 1, + pageSize: 10, + sortField: inputField, + sortDirection: 'asc', + }); + + expect(query.body.sort).toEqual([{ [mappedField]: 'asc' }]); + }); + + it.each` + inputField | mappedField + ${EndpointSortableField.LAST_SEEN} | ${EndpointSortableField.LAST_SEEN} + ${EndpointSortableField.ENROLLED_AT} | ${'united.agent.enrolled_at'} + `('correctly maps date field $inputField', async ({ inputField, mappedField }) => { + const query = await buildUnitedIndexQuery(soClient, { + page: 1, + pageSize: 10, + sortField: inputField, + sortDirection: 'asc', + }); + + expect(query.body.sort).toEqual([ + { [mappedField]: { order: 'asc', unmapped_type: 'date' } }, + ]); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index b790283c03c57..e51655010b40c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -9,9 +9,12 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { buildAgentStatusRuntimeField } from '@kbn/fleet-plugin/server'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { EndpointSortableField } from '../../../../common/endpoint/types'; import { ENDPOINT_DEFAULT_PAGE, ENDPOINT_DEFAULT_PAGE_SIZE, + ENDPOINT_DEFAULT_SORT_DIRECTION, + ENDPOINT_DEFAULT_SORT_FIELD, metadataCurrentIndexPattern, METADATA_UNITED_INDEX, } from '../../../../common/endpoint/constants'; @@ -55,9 +58,25 @@ export const MetadataSortMethod: estypes.SortCombinations[] = [ }, ]; -const UnitedMetadataSortMethod: estypes.SortCombinations[] = [ - { 'united.agent.enrolled_at': { order: 'desc', unmapped_type: 'date' } }, -]; +const getUnitedMetadataSortMethod = ( + sortField: EndpointSortableField, + sortDirection: 'asc' | 'desc' +): estypes.SortCombinations[] => { + const DATE_FIELDS = [EndpointSortableField.LAST_SEEN, EndpointSortableField.ENROLLED_AT]; + + const mappedUnitedMetadataSortField = + sortField === EndpointSortableField.HOST_STATUS + ? 'status' + : sortField === EndpointSortableField.ENROLLED_AT + ? 'united.agent.enrolled_at' + : sortField.replace('metadata.', 'united.endpoint.'); + + if (DATE_FIELDS.includes(sortField)) { + return [{ [mappedUnitedMetadataSortField]: { order: sortDirection, unmapped_type: 'date' } }]; + } else { + return [{ [mappedUnitedMetadataSortField]: sortDirection }]; + } +}; export function getESQueryHostMetadataByID(agentID: string): estypes.SearchRequest { return { @@ -128,6 +147,17 @@ export function getESQueryHostMetadataByIDs(agentIDs: string[]) { }; } +const lastCheckinRuntimeField = { + last_checkin: { + type: 'date', + script: { + lang: 'painless', + source: + "emit(doc['united.agent.last_checkin'].size() > 0 ? doc['united.agent.last_checkin'].value.toInstant().toEpochMilli() : doc['united.endpoint.@timestamp'].value.toInstant().toEpochMilli());", + }, + }, +}; + interface BuildUnitedIndexQueryResponse { body: { query: Record; @@ -151,6 +181,8 @@ export async function buildUnitedIndexQuery( pageSize = ENDPOINT_DEFAULT_PAGE_SIZE, hostStatuses = [], kuery = '', + sortField = ENDPOINT_DEFAULT_SORT_FIELD, + sortDirection = ENDPOINT_DEFAULT_SORT_DIRECTION, } = queryOptions || {}; const statusesKuery = buildStatusesKuery(hostStatuses); @@ -204,13 +236,15 @@ export async function buildUnitedIndexQuery( }; } - const runtimeMappings = await buildAgentStatusRuntimeField(soClient, 'united.agent.'); + const statusRuntimeField = await buildAgentStatusRuntimeField(soClient, 'united.agent.'); + const runtimeMappings = { ...statusRuntimeField, ...lastCheckinRuntimeField }; + const fields = Object.keys(runtimeMappings); return { body: { query, track_total_hits: true, - sort: UnitedMetadataSortMethod, + sort: getUnitedMetadataSortMethod(sortField as EndpointSortableField, sortDirection), fields, runtime_mappings: runtimeMappings, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts index 61008a99b5515..d5a973593f225 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts @@ -13,7 +13,7 @@ import type { } from '@kbn/core/server'; import type { SearchResponse, SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; -import type { Agent, AgentPolicy, AgentStatus, PackagePolicy } from '@kbn/fleet-plugin/common'; +import type { Agent, AgentPolicy, PackagePolicy } from '@kbn/fleet-plugin/common'; import type { AgentPolicyServiceInterface, PackagePolicyClient } from '@kbn/fleet-plugin/server'; import { AgentNotFoundError } from '@kbn/fleet-plugin/server'; import type { @@ -295,7 +295,7 @@ export class EndpointMetadataService { }, }, last_checkin: - _fleetAgent?.last_checkin || new Date(endpointMetadata['@timestamp']).toISOString(), + fleetAgent?.last_checkin || new Date(endpointMetadata['@timestamp']).toISOString(), }; } @@ -438,12 +438,16 @@ export class EndpointMetadataService { const agentPolicy = agentPoliciesMap[_agent.policy_id!]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const endpointPolicy = endpointPoliciesMap[_agent.policy_id!]; - // add the agent status from the fleet runtime field to - // the agent object + + const runtimeFields: Partial = { + status: doc?.fields?.status?.[0], + last_checkin: doc?.fields?.last_checkin?.[0], + }; const agent: typeof _agent = { ..._agent, - status: doc?.fields?.status?.[0] as AgentStatus, + ...runtimeFields, }; + hosts.push( await this.enrichHostMetadata(fleetServices, metadata, agent, agentPolicy, endpointPolicy) ); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts index 3a81ad1c71dcb..fd67cd0a1d8bd 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.fixtures.ts @@ -5,7 +5,7 @@ * 2.0. */ -export function generateAgentDocs(timestamp: number, policyId: string) { +export function generateAgentDocs(timestamps: number[], policyId: string) { return [ { access_api_key_id: 'w4zJBHwBfQcM6aSYIRjO', @@ -15,7 +15,7 @@ export function generateAgentDocs(timestamp: number, policyId: string) { id: '963b081e-60d1-482c-befd-a5815fa8290f', version: '8.0.0', }, - enrolled_at: timestamp, + enrolled_at: timestamps[0], local_metadata: { elastic: { agent: { @@ -53,9 +53,9 @@ export function generateAgentDocs(timestamp: number, policyId: string) { default_api_key_id: 'x4zJBHwBfQcM6aSYYxiY', policy_revision_idx: 1, policy_coordinator_idx: 1, - updated_at: timestamp, + updated_at: timestamps[0], last_checkin_status: 'online', - last_checkin: timestamp, + last_checkin: timestamps[0], }, { access_api_key_id: 'w4zJBHwBfQcM6aSYIRjO', @@ -65,7 +65,7 @@ export function generateAgentDocs(timestamp: number, policyId: string) { id: '3838df35-a095-4af4-8fce-0b6d78793f2e', version: '8.0.0', }, - enrolled_at: timestamp, + enrolled_at: timestamps[1], local_metadata: { elastic: { agent: { @@ -103,9 +103,9 @@ export function generateAgentDocs(timestamp: number, policyId: string) { default_api_key_id: 'x4zJBHwBfQcM6aSYYxiY', policy_revision_idx: 1, policy_coordinator_idx: 1, - updated_at: timestamp, + updated_at: timestamps[1], last_checkin_status: 'online', - last_checkin: timestamp, + last_checkin: timestamps[1], }, ]; } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 6e1fb2bcd5c13..b92a26e785127 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -15,12 +15,18 @@ import { METADATA_UNITED_TRANSFORM, METADATA_TRANSFORMS_STATUS_ROUTE, metadataTransformPrefix, + ENDPOINT_DEFAULT_SORT_FIELD, + ENDPOINT_DEFAULT_SORT_DIRECTION, } from '@kbn/security-solution-plugin/common/endpoint/constants'; import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import { indexFleetEndpointPolicy } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/index_fleet_endpoint_policy'; import { TRANSFORM_STATES } from '@kbn/security-solution-plugin/common/constants'; import type { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; +import { + EndpointSortableField, + MetadataListResponse, +} from '@kbn/security-solution-plugin/common/endpoint/types'; import { generateAgentDocs, generateMetadataDocs } from './metadata.fixtures'; import { deleteAllDocsFromMetadataCurrentIndex, @@ -40,6 +46,9 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata apis', () => { describe('list endpoints GET route', () => { const numberOfHostsInFixture = 2; + let agent1Timestamp: number; + let agent2Timestamp: number; + let metadataTimestamp: number; before(async () => { await deleteAllDocsFromFleetAgents(getService); @@ -56,10 +65,12 @@ export default function ({ getService }: FtrProviderContext) { '1.1.1' ); const policyId = policy.integrationPolicies[0].policy_id; - const currentTime = new Date().getTime(); + agent1Timestamp = new Date().getTime(); + agent2Timestamp = agent1Timestamp + 33; + metadataTimestamp = agent1Timestamp + 666; - const agentDocs = generateAgentDocs(currentTime, policyId); - const metadataDocs = generateMetadataDocs(currentTime); + const agentDocs = generateAgentDocs([agent1Timestamp, agent2Timestamp], policyId); + const metadataDocs = generateMetadataDocs(metadataTimestamp); await Promise.all([ bulkIndex(getService, AGENTS_INDEX, agentDocs), @@ -294,6 +305,92 @@ export default function ({ getService }: FtrProviderContext) { expect(body.page).to.eql(0); expect(body.pageSize).to.eql(10); }); + + describe('`last_checkin` runtime field', () => { + it('should sort based on `last_checkin` - because it is a runtime field', async () => { + const { body: bodyAsc }: { body: MetadataListResponse } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .query({ + sortField: 'last_checkin', + sortDirection: 'asc', + }) + .expect(200); + + expect(bodyAsc.data[0].last_checkin).to.eql(new Date(agent1Timestamp).toISOString()); + expect(bodyAsc.data[1].last_checkin).to.eql(new Date(agent2Timestamp).toISOString()); + + const { body: bodyDesc }: { body: MetadataListResponse } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .query({ + sortField: 'last_checkin', + sortDirection: 'desc', + }) + .expect(200); + + expect(bodyDesc.data[0].last_checkin).to.eql(new Date(agent2Timestamp).toISOString()); + expect(bodyDesc.data[1].last_checkin).to.eql(new Date(agent1Timestamp).toISOString()); + }); + }); + + describe('sorting', () => { + it('metadata api should return 400 with not supported sorting field', async () => { + await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .query({ + sortField: 'abc', + }) + .expect(400); + }); + + it('metadata api should sort by enrollment date by default', async () => { + const { body }: { body: MetadataListResponse } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .expect(200); + + expect(body.sortDirection).to.eql(ENDPOINT_DEFAULT_SORT_DIRECTION); + expect(body.sortField).to.eql(ENDPOINT_DEFAULT_SORT_FIELD); + }); + + for (const field of Object.values(EndpointSortableField)) { + it(`metadata api should be able to sort by ${field}`, async () => { + let body: MetadataListResponse; + + ({ body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .query({ + sortField: field, + sortDirection: 'asc', + }) + .expect(200)); + + expect(body.sortDirection).to.eql('asc'); + expect(body.sortField).to.eql(field); + + ({ body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .set('Elastic-Api-Version', '2023-10-31') + .query({ + sortField: field, + sortDirection: 'desc', + }) + .expect(200)); + + expect(body.sortDirection).to.eql('desc'); + expect(body.sortField).to.eql(field); + }); + } + }); }); describe('get metadata transforms', () => {