[Fleet] Agent details page UI (#64983)

* Fix empty host name column in agent list

* Fix empty version column in agent list

* Consolidate page header styling inconsistencies

* Add tabs to agent details

* Add right-side header content and actions menu

* Give headers more spacing when there are tabs present

* Add details tab

* Use ECS formatted metadata

* Make activity log table pretty

* Return agent event SO id from list API

* Fix i18n

* Add types for new agent events and differentiate from stored agent events

* Adjust test

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jen Huang 2020-05-04 16:06:34 -07:00 committed by GitHub
parent bda8309864
commit f9be590d51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 694 additions and 351 deletions

View file

@ -34,7 +34,7 @@ export interface AgentActionSOAttributes extends SavedObjectAttributes {
data?: string;
}
export interface AgentEvent {
export interface NewAgentEvent {
type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION';
subtype: // State
| 'RUNNING'
@ -58,7 +58,11 @@ export interface AgentEvent {
stream_id?: string;
}
export interface AgentEventSOAttributes extends AgentEvent, SavedObjectAttributes {}
export interface AgentEvent extends NewAgentEvent {
id: string;
}
export interface AgentEventSOAttributes extends NewAgentEvent, SavedObjectAttributes {}
type MetadataValue = string | AgentMetadata;

View file

@ -4,7 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models';
import {
Agent,
AgentAction,
NewAgentEvent,
AgentEvent,
AgentStatus,
AgentType,
NewAgentAction,
} from '../models';
export interface GetAgentsRequest {
query: {
@ -40,7 +48,7 @@ export interface PostAgentCheckinRequest {
};
body: {
local_metadata?: Record<string, any>;
events?: AgentEvent[];
events?: NewAgentEvent[];
};
}

View file

@ -64,6 +64,7 @@ export const Header: React.FC<HeaderProps> = ({
<EuiFlexGroup>
{tabs ? (
<EuiFlexItem>
<EuiSpacer size="s" />
<Tabs>
{tabs.map(props => (
<EuiTab {...props} key={props.id}>

View file

@ -5,11 +5,12 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner';
export const Loading: React.FunctionComponent<{}> = () => (
export const Loading: React.FunctionComponent<{ size?: EuiLoadingSpinnerSize }> = ({ size }) => (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
<EuiLoadingSpinner size={size || 'xl'} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react';
import { IFieldType } from 'src/plugins/data/public';
// @ts-ignore
import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDebounce, useStartDeps } from '../hooks';
import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants';
@ -30,9 +31,15 @@ interface Props {
value: string;
fieldPrefix: string;
onChange: (newValue: string) => void;
placeholder?: string;
}
export const SearchBar: React.FunctionComponent<Props> = ({ value, fieldPrefix, onChange }) => {
export const SearchBar: React.FunctionComponent<Props> = ({
value,
fieldPrefix,
onChange,
placeholder,
}) => {
const { suggestions } = useSuggestions(fieldPrefix, value);
// TODO fix type when correctly typed in EUI
@ -52,7 +59,12 @@ export const SearchBar: React.FunctionComponent<Props> = ({ value, fieldPrefix,
// @ts-ignore
value={value}
icon={'search'}
placeholder={'Search'}
placeholder={
placeholder ||
i18n.translate('xpack.ingestManager.defaultSearchPlaceholderText', {
defaultMessage: 'Search',
})
}
onInputChange={onChangeSearch}
onItemClick={onAutocompleteClick}
suggestions={suggestions.map(suggestion => {

View file

@ -29,7 +29,7 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{
const leftColumn = (
<EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<EuiButtonEmpty size="s" iconType="arrowLeft" flush="left" href={cancelUrl}>
<EuiButtonEmpty size="xs" iconType="arrowLeft" flush="left" href={cancelUrl}>
<FormattedMessage
id="xpack.ingestManager.createDatasource.cancelLinkText"
defaultMessage="Cancel"

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, memo, useMemo, useState } from 'react';
import React, { memo, useMemo, useState } from 'react';
import { Redirect, useRouteMatch, Switch, Route } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedDate } from '@kbn/i18n/react';
@ -13,7 +13,6 @@ import {
EuiCallOut,
EuiText,
EuiSpacer,
EuiTitle,
EuiButtonEmpty,
EuiI18nNumber,
EuiDescriptionList,
@ -72,46 +71,40 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => {
const headerLeftContent = useMemo(
() => (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<div>
<EuiButtonEmpty iconType="arrowLeft" href={configListLink} flush="left" size="xs">
<FormattedMessage
id="xpack.ingestManager.configDetails.viewAgentListTitle"
defaultMessage="View all agent configurations"
/>
</EuiButtonEmpty>
</div>
<EuiTitle size="l">
<h1>
{(agentConfig && agentConfig.name) || (
<FormattedMessage
id="xpack.ingestManager.configDetails.configDetailsTitle"
defaultMessage="Config '{id}'"
values={{
id: configId,
}}
/>
)}
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
{agentConfig && agentConfig.description ? (
<Fragment>
<EuiSpacer size="s" />
<EuiText color="subdued" size="s">
{agentConfig.description}
</EuiText>
</Fragment>
) : null}
<EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" href={configListLink} flush="left" size="xs">
<FormattedMessage
id="xpack.ingestManager.configDetails.viewAgentListTitle"
defaultMessage="View all agent configurations"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h1>
{(agentConfig && agentConfig.name) || (
<FormattedMessage
id="xpack.ingestManager.configDetails.configDetailsTitle"
defaultMessage="Config '{id}'"
values={{
id: configId,
}}
/>
)}
</h1>
</EuiText>
</EuiFlexItem>
{agentConfig && agentConfig.description ? (
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiText color="subdued" size="s">
{agentConfig.description}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
</React.Fragment>
) : null}
</EuiFlexGroup>
),
[configListLink, agentConfig, configId]
);

View file

@ -52,7 +52,7 @@ export const HeroImage = memo(() => {
? toAssets('illustration_integrations_darkmode.svg')
: toAssets('illustration_integrations_lightmode.svg'),
}))`
margin-bottom: -60px;
margin-bottom: -68px;
width: 80%;
`;

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useState, useCallback } from 'react';
import { EuiButton, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { Agent } from '../../../../types';
import { useCapabilities } from '../../../../hooks';
import { useAgentRefresh } from '../hooks';
import { AgentUnenrollProvider, AgentReassignConfigFlyout } from '../../components';
export const AgentDetailsActionMenu: React.FunctionComponent<{
agent: Agent;
}> = memo(({ agent }) => {
const hasWriteCapabilites = useCapabilities().write;
const refreshAgent = useAgentRefresh();
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState<boolean>(false);
const handleCloseMenu = useCallback(() => setIsActionsPopoverOpen(false), [
setIsActionsPopoverOpen,
]);
const handleToggleMenu = useCallback(() => setIsActionsPopoverOpen(!isActionsPopoverOpen), [
isActionsPopoverOpen,
]);
const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false);
return (
<>
{isReassignFlyoutOpen && (
<AgentReassignConfigFlyout agent={agent} onClose={() => setIsReassignFlyoutOpen(false)} />
)}
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
button={
<EuiButton onClick={handleToggleMenu} iconType="arrowDown" iconSide="right">
<FormattedMessage
id="xpack.ingestManager.agentDetails.actionsButton"
defaultMessage="Actions"
/>
</EuiButton>
}
isOpen={isActionsPopoverOpen}
closePopover={handleCloseMenu}
>
<EuiContextMenuPanel
items={[
<EuiContextMenuItem
icon="pencil"
onClick={() => {
handleCloseMenu();
setIsReassignFlyoutOpen(true);
}}
key="reassignConfig"
>
<FormattedMessage
id="xpack.ingestManager.agentList.reassignActionText"
defaultMessage="Assign new agent config"
/>
</EuiContextMenuItem>,
<AgentUnenrollProvider key="unenrollAgent">
{unenrollAgentsPrompt => (
<EuiContextMenuItem
icon="cross"
disabled={!hasWriteCapabilites || !agent.active}
onClick={() => {
unenrollAgentsPrompt([agent.id], 1, refreshAgent);
}}
>
<FormattedMessage
id="xpack.ingestManager.agentList.unenrollOneButton"
defaultMessage="Unenroll"
/>
</EuiContextMenuItem>
)}
</AgentUnenrollProvider>,
]}
/>
</EuiPopover>
</>
);
});

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import {
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Agent, AgentConfig } from '../../../../types';
import { AGENT_CONFIG_DETAILS_PATH } from '../../../../constants';
import { useLink } from '../../../../hooks';
import { AgentHealth } from '../../components';
export const AgentDetailsContent: React.FunctionComponent<{
agent: Agent;
agentConfig?: AgentConfig;
}> = memo(({ agent, agentConfig }) => {
const agentConfigUrl = useLink(AGENT_CONFIG_DETAILS_PATH);
return (
<EuiDescriptionList>
{[
{
title: i18n.translate('xpack.ingestManager.agentDetails.hostNameLabel', {
defaultMessage: 'Host name',
}),
description: agent.local_metadata['host.hostname'],
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.hostIdLabel', {
defaultMessage: 'Host ID',
}),
description: agent.id,
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', {
defaultMessage: 'Status',
}),
description: <AgentHealth agent={agent} />,
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.agentConfigurationLabel', {
defaultMessage: 'Agent configuration',
}),
description: agentConfig ? (
<EuiLink href={`${agentConfigUrl}${agent.config_id}`}>
{agentConfig.name || agent.config_id}
</EuiLink>
) : (
agent.config_id || '-'
),
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.versionLabel', {
defaultMessage: 'Agent version',
}),
description: agent.local_metadata['agent.version'],
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.platformLabel', {
defaultMessage: 'Platform',
}),
description: agent.local_metadata['os.platform'],
},
].map(({ title, description }) => {
return (
<EuiFlexGroup>
<EuiFlexItem grow={3}>
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
</EuiFlexItem>
<EuiFlexItem grow={7}>
<EuiDescriptionListDescription>{description}</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
);
})}
</EuiDescriptionList>
);
});

View file

@ -13,14 +13,19 @@ import {
EuiButton,
EuiSpacer,
EuiFlexItem,
EuiTitle,
EuiBadge,
EuiText,
EuiButtonIcon,
EuiCodeBlock,
} from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedTime } from '@kbn/i18n/react';
import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../../constants';
import { Agent, AgentEvent } from '../../../../types';
import { usePagination, useGetOneAgentEvents } from '../../../../hooks';
import { SearchBar } from '../../../../components/search_bar';
import { TYPE_LABEL, SUBTYPE_LABEL } from './type_labels';
function useSearch() {
const [state, setState] = useState<{ search: string }>({
@ -41,6 +46,9 @@ function useSearch() {
export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => {
const { pageSizeOptions, pagination, setPagination } = usePagination();
const { search, setSearch } = useSearch();
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{
[key: string]: JSX.Element;
}>({});
const { isLoading, data, sendRequest } = useGetOneAgentEvents(agent.id, {
page: pagination.currentPage,
@ -59,6 +67,49 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag
pageSizeOptions,
};
const toggleDetails = (agentEvent: AgentEvent) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[agentEvent.id]) {
delete itemIdToExpandedRowMapValues[agentEvent.id];
} else {
const details = (
<div style={{ width: '100%' }}>
<div>
<EuiText size="s">
<strong>
<FormattedMessage
id="xpack.ingestManager.agentEventsList.messageDetailsTitle"
defaultMessage="Message"
/>
</strong>
<EuiSpacer size="xs" />
<p>{agentEvent.message}</p>
</EuiText>
</div>
{agentEvent.payload ? (
<div>
<EuiSpacer size="s" />
<EuiText size="s">
<strong>
<FormattedMessage
id="xpack.ingestManager.agentEventsList.payloadDetailsTitle"
defaultMessage="Payload"
/>
</strong>
</EuiText>
<EuiSpacer size="xs" />
<EuiCodeBlock language="json" paddingSize="s" overflowHeight={200}>
{JSON.stringify(agentEvent.payload, null, 2)}
</EuiCodeBlock>
</div>
) : null}
</div>
);
itemIdToExpandedRowMapValues[agentEvent.id] = details;
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
const columns = [
{
field: 'timestamp',
@ -66,40 +117,63 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag
defaultMessage: 'Timestamp',
}),
render: (timestamp: string) => (
<FormattedTime value={new Date(timestamp)} month="numeric" day="numeric" year="numeric" />
<FormattedTime
value={new Date(timestamp)}
month="short"
day="numeric"
year="numeric"
hour="numeric"
minute="numeric"
second="numeric"
/>
),
sortable: true,
width: '18%',
},
{
field: 'type',
name: i18n.translate('xpack.ingestManager.agentEventsList.typeColumnTitle', {
defaultMessage: 'Type',
}),
width: '90px',
width: '10%',
render: (type: AgentEvent['type']) =>
TYPE_LABEL[type] || <EuiBadge color="hollow">{type}</EuiBadge>,
},
{
field: 'subtype',
name: i18n.translate('xpack.ingestManager.agentEventsList.subtypeColumnTitle', {
defaultMessage: 'Subtype',
}),
width: '90px',
width: '13%',
render: (subtype: AgentEvent['subtype']) =>
SUBTYPE_LABEL[subtype] || <EuiBadge color="hollow">{subtype}</EuiBadge>,
},
{
field: 'message',
name: i18n.translate('xpack.ingestManager.agentEventsList.messageColumnTitle', {
defaultMessage: 'Message',
}),
render: (message: string) => <EuiText size="xs">{message}</EuiText>,
truncateText: true,
},
{
field: 'payload',
name: i18n.translate('xpack.ingestManager.agentEventsList.paylodColumnTitle', {
defaultMessage: 'Payload',
}),
truncateText: true,
render: (payload: any) => (
<span>
<code>{payload && JSON.stringify(payload, null, 2)}</code>
</span>
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (agentEvent: AgentEvent) => (
<EuiButtonIcon
onClick={() => toggleDetails(agentEvent)}
aria-label={
itemIdToExpandedRowMap[agentEvent.id]
? i18n.translate('xpack.ingestManager.agentEventsList.collapseDetailsAriaLabel', {
defaultMessage: 'Hide details',
})
: i18n.translate('xpack.ingestManager.agentEventsList.expandDetailsAriaLabel', {
defaultMessage: 'Show details',
})
}
iconType={itemIdToExpandedRowMap[agentEvent.id] ? 'arrowUp' : 'arrowDown'}
/>
),
},
];
@ -120,25 +194,20 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag
return (
<>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.ingestManager.agentEventsList.title"
defaultMessage="Activity Log"
/>
</h3>
</EuiTitle>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem>
<SearchBar
value={search}
onChange={setSearch}
fieldPrefix={AGENT_EVENT_SAVED_OBJECT_TYPE}
placeholder={i18n.translate(
'xpack.ingestManager.agentEventsList.searchPlaceholderText',
{ defaultMessage: 'Search for activity logs' }
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={null}>
<EuiButton color="secondary" iconType="refresh" onClick={onClickRefresh}>
<EuiButton iconType="refresh" onClick={onClickRefresh}>
<FormattedMessage
id="xpack.ingestManager.agentEventsList.refreshButton"
defaultMessage="Refresh"
@ -150,9 +219,11 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag
<EuiBasicTable<AgentEvent>
onChange={onChange}
items={list}
itemId="id"
columns={columns}
pagination={paginationOptions}
loading={isLoading}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
/>
</>
);

View file

@ -1,206 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, Fragment, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiDescriptionList,
EuiButton,
EuiPopover,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiButtonEmpty,
EuiIconTip,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiTextColor,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAgentRefresh } from '../hooks';
import { AgentMetadataFlyout } from './metadata_flyout';
import { Agent } from '../../../../types';
import { AgentHealth } from '../../components/agent_health';
import { useCapabilities, useGetOneAgentConfig } from '../../../../hooks';
import { Loading } from '../../../../components';
import { ConnectedLink, AgentReassignConfigFlyout } from '../../components';
import { AgentUnenrollProvider } from '../../components/agent_unenroll_provider';
const Item: React.FunctionComponent<{ label: string }> = ({ label, children }) => {
return (
<EuiFlexItem grow={false}>
<EuiDescriptionList compressed>
<EuiDescriptionListTitle>{label}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{children}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
);
};
function useFlyout() {
const [isVisible, setVisible] = useState(false);
return {
isVisible,
show: () => setVisible(true),
hide: () => setVisible(false),
};
}
interface Props {
agent: Agent;
}
export const AgentDetailSection: React.FunctionComponent<Props> = ({ agent }) => {
const hasWriteCapabilites = useCapabilities().write;
const metadataFlyout = useFlyout();
const refreshAgent = useAgentRefresh();
// Actions menu
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const handleCloseMenu = useCallback(() => setIsActionsPopoverOpen(false), [
setIsActionsPopoverOpen,
]);
const handleToggleMenu = useCallback(() => setIsActionsPopoverOpen(!isActionsPopoverOpen), [
isActionsPopoverOpen,
]);
const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false);
// Fetch AgentConfig information
const { isLoading: isAgentConfigLoading, data: agentConfigData } = useGetOneAgentConfig(
agent.config_id
);
const items = [
{
title: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', {
defaultMessage: 'Status',
}),
description: <AgentHealth agent={agent} />,
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.idLabel', {
defaultMessage: 'ID',
}),
description: agent.id,
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.typeLabel', {
defaultMessage: 'Type',
}),
description: agent.type,
},
{
title: i18n.translate('xpack.ingestManager.agentDetails.agentConfigLabel', {
defaultMessage: 'AgentConfig',
}),
description: isAgentConfigLoading ? (
<Loading />
) : agentConfigData && agentConfigData.item ? (
<ConnectedLink color="primary" path={`/configs/${agent.config_id}`}>
{agentConfigData.item.name}
</ConnectedLink>
) : (
<Fragment>
<EuiIconTip
position="bottom"
color="primary"
content={
<FormattedMessage
id="xpack.ingestManager.agentDetails.unavailableConfigTooltipText"
defaultMessage="This config is no longer available"
/>
}
/>{' '}
<EuiTextColor color="subdued">{agent.config_id}</EuiTextColor>
</Fragment>
),
},
];
return (
<>
{isReassignFlyoutOpen && (
<AgentReassignConfigFlyout agent={agent} onClose={() => setIsReassignFlyoutOpen(false)} />
)}
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.ingestManager.agentDetails.agentDetailsTitle"
defaultMessage="Agent detail"
/>
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
button={
<EuiButton onClick={handleToggleMenu}>
<FormattedMessage
id="xpack.ingestManager.agentDetails.actionsButton"
defaultMessage="Actions"
/>
</EuiButton>
}
isOpen={isActionsPopoverOpen}
closePopover={handleCloseMenu}
>
<EuiContextMenuPanel
items={[
<EuiContextMenuItem
icon="pencil"
onClick={() => {
handleCloseMenu();
setIsReassignFlyoutOpen(true);
}}
key="reassignConfig"
>
<FormattedMessage
id="xpack.ingestManager.agentList.reassignActionText"
defaultMessage="Assign new agent config"
/>
</EuiContextMenuItem>,
<AgentUnenrollProvider>
{unenrollAgentsPrompt => (
<EuiContextMenuItem
icon="cross"
disabled={!hasWriteCapabilites || !agent.active}
onClick={() => {
unenrollAgentsPrompt([agent.id], 1, refreshAgent);
}}
>
<FormattedMessage
id="xpack.ingestManager.agentList.unenrollOneButton"
defaultMessage="Unenroll"
/>
</EuiContextMenuItem>
)}
</AgentUnenrollProvider>,
]}
/>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'xl'} />
<EuiFlexGroup alignItems="flexStart" justifyContent="spaceBetween">
{items.map((item, idx) => (
<Item key={idx} label={item.title}>
{item.description}
</Item>
))}
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => metadataFlyout.show()}>View metadata</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
{metadataFlyout.isVisible && <AgentMetadataFlyout flyout={metadataFlyout} agent={agent} />}
</>
);
};

View file

@ -4,4 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { AgentEventsTable } from './agent_events_table';
export { AgentDetailSection } from './details_section';
export { AgentDetailsActionMenu } from './actions_menu';
export { AgentDetailsContent } from './agent_details';

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { AgentEvent } from '../../../../types';
export const TYPE_LABEL: { [key in AgentEvent['type']]: JSX.Element } = {
STATE: (
<EuiBadge color="hollow">
<FormattedMessage id="xpack.ingestManager.agentEventType.stateLabel" defaultMessage="State" />
</EuiBadge>
),
ERROR: (
<EuiBadge color="danger">
<FormattedMessage id="xpack.ingestManager.agentEventType.errorLabel" defaultMessage="Error" />
</EuiBadge>
),
ACTION_RESULT: (
<EuiBadge color="secondary">
<FormattedMessage
id="xpack.ingestManager.agentEventType.actionResultLabel"
defaultMessage="Action result"
/>
</EuiBadge>
),
ACTION: (
<EuiBadge color="primary">
<FormattedMessage
id="xpack.ingestManager.agentEventType.actionLabel"
defaultMessage="Action"
/>
</EuiBadge>
),
};
export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = {
RUNNING: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.runningLabel"
defaultMessage="Running"
/>
</EuiBadge>
),
STARTING: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.startingLabel"
defaultMessage="Starting"
/>
</EuiBadge>
),
IN_PROGRESS: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.inProgressLabel"
defaultMessage="In progress"
/>
</EuiBadge>
),
CONFIG: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.configLabel"
defaultMessage="Config"
/>
</EuiBadge>
),
FAILED: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.failedLabel"
defaultMessage="Failed"
/>
</EuiBadge>
),
STOPPING: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.stoppingLabel"
defaultMessage="Stopping"
/>
</EuiBadge>
),
STOPPED: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.stoppedLabel"
defaultMessage="Stopped"
/>
</EuiBadge>
),
DATA_DUMP: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.dataDumpLabel"
defaultMessage="Data dump"
/>
</EuiBadge>
),
ACKNOWLEDGED: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.acknowledgedLabel"
defaultMessage="Acknowledged"
/>
</EuiBadge>
),
UNKNOWN: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.ingestManager.agentEventSubtype.unknownLabel"
defaultMessage="Unknown"
/>
</EuiBadge>
),
};

View file

@ -3,64 +3,229 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { useRouteMatch } from 'react-router-dom';
import React, { useMemo } from 'react';
import { useRouteMatch, Switch, Route } from 'react-router-dom';
import styled from 'styled-components';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiText,
EuiLink,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCallOut, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AgentEventsTable, AgentDetailSection } from './components';
import { AgentRefreshContext } from './hooks';
import { Loading } from '../../../components';
import { useGetOneAgent } from '../../../hooks';
import {
FLEET_AGENTS_PATH,
FLEET_AGENT_DETAIL_PATH,
AGENT_CONFIG_DETAILS_PATH,
} from '../../../constants';
import { Loading, Error } from '../../../components';
import { useGetOneAgent, useGetOneAgentConfig, useLink } from '../../../hooks';
import { WithHeaderLayout } from '../../../layouts';
import { AgentHealth } from '../components';
import { AgentEventsTable, AgentDetailsActionMenu, AgentDetailsContent } from './components';
const Divider = styled.div`
width: 0;
height: 100%;
border-left: ${props => props.theme.eui.euiBorderThin};
`;
export const AgentDetailsPage: React.FunctionComponent = () => {
const {
params: { agentId },
} = useRouteMatch();
const agentRequest = useGetOneAgent(agentId, {
params: { agentId, tabId = '' },
} = useRouteMatch<{ agentId: string; tabId?: string }>();
const {
isLoading,
isInitialRequest,
error,
data: agentData,
sendRequest: sendAgentRequest,
} = useGetOneAgent(agentId, {
pollIntervalMs: 5000,
});
const {
isLoading: isAgentConfigLoading,
data: agentConfigData,
sendRequest: sendAgentConfigRequest,
} = useGetOneAgentConfig(agentData?.item?.config_id);
if (agentRequest.isLoading && agentRequest.isInitialRequest) {
return <Loading />;
}
const agentListUrl = useLink(FLEET_AGENTS_PATH);
const agentActivityTabUrl = useLink(`${FLEET_AGENT_DETAIL_PATH}${agentId}/activity`);
const agentDetailsTabUrl = useLink(`${FLEET_AGENT_DETAIL_PATH}${agentId}/details`);
const agentConfigUrl = useLink(AGENT_CONFIG_DETAILS_PATH);
if (agentRequest.error) {
return (
<WithHeaderLayout>
<EuiCallOut
title={i18n.translate('xpack.ingestManager.agentDetails.unexceptedErrorTitle', {
defaultMessage: 'An error happened while loading the agent',
})}
color="danger"
iconType="alert"
>
<p>
<EuiText>{agentRequest.error.message}</EuiText>
</p>
</EuiCallOut>
</WithHeaderLayout>
);
}
const headerLeftContent = useMemo(
() => (
<EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" href={agentListUrl} flush="left" size="xs">
<FormattedMessage
id="xpack.ingestManager.agentDetails.viewAgentListTitle"
defaultMessage="View all agent configurations"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h1>
{agentData?.item?.local_metadata['host.hostname'] || (
<FormattedMessage
id="xpack.ingestManager.agentDetails.agentDetailsTitle"
defaultMessage="Agent '{id}'"
values={{
id: agentId,
}}
/>
)}
</h1>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[agentData, agentId, agentListUrl]
);
if (!agentRequest.data) {
return (
<WithHeaderLayout>
<FormattedMessage
id="xpack.ingestManager.agentDetails.agentNotFoundErrorTitle"
defaultMessage="Agent Not found"
/>
</WithHeaderLayout>
);
}
const headerRightContent = useMemo(
() =>
agentData && agentData.item ? (
<EuiFlexGroup justifyContent={'flexEnd'} direction="row">
{[
{
label: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', {
defaultMessage: 'Status',
}),
content: <AgentHealth agent={agentData.item} />,
},
{ isDivider: true },
{
label: i18n.translate('xpack.ingestManager.agentDetails.configurationLabel', {
defaultMessage: 'Configuration',
}),
content: isAgentConfigLoading ? (
<Loading size="m" />
) : agentConfigData?.item ? (
<EuiLink href={`${agentConfigUrl}${agentData.item.config_id}`}>
{agentConfigData.item.name || agentData.item.config_id}
</EuiLink>
) : (
agentData.item.config_id || '-'
),
},
{ isDivider: true },
{
content: <AgentDetailsActionMenu agent={agentData.item} />,
},
].map((item, index) => (
<EuiFlexItem grow={false} key={index}>
{item.isDivider ?? false ? (
<Divider />
) : item.label ? (
<EuiDescriptionList compressed textStyle="reverse" style={{ textAlign: 'right' }}>
<EuiDescriptionListTitle>{item.label}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{item.content}</EuiDescriptionListDescription>
</EuiDescriptionList>
) : (
item.content
)}
</EuiFlexItem>
))}
</EuiFlexGroup>
) : (
undefined
),
[agentConfigData, agentConfigUrl, agentData, isAgentConfigLoading]
);
const agent = agentRequest.data.item;
const headerTabs = useMemo(() => {
return [
{
id: 'activity_log',
name: i18n.translate('xpack.ingestManager.agentDetails.subTabs.activityLogTab', {
defaultMessage: 'Activity log',
}),
href: agentActivityTabUrl,
isSelected: !tabId || tabId === 'activity',
},
{
id: 'details',
name: i18n.translate('xpack.ingestManager.agentDetails.subTabs.detailsTab', {
defaultMessage: 'Agent details',
}),
href: agentDetailsTabUrl,
isSelected: tabId === 'details',
},
];
}, [agentActivityTabUrl, agentDetailsTabUrl, tabId]);
return (
<AgentRefreshContext.Provider value={{ refresh: () => agentRequest.sendRequest() }}>
<WithHeaderLayout leftColumn={<AgentDetailSection agent={agent} />}>
<AgentEventsTable agent={agent} />
<AgentRefreshContext.Provider
value={{
refresh: () => {
sendAgentRequest();
sendAgentConfigRequest();
},
}}
>
<WithHeaderLayout
leftColumn={headerLeftContent}
rightColumn={headerRightContent}
tabs={(headerTabs as unknown) as EuiTabProps[]}
>
{isLoading && isInitialRequest ? (
<Loading />
) : error ? (
<Error
title={
<FormattedMessage
id="xpack.ingestManager.agentDetails.unexceptedErrorTitle"
defaultMessage="Error loading agent"
/>
}
error={error}
/>
) : agentData && agentData.item ? (
<Switch>
<Route
path={`${FLEET_AGENT_DETAIL_PATH}:agentId/details`}
render={() => {
return (
<AgentDetailsContent agent={agentData.item} agentConfig={agentConfigData?.item} />
);
}}
/>
<Route
path={`${FLEET_AGENT_DETAIL_PATH}:agentId`}
render={() => {
return <AgentEventsTable agent={agentData.item} />;
}}
/>
</Switch>
) : (
<Error
title={
<FormattedMessage
id="xpack.ingestManager.agentDetails.agentNotFoundErrorTitle"
defaultMessage="Agent not found"
/>
}
error={i18n.translate(
'xpack.ingestManager.agentDetails.agentNotFoundErrorDescription',
{
defaultMessage: 'Cannot found agent ID {agentId}',
values: {
agentId,
},
}
)}
/>
)}
</WithHeaderLayout>
</AgentRefreshContext.Provider>
);

View file

@ -238,13 +238,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const columns = [
{
field: 'local_metadata.host.hostname',
field: 'local_metadata.host',
name: i18n.translate('xpack.ingestManager.agentList.hostColumnTitle', {
defaultMessage: 'Host',
}),
render: (host: string, agent: Agent) => (
<ConnectedLink color="primary" path={`${FLEET_AGENT_DETAIL_PATH}${agent.id}`}>
{host}
{agent.local_metadata['host.hostname'] || host || ''}
</ConnectedLink>
),
},
@ -308,11 +308,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
},
},
{
field: 'local_metadata.agent_version',
field: 'local_metadata.version',
width: '100px',
name: i18n.translate('xpack.ingestManager.agentList.versionTitle', {
defaultMessage: 'Version',
}),
render: (version: string, agent: Agent) =>
agent.local_metadata['agent.version'] || version || '',
},
{
field: 'last_checkin',

View file

@ -8,3 +8,5 @@ export * from './loading';
export * from './agent_reassign_config_flyout';
export * from './navigation/child_routes';
export * from './navigation/connected_link';
export * from './agent_health';
export * from './agent_unenroll_provider';

View file

@ -42,7 +42,7 @@ export const FleetApp: React.FunctionComponent = () => {
<Router>
<Switch>
<Route path="/fleet" exact={true} render={() => <Redirect to="/fleet/agents" />} />
<Route path="/fleet/agents/:agentId">
<Route path="/fleet/agents/:agentId/:tabId?">
<AgentDetailsPage />
</Route>
<Route path="/fleet/agents">

View file

@ -7,6 +7,7 @@
import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'src/core/server';
import {
Agent,
NewAgentEvent,
AgentEvent,
AgentAction,
AgentSOAttributes,
@ -23,7 +24,7 @@ import { appContextService } from '../app_context';
export async function agentCheckin(
soClient: SavedObjectsClientContract,
agent: Agent,
events: AgentEvent[],
events: NewAgentEvent[],
localMetadata?: any
) {
const updateData: {
@ -85,10 +86,10 @@ export async function agentCheckin(
async function processEventsForCheckin(
soClient: SavedObjectsClientContract,
agent: Agent,
events: AgentEvent[]
events: NewAgentEvent[]
) {
const acknowledgedActionIds: string[] = [];
const updatedErrorEvents = [...agent.current_error_events];
const updatedErrorEvents: Array<AgentEvent | NewAgentEvent> = [...agent.current_error_events];
for (const event of events) {
// @ts-ignore
event.config_id = agent.config_id;
@ -122,7 +123,7 @@ async function processEventsForCheckin(
async function createEventsForAgent(
soClient: SavedObjectsClientContract,
agentId: string,
events: AgentEvent[]
events: NewAgentEvent[]
) {
const objects: Array<SavedObjectsBulkCreateObject<AgentEventSOAttributes>> = events.map(
eventData => {
@ -139,11 +140,11 @@ async function createEventsForAgent(
return soClient.bulkCreate(objects);
}
function isErrorOrState(event: AgentEvent) {
function isErrorOrState(event: AgentEvent | NewAgentEvent) {
return event.type === 'STATE' || event.type === 'ERROR';
}
function isActionEvent(event: AgentEvent) {
function isActionEvent(event: AgentEvent | NewAgentEvent) {
return (
event.type === 'ACTION' && (event.subtype === 'ACKNOWLEDGED' || event.subtype === 'UNKNOWN')
);

View file

@ -39,6 +39,7 @@ export async function getAgentEvents(
const items: AgentEvent[] = saved_objects.map(so => {
return {
id: so.id,
...so.attributes,
payload: so.attributes.payload ? JSON.parse(so.attributes.payload) : undefined,
};

View file

@ -12,6 +12,7 @@ export {
AgentSOAttributes,
AgentStatus,
AgentType,
NewAgentEvent,
AgentEvent,
AgentEventSOAttributes,
AgentAction,

View file

@ -49,8 +49,13 @@ export const AckEventSchema = schema.object({
...{ action_id: schema.string() },
});
export const NewAgentEventSchema = schema.object({
...AgentEventBase,
});
export const AgentEventSchema = schema.object({
...AgentEventBase,
id: schema.string(),
});
export const NewAgentActionSchema = schema.object({

View file

@ -5,7 +5,12 @@
*/
import { schema } from '@kbn/config-schema';
import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models';
import {
AckEventSchema,
NewAgentEventSchema,
AgentTypeSchema,
NewAgentActionSchema,
} from '../models';
export const GetAgentsRequestSchema = {
query: schema.object({
@ -28,7 +33,7 @@ export const PostAgentCheckinRequestSchema = {
}),
body: schema.object({
local_metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())),
events: schema.maybe(schema.arrayOf(AgentEventSchema)),
events: schema.maybe(schema.arrayOf(NewAgentEventSchema)),
}),
};

View file

@ -8213,15 +8213,10 @@
"xpack.ingestManager.agentConfigList.revisionNumber": "rev. {revNumber}",
"xpack.ingestManager.agentConfigList.updatedOnColumnTitle": "最終更新日:",
"xpack.ingestManager.agentConfigList.viewConfigActionText": "構成を表示",
"xpack.ingestManager.agentDetails.agentConfigLabel": "AgentConfig",
"xpack.ingestManager.agentDetails.agentDetailsTitle": "エージェントの詳細",
"xpack.ingestManager.agentDetails.agentNotFoundErrorTitle": "エージェントが見つかりません",
"xpack.ingestManager.agentDetails.idLabel": "ID",
"xpack.ingestManager.agentDetails.localMetadataSectionSubtitle": "メタデータを読み込み中",
"xpack.ingestManager.agentDetails.metadataSectionTitle": "メタデータ",
"xpack.ingestManager.agentDetails.statusLabel": "ステータス",
"xpack.ingestManager.agentDetails.typeLabel": "タイプ",
"xpack.ingestManager.agentDetails.unavailableConfigTooltipText": "この構成は利用できなくなりました",
"xpack.ingestManager.agentDetails.unexceptedErrorTitle": "エージェントを読み込む間にエラーが発生しました",
"xpack.ingestManager.agentDetails.userProvidedMetadataSectionSubtitle": "ユーザー提供メタデータ",
"xpack.ingestManager.agentEnrollment.apiKeySelectionDescription": "ご希望のエージェント構成とプラットフォームをすばやく選択できます。次いで、以下の手順に従ってエージェントをセットアップして登録します。",
@ -8237,11 +8232,9 @@
"xpack.ingestManager.agentEnrollment.stepTestAgents": "エージェントのテスト",
"xpack.ingestManager.agentEnrollment.testAgentLoadingMessage": "新しいエージェントの登録を待っています",
"xpack.ingestManager.agentEventsList.messageColumnTitle": "メッセージ",
"xpack.ingestManager.agentEventsList.paylodColumnTitle": "ペイロード",
"xpack.ingestManager.agentEventsList.refreshButton": "更新",
"xpack.ingestManager.agentEventsList.subtypeColumnTitle": "サブタイプ",
"xpack.ingestManager.agentEventsList.timestampColumnTitle": "タイムスタンプ",
"xpack.ingestManager.agentEventsList.title": "アクティビティログ",
"xpack.ingestManager.agentEventsList.typeColumnTitle": "タイプ",
"xpack.ingestManager.agentHealth.checkInTooltipText": "前回のチェックイン {lastCheckIn}",
"xpack.ingestManager.agentHealth.errorStatusText": "エラー",

View file

@ -8219,15 +8219,10 @@
"xpack.ingestManager.agentConfigList.revisionNumber": "修订 {revNumber}",
"xpack.ingestManager.agentConfigList.updatedOnColumnTitle": "最后更新时间",
"xpack.ingestManager.agentConfigList.viewConfigActionText": "查看配置",
"xpack.ingestManager.agentDetails.agentConfigLabel": "代理配置",
"xpack.ingestManager.agentDetails.agentDetailsTitle": "代理详情",
"xpack.ingestManager.agentDetails.agentNotFoundErrorTitle": "未找到代理",
"xpack.ingestManager.agentDetails.idLabel": "ID",
"xpack.ingestManager.agentDetails.localMetadataSectionSubtitle": "本地元数据",
"xpack.ingestManager.agentDetails.metadataSectionTitle": "元数据",
"xpack.ingestManager.agentDetails.statusLabel": "状态",
"xpack.ingestManager.agentDetails.typeLabel": "类型",
"xpack.ingestManager.agentDetails.unavailableConfigTooltipText": "此配置不再可用",
"xpack.ingestManager.agentDetails.unexceptedErrorTitle": "加载代理时发生错误",
"xpack.ingestManager.agentDetails.userProvidedMetadataSectionSubtitle": "用户提供的元数据",
"xpack.ingestManager.agentEnrollment.apiKeySelectionDescription": "快速选择所需的代理配置和平台。然后,根据下面的说明设置和注册代理。",
@ -8243,11 +8238,9 @@
"xpack.ingestManager.agentEnrollment.stepTestAgents": "测试代理",
"xpack.ingestManager.agentEnrollment.testAgentLoadingMessage": "正在等候新代理注册",
"xpack.ingestManager.agentEventsList.messageColumnTitle": "消息",
"xpack.ingestManager.agentEventsList.paylodColumnTitle": "负载",
"xpack.ingestManager.agentEventsList.refreshButton": "刷新",
"xpack.ingestManager.agentEventsList.subtypeColumnTitle": "子类型",
"xpack.ingestManager.agentEventsList.timestampColumnTitle": "时间戳",
"xpack.ingestManager.agentEventsList.title": "活动日志",
"xpack.ingestManager.agentEventsList.typeColumnTitle": "类型",
"xpack.ingestManager.agentHealth.checkInTooltipText": "上次签入时间 {lastCheckIn}",
"xpack.ingestManager.agentHealth.errorStatusText": "错误",

View file

@ -106,7 +106,7 @@ export default function(providerContext: FtrProviderContext) {
item.action_id === '48cebde1-c906-4893-b89f-595d943b72a2'
);
expect(expectedEvents.length).to.eql(2);
const expectedEvent = expectedEvents.find(
const { id, ...expectedEvent } = expectedEvents.find(
(item: Record<string, string>) => item.action_id === '48cebde1-c906-4893-b89f-595d943b72a1'
);
expect(expectedEvent).to.eql({