[Fleet] Add view agents button to activity flyout (#152555)

Part of https://github.com/elastic/kibana/issues/141206

## Summary

### Server side:
- Create a new API that returns Agents by actions Ids:
```
POST kbn:/api/fleet/agents 
{
	actionIds: [
    	'action1',
        'action2'
    ]
}
```

### UI:
Add "view agents" button to activity flyout; when clicking on it, the
button will take the user to the agent list and display only the subset
of agents affected by the action.

<img width="1100" alt="Screenshot 2023-03-09 at 16 27 41"
src="https://user-images.githubusercontent.com/16084106/224072551-bf7b6cf3-9f32-4a79-8e61-d7dc35f4db54.png">

Also addiing a "clear filters" on top of the header to be able to remove
the applied filter after the button is selected

I also did some refactoring of the `AgentListPage` component, mostly
extracted some small components and the `kueryBuilder` function, that
became its own function and it's also been tested.

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2023-03-13 14:48:30 +01:00 committed by GitHub
parent 143a28b269
commit eb60253fd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 742 additions and 117 deletions

View file

@ -1637,6 +1637,54 @@
"basicAuth": []
}
]
},
"post": {
"summary": "List agents by action ids",
"tags": [
"Agents"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/agent_get_by_actions"
}
}
}
},
"400": {
"$ref": "#/components/responses/error"
}
},
"operationId": "get-agents-by-actions",
"parameters": [
{
"$ref": "#/components/parameters/kbn_xsrf"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"actionIds": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"policy_id"
]
}
}
}
}
}
},
"/agents/bulk_upgrade": {
@ -6137,6 +6185,16 @@
"perPage"
]
},
"agent_get_by_actions": {
"title": "Agents get by action ids",
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"bulk_upgrade_agents": {
"title": "Bulk upgrade agents",
"type": "object",

View file

@ -1021,6 +1021,35 @@ paths:
type: boolean
security:
- basicAuth: []
post:
summary: List agents by action ids
tags:
- Agents
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/agent_get_by_actions'
'400':
$ref: '#/components/responses/error'
operationId: get-agents-by-actions
parameters:
- $ref: '#/components/parameters/kbn_xsrf'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
actionIds:
type: array
items:
type: string
required:
- policy_id
/agents/bulk_upgrade:
post:
summary: Bulk upgrade agents
@ -3890,6 +3919,13 @@ components:
- total
- page
- perPage
agent_get_by_actions:
title: Agents get by action ids
type: array
items:
type: array
items:
type: string
bulk_upgrade_agents:
title: Bulk upgrade agents
type: object

View file

@ -0,0 +1,6 @@
title: Agents get by action ids
type: array
items:
type: array
items:
type: string

View file

@ -28,3 +28,32 @@ get:
type: boolean
security:
- basicAuth: []
post:
summary: List agents by action ids
tags:
- Agents
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: ../components/schemas/agent_get_by_actions.yaml
'400':
$ref: ../components/responses/error.yaml
operationId: get-agents-by-actions
parameters:
- $ref: ../components/headers/kbn_xsrf.yaml
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
actionIds:
type: array
items:
type: string
required:
- policy_id

View file

@ -220,6 +220,7 @@ export const agentRouteService = {
'{fileName}',
fileName
),
getAgentsByActionsPath: () => AGENT_API_ROUTES.LIST_PATTERN,
};
export const outputRoutesService = {

View file

@ -233,3 +233,13 @@ export interface GetActionStatusResponse {
export interface GetAvailableVersionsResponse {
items: string[];
}
export interface PostRetrieveAgentsByActionsRequest {
body: {
actionIds: string[];
};
}
export interface PostRetrieveAgentsByActionsResponse {
items: string[];
}

View file

@ -26,12 +26,16 @@ describe('AgentActivityFlyout', () => {
const mockOnClose = jest.fn();
const mockOnAbortSuccess = jest.fn();
const mockAbortUpgrade = jest.fn();
const mockSetSearch = jest.fn();
const mockSetSelectedStatus = jest.fn();
beforeEach(() => {
mockOnClose.mockReset();
mockOnAbortSuccess.mockReset();
mockAbortUpgrade.mockReset();
mockUseActionStatus.mockReset();
mockSetSearch.mockReset();
mockSetSelectedStatus.mockReset();
mockUseGetAgentPolicies.mockReturnValue({
data: {
items: [
@ -60,6 +64,8 @@ describe('AgentActivityFlyout', () => {
onClose={mockOnClose}
onAbortSuccess={mockOnAbortSuccess}
refreshAgentActivity={false}
setSearch={mockSetSearch}
setSelectedStatus={mockSetSelectedStatus}
/>
</IntlProvider>
);

View file

@ -8,6 +8,7 @@
import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
@ -24,16 +25,25 @@ import {
EuiEmptyPrompt,
EuiButtonEmpty,
EuiFlyoutFooter,
EuiSpacer,
} from '@elastic/eui';
import styled from 'styled-components';
import type { ActionStatus } from '../../../../types';
import { useActionStatus } from '../hooks';
import { useGetAgentPolicies, useStartServices } from '../../../../hooks';
import {
useGetAgentPolicies,
useStartServices,
sendPostRetrieveAgentsByActions,
} from '../../../../hooks';
import { SO_SEARCH_LIMIT } from '../../../../constants';
import { Loading } from '../../components';
import { getKuery } from '../utils/get_kuery';
import { AGENT_STATUSES } from '../../services/agent_status';
import { getTodayActions, getOtherDaysActions } from './agent_activity_helper';
import { ViewErrors } from './view_errors';
@ -51,7 +61,10 @@ export const AgentActivityFlyout: React.FunctionComponent<{
onClose: () => void;
onAbortSuccess: () => void;
refreshAgentActivity: boolean;
}> = ({ onClose, onAbortSuccess, refreshAgentActivity }) => {
setSearch: (search: string) => void;
setSelectedStatus: (status: string[]) => void;
}> = ({ onClose, onAbortSuccess, refreshAgentActivity, setSearch, setSelectedStatus }) => {
const { notifications } = useStartServices();
const { data: agentPoliciesData } = useGetAgentPolicies({
perPage: SO_SEARCH_LIMIT,
});
@ -66,7 +79,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
return policy?.name ?? policyId;
};
const currentActionsEnriched = currentActions.map((a) => ({
const currentActionsEnriched: ActionStatus[] = currentActions.map((a) => ({
...a,
newPolicyId: getAgentPolicyName(a.newPolicyId ?? ''),
}));
@ -78,6 +91,27 @@ export const AgentActivityFlyout: React.FunctionComponent<{
const todayActions = getTodayActions(completedActions);
const otherDays = getOtherDaysActions(completedActions);
const onClickViewAgents = async (action: ActionStatus) => {
try {
const { data } = await sendPostRetrieveAgentsByActions({ actionIds: [action.actionId] });
if (data?.items?.length) {
const kuery = getKuery({
selectedAgentIds: data.items,
});
setSearch(kuery);
}
setSelectedStatus(AGENT_STATUSES);
onClose();
} catch (err) {
notifications.toasts.addError(err, {
title: i18n.translate('xpack.fleet.agentActivityFlyout.error', {
defaultMessage: 'Error viewing selected agents',
}),
});
}
};
return (
<>
<EuiFlyout data-test-subj="agentActivityFlyout" onClose={onClose} size="m" paddingSize="none">
@ -161,6 +195,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
}
actions={inProgressActions}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
/>
) : null}
{todayActions.length > 0 ? (
@ -173,6 +208,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
}
actions={todayActions}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
/>
) : null}
{Object.keys(otherDays).map((day) => (
@ -181,6 +217,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
title={<FormattedDate value={day} year="numeric" month="short" day="2-digit" />}
actions={otherDays[day]}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
/>
))}
</FullHeightFlyoutBody>
@ -207,7 +244,8 @@ const ActivitySection: React.FunctionComponent<{
title: ReactNode;
actions: ActionStatus[];
abortUpgrade: (action: ActionStatus) => Promise<void>;
}> = ({ title, actions, abortUpgrade }) => {
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ title, actions, abortUpgrade, onClickViewAgents }) => {
return (
<>
<EuiPanel color="subdued" hasBorder={true} borderRadius="none">
@ -221,9 +259,14 @@ const ActivitySection: React.FunctionComponent<{
action={currentAction}
abortUpgrade={abortUpgrade}
key={currentAction.actionId}
onClickViewAgents={onClickViewAgents}
/>
) : (
<ActivityItem action={currentAction} key={currentAction.actionId} />
<ActivityItem
action={currentAction}
key={currentAction.actionId}
onClickViewAgents={onClickViewAgents}
/>
)
)}
</>
@ -323,7 +366,10 @@ const inProgressDescription = (time?: string) => (
/>
);
const ActivityItem: React.FunctionComponent<{ action: ActionStatus }> = ({ action }) => {
const ActivityItem: React.FunctionComponent<{
action: ActionStatus;
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ action, onClickViewAgents }) => {
const completeTitle = (
<EuiText>
<FormattedMessage
@ -509,6 +555,8 @@ const ActivityItem: React.FunctionComponent<{ action: ActionStatus }> = ({ actio
) : null}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
</EuiPanel>
);
};
@ -516,7 +564,8 @@ const ActivityItem: React.FunctionComponent<{ action: ActionStatus }> = ({ actio
export const UpgradeInProgressActivityItem: React.FunctionComponent<{
action: ActionStatus;
abortUpgrade: (action: ActionStatus) => Promise<void>;
}> = ({ action, abortUpgrade }) => {
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ action, abortUpgrade, onClickViewAgents }) => {
const { docLinks } = useStartServices();
const [isAborting, setIsAborting] = useState(false);
const onClickAbortUpgrade = useCallback(async () => {
@ -601,6 +650,9 @@ export const UpgradeInProgressActivityItem: React.FunctionComponent<{
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{showCancelButton ? (
<EuiButton
@ -622,3 +674,22 @@ export const UpgradeInProgressActivityItem: React.FunctionComponent<{
</EuiPanel>
);
};
const ViewAgentsButton: React.FunctionComponent<{
action: ActionStatus;
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ action, onClickViewAgents }) => {
return action.type !== 'UPDATE_TAGS' ? (
<EuiButtonEmpty
size="m"
onClick={() => onClickViewAgents(action)}
flush="left"
data-test-subj="agentActivityFlyout.viewAgentsButton"
>
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.viewAgentsButton"
defaultMessage="View Agents"
/>
</EuiButtonEmpty>
) : null;
};

View file

@ -23,13 +23,15 @@ import { isAgentUpgradeable, ExperimentalFeaturesService } from '../../../../ser
import { AgentHealth } from '../../components';
import type { Pagination } from '../../../../hooks';
import { useLink, useKibanaVersion } from '../../../../hooks';
import { useLink, useKibanaVersion, useAuthz } from '../../../../hooks';
import { AgentPolicySummaryLine } from '../../../../components';
import { Tags } from '../../components/tags';
import type { AgentMetrics } from '../../../../../../../common/types';
import { formatAgentCPU, formatAgentMemory } from '../../services/agent_metrics';
import { EmptyPrompt } from './empty_prompt';
const VERSION_FIELD = 'local_metadata.elastic.agent.version';
const HOSTNAME_FIELD = 'local_metadata.host.hostname';
function safeMetadata(val: any) {
@ -50,10 +52,18 @@ interface Props {
tableRef?: React.Ref<any>;
showUpgradeable: boolean;
totalAgents?: number;
noItemsMessage: JSX.Element;
pagination: Pagination;
onTableChange: (criteria: CriteriaWithPagination<Agent>) => void;
pageSizeOptions: number[];
isUsingFilter: boolean;
setEnrollmentFlyoutState: (
value: React.SetStateAction<{
isOpen: boolean;
selectedPolicyId?: string | undefined;
}>
) => void;
clearFilters: () => void;
isCurrentRequestIncremented: boolean;
}
export const AgentListTable: React.FC<Props> = (props: Props) => {
@ -65,15 +75,19 @@ export const AgentListTable: React.FC<Props> = (props: Props) => {
sortField,
sortOrder,
tableRef,
noItemsMessage,
onTableChange,
onSelectionChange,
totalAgents = 0,
showUpgradeable,
pagination,
pageSizeOptions,
isUsingFilter,
setEnrollmentFlyoutState,
clearFilters,
isCurrentRequestIncremented,
} = props;
const hasFleetAllPrivileges = useAuthz().fleet.all;
const { displayAgentMetrics } = ExperimentalFeaturesService.get();
const { getHref } = useLink();
@ -88,6 +102,34 @@ export const AgentListTable: React.FC<Props> = (props: Props) => {
return !isHosted;
};
const noItemsMessage =
isLoading && isCurrentRequestIncremented ? (
<FormattedMessage
id="xpack.fleet.agentList.loadingAgentsMessage"
defaultMessage="Loading agents…"
/>
) : isUsingFilter ? (
<FormattedMessage
id="xpack.fleet.agentList.noFilteredAgentsPrompt"
defaultMessage="No agents found. {clearFiltersLink}"
values={{
clearFiltersLink: (
<EuiLink onClick={() => clearFilters()}>
<FormattedMessage
id="xpack.fleet.agentList.clearFiltersLinkText"
defaultMessage="Clear filters"
/>
</EuiLink>
),
}}
/>
) : (
<EmptyPrompt
hasFleetAllPrivileges={hasFleetAllPrivileges}
setEnrollmentFlyoutState={setEnrollmentFlyoutState}
/>
);
const sorting = {
sort: {
field: sortField,

View file

@ -6,7 +6,8 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Agent, SimplifiedAgentStatus } from '../../../../types';
@ -25,6 +26,8 @@ export const AgentTableHeader: React.FunctionComponent<{
setSelectionMode: (mode: SelectionMode) => void;
selectedAgents: Agent[];
setSelectedAgents: (agents: Agent[]) => void;
clearFilters: () => void;
isUsingFilter: boolean;
}> = ({
agentStatus,
totalAgents,
@ -34,20 +37,34 @@ export const AgentTableHeader: React.FunctionComponent<{
selectedAgents,
setSelectedAgents,
showInactive,
clearFilters,
isUsingFilter,
}) => {
return (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<AgentsSelectionStatus
totalAgents={totalAgents}
selectableAgents={selectableAgents}
selectionMode={selectionMode}
setSelectionMode={setSelectionMode}
selectedAgents={selectedAgents}
setSelectedAgents={setSelectedAgents}
/>
</EuiFlexItem>
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<AgentsSelectionStatus
totalAgents={totalAgents}
selectableAgents={selectableAgents}
selectionMode={selectionMode}
setSelectionMode={setSelectionMode}
selectedAgents={selectedAgents}
setSelectedAgents={setSelectedAgents}
/>
</EuiFlexItem>
{isUsingFilter ? (
<EuiFlexItem grow={false}>
<EuiLink onClick={() => clearFilters()}>
<FormattedMessage
id="xpack.fleet.agentList.header.clearFiltersLinkText"
defaultMessage="Clear filters"
/>
</EuiLink>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiFlexItem grow={false}>
{agentStatus && (
<AgentStatusBadges showInactive={showInactive} agentStatus={agentStatus} />

View file

@ -47,6 +47,10 @@ export function useActionStatus(onAbortSuccess: () => void, refreshAgentActivity
if (refreshAgentActivity) {
refreshActions();
}
return () => {
setCurrentActions([]);
setIsFirstLoading(true);
};
}, [refreshActions, refreshAgentActivity]);
const abortUpgrade = useCallback(

View file

@ -7,18 +7,14 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { differenceBy, isEqual } from 'lodash';
import type { EuiBasicTable } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import { EuiSpacer, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { agentStatusesToSummary } from '../../../../../../common/services';
import type { Agent, AgentPolicy, SimplifiedAgentStatus } from '../../../types';
import {
usePagination,
useAuthz,
useGetAgentPolicies,
sendGetAgents,
sendGetAgentStatus,
@ -35,7 +31,7 @@ import {
ExperimentalFeaturesService,
policyHasFleetServer,
} from '../../../services';
import { AGENTS_PREFIX, SO_SEARCH_LIMIT } from '../../../constants';
import { SO_SEARCH_LIMIT } from '../../../constants';
import {
AgentReassignAgentPolicyModal,
AgentUnenrollAgentModal,
@ -54,7 +50,7 @@ import { TagsAddRemove } from './components/tags_add_remove';
import { AgentActivityFlyout } from './components';
import { TableRowActions } from './components/table_row_actions';
import { AgentListTable } from './components/agent_list_table';
import { EmptyPrompt } from './components/empty_prompt';
import { getKuery } from './utils/get_kuery';
const REFRESH_INTERVAL_MS = 30000;
@ -64,7 +60,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const { notifications, cloud } = useStartServices();
useBreadcrumbs('agent_list');
const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || '';
const hasFleetAllPrivileges = useAuthz().fleet.all;
// Agent data states
const [showUpgradeable, setShowUpgradeable] = useState<boolean>(false);
@ -78,6 +73,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const { pagination, pageSizeOptions, setPagination } = usePagination();
const [sortField, setSortField] = useState<keyof Agent>('enrolled_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const VERSION_FIELD = 'local_metadata.elastic.agent.version';
const HOSTNAME_FIELD = 'local_metadata.host.hostname';
@ -162,64 +158,19 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setSortOrder(sort!.direction);
};
// Kuery
const kuery = useMemo(() => {
let kueryBuilder = search.trim();
if (selectedAgentPolicies.length) {
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and`;
}
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.policy_id : (${selectedAgentPolicies
.map((agentPolicy) => `"${agentPolicy}"`)
.join(' or ')})`;
}
if (selectedTags.length) {
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and`;
}
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags
.map((tag) => `"${tag}"`)
.join(' or ')})`;
}
if (selectedStatus.length) {
const kueryStatus = selectedStatus
.map((status) => {
switch (status) {
case 'healthy':
return AgentStatusKueryHelper.buildKueryForOnlineAgents();
case 'unhealthy':
return AgentStatusKueryHelper.buildKueryForErrorAgents();
case 'offline':
return AgentStatusKueryHelper.buildKueryForOfflineAgents();
case 'updating':
return AgentStatusKueryHelper.buildKueryForUpdatingAgents();
case 'inactive':
return AgentStatusKueryHelper.buildKueryForInactiveAgents();
case 'unenrolled':
return AgentStatusKueryHelper.buildKueryForUnenrolledAgents();
}
return undefined;
})
.filter((statusKuery) => statusKuery !== undefined)
.join(' or ');
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and (${kueryStatus})`;
} else {
kueryBuilder = kueryStatus;
}
}
return kueryBuilder;
}, [search, selectedAgentPolicies, selectedTags, selectedStatus]);
const showInactive = useMemo(() => {
return selectedStatus.some((status) => status === 'inactive' || status === 'unenrolled');
}, [selectedStatus]);
const kuery = useMemo(() => {
return getKuery({
search,
selectedAgentPolicies,
selectedTags,
selectedStatus,
});
}, [search, selectedAgentPolicies, selectedStatus, selectedTags]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsStatus, setAgentsStatus] = useState<
{ [key in SimplifiedAgentStatus]: number } | undefined
@ -275,6 +226,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
currentRequestRef.current++;
const currentRequest = currentRequestRef.current;
isLoadingVar.current = true;
try {
setIsLoading(true);
const [agentsResponse, totalInactiveAgentsResponse, agentTagsResponse] =
@ -354,13 +306,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
pagination.currentPage,
pagination.pageSize,
kuery,
showInactive,
showUpgradeable,
allTags,
notifications.toasts,
sortField,
sortOrder,
showInactive,
showUpgradeable,
displayAgentMetrics,
allTags,
notifications.toasts,
]
);
@ -450,33 +402,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setShowAgentActivityTour({ isOpen: true });
};
const noItemsMessage =
isLoading && currentRequestRef?.current === 1 ? (
<FormattedMessage
id="xpack.fleet.agentList.loadingAgentsMessage"
defaultMessage="Loading agents…"
/>
) : isUsingFilter ? (
<FormattedMessage
id="xpack.fleet.agentList.noFilteredAgentsPrompt"
defaultMessage="No agents found. {clearFiltersLink}"
values={{
clearFiltersLink: (
<EuiLink onClick={() => clearFilters()}>
<FormattedMessage
id="xpack.fleet.agentList.clearFiltersLinkText"
defaultMessage="Clear filters"
/>
</EuiLink>
),
}}
/>
) : (
<EmptyPrompt
hasFleetAllPrivileges={hasFleetAllPrivileges}
setEnrollmentFlyoutState={setEnrollmentFlyoutState}
/>
);
const isCurrentRequestIncremented = currentRequestRef?.current === 1;
return (
<>
{isAgentActivityFlyoutOpen ? (
@ -485,6 +411,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
onAbortSuccess={fetchData}
onClose={() => setAgentActivityFlyoutOpen(false)}
refreshAgentActivity={isLoading}
setSearch={setSearch}
setSelectedStatus={setSelectedStatus}
/>
</EuiPortal>
) : null}
@ -615,6 +543,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setSelectionMode('manual');
}
}}
clearFilters={clearFilters}
isUsingFilter={isUsingFilter}
/>
<EuiSpacer size="s" />
{/* Agent list table */}
@ -631,8 +561,11 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
showUpgradeable={showUpgradeable}
onTableChange={onTableChange}
pagination={pagination}
noItemsMessage={noItemsMessage}
totalAgents={Math.min(totalAgents, SO_SEARCH_LIMIT)}
isUsingFilter={isUsingFilter}
setEnrollmentFlyoutState={setEnrollmentFlyoutState}
clearFilters={clearFilters}
isCurrentRequestIncremented={isCurrentRequestIncremented}
/>
</>
);

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getKuery } from './get_kuery';
describe('getKuery', () => {
const search = 'base search';
const selectedTags = ['tag_1', 'tag_2', 'tag_3'];
const selectedAgentIds = ['agent_id1', 'agent_id2'];
const selectedAgentPolicies = ['policy1', 'policy2', 'policy3'];
const selectedStatus = ['healthy', 'unhealthy'];
const healthyStatuses = ['healthy', 'updating'];
const inactiveStatuses = ['unhealthy', 'offline', 'inactive', 'unenrolled'];
it('should return a kuery with base search', () => {
expect(getKuery({ search })).toEqual('base search');
});
it('should return a kuery with selected tags', () => {
expect(getKuery({ selectedTags })).toEqual(
'fleet-agents.tags : ("tag_1" or "tag_2" or "tag_3")'
);
});
it('should return a kuery with selected agent policies', () => {
expect(getKuery({ selectedAgentPolicies })).toEqual(
'fleet-agents.policy_id : ("policy1" or "policy2" or "policy3")'
);
});
it('should return a kuery with selected agent ids', () => {
expect(getKuery({ selectedAgentIds })).toEqual(
'fleet-agents.agent.id : ("agent_id1" or "agent_id2")'
);
});
it('should return a kuery with healthy selected status', () => {
expect(getKuery({ selectedStatus: healthyStatuses })).toEqual(
'status:online or (status:updating or status:unenrolling or status:enrolling)'
);
});
it('should return a kuery with unhealthy selected status', () => {
expect(getKuery({ selectedStatus: inactiveStatuses })).toEqual(
'(status:error or status:degraded) or status:offline or status:inactive or status:unenrolled'
);
});
it('should return a kuery with a combination of previous kueries', () => {
expect(getKuery({ search, selectedTags, selectedStatus })).toEqual(
'((base search) and fleet-agents.tags : ("tag_1" or "tag_2" or "tag_3")) and (status:online or (status:error or status:degraded))'
);
});
it('should return empty string if nothing is passed', () => {
expect(getKuery({})).toEqual('');
});
});

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AgentStatusKueryHelper } from '../../../../services';
import { AGENTS_PREFIX } from '../../../../constants';
export const getKuery = ({
search,
selectedAgentPolicies,
selectedTags,
selectedStatus,
selectedAgentIds,
}: {
search?: string;
selectedAgentPolicies?: string[];
selectedTags?: string[];
selectedStatus?: string[];
selectedAgentIds?: string[];
}) => {
let kueryBuilder = '';
if (search) {
kueryBuilder = search.trim();
}
if (selectedAgentPolicies?.length) {
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and`;
}
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.policy_id : (${selectedAgentPolicies
.map((agentPolicy) => `"${agentPolicy}"`)
.join(' or ')})`;
}
if (selectedTags?.length) {
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and`;
}
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags
.map((tag) => `"${tag}"`)
.join(' or ')})`;
}
if (selectedAgentIds?.length) {
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and`;
}
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.agent.id : (${selectedAgentIds
.map((id) => `"${id}"`)
.join(' or ')})`;
}
if (selectedStatus?.length) {
const kueryStatus = selectedStatus
.map((status) => {
switch (status) {
case 'healthy':
return AgentStatusKueryHelper.buildKueryForOnlineAgents();
case 'unhealthy':
return AgentStatusKueryHelper.buildKueryForErrorAgents();
case 'offline':
return AgentStatusKueryHelper.buildKueryForOfflineAgents();
case 'updating':
return AgentStatusKueryHelper.buildKueryForUpdatingAgents();
case 'inactive':
return AgentStatusKueryHelper.buildKueryForInactiveAgents();
case 'unenrolled':
return AgentStatusKueryHelper.buildKueryForUnenrolledAgents();
}
return undefined;
})
.filter((statusKuery) => statusKuery !== undefined)
.join(' or ');
if (kueryBuilder) {
kueryBuilder = `(${kueryBuilder}) and (${kueryStatus})`;
} else {
kueryBuilder = kueryStatus;
}
}
return kueryBuilder.trim();
};

View file

@ -44,6 +44,8 @@ import type {
PostNewAgentActionResponse,
GetCurrentUpgradesResponse,
GetAvailableVersionsResponse,
PostRetrieveAgentsByActionsRequest,
PostRetrieveAgentsByActionsResponse,
} from '../../types';
import { useRequest, sendRequest } from './use_request';
@ -264,6 +266,14 @@ export function sendPostCancelAction(actionId: string) {
});
}
export function sendPostRetrieveAgentsByActions(body: PostRetrieveAgentsByActionsRequest['body']) {
return sendRequest<PostRetrieveAgentsByActionsResponse>({
path: agentRouteService.getAgentsByActionsPath(),
method: 'post',
body,
});
}
export function sendPutAgentTagsUpdate(
agentId: string,
body: UpdateAgentRequest['body'],

View file

@ -133,6 +133,8 @@ export type {
GetAvailableVersionsResponse,
PostHealthCheckRequest,
PostHealthCheckResponse,
PostRetrieveAgentsByActionsRequest,
PostRetrieveAgentsByActionsResponse,
} from '../../common/types';
export {
entries,

View file

@ -30,6 +30,7 @@ import type {
GetActionStatusResponse,
GetAgentUploadsResponse,
PostAgentReassignResponse,
PostRetrieveAgentsByActionsResponse,
} from '../../../common/types';
import type {
GetAgentsRequestSchema,
@ -45,6 +46,7 @@ import type {
PostBulkUpdateAgentTagsRequestSchema,
GetActionStatusRequestSchema,
GetAgentUploadFileRequestSchema,
PostRetrieveAgentsByActionsRequestSchema,
} from '../../types';
import { defaultFleetErrorHandler } from '../../errors';
import * as AgentService from '../../services/agents';
@ -410,6 +412,23 @@ export const getActionStatusHandler: RequestHandler<
}
};
export const postRetrieveAgentsByActionsHandler: RequestHandler<
undefined,
undefined,
TypeOf<typeof PostRetrieveAgentsByActionsRequestSchema.body>
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
try {
const agents = await AgentService.getAgentsByActionsIds(esClient, request.body.actionIds);
const body: PostRetrieveAgentsByActionsResponse = { items: agents };
return response.ok({ body });
} catch (error) {
return defaultFleetErrorHandler({ error, response });
}
};
export const getAgentUploadsHandler: RequestHandler<
TypeOf<typeof GetOneAgentRequestSchema.params>
> = async (context, request, response) => {

View file

@ -32,6 +32,7 @@ import {
PostBulkRequestDiagnosticsActionRequestSchema,
ListAgentUploadsRequestSchema,
GetAgentUploadFileRequestSchema,
PostRetrieveAgentsByActionsRequestSchema,
} from '../../types';
import * as AgentService from '../../services/agents';
import type { FleetConfigType } from '../..';
@ -56,6 +57,7 @@ import {
getAgentUploadsHandler,
getAgentUploadFileHandler,
postAgentsReassignHandler,
postRetrieveAgentsByActionsHandler,
} from './handlers';
import {
postNewAgentActionHandlerBuilder,
@ -168,6 +170,17 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT
getAgentActions: AgentService.getAgentActions,
})
);
// Get agents by Action_Ids
router.post(
{
path: AGENT_API_ROUTES.LIST_PATTERN,
validate: PostRetrieveAgentsByActionsRequestSchema,
fleetAuthz: {
fleet: { all: true }, // Authorizations?
},
},
postRetrieveAgentsByActionsHandler
);
router.post(
{

View file

@ -10,7 +10,7 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { createAppContextStartContractMock } from '../../mocks';
import { appContextService } from '../app_context';
import { cancelAgentAction } from './actions';
import { cancelAgentAction, getAgentsByActionsIds } from './actions';
import { bulkUpdateAgents } from './crud';
jest.mock('./crud');
@ -114,4 +114,59 @@ describe('Agent actions', () => {
);
});
});
describe('getAgentsByActionsIds', () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
it('should find agents by passing actions Ids', async () => {
esClientMock.search.mockResolvedValue({
hits: {
hits: [
{
_source: {
action_id: 'action2',
agents: ['agent3', 'agent4'],
expiration: '2022-05-12T18:16:18.019Z',
type: 'UPGRADE',
},
},
],
},
} as any);
const actionsIds = ['action2'];
expect(await getAgentsByActionsIds(esClientMock, actionsIds)).toEqual(['agent3', 'agent4']);
});
it('should find agents by passing multiple actions Ids', async () => {
esClientMock.search.mockResolvedValue({
hits: {
hits: [
{
_source: {
action_id: 'action2',
agents: ['agent3', 'agent4'],
expiration: '2022-05-12T18:16:18.019Z',
type: 'UPGRADE',
},
},
{
_source: {
action_id: 'action3',
agents: ['agent5', 'agent6', 'agent7'],
expiration: '2022-05-12T18:16:18.019Z',
type: 'UNENROLL',
},
},
],
},
} as any);
const actionsIds = ['action2', 'actions3'];
expect(await getAgentsByActionsIds(esClientMock, actionsIds)).toEqual([
'agent3',
'agent4',
'agent5',
'agent6',
'agent7',
]);
});
});
});

View file

@ -195,7 +195,7 @@ export async function getAgentActions(esClient: ElasticsearchClient, actionId: s
return res.hits.hits.map((hit) => ({
...hit._source,
id: hit._id,
}));
})) as FleetServerAgentAction[];
}
export async function getUnenrollAgentActions(
@ -347,6 +347,41 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId:
} as AgentAction;
}
async function getAgentActionsByIds(esClient: ElasticsearchClient, actionIds: string[]) {
const res = await esClient.search<FleetServerAgentAction>({
index: AGENT_ACTIONS_INDEX,
query: {
bool: {
filter: [
{
terms: {
action_id: actionIds,
},
},
],
},
},
size: SO_SEARCH_LIMIT,
});
if (res.hits.hits.length === 0) {
throw new AgentActionNotFoundError('Action not found');
}
return res.hits.hits.map((hit) => ({
...hit._source,
id: hit._id,
})) as FleetServerAgentAction[];
}
export const getAgentsByActionsIds = async (
esClient: ElasticsearchClient,
actionsIds: string[]
) => {
const actions = await getAgentActionsByIds(esClient, actionsIds);
return actions.flatMap((a) => a?.agents).filter((agent) => !!agent) as string[];
};
export interface ActionsService {
getAgent: (
esClient: ElasticsearchClient,
@ -360,6 +395,5 @@ export interface ActionsService {
esClient: ElasticsearchClient,
newAgentAction: Omit<AgentAction, 'id'>
) => Promise<AgentAction>;
getAgentActions: (esClient: ElasticsearchClient, actionId: string) => Promise<any[]>;
}

View file

@ -60,6 +60,12 @@ export const PostCancelActionRequestSchema = {
}),
};
export const PostRetrieveAgentsByActionsRequestSchema = {
body: schema.object({
actionIds: schema.arrayOf(schema.string()),
}),
};
export const PostAgentUnenrollRequestSchema = {
params: schema.object({
agentId: schema.string(),

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { v4 as uuidv4 } from 'uuid';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const es = getService('es');
describe('fleet_get_agents_by_actions', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents');
await es.create({
index: '.fleet-actions',
refresh: 'wait_for',
id: uuidv4(),
body: {
'@timestamp': new Date().toISOString(),
expiration: new Date().toISOString(),
agents: ['agent1', 'agent2'],
action_id: 'action000001',
data: {},
type: 'UPGRADE',
},
});
await es.create({
index: '.fleet-actions',
refresh: 'wait_for',
id: uuidv4(),
body: {
'@timestamp': new Date().toISOString(),
expiration: new Date().toISOString(),
agents: ['agent3', 'agent4', 'agent5'],
action_id: 'action000002',
data: {},
type: 'UNENROLL',
},
});
await es.create({
index: '.fleet-actions',
refresh: 'wait_for',
id: uuidv4(),
body: {
'@timestamp': new Date().toISOString(),
expiration: new Date().toISOString(),
action_id: 'action000003',
data: {},
type: 'UNENROLL',
},
});
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
});
describe('POST /agents/', () => {
it('should return a list of agents corresponding to the payload action_ids', async () => {
const { body: apiResponse } = await supertest
.post(`/api/fleet/agents`)
.set('kbn-xsrf', 'xx')
.send({
actionIds: ['action000001', 'action000002'],
})
.expect(200);
expect(apiResponse.items).to.eql(['agent3', 'agent4', 'agent5', 'agent1', 'agent2']);
});
it('should return a list of agents corresponding to the payload action_ids', async () => {
const { body: apiResponse } = await supertest
.post(`/api/fleet/agents`)
.set('kbn-xsrf', 'xx')
.send({
actionIds: ['action000002'],
})
.expect(200);
expect(apiResponse.items).to.eql(['agent3', 'agent4', 'agent5']);
});
it('should return an empty list of agents if there are not agents on the action', async () => {
const { body: apiResponse } = await supertest
.post(`/api/fleet/agents`)
.set('kbn-xsrf', 'xx')
.send({
actionIds: ['action000003'],
})
.expect(200);
expect(apiResponse.items).to.eql([]);
});
it('should return a 404 when action_ids are empty', async () => {
await supertest
.post(`/api/fleet/agents`)
.set('kbn-xsrf', 'xx')
.send({
actionIds: [],
})
.expect(404);
});
it('should return a 404 when action_ids do not exist', async () => {
await supertest
.post(`/api/fleet/agents`)
.set('kbn-xsrf', 'xx')
.send({
actionIds: ['non_existent_action'],
})
.expect(404);
});
});
});
}

View file

@ -20,5 +20,6 @@ export default function loadTests({ loadTestFile }) {
loadTestFile(require.resolve('./available_versions'));
loadTestFile(require.resolve('./request_diagnostics'));
loadTestFile(require.resolve('./uploads'));
loadTestFile(require.resolve('./get_agents_by_actions'));
});
}