Skip to content

Commit

Permalink
[Fleet] Actions menu for Fleet agents has correct agent count and bul…
Browse files Browse the repository at this point in the history
…k actions are correctly processed for all selected agents (#177035)

## Summary

Closes #167269
Closes #171914
Closes #167241

Changes:
- Agent count in `Actions` menu includes all selectable agents across
all pages, including agents with inactive status.
- `Actions` menu items are enabled if at least one agent is selected, no
matter its status.
- Fix bug where managed agents could be accidentally selected in query
mode when changing filtering.
- Changing agent status or agent policy filtering while in bulk
selection mode sets selection mode back to manual. This is to avoid a
bad state where bulk selection mode is still enabled and more
(unselected) agents are listed.
- Fix the bulk selection query when some agents are excluded (managed
agent policies).
- Agent upgrades in bulk selection mode includes all selected agents,
including agents with inactive status.
- Agent policy reassign in bulk selection mode includes all selected
agents, including agents with inactive status.

### Steps for testing

Cf. screen recording below.

#### Setup

1. Enroll a Fleet Server with a managed agent policy (e.g. by making
sure the preconfigured agent policy for Fleet Server has `is_managed:
true`).
2. Create agent policy "Agent policy 1". In the agent policy settings,
set the inactivity timeout to a low value, e.g. 10 seconds.
3. Enroll 7 agents on agent policy "Agent policy 1" (e.g. with Horde).
Once they are enrolled, kill the agents: they will become inactive in
Fleet.
4. Create agent policy "Agent policy 2". Enroll 7 agents on it.

#### UI

1. In the Agents table, change the filtering to include inactive status.
You should see 15 agents: 7 Healthy, 7 Inactive, 1 (Healthy) Fleet
Server. The Fleet Server should not be manually selectable (managed
agent policy).
2. Select one inactive agent. In the Actions menu, the agent count
should be 1 and actions should be available. NB: the action to schedule
an upgrade requires Platinum license, so it may be disabled.
3. Manually select all agents: above the table, it should say `Showing
15 agents | 14 agents selected`. In the Actions menu, the agent count
should be 14 and actions should be available.
4. Change the number of rows per page to 5; select all agents on the
first page and then click `Select everything on all pages` (bulk
selection): above the table, it should say `Showing 15 agents | All
agents selected`. In the Actions menu, the agent count should be 14 and
actions should be available.
5. Go to page 2, where 2 Healthy and 3 Inactive agents should be listed.
Bulk select all agents again. Change the filtering to exclude inactive
status: there should be 3 remaining agents (2 Healthy and Fleet Server)
and Fleet Server should not be selected. Above the table, it should say
`Showing 8 agents | 2 agents selected`. In the Actions menu, the agent
count should be 2 and actions should be available.
6. Change the filtering to include inactive status again: you should see
2 selected Healthy agents and 3 unselected Inactive agents. Above the
table, it should say `Showing 15 agents | 2 agents selected`. In the
Actions menu, the agent count should be 2 and actions should be
available.

#### Bulk agent actions

1. Bulk select all 14 agents (7 Healthy, 7 Inactive) and, in the Actions
menu, click "Upgrade 14 agents". The upgrade should be kicked off for
all agents. In the Agents Activity flyout, you should be able to follow
the upgrades for the 14 agents.
2. Create a new agent policy "Agent policy 3". Bulk select all 14 agents
(7 Healthy, 7 Inactive). In the Actions menu, click "Assign to new
policy" and select "Agent policy 3". All 14 agents should be reassigned
to the new policy (NB: Inactive agents will get Offline status).
3. Bulk select all 14 agents (7 Healthy, 7 Inactive) and, in the Actions
menu, click "Unenroll 14 agents". All agents should be unrenrolled.

### Screen recording

The following recording shows the main UI fixes:
- Bulk selection with inactive agents gets correct agent count
- Changing the filtering in bulk selection mode changes to manual mode
- Managed policy agent cannot be selected


https://github.com/elastic/kibana/assets/23701614/e52b225c-2951-4729-8903-551fcc793068

### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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 <[email protected]>
  • Loading branch information
jillguyonnet and kibanamachine authored Mar 12, 2024
1 parent 87df7ab commit 7e8ee65
Show file tree
Hide file tree
Showing 16 changed files with 133 additions and 216 deletions.
3 changes: 3 additions & 0 deletions x-pack/plugins/fleet/common/types/rest_spec/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface PostBulkAgentUpgradeRequest {
rollout_duration_seconds?: number;
start_time?: string;
force?: boolean;
includeInactive?: boolean;
};
}

Expand Down Expand Up @@ -147,6 +148,7 @@ export interface PostBulkAgentReassignRequest {
policy_id: string;
agents: string[] | string;
batchSize?: number;
includeInactive?: boolean;
};
}

Expand Down Expand Up @@ -185,6 +187,7 @@ export interface PostBulkUpdateAgentTagsRequest {
agents: string[] | string;
tagsToAdd?: string[];
tagsToRemove?: string[];
includeInactive?: boolean;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,29 @@ import { fireEvent, act } from '@testing-library/react';
import type { Agent } from '../../../../types';

import { createFleetTestRendererMock } from '../../../../../../mock';
import type { LicenseService } from '../../../../services';
import { ExperimentalFeaturesService } from '../../../../services';
import { AgentReassignAgentPolicyModal } from '../../components/agent_reassign_policy_modal';

import { useLicense } from '../../../../../../hooks/use_license';

import { AgentBulkActions } from './bulk_actions';

jest.mock('../../../../../../services/experimental_features');
const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService);

jest.mock('../../../../hooks', () => ({
...jest.requireActual('../../../../hooks'),
}));
jest.mock('../../../../../../hooks/use_license');
const mockedUseLicence = useLicense as jest.MockedFunction<typeof useLicense>;

jest.mock('../../components/agent_reassign_policy_modal');

const defaultProps = {
shownAgents: 10,
inactiveShownAgents: 0,
nAgentsInTable: 10,
totalManagedAgentIds: [],
inactiveManagedAgentIds: [],
selectionMode: 'manual',
currentQuery: '',
selectedAgents: [],
visibleAgents: [],
agentsOnCurrentPage: [],
refreshAgents: () => undefined,
allTags: [],
agentPolicies: [],
Expand All @@ -43,50 +43,28 @@ const defaultProps = {
describe('AgentBulkActions', () => {
beforeAll(() => {
mockedExperimentalFeaturesService.get.mockReturnValue({
diagnosticFileUploadEnabled: false,
diagnosticFileUploadEnabled: true,
} as any);
});

beforeEach(() => {
mockedUseLicence.mockReturnValue({
hasAtLeast: () => false,
} as unknown as LicenseService);
jest.mocked(AgentReassignAgentPolicyModal).mockReset();
jest.mocked(AgentReassignAgentPolicyModal).mockReturnValue(null);
});

function render(props: any) {
const renderer = createFleetTestRendererMock();

return renderer.render(<AgentBulkActions {...props} />);
}

describe('When in manual mode', () => {
it('should show only disabled actions if no agents are active', async () => {
const results = render({
...defaultProps,
inactiveShownAgents: 10,
selectedAgents: [{ id: 'agent1' }, { id: 'agent2' }] as Agent[],
});

const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
await act(async () => {
fireEvent.click(bulkActionsButton);
});

expect(results.getByText('Add / remove tags').closest('button')!).toBeDisabled();
expect(results.getByText('Assign to new policy').closest('button')!).toBeDisabled();
expect(results.getByText('Unenroll 2 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Upgrade 2 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeDisabled();
expect(results.queryByText('Request diagnostics for 2 agents')).toBeNull();
expect(results.getByText('Restart upgrade 2 agents').closest('button')!).toBeDisabled();
});

it('should show available actions for 2 selected agents if they are active', async () => {
describe('When in manual selection mode', () => {
it('should show the available actions for the selected agents', async () => {
const results = render({
...defaultProps,
selectedAgents: [
{ id: 'agent1', tags: ['oldTag'], active: true },
{ id: 'agent2', active: true },
] as Agent[],
selectedAgents: [{ id: 'agent1', tags: ['oldTag'] }, { id: 'agent2' }] as Agent[],
});

const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
Expand All @@ -100,38 +78,32 @@ describe('AgentBulkActions', () => {
expect(results.getByText('Upgrade 2 agents').closest('button')!).toBeEnabled();
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Restart upgrade 2 agents').closest('button')!).toBeEnabled();
expect(
results.getByText('Request diagnostics for 2 agents').closest('button')!
).toBeEnabled();
});

it('should add actions if mockedExperimentalFeaturesService is enabled', async () => {
mockedExperimentalFeaturesService.get.mockReturnValue({
diagnosticFileUploadEnabled: true,
} as any);
it('should allow scheduled upgrades if the license allows it', async () => {
mockedUseLicence.mockReturnValue({
hasAtLeast: () => true,
} as unknown as LicenseService);

const results = render({
...defaultProps,
selectedAgents: [
{ id: 'agent1', tags: ['oldTag'], active: true },
{ id: 'agent2', active: true },
] as Agent[],
selectedAgents: [{ id: 'agent1', tags: ['oldTag'] }, { id: 'agent2' }] as Agent[],
});

const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
await act(async () => {
fireEvent.click(bulkActionsButton);
});

expect(
results.getByText('Request diagnostics for 2 agents').closest('button')!
).toBeEnabled();
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeEnabled();
});
});

describe('When in query mode', () => {
mockedExperimentalFeaturesService.get.mockReturnValue({
diagnosticFileUploadEnabled: true,
} as any);

it('should show correct actions for active agents when no managed policies exist', async () => {
describe('When in query selection mode', () => {
it('should show the available actions for all agents when no managed agents are listed', async () => {
const results = render({
...defaultProps,
selectionMode: 'query',
Expand All @@ -153,7 +125,7 @@ describe('AgentBulkActions', () => {
expect(results.getByText('Restart upgrade 10 agents').closest('button')!).toBeEnabled();
});

it('should show correct actions for the active agents and exclude the managed agents from the count', async () => {
it('should show the available actions for all agents except managed agents', async () => {
const results = render({
...defaultProps,
totalManagedAgentIds: ['agentId1', 'agentId2'],
Expand All @@ -176,49 +148,7 @@ describe('AgentBulkActions', () => {
expect(results.getByText('Restart upgrade 8 agents').closest('button')!).toBeEnabled();
});

it('should show correct actions also when there are inactive managed agents', async () => {
const results = render({
...defaultProps,
inactiveManagedAgentIds: ['agentId1', 'agentId2'],
totalManagedAgentIds: ['agentId1', 'agentId2', 'agentId3'],
selectionMode: 'query',
});

const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
await act(async () => {
fireEvent.click(bulkActionsButton);
});

expect(results.getByText('Add / remove tags').closest('button')!).toBeEnabled();
expect(results.getByText('Assign to new policy').closest('button')!).toBeEnabled();
expect(results.getByText('Unenroll 9 agents').closest('button')!).toBeEnabled();
expect(results.getByText('Upgrade 9 agents').closest('button')!).toBeEnabled();
expect(results.getByText('Schedule upgrade for 9 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Restart upgrade 9 agents').closest('button')!).toBeEnabled();
});

it('should show disabled actions when only inactive agents are selected', async () => {
const results = render({
...defaultProps,
inactiveShownAgents: 10,
selectedAgents: [{ id: 'agent1' }, { id: 'agent2' }] as Agent[],
selectionMode: 'query',
});

const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
await act(async () => {
fireEvent.click(bulkActionsButton);
});

expect(results.getByText('Add / remove tags').closest('button')!).toBeDisabled();
expect(results.getByText('Assign to new policy').closest('button')!).toBeDisabled();
expect(results.getByText('Unenroll 0 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Upgrade 0 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Schedule upgrade for 0 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Restart upgrade 0 agents').closest('button')!).toBeDisabled();
});

it('should generate a correct kuery to select agents', async () => {
it('should generate a correct kuery to select agents when no managed agents are listed', async () => {
const results = render({
...defaultProps,
selectionMode: 'query',
Expand All @@ -243,7 +173,7 @@ describe('AgentBulkActions', () => {
);
});

it('should generate a correct kuery to select agents with managed agents too', async () => {
it('should generate a correct kuery that excludes managed agents', async () => {
const results = render({
...defaultProps,
totalManagedAgentIds: ['agentId1', 'agentId2'],
Expand All @@ -263,7 +193,7 @@ describe('AgentBulkActions', () => {

expect(jest.mocked(AgentReassignAgentPolicyModal)).toHaveBeenCalledWith(
expect.objectContaining({
agents: '(Base query) AND NOT (fleet-agents.agent.id : ("agentId1" or "agentId2"))',
agents: '((Base query)) AND NOT (fleet-agents.agent.id : ("agentId1" or "agentId2"))',
}),
expect.anything()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,24 @@ import type { SelectionMode } from './types';
import { TagsAddRemove } from './tags_add_remove';

export interface Props {
shownAgents: number;
inactiveShownAgents: number;
nAgentsInTable: number;
totalManagedAgentIds: string[];
inactiveManagedAgentIds: string[];
selectionMode: SelectionMode;
currentQuery: string;
selectedAgents: Agent[];
visibleAgents: Agent[];
agentsOnCurrentPage: Agent[];
refreshAgents: (args?: { refreshTags?: boolean }) => void;
allTags: string[];
agentPolicies: AgentPolicy[];
}

export const AgentBulkActions: React.FunctionComponent<Props> = ({
shownAgents,
inactiveShownAgents,
nAgentsInTable,
totalManagedAgentIds,
inactiveManagedAgentIds,
selectionMode,
currentQuery,
selectedAgents,
visibleAgents,
agentsOnCurrentPage,
refreshAgents,
allTags,
agentPolicies,
Expand Down Expand Up @@ -87,26 +83,17 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
const excludedKuery = `${AGENTS_PREFIX}.agent.id : (${totalManagedAgentIds
.map((id) => `"${id}"`)
.join(' or ')})`;
return `${currentQuery} AND NOT (${excludedKuery})`;
return `(${currentQuery}) AND NOT (${excludedKuery})`;
} else {
return currentQuery;
}
}, [currentQuery, totalManagedAgentIds]);

const totalActiveAgents = shownAgents - inactiveShownAgents;

// exclude inactive agents from the count
const agents = selectionMode === 'manual' ? selectedAgents : selectionQuery;
const agentCount =
selectionMode === 'manual'
? selectedAgents.length
: totalActiveAgents - (totalManagedAgentIds?.length - inactiveManagedAgentIds?.length);

// Check if user is working with only inactive agents
const atLeastOneActiveAgentSelected =
selectionMode === 'manual'
? !!selectedAgents.find((agent) => agent.active)
: shownAgents > inactiveShownAgents;
const agents = selectionMode === 'manual' ? selectedAgents : selectionQuery;
: nAgentsInTable - totalManagedAgentIds?.length;

const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
const { diagnosticFileUploadEnabled } = ExperimentalFeaturesService.get();
Expand All @@ -121,7 +108,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="tag" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: (event: any) => {
setTagsPopoverButton((event.target as Element).closest('button')!);
setIsTagAddVisible(!isTagAddVisible);
Expand All @@ -136,7 +122,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="pencil" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setIsReassignFlyoutOpen(true);
Expand All @@ -154,7 +139,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="trash" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setIsUnenrollModalOpen(true);
Expand All @@ -172,7 +156,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="refresh" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: false, isUpdating: false });
Expand All @@ -190,7 +173,7 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="timeRefresh" size="m" />,
disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade,
disabled: !isLicenceAllowingScheduleUpgrade,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: true, isUpdating: false });
Expand All @@ -210,7 +193,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="refresh" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: false, isUpdating: true });
Expand All @@ -230,7 +212,6 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
/>
),
icon: <EuiIcon type="download" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setIsRequestDiagnosticsModalOpen(true);
Expand All @@ -246,8 +227,8 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
];

const getSelectedTagsFromAgents = useMemo(
() => getCommonTags(agents, visibleAgents ?? [], agentPolicies),
[agents, visibleAgents, agentPolicies]
() => getCommonTags(agents, agentsOnCurrentPage ?? [], agentPolicies),
[agents, agentsOnCurrentPage, agentPolicies]
);

return (
Expand Down
Loading

0 comments on commit 7e8ee65

Please sign in to comment.