[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>
This commit is contained in:
Gergő Ábrahám 2023-08-11 16:29:28 +02:00 committed by GitHub
parent 07ad32ff9e
commit 169d197d1a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 514 additions and 99 deletions

View file

@ -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([

View file

@ -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<string, number> = {
ES_CONNECTION_ERROR: -272,

View file

@ -1325,26 +1325,39 @@ export interface ListPageRouteState {
backButtonLabel?: string;
}
/**
* REST API standard base response for list types
*/
interface BaseListResponse<D = unknown> {
data: D[];
page: number;
pageSize: number;
total: number;
}
export interface AdditionalOnSwitchChangeParams {
value: boolean;
policyConfigData: UIPolicyConfig;
protectionOsList: ImmutableArray<Partial<keyof UIPolicyConfig>>;
}
/** 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<HostInfo>;
export interface MetadataListResponse {
data: HostInfo[];
page: number;
pageSize: number;
total: number;
sortField: EndpointSortableField;
sortDirection: 'asc' | 'desc';
}
export type { EndpointPrivileges } from './authz';

View file

@ -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<typeof indexEndpointHosts>;
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);
});
});
});

View file

@ -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<EndpointState> => {
pageSize: 10,
pageIndex: 0,
total: 0,
sortDirection: ENDPOINT_DEFAULT_SORT_DIRECTION,
sortField: ENDPOINT_DEFAULT_SORT_FIELD,
loading: false,
error: undefined,
location: undefined,

View file

@ -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<EndpointState>;
@ -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,

View file

@ -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,
},
});

View file

@ -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,
};
},

View file

@ -63,13 +63,15 @@ const handleMetadataTransformStatsChanged: CaseReducer<MetadataTransformStatsCha
/* eslint-disable-next-line complexity */
export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => {
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,
};

View file

@ -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<EndpointState>): number => state.page
export const pageSize = (state: Immutable<EndpointState>): number => state.pageSize;
export const sortField = (state: Immutable<EndpointState>): EndpointSortableField =>
state.sortField;
export const sortDirection = (state: Immutable<EndpointState>): 'asc' | 'desc' =>
state.sortDirection;
export const totalHits = (state: Immutable<EndpointState>): number => state.total;
export const listLoading = (state: Immutable<EndpointState>): boolean => state.loading;
@ -94,6 +104,8 @@ export const uiQueryParams: (
'selected_endpoint',
'show',
'admin_query',
'sort_field',
'sort_direction',
];
const allowedShowValues: Array<EndpointIndexUIQueryParams['show']> = [
@ -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;
}

View file

@ -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*/

View file

@ -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<typeof useAppUrl>['getAppUrl'];
}
const columnWidths: Record<
Exclude<EndpointSortableField, EndpointSortableField.ENROLLED_AT> | '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 (
<EndpointAgentStatus
@ -140,16 +160,22 @@ const getEndpointListColumns = ({
},
},
{
field: 'metadata.Endpoint.policy.applied',
width: '15%',
field: EndpointSortableField.POLICY_NAME,
width: columnWidths[EndpointSortableField.POLICY_NAME],
name: i18n.translate('xpack.securitySolution.endpoint.list.policy', {
defaultMessage: 'Policy',
}),
sortable: true,
truncateText: true,
render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => {
render: (
policyName: HostInfo['metadata']['Endpoint']['policy']['applied']['name'],
item: HostInfo
) => {
const policy = item.metadata.Endpoint.policy.applied;
return (
<>
<EuiToolTip content={policy.name} anchorClassName="eui-textTruncate">
<EuiToolTip content={policyName} anchorClassName="eui-textTruncate">
{canReadPolicyManagement ? (
<EndpointPolicyLink
policyId={policy.id}
@ -157,10 +183,10 @@ const getEndpointListColumns = ({
data-test-subj="policyNameCellLink"
backLink={backToEndpointList}
>
{policy.name}
{policyName}
</EndpointPolicyLink>
) : (
<>{policy.name}</>
<>{policyName}</>
)}
</EuiToolTip>
{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 (
<EuiToolTip
content={POLICY_STATUS_TO_TEXT[policy.status]}
anchorClassName="eui-textTruncate"
>
<EuiToolTip content={POLICY_STATUS_TO_TEXT[status]} anchorClassName="eui-textTruncate">
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policy.status]}
color={POLICY_STATUS_TO_HEALTH_COLOR[status]}
className="eui-textTruncate eui-fullWidth"
data-test-subj="rowPolicyStatus"
>
<EndpointListNavLink
name={POLICY_STATUS_TO_TEXT[policy.status]}
name={POLICY_STATUS_TO_TEXT[status]}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="policyStatusCellLink"
@ -220,11 +247,12 @@ const getEndpointListColumns = ({
},
},
{
field: 'metadata.host.os.name',
width: '9%',
field: EndpointSortableField.HOST_OS_NAME,
width: columnWidths[EndpointSortableField.HOST_OS_NAME],
name: i18n.translate('xpack.securitySolution.endpoint.list.os', {
defaultMessage: 'OS',
}),
sortable: true,
render: (os: string) => {
return (
<EuiToolTip content={os} anchorClassName="eui-textTruncate">
@ -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 (
<EuiToolTip content={ip.toString().replace(',', ', ')} anchorClassName="eui-textTruncate">
@ -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 (
<EuiToolTip content={version} anchorClassName="eui-textTruncate">
@ -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 (
<FormattedDate
fieldName={lastActiveColumnName}
@ -285,7 +316,7 @@ const getEndpointListColumns = ({
},
{
field: '',
width: '8%',
width: columnWidths.actions,
name: i18n.translate('xpack.securitySolution.endpoint.list.actions', {
defaultMessage: 'Actions',
}),
@ -303,12 +334,18 @@ const getEndpointListColumns = ({
// FIXME: this needs refactoring - we are pulling in all selectors from endpoint, which includes many more than what the list uses
const selector = (createStructuredSelector as CreateStructuredSelector)(selectors);
const stateHandleDeployEndpointsClick: AgentPolicyDetailsDeployAgentAction = {
onDoneNavigateTo: [APP_UI_ID, { path: getEndpointListPath({ name: 'endpointList' }) }],
};
export const EndpointList = () => {
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<HostInfoInterface>) => {
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<CreatePackagePolicyRouteState>(
'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<AgentPolicyDetailsDeployAgentAction>('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 (
<EuiBasicTable
data-test-subj="endpointListTable"
items={[...listData]}
items={mutableListData}
columns={columns}
error={listError?.message}
pagination={paginationSetup}
onChange={onTableChange}
loading={loading}
rowProps={setTableRowProps}
sorting={sorting}
/>
);
} 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 (

View file

@ -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 });

View file

@ -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 () => {

View file

@ -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<SavedObjectsClientContract>;
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' } },
]);
});
});
});
});

View file

@ -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<string, unknown>;
@ -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,
},

View file

@ -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<typeof _agent> = {
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)
);

View file

@ -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],
},
];
}

View file

@ -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', () => {