From 7bc9b8d2b971216cd363b7eb445b4df550b3f53c Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Thu, 19 Jan 2023 17:59:47 +0000 Subject: [PATCH] [Fleet] Agent List: Inform users when agents have become inactive since last page view (#149226) --- .../components/agent_status_filter.test.tsx | 111 ++++++++++ .../components/agent_status_filter.tsx | 207 ++++++++++++++++++ .../components/search_and_filter_bar.tsx | 91 +------- .../agents/agent_list_page/hooks/index.tsx | 2 + ...ctive_agents_callout_has_been_dismissed.ts | 29 +++ .../use_last_seen_inactive_agents_count.ts | 28 +++ 6 files changed, 384 insertions(+), 84 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.test.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_inactive_agents_callout_has_been_dismissed.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_last_seen_inactive_agents_count.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.test.tsx new file mode 100644 index 0000000000000..71853b29c3820 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.test.tsx @@ -0,0 +1,111 @@ +/* + * 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, act, fireEvent, waitForElementToBeRemoved, waitFor } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { AgentStatusFilter } from './agent_status_filter'; + +const PARTIAL_TOUR_TEXT = 'Some agents have become inactive and have been hidden'; + +const renderComponent = (props: React.ComponentProps) => { + return render( + + + + ); +}; + +const mockLocalStorage: Record = {}; +describe('AgentStatusFilter', () => { + beforeEach(() => { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn((key) => mockLocalStorage[key]), + setItem: jest.fn((key, val) => (mockLocalStorage[key] = val)), + }, + writable: true, + }); + }); + + it('Renders all statuses', () => { + const { getByText } = renderComponent({ + selectedStatus: [], + onSelectedStatusChange: () => {}, + totalInactiveAgents: 0, + isOpenByDefault: true, + }); + + expect(getByText('Healthy')).toBeInTheDocument(); + expect(getByText('Unhealthy')).toBeInTheDocument(); + expect(getByText('Updating')).toBeInTheDocument(); + expect(getByText('Offline')).toBeInTheDocument(); + expect(getByText('Inactive')).toBeInTheDocument(); + expect(getByText('Unenrolled')).toBeInTheDocument(); + }); + + it('Shows tour and inactive count if first time seeing newly inactive agents', async () => { + const { container, getByText, queryByText } = renderComponent({ + selectedStatus: [], + onSelectedStatusChange: () => {}, + totalInactiveAgents: 999, + }); + + await act(async () => { + expect(getByText(PARTIAL_TOUR_TEXT, { exact: false })).toBeVisible(); + + const statusFilterButton = container.querySelector( + '[data-test-subj="agentList.statusFilter"]' + ); + + expect(statusFilterButton).not.toBeNull(); + + fireEvent.click(statusFilterButton!); + + await waitForElementToBeRemoved(() => queryByText(PARTIAL_TOUR_TEXT, { exact: false })); + + expect(getByText('999')).toBeInTheDocument(); + + expect(mockLocalStorage['fleet.inactiveAgentsCalloutHasBeenDismissed']).toBe('true'); + }); + }); + + it('Should not show tour if previously been dismissed', async () => { + mockLocalStorage['fleet.inactiveAgentsCalloutHasBeenDismissed'] = 'true'; + + const { getByText } = renderComponent({ + selectedStatus: [], + onSelectedStatusChange: () => {}, + totalInactiveAgents: 999, + }); + + await act(async () => { + expect(getByText(PARTIAL_TOUR_TEXT, { exact: false })).not.toBeVisible(); + }); + }); + + it('Should should show difference between last seen inactive agents and total agents', async () => { + mockLocalStorage['fleet.lastSeenInactiveAgentsCount'] = '100'; + + const { getByText, container } = renderComponent({ + selectedStatus: [], + onSelectedStatusChange: () => {}, + totalInactiveAgents: 999, + }); + + await act(async () => { + const statusFilterButton = container.querySelector( + '[data-test-subj="agentList.statusFilter"]' + ); + + expect(statusFilterButton).not.toBeNull(); + fireEvent.click(statusFilterButton!); + + await waitFor(() => expect(getByText('899')).toBeInTheDocument()); + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx new file mode 100644 index 0000000000000..f75fc74d601d6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx @@ -0,0 +1,207 @@ +/* + * 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 { + EuiFilterButton, + EuiFilterSelectItem, + EuiNotificationBadge, + EuiPopover, + EuiText, + EuiTourStep, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { useInactiveAgentsCalloutHasBeenDismissed, useLastSeenInactiveAgentsCount } from '../hooks'; + +const statusFilters = [ + { + status: 'healthy', + label: i18n.translate('xpack.fleet.agentList.statusHealthyFilterText', { + defaultMessage: 'Healthy', + }), + }, + { + status: 'unhealthy', + label: i18n.translate('xpack.fleet.agentList.statusUnhealthyFilterText', { + defaultMessage: 'Unhealthy', + }), + }, + { + status: 'updating', + label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, + { + status: 'offline', + label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { + defaultMessage: 'Offline', + }), + }, + { + status: 'inactive', + label: i18n.translate('xpack.fleet.agentList.statusInactiveFilterText', { + defaultMessage: 'Inactive', + }), + }, + { + status: 'unenrolled', + label: i18n.translate('xpack.fleet.agentList.statusUnenrolledFilterText', { + defaultMessage: 'Unenrolled', + }), + }, +]; + +const LeftpaddedNotificationBadge = styled(EuiNotificationBadge)` + margin-left: 10px; +`; + +const TourStepNoHeaderFooter = styled(EuiTourStep)` + .euiTourFooter { + display: none; + } + .euiTourHeader { + display: none; + } +`; + +const InactiveAgentsTourStep: React.FC<{ isOpen: boolean }> = ({ children, isOpen }) => ( + + + + } + isStepOpen={isOpen} + minWidth={300} + step={1} + stepsTotal={0} + title="" + onFinish={() => {}} + anchorPosition="upCenter" + maxWidth={280} + > + {children as React.ReactElement} + +); + +export const AgentStatusFilter: React.FC<{ + selectedStatus: string[]; + onSelectedStatusChange: (status: string[]) => void; + disabled?: boolean; + totalInactiveAgents: number; + isOpenByDefault?: boolean; +}> = (props) => { + const { + selectedStatus, + onSelectedStatusChange, + disabled, + totalInactiveAgents, + isOpenByDefault = false, + } = props; + const [lastSeenInactiveAgentsCount, setLastSeenInactiveAgentsCount] = + useLastSeenInactiveAgentsCount(); + const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] = + useInactiveAgentsCalloutHasBeenDismissed(); + + const newlyInactiveAgentsCount = useMemo(() => { + const newVal = totalInactiveAgents - lastSeenInactiveAgentsCount; + + if (newVal < 0) { + return 0; + } + + return newVal; + }, [lastSeenInactiveAgentsCount, totalInactiveAgents]); + + useMemo(() => { + if (selectedStatus.length && selectedStatus.includes('inactive') && newlyInactiveAgentsCount) { + setLastSeenInactiveAgentsCount(totalInactiveAgents); + } + }, [ + selectedStatus, + newlyInactiveAgentsCount, + setLastSeenInactiveAgentsCount, + totalInactiveAgents, + ]); + + useMemo(() => { + // reduce the number of last seen inactive agents count to the total inactive agents count + // e.g if agents have become healthy again + if (totalInactiveAgents > 0 && lastSeenInactiveAgentsCount > totalInactiveAgents) { + setLastSeenInactiveAgentsCount(totalInactiveAgents); + } + }, [lastSeenInactiveAgentsCount, totalInactiveAgents, setLastSeenInactiveAgentsCount]); + + // Status for filtering + const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(isOpenByDefault); + + const updateIsStatusFilterOpen = (isOpen: boolean) => { + if (isOpen && newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed) { + setInactiveAgentsCalloutHasBeenDismissed(true); + } + + setIsStatusFilterOpen(isOpen); + }; + return ( + 0 && !inactiveAgentsCalloutHasBeenDismissed} + > + updateIsStatusFilterOpen(!isStatusFilterOpen)} + isSelected={isStatusFilterOpen} + hasActiveFilters={selectedStatus.length > 0} + numActiveFilters={selectedStatus.length} + numFilters={statusFilters.length} + disabled={disabled} + data-test-subj="agentList.statusFilter" + > + + + } + isOpen={isStatusFilterOpen} + closePopover={() => updateIsStatusFilterOpen(false)} + panelPaddingSize="none" + > +
+ {statusFilters.map(({ label, status }, idx) => ( + { + if (selectedStatus.includes(status)) { + onSelectedStatusChange([...selectedStatus.filter((s) => s !== status)]); + } else { + onSelectedStatusChange([...selectedStatus, status]); + } + }} + > + + {label} + {status === 'inactive' && newlyInactiveAgentsCount > 0 && ( + + {newlyInactiveAgentsCount} + + )} + + + ))} +
+
+
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 105fbe9773536..db173776e2458 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -18,7 +18,6 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; @@ -31,45 +30,7 @@ import { MAX_TAG_DISPLAY_LENGTH, truncateTag } from '../utils'; import { AgentBulkActions } from './bulk_actions'; import type { SelectionMode } from './types'; import { AgentActivityButton } from './agent_activity_button'; - -const statusFilters = [ - { - status: 'healthy', - label: i18n.translate('xpack.fleet.agentList.statusHealthyFilterText', { - defaultMessage: 'Healthy', - }), - }, - { - status: 'unhealthy', - label: i18n.translate('xpack.fleet.agentList.statusUnhealthyFilterText', { - defaultMessage: 'Unhealthy', - }), - }, - { - status: 'updating', - label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { - defaultMessage: 'Updating', - }), - }, - { - status: 'offline', - label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { - defaultMessage: 'Offline', - }), - }, - { - status: 'inactive', - label: i18n.translate('xpack.fleet.agentList.statusInactiveFilterText', { - defaultMessage: 'Inactive', - }), - }, - { - status: 'unenrolled', - label: i18n.translate('xpack.fleet.agentList.statusUnenrolledFilterText', { - defaultMessage: 'Unenrolled', - }), - }, -]; +import { AgentStatusFilter } from './agent_status_filter'; const ClearAllTagsFilterItem = styled(EuiFilterSelectItem)` padding: ${(props) => props.theme.eui.euiSizeS}; @@ -133,9 +94,6 @@ export const SearchAndFilterBar: React.FunctionComponent<{ // Policies state for filtering const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); - // Status for filtering - const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(false); - const [isTagsFilterOpen, setIsTagsFilterOpen] = useState(false); // Add a agent policy id to current search @@ -223,47 +181,12 @@ export const SearchAndFilterBar: React.FunctionComponent<{ - setIsStatusFilterOpen(!isStatusFilterOpen)} - isSelected={isStatusFilterOpen} - hasActiveFilters={selectedStatus.length > 0} - numActiveFilters={selectedStatus.length} - numFilters={statusFilters.length} - disabled={agentPolicies.length === 0} - data-test-subj="agentList.statusFilter" - > - - - } - isOpen={isStatusFilterOpen} - closePopover={() => setIsStatusFilterOpen(false)} - panelPaddingSize="none" - > -
- {statusFilters.map(({ label, status }, idx) => ( - { - if (selectedStatus.includes(status)) { - onSelectedStatusChange([...selectedStatus.filter((s) => s !== status)]); - } else { - onSelectedStatusChange([...selectedStatus, status]); - } - }} - > - {label} - - ))} -
-
+ void] => { + const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] = + useState(false); + + useEffect(() => { + const storageValue = localStorage.getItem(LOCAL_STORAGE_KEY); + if (storageValue) { + setInactiveAgentsCalloutHasBeenDismissed(Boolean(storageValue)); + } + }, []); + + const updateInactiveAgentsCalloutHasBeenDismissed = (newValue: boolean) => { + localStorage.setItem(LOCAL_STORAGE_KEY, newValue.toString()); + setInactiveAgentsCalloutHasBeenDismissed(newValue); + }; + + return [inactiveAgentsCalloutHasBeenDismissed, updateInactiveAgentsCalloutHasBeenDismissed]; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_last_seen_inactive_agents_count.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_last_seen_inactive_agents_count.ts new file mode 100644 index 0000000000000..ce767c82eec37 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_last_seen_inactive_agents_count.ts @@ -0,0 +1,28 @@ +/* + * 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 { useState, useEffect } from 'react'; + +const LOCAL_STORAGE_KEY = 'fleet.lastSeenInactiveAgentsCount'; + +export const useLastSeenInactiveAgentsCount = (): [number, (val: number) => void] => { + const [lastSeenInactiveAgentsCount, setLastSeenInactiveAgentsCount] = useState(0); + + useEffect(() => { + const storageValue = localStorage.getItem(LOCAL_STORAGE_KEY); + if (storageValue) { + setLastSeenInactiveAgentsCount(parseInt(storageValue, 10)); + } + }, []); + + const updateLastSeenInactiveAgentsCount = (inactiveAgents: number) => { + localStorage.setItem(LOCAL_STORAGE_KEY, inactiveAgents.toString()); + setLastSeenInactiveAgentsCount(inactiveAgents); + }; + + return [lastSeenInactiveAgentsCount, updateLastSeenInactiveAgentsCount]; +};