mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[8.8] [Fleet] Modify query for Agents bulk actions to exclude unselectable agents (#157386) (#157742)
# Backport This will backport the following commits from `main` to `8.8`: - [[Fleet] Modify query for Agents bulk actions to exclude unselectable agents (#157386)](https://github.com/elastic/kibana/pull/157386) <!--- Backport version: 8.9.7 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Cristina Amico","email":"criamico@users.noreply.github.com"},"sourceCommit":{"committedDate":"2023-05-15T14:45:02Z","message":"[Fleet] Modify query for Agents bulk actions to exclude unselectable agents (#157386)\n\nCloses https://github.com/elastic/kibana/issues/152180\r\n\r\n## Summary\r\n\r\nModify the query for Agents bulk actions to exclude those agents who are\r\nunselectable.\r\n\r\n- In BulkActions component, fetch the agent policies with `is_managed =\r\ntrue`\r\n- Then find the agents that have those policies and are not unenrolled\r\n- Exclude the previous agent ids from the bulk selection query\r\n\r\nDoing these two queries first ensures that we have the correct count of\r\nagents to exclude so we can subtract it to the total.\r\n\r\n### Testing\r\n\r\n- Update an agent policy to be \"managed\". I did it with the fleet server\r\npolicy to simulate the behaviour on cloud:\r\n```\r\nPUT kbn:/api/fleet/agent_policies/agent-policy-id\r\n{\r\n \"name\": \"Managed agent policy\",\r\n \"description\": \"\",\r\n \"namespace\": \"default\",\r\n \"monitoring_enabled\": [\r\n \"logs\",\r\n \"metrics\"\r\n ],\r\n \"is_managed\": true\r\n}\r\n```\r\n\r\n- Enroll some agents, it's better if they are more than 20 so you can\r\nhave them on different pages. I used\r\n[Horde](https://github.com/elastic/horde) for this step.\r\n- Select all of them, then click on `Select everything on all pages`\r\n- Click on Actions and check that the selected number of agents doesn't\r\ninclude your managed ones\r\n- Select an action and check that the managed actions are not included\r\nin the action\r\n\r\n\r\ncb4cefd6
-ca0e-4a72-93c8-19223a491230\r\n\r\n\r\n### Checklist\r\n- [ ] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>","sha":"14f5c470b942e28f38236f0e6134e918ece45fea","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Fleet","backport:prev-minor","v8.9.0"],"number":157386,"url":"https://github.com/elastic/kibana/pull/157386","mergeCommit":{"message":"[Fleet] Modify query for Agents bulk actions to exclude unselectable agents (#157386)\n\nCloses https://github.com/elastic/kibana/issues/152180\r\n\r\n## Summary\r\n\r\nModify the query for Agents bulk actions to exclude those agents who are\r\nunselectable.\r\n\r\n- In BulkActions component, fetch the agent policies with `is_managed =\r\ntrue`\r\n- Then find the agents that have those policies and are not unenrolled\r\n- Exclude the previous agent ids from the bulk selection query\r\n\r\nDoing these two queries first ensures that we have the correct count of\r\nagents to exclude so we can subtract it to the total.\r\n\r\n### Testing\r\n\r\n- Update an agent policy to be \"managed\". I did it with the fleet server\r\npolicy to simulate the behaviour on cloud:\r\n```\r\nPUT kbn:/api/fleet/agent_policies/agent-policy-id\r\n{\r\n \"name\": \"Managed agent policy\",\r\n \"description\": \"\",\r\n \"namespace\": \"default\",\r\n \"monitoring_enabled\": [\r\n \"logs\",\r\n \"metrics\"\r\n ],\r\n \"is_managed\": true\r\n}\r\n```\r\n\r\n- Enroll some agents, it's better if they are more than 20 so you can\r\nhave them on different pages. I used\r\n[Horde](https://github.com/elastic/horde) for this step.\r\n- Select all of them, then click on `Select everything on all pages`\r\n- Click on Actions and check that the selected number of agents doesn't\r\ninclude your managed ones\r\n- Select an action and check that the managed actions are not included\r\nin the action\r\n\r\n\r\ncb4cefd6
-ca0e-4a72-93c8-19223a491230\r\n\r\n\r\n### Checklist\r\n- [ ] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>","sha":"14f5c470b942e28f38236f0e6134e918ece45fea"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/157386","number":157386,"mergeCommit":{"message":"[Fleet] Modify query for Agents bulk actions to exclude unselectable agents (#157386)\n\nCloses https://github.com/elastic/kibana/issues/152180\r\n\r\n## Summary\r\n\r\nModify the query for Agents bulk actions to exclude those agents who are\r\nunselectable.\r\n\r\n- In BulkActions component, fetch the agent policies with `is_managed =\r\ntrue`\r\n- Then find the agents that have those policies and are not unenrolled\r\n- Exclude the previous agent ids from the bulk selection query\r\n\r\nDoing these two queries first ensures that we have the correct count of\r\nagents to exclude so we can subtract it to the total.\r\n\r\n### Testing\r\n\r\n- Update an agent policy to be \"managed\". I did it with the fleet server\r\npolicy to simulate the behaviour on cloud:\r\n```\r\nPUT kbn:/api/fleet/agent_policies/agent-policy-id\r\n{\r\n \"name\": \"Managed agent policy\",\r\n \"description\": \"\",\r\n \"namespace\": \"default\",\r\n \"monitoring_enabled\": [\r\n \"logs\",\r\n \"metrics\"\r\n ],\r\n \"is_managed\": true\r\n}\r\n```\r\n\r\n- Enroll some agents, it's better if they are more than 20 so you can\r\nhave them on different pages. I used\r\n[Horde](https://github.com/elastic/horde) for this step.\r\n- Select all of them, then click on `Select everything on all pages`\r\n- Click on Actions and check that the selected number of agents doesn't\r\ninclude your managed ones\r\n- Select an action and check that the managed actions are not included\r\nin the action\r\n\r\n\r\ncb4cefd6
-ca0e-4a72-93c8-19223a491230\r\n\r\n\r\n### Checklist\r\n- [ ] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>","sha":"14f5c470b942e28f38236f0e6134e918ece45fea"}}]}] BACKPORT--> Co-authored-by: Cristina Amico <criamico@users.noreply.github.com>
This commit is contained in:
parent
24c9d1bf4f
commit
76b43a71df
3 changed files with 335 additions and 13 deletions
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
import { fireEvent, act } from '@testing-library/react';
|
||||
|
||||
import type { Agent } from '../../../../types';
|
||||
|
||||
import { createFleetTestRendererMock } from '../../../../../../mock';
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
import { sendGetAgents, sendGetAgentPolicies } from '../../../../hooks';
|
||||
|
||||
import { AgentBulkActions } from './bulk_actions';
|
||||
|
||||
jest.mock('../../../../../../services/experimental_features');
|
||||
const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService);
|
||||
|
||||
jest.mock('../../../../hooks', () => ({
|
||||
...jest.requireActual('../../../../hooks'),
|
||||
sendGetAgents: jest.fn(),
|
||||
sendGetAgentPolicies: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSendGetAgents = sendGetAgents as jest.Mock;
|
||||
const mockedSendGetAgentPolicies = sendGetAgentPolicies as jest.Mock;
|
||||
|
||||
describe('AgentBulkActions', () => {
|
||||
beforeAll(() => {
|
||||
mockedExperimentalFeaturesService.get.mockReturnValue({
|
||||
diagnosticFileUploadEnabled: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
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 selectedAgents: Agent[] = [{ id: 'agent1' }, { id: 'agent2' }] as Agent[];
|
||||
|
||||
const props = {
|
||||
totalAgents: 10,
|
||||
totalInactiveAgents: 10,
|
||||
selectionMode: 'manual',
|
||||
currentQuery: '',
|
||||
selectedAgents,
|
||||
visibleAgents: [],
|
||||
refreshAgents: () => undefined,
|
||||
allTags: [],
|
||||
agentPolicies: [],
|
||||
};
|
||||
const results = render(props);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should show available actions for 2 selected agents if they are active', async () => {
|
||||
const selectedAgents: Agent[] = [
|
||||
{ id: 'agent1', tags: ['oldTag'], active: true },
|
||||
{ id: 'agent2', active: true },
|
||||
] as Agent[];
|
||||
|
||||
const props = {
|
||||
totalAgents: 10,
|
||||
totalInactiveAgents: 0,
|
||||
selectionMode: 'manual',
|
||||
currentQuery: '',
|
||||
selectedAgents,
|
||||
visibleAgents: [],
|
||||
refreshAgents: () => undefined,
|
||||
allTags: [],
|
||||
agentPolicies: [],
|
||||
};
|
||||
const results = render(props);
|
||||
|
||||
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 2 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Upgrade 2 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should add actions if mockedExperimentalFeaturesService is enabled', async () => {
|
||||
mockedExperimentalFeaturesService.get.mockReturnValue({
|
||||
diagnosticFileUploadEnabled: true,
|
||||
} as any);
|
||||
|
||||
const selectedAgents: Agent[] = [
|
||||
{ id: 'agent1', tags: ['oldTag'], active: true },
|
||||
{ id: 'agent2', active: true },
|
||||
] as Agent[];
|
||||
|
||||
const props = {
|
||||
totalAgents: 10,
|
||||
totalInactiveAgents: 0,
|
||||
selectionMode: 'manual',
|
||||
currentQuery: '',
|
||||
selectedAgents,
|
||||
visibleAgents: [],
|
||||
refreshAgents: () => undefined,
|
||||
allTags: [],
|
||||
agentPolicies: [],
|
||||
unselectableAgents: [],
|
||||
};
|
||||
const results = render(props);
|
||||
|
||||
const bulkActionsButton = results.getByTestId('agentBulkActionsButton');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(bulkActionsButton);
|
||||
});
|
||||
|
||||
expect(
|
||||
results.getByText('Request diagnostics for 2 agents').closest('button')!
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When in query mode', () => {
|
||||
it('should show correct actions for the active agents', async () => {
|
||||
mockedSendGetAgentPolicies.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
name: 'Managed agent policy',
|
||||
namespace: 'default',
|
||||
description: '',
|
||||
monitoring_enabled: ['logs', 'metrics'],
|
||||
is_managed: true,
|
||||
id: 'test-managed-policy',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedSendGetAgents.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
totalInactive: 0,
|
||||
},
|
||||
});
|
||||
const selectedAgents: Agent[] = [];
|
||||
|
||||
const props = {
|
||||
totalAgents: 10,
|
||||
totalInactiveAgents: 0,
|
||||
selectionMode: 'query',
|
||||
currentQuery: '(Base query)',
|
||||
selectedAgents,
|
||||
visibleAgents: [],
|
||||
refreshAgents: () => undefined,
|
||||
allTags: [],
|
||||
agentPolicies: [],
|
||||
};
|
||||
const results = render(props);
|
||||
|
||||
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 10 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Upgrade 10 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Schedule upgrade for 10 agents').closest('button')!).toBeDisabled();
|
||||
expect(
|
||||
results.getByText('Request diagnostics for 10 agents').closest('button')!
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should show correct actions for the active agents and exclude the managed agents from the count', async () => {
|
||||
const selectedAgents: Agent[] = [];
|
||||
mockedSendGetAgentPolicies.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
name: 'Managed agent policy',
|
||||
namespace: 'default',
|
||||
description: '',
|
||||
monitoring_enabled: ['logs', 'metrics'],
|
||||
is_managed: true,
|
||||
id: 'test-managed-policy',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
mockedSendGetAgents.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: ['agentId1', 'agentId2'],
|
||||
total: 2,
|
||||
totalInactive: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const props = {
|
||||
totalAgents: 10,
|
||||
totalInactiveAgents: 0,
|
||||
selectionMode: 'query',
|
||||
currentQuery: '(Base query)',
|
||||
selectedAgents,
|
||||
visibleAgents: [],
|
||||
refreshAgents: () => undefined,
|
||||
allTags: [],
|
||||
agentPolicies: [],
|
||||
};
|
||||
const results = render(props);
|
||||
|
||||
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 8 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Upgrade 8 agents').closest('button')!).toBeEnabled();
|
||||
expect(results.getByText('Schedule upgrade for 8 agents').closest('button')!).toBeDisabled();
|
||||
expect(
|
||||
results.getByText('Request diagnostics for 8 agents').closest('button')!
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -23,8 +23,13 @@ import {
|
|||
AgentUnenrollAgentModal,
|
||||
AgentUpgradeAgentModal,
|
||||
} from '../../components';
|
||||
import { useLicense } from '../../../../hooks';
|
||||
import { LICENSE_FOR_SCHEDULE_UPGRADE } from '../../../../../../../common/constants';
|
||||
import { useLicense, sendGetAgents, sendGetAgentPolicies } from '../../../../hooks';
|
||||
import {
|
||||
LICENSE_FOR_SCHEDULE_UPGRADE,
|
||||
AGENTS_PREFIX,
|
||||
SO_SEARCH_LIMIT,
|
||||
AGENT_POLICY_SAVED_OBJECT_TYPE,
|
||||
} from '../../../../../../../common/constants';
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
import { getCommonTags } from '../utils';
|
||||
|
@ -72,6 +77,64 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
|
|||
const [isTagAddVisible, setIsTagAddVisible] = useState<boolean>(false);
|
||||
const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [managedAgents, setManagedAgents] = useState<string[]>([]);
|
||||
|
||||
// get all the managed policies
|
||||
const fetchManagedAgents = useCallback(async () => {
|
||||
if (selectionMode === 'query') {
|
||||
const managedPoliciesKuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.is_managed:true`;
|
||||
|
||||
const agentPoliciesResponse = await sendGetAgentPolicies({
|
||||
kuery: managedPoliciesKuery,
|
||||
perPage: SO_SEARCH_LIMIT,
|
||||
full: false,
|
||||
});
|
||||
|
||||
if (agentPoliciesResponse.error) {
|
||||
throw new Error(agentPoliciesResponse.error.message);
|
||||
}
|
||||
|
||||
const managedPolicies = agentPoliciesResponse.data?.items ?? [];
|
||||
|
||||
// find all the agents that have those policies and are not unenrolled
|
||||
const policiesKuery = managedPolicies
|
||||
.map((policy) => `policy_id:"${policy.id}"`)
|
||||
.join(' or ');
|
||||
const kuery = `NOT (status:unenrolled) and ${policiesKuery}`;
|
||||
const response = await sendGetAgents({
|
||||
kuery,
|
||||
perPage: SO_SEARCH_LIMIT,
|
||||
showInactive: true,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.data?.items ?? [];
|
||||
}
|
||||
return [];
|
||||
}, [selectionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDataAsync() {
|
||||
const allManagedAgents = await fetchManagedAgents();
|
||||
setManagedAgents(allManagedAgents?.map((agent) => agent.id));
|
||||
}
|
||||
fetchDataAsync();
|
||||
}, [fetchManagedAgents]);
|
||||
|
||||
// update the query removing the "managed" agents
|
||||
const selectionQuery = useMemo(() => {
|
||||
if (managedAgents) {
|
||||
const excludedKuery = `${AGENTS_PREFIX}.agent.id : (${managedAgents
|
||||
.map((id) => `"${id}"`)
|
||||
.join(' or ')})`;
|
||||
return `${currentQuery} AND NOT (${excludedKuery})`;
|
||||
} else {
|
||||
return currentQuery;
|
||||
}
|
||||
}, [currentQuery, managedAgents]);
|
||||
|
||||
// Check if user is working with only inactive agents
|
||||
const atLeastOneActiveAgentSelected =
|
||||
|
@ -79,8 +142,12 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
|
|||
? !!selectedAgents.find((agent) => agent.active)
|
||||
: totalAgents > totalInactiveAgents;
|
||||
const totalActiveAgents = totalAgents - totalInactiveAgents;
|
||||
const agentCount = selectionMode === 'manual' ? selectedAgents.length : totalActiveAgents;
|
||||
const agents = selectionMode === 'manual' ? selectedAgents : currentQuery;
|
||||
|
||||
const agentCount =
|
||||
selectionMode === 'manual' ? selectedAgents.length : totalActiveAgents - managedAgents?.length;
|
||||
|
||||
const agents = selectionMode === 'manual' ? selectedAgents : selectionQuery;
|
||||
|
||||
const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
|
||||
const { diagnosticFileUploadEnabled } = ExperimentalFeaturesService.get();
|
||||
|
||||
|
|
|
@ -162,6 +162,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
return selectedStatus.some((status) => status === 'inactive' || status === 'unenrolled');
|
||||
}, [selectedStatus]);
|
||||
|
||||
// filters kuery
|
||||
const kuery = useMemo(() => {
|
||||
return getKuery({
|
||||
search,
|
||||
|
@ -344,14 +345,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
}, {} as { [k: string]: AgentPolicy });
|
||||
}, [agentPolicies]);
|
||||
|
||||
const isAgentSelectable = (agent: Agent) => {
|
||||
if (!agent.active) return false;
|
||||
if (!agent.policy_id) return true;
|
||||
|
||||
const agentPolicy = agentPoliciesIndexedById[agent.policy_id];
|
||||
const isHosted = agentPolicy?.is_managed === true;
|
||||
return !isHosted;
|
||||
};
|
||||
const isAgentSelectable = useCallback(
|
||||
(agent: Agent) => {
|
||||
if (!agent.active) return false;
|
||||
if (!agent.policy_id) return true;
|
||||
const agentPolicy = agentPoliciesIndexedById[agent.policy_id];
|
||||
const isHosted = agentPolicy?.is_managed === true;
|
||||
return !isHosted;
|
||||
},
|
||||
[agentPoliciesIndexedById]
|
||||
);
|
||||
|
||||
const onSelectionChange = (newAgents: Agent[]) => {
|
||||
setSelectedAgents(newAgents);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue