mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Fleet] Add "Label" column + filter to Agent list table (#131070)
* Add basic labels implementation for Agent list table * Lay plumbing for filtering based on tags Ref #130717 * Finalize wiring up tags to API * Fix render error when tags empty * Add test for tags component Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
a8017dffd4
commit
bbe80fe26e
5 changed files with 188 additions and 6 deletions
|
@ -104,6 +104,7 @@ interface AgentBase {
|
|||
last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating';
|
||||
user_provided_metadata: AgentMetadata;
|
||||
local_metadata: AgentMetadata;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Agent extends AgentBase {
|
||||
|
@ -216,6 +217,10 @@ export interface FleetServerAgent {
|
|||
* The last acknowledged action sequence number for the Elastic Agent
|
||||
*/
|
||||
action_seq_no?: number;
|
||||
/**
|
||||
* A list of tags used for organizing/filtering agents
|
||||
*/
|
||||
tags?: string[];
|
||||
}
|
||||
/**
|
||||
* An Elastic Agent metadata
|
||||
|
|
|
@ -70,6 +70,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
onSelectedStatusChange: (selectedStatus: string[]) => void;
|
||||
showUpgradeable: boolean;
|
||||
onShowUpgradeableChange: (showUpgradeable: boolean) => void;
|
||||
tags: string[];
|
||||
selectedTags: string[];
|
||||
onSelectedTagsChange: (selectedTags: string[]) => void;
|
||||
totalAgents: number;
|
||||
totalInactiveAgents: number;
|
||||
selectionMode: SelectionMode;
|
||||
|
@ -87,6 +90,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
onSelectedStatusChange,
|
||||
showUpgradeable,
|
||||
onShowUpgradeableChange,
|
||||
tags,
|
||||
selectedTags,
|
||||
onSelectedTagsChange,
|
||||
totalAgents,
|
||||
totalInactiveAgents,
|
||||
selectionMode,
|
||||
|
@ -100,7 +106,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState<boolean>(false);
|
||||
|
||||
// Status for filtering
|
||||
const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState<boolean>(false);
|
||||
const [isStatusFilterOpen, setIsStatusFilterOpen] = useState<boolean>(false);
|
||||
|
||||
const [isTagsFilterOpen, setIsTagsFilterOpen] = useState<boolean>(false);
|
||||
|
||||
// Add a agent policy id to current search
|
||||
const addAgentPolicyFilter = (policyId: string) => {
|
||||
|
@ -114,6 +122,14 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
);
|
||||
};
|
||||
|
||||
const addTagsFilter = (tag: string) => {
|
||||
onSelectedTagsChange([...selectedTags, tag]);
|
||||
};
|
||||
|
||||
const removeTagsFilter = (tag: string) => {
|
||||
onSelectedTagsChange(selectedTags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEnrollmentFlyoutOpen ? (
|
||||
|
@ -146,7 +162,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
button={
|
||||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
onClick={() => setIsStatutsFilterOpen(!isStatusFilterOpen)}
|
||||
onClick={() => setIsStatusFilterOpen(!isStatusFilterOpen)}
|
||||
isSelected={isStatusFilterOpen}
|
||||
hasActiveFilters={selectedStatus.length > 0}
|
||||
disabled={agentPolicies.length === 0}
|
||||
|
@ -159,7 +175,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
</EuiFilterButton>
|
||||
}
|
||||
isOpen={isStatusFilterOpen}
|
||||
closePopover={() => setIsStatutsFilterOpen(false)}
|
||||
closePopover={() => setIsStatusFilterOpen(false)}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<div className="euiFilterSelect__items">
|
||||
|
@ -180,6 +196,46 @@ export const SearchAndFilterBar: React.FunctionComponent<{
|
|||
))}
|
||||
</div>
|
||||
</EuiPopover>
|
||||
<EuiPopover
|
||||
ownFocus
|
||||
button={
|
||||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
onClick={() => setIsTagsFilterOpen(!isTagsFilterOpen)}
|
||||
isSelected={isTagsFilterOpen}
|
||||
hasActiveFilters={selectedTags.length > 0}
|
||||
numFilters={selectedTags.length}
|
||||
disabled={tags.length === 0}
|
||||
data-test-subj="agentList.tagsFilter"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.agentList.tagsFilterText"
|
||||
defaultMessage="Tags"
|
||||
/>
|
||||
</EuiFilterButton>
|
||||
}
|
||||
isOpen={isTagsFilterOpen}
|
||||
closePopover={() => setIsTagsFilterOpen(false)}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<div className="euiFilterSelect__items">
|
||||
{tags.map((tag, index) => (
|
||||
<EuiFilterSelectItem
|
||||
checked={selectedTags.includes(tag) ? 'on' : undefined}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
removeTagsFilter(tag);
|
||||
} else {
|
||||
addTagsFilter(tag);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</EuiFilterSelectItem>
|
||||
))}
|
||||
</div>
|
||||
</EuiPopover>
|
||||
<EuiPopover
|
||||
ownFocus
|
||||
button={
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import { Tags } from './tags';
|
||||
|
||||
describe('Tags', () => {
|
||||
describe('when list is short', () => {
|
||||
it('renders a comma-separated list of tags', () => {
|
||||
const tags = ['tag1', 'tag2'];
|
||||
render(<Tags tags={tags} />);
|
||||
|
||||
expect(screen.getByTestId('agentTags')).toHaveTextContent('tag1, tag2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when list is long', () => {
|
||||
it('renders a truncated list of tags with full list displayed in tooltip on hover', async () => {
|
||||
const tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
|
||||
render(<Tags tags={tags} />);
|
||||
|
||||
const tagsNode = screen.getByTestId('agentTags');
|
||||
|
||||
expect(tagsNode).toHaveTextContent('tag1, tag2, tag3 + 2 more');
|
||||
|
||||
fireEvent.mouseEnter(tagsNode);
|
||||
await waitFor(() => {
|
||||
screen.getByTestId('agentTagsTooltip');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('agentTagsTooltip')).toHaveTextContent(
|
||||
'tag1, tag2, tag3, tag4, tag5'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { EuiToolTip } from '@elastic/eui';
|
||||
import { take } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const MAX_TAGS_TO_DISPLAY = 3;
|
||||
|
||||
export const Tags: React.FunctionComponent<Props> = ({ tags }) => {
|
||||
return (
|
||||
<>
|
||||
{tags.length > MAX_TAGS_TO_DISPLAY ? (
|
||||
<>
|
||||
<EuiToolTip content={<span data-test-subj="agentTagsTooltip">{tags.join(', ')}</span>}>
|
||||
<span data-test-subj="agentTags">
|
||||
{take(tags, 3).join(', ')} + {tags.length - MAX_TAGS_TO_DISPLAY} more
|
||||
</span>
|
||||
</EuiToolTip>
|
||||
</>
|
||||
) : (
|
||||
<span data-test-subj="agentTags">{tags.join(', ')}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -50,6 +50,7 @@ import { agentFlyoutContext } from '..';
|
|||
import { AgentTableHeader } from './components/table_header';
|
||||
import type { SelectionMode } from './components/types';
|
||||
import { SearchAndFilterBar } from './components/search_and_filter_bar';
|
||||
import { Tags } from './components/tags';
|
||||
import { TableRowActions } from './components/table_row_actions';
|
||||
import { EmptyPrompt } from './components/empty_prompt';
|
||||
|
||||
|
@ -98,14 +99,21 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
// Status for filtering
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
const isUsingFilter =
|
||||
search.trim() || selectedAgentPolicies.length || selectedStatus.length || showUpgradeable;
|
||||
search.trim() ||
|
||||
selectedAgentPolicies.length ||
|
||||
selectedStatus.length ||
|
||||
selectedTags.length ||
|
||||
showUpgradeable;
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setDraftKuery('');
|
||||
setSearch('');
|
||||
setSelectedAgentPolicies([]);
|
||||
setSelectedStatus([]);
|
||||
setSelectedTags([]);
|
||||
setShowUpgradeable(false);
|
||||
}, [setSearch, setDraftKuery, setSelectedAgentPolicies, setSelectedStatus, setShowUpgradeable]);
|
||||
|
||||
|
@ -135,6 +143,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
.map((agentPolicy) => `"${agentPolicy}"`)
|
||||
.join(' or ')})`;
|
||||
}
|
||||
|
||||
if (selectedTags.length) {
|
||||
kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags
|
||||
.map((tag) => `"${tag}"`)
|
||||
.join(' or ')})`;
|
||||
}
|
||||
|
||||
if (selectedStatus.length) {
|
||||
const kueryStatus = selectedStatus
|
||||
.map((status) => {
|
||||
|
@ -164,7 +179,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
}
|
||||
|
||||
return kueryBuilder;
|
||||
}, [selectedStatus, selectedAgentPolicies, search]);
|
||||
}, [search, selectedAgentPolicies, selectedTags, selectedStatus]);
|
||||
|
||||
const showInactive = useMemo(() => {
|
||||
return selectedStatus.includes('inactive');
|
||||
|
@ -174,6 +189,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
const [agentsStatus, setAgentsStatus] = useState<
|
||||
{ [key in SimplifiedAgentStatus]: number } | undefined
|
||||
>();
|
||||
const [allTags, setAllTags] = useState<string[]>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [totalAgents, setTotalAgents] = useState(0);
|
||||
const [totalInactiveAgents, setTotalInactiveAgents] = useState(0);
|
||||
|
@ -224,6 +240,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
inactive: agentsRequest.data.totalInactive,
|
||||
});
|
||||
|
||||
// Only set tags on the first request - we don't want the list of tags to update based
|
||||
// on the returned set of agents from the API
|
||||
if (allTags === undefined) {
|
||||
const newAllTags = Array.from(
|
||||
new Set(agentsRequest.data.items.flatMap((agent) => agent.tags ?? []))
|
||||
);
|
||||
|
||||
setAllTags(newAllTags);
|
||||
}
|
||||
|
||||
setAgents(agentsRequest.data.items);
|
||||
setTotalAgents(agentsRequest.data.total);
|
||||
setTotalInactiveAgents(agentsRequest.data.totalInactive);
|
||||
|
@ -237,7 +263,15 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
setIsLoading(false);
|
||||
}
|
||||
fetchDataAsync();
|
||||
}, [pagination, kuery, showInactive, showUpgradeable, notifications.toasts]);
|
||||
}, [
|
||||
pagination.currentPage,
|
||||
pagination.pageSize,
|
||||
kuery,
|
||||
showInactive,
|
||||
showUpgradeable,
|
||||
allTags,
|
||||
notifications.toasts,
|
||||
]);
|
||||
|
||||
// Send request to get agent list and status
|
||||
useEffect(() => {
|
||||
|
@ -319,6 +353,14 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
}),
|
||||
render: (active: boolean, agent: any) => <AgentHealth agent={agent} />,
|
||||
},
|
||||
{
|
||||
field: 'tags',
|
||||
width: '240px',
|
||||
name: i18n.translate('xpack.fleet.agentList.tagsColumnTitle', {
|
||||
defaultMessage: 'Tags',
|
||||
}),
|
||||
render: (tags: string[] = [], agent: any) => <Tags tags={tags} />,
|
||||
},
|
||||
{
|
||||
field: 'policy_id',
|
||||
name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', {
|
||||
|
@ -481,6 +523,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
onSelectedStatusChange={setSelectedStatus}
|
||||
showUpgradeable={showUpgradeable}
|
||||
onShowUpgradeableChange={setShowUpgradeable}
|
||||
tags={allTags ?? []}
|
||||
selectedTags={selectedTags}
|
||||
onSelectedTagsChange={setSelectedTags}
|
||||
totalAgents={totalAgents}
|
||||
totalInactiveAgents={totalInactiveAgents}
|
||||
selectionMode={selectionMode}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue