mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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  ### 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:
parent
07ad32ff9e
commit
169d197d1a
19 changed files with 514 additions and 99 deletions
|
@ -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([
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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*/
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue