Skip to content

Commit

Permalink
[Fleet] Add "Label" column + filter to Agent list table (elastic#131070)
Browse files Browse the repository at this point in the history
* Add basic labels implementation for Agent list table

* Lay plumbing for filtering based on tags

Ref elastic#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>
  • Loading branch information
2 people authored and kertal committed May 24, 2022
1 parent a458e23 commit 9d3c667
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 6 deletions.
5 changes: 5 additions & 0 deletions x-pack/plugins/fleet/common/types/models/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -87,6 +90,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{
onSelectedStatusChange,
showUpgradeable,
onShowUpgradeableChange,
tags,
selectedTags,
onSelectedTagsChange,
totalAgents,
totalInactiveAgents,
selectionMode,
Expand All @@ -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) => {
Expand All @@ -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 ? (
Expand Down Expand Up @@ -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}
Expand All @@ -159,7 +175,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{
</EuiFilterButton>
}
isOpen={isStatusFilterOpen}
closePopover={() => setIsStatutsFilterOpen(false)}
closePopover={() => setIsStatusFilterOpen(false)}
panelPaddingSize="none"
>
<div className="euiFilterSelect__items">
Expand All @@ -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={
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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}
Expand Down

0 comments on commit 9d3c667

Please sign in to comment.