-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Fleet] Agent List: Inform users when agents have become inactive sin…
…ce last page view (#149226)
- Loading branch information
Showing
6 changed files
with
384 additions
and
84 deletions.
There are no files selected for viewing
111 changes: 111 additions & 0 deletions
111
...pplications/fleet/sections/agents/agent_list_page/components/agent_status_filter.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof AgentStatusFilter>) => { | ||
return render( | ||
<IntlProvider timeZone="UTC" locale="en"> | ||
<AgentStatusFilter {...props} /> | ||
</IntlProvider> | ||
); | ||
}; | ||
|
||
const mockLocalStorage: Record<any, any> = {}; | ||
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()); | ||
}); | ||
}); | ||
}); |
207 changes: 207 additions & 0 deletions
207
...lic/applications/fleet/sections/agents/agent_list_page/components/agent_status_filter.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }) => ( | ||
<TourStepNoHeaderFooter | ||
content={ | ||
<EuiText size="s"> | ||
<FormattedMessage | ||
id="xpack.fleet.agentList.inactiveAgentsTourStepContent" | ||
defaultMessage="Some agents have become inactive and have been hidden. Use status filters to show inactive or unenrolled agents." | ||
/> | ||
</EuiText> | ||
} | ||
isStepOpen={isOpen} | ||
minWidth={300} | ||
step={1} | ||
stepsTotal={0} | ||
title="" | ||
onFinish={() => {}} | ||
anchorPosition="upCenter" | ||
maxWidth={280} | ||
> | ||
{children as React.ReactElement} | ||
</TourStepNoHeaderFooter> | ||
); | ||
|
||
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<boolean>(isOpenByDefault); | ||
|
||
const updateIsStatusFilterOpen = (isOpen: boolean) => { | ||
if (isOpen && newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed) { | ||
setInactiveAgentsCalloutHasBeenDismissed(true); | ||
} | ||
|
||
setIsStatusFilterOpen(isOpen); | ||
}; | ||
return ( | ||
<InactiveAgentsTourStep | ||
isOpen={newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed} | ||
> | ||
<EuiPopover | ||
ownFocus | ||
button={ | ||
<EuiFilterButton | ||
iconType="arrowDown" | ||
onClick={() => updateIsStatusFilterOpen(!isStatusFilterOpen)} | ||
isSelected={isStatusFilterOpen} | ||
hasActiveFilters={selectedStatus.length > 0} | ||
numActiveFilters={selectedStatus.length} | ||
numFilters={statusFilters.length} | ||
disabled={disabled} | ||
data-test-subj="agentList.statusFilter" | ||
> | ||
<FormattedMessage id="xpack.fleet.agentList.statusFilterText" defaultMessage="Status" /> | ||
</EuiFilterButton> | ||
} | ||
isOpen={isStatusFilterOpen} | ||
closePopover={() => updateIsStatusFilterOpen(false)} | ||
panelPaddingSize="none" | ||
> | ||
<div className="euiFilterSelect__items"> | ||
{statusFilters.map(({ label, status }, idx) => ( | ||
<EuiFilterSelectItem | ||
key={idx} | ||
checked={selectedStatus.includes(status) ? 'on' : undefined} | ||
onClick={() => { | ||
if (selectedStatus.includes(status)) { | ||
onSelectedStatusChange([...selectedStatus.filter((s) => s !== status)]); | ||
} else { | ||
onSelectedStatusChange([...selectedStatus, status]); | ||
} | ||
}} | ||
> | ||
<span> | ||
{label} | ||
{status === 'inactive' && newlyInactiveAgentsCount > 0 && ( | ||
<LeftpaddedNotificationBadge> | ||
{newlyInactiveAgentsCount} | ||
</LeftpaddedNotificationBadge> | ||
)} | ||
</span> | ||
</EuiFilterSelectItem> | ||
))} | ||
</div> | ||
</EuiPopover> | ||
</InactiveAgentsTourStep> | ||
); | ||
}; |
Oops, something went wrong.