[Security Solution] tgrid cellActions enhancement (#113419)

* Alerts cellAction enhancement

* styling

* fix types

* expandable topN

* fix types

* styling for filters

* styling

* rm getDefaultCellActions

* styling

* globalFilters for topN

* rm unused i18n keys

* unit test

* add i18n

* rename component

* fix types

* update i18n keys

* unit tests

* styling for reason row renderer

* rename file

* fix Circular Dependencies

* update wording/icons for show top N

* cell value text overflow

* reason in grid-view

* unit test

* default selected option for topN

* lint error

* configurable paddingSize and showLegend for topN

* update snapshot

* rename reason title

* fix cypress

* fix cypress

* fix unit tests

* fix default cell actions

* fix page crashing

* unit test

* add unit tests

* code review

* fix missing props

* fix expand ip button

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2021-10-14 15:17:28 +01:00 committed by GitHub
parent cdce98c8a3
commit 56a2e788ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1556 additions and 493 deletions

View file

@ -210,7 +210,7 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
class="eventFieldsTable__fieldValue"
>
<span
class="euiToolTipAnchor"
class="euiToolTipAnchor eui-textTruncate eui-alignMiddle"
>
Nov 25, 2020 @ 15:42:39.417
</span>
@ -872,7 +872,7 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
class="eventFieldsTable__fieldValue"
>
<span
class="euiToolTipAnchor"
class="euiToolTipAnchor eui-textTruncate eui-alignMiddle"
>
Nov 25, 2020 @ 15:42:39.417
</span>

View file

@ -25,7 +25,7 @@ import {
} from '../../../../timelines/components/timeline/body/renderers/constants';
import { BYTES_FORMAT } from '../../../../timelines/components/timeline/body/renderers/bytes';
import { EVENT_DURATION_FIELD_NAME } from '../../../../timelines/components/duration';
import { PORT_NAMES } from '../../../../network/components/port';
import { PORT_NAMES } from '../../../../network/components/port/helpers';
import { INDICATOR_REFERENCE } from '../../../../../common/cti/constants';
import { BrowserField } from '../../../containers/source';
import { DataProvider, IS_OPERATOR } from '../../../../../common/types';

View file

@ -92,23 +92,27 @@ PreferenceFormattedP1DTDate.displayName = 'PreferenceFormattedP1DTDate';
* - a long representation of the date that includes the day of the week (e.g. Thursday, March 21, 2019 6:47pm)
* - the raw date value (e.g. 2019-03-22T00:47:46Z)
*/
export const FormattedDate = React.memo<{
interface FormattedDateProps {
className?: string;
fieldName: string;
value?: string | number | null;
className?: string;
}>(({ value, fieldName, className = '' }): JSX.Element => {
if (value == null) {
return getOrEmptyTagFromValue(value);
}
export const FormattedDate = React.memo<FormattedDateProps>(
({ value, fieldName, className = '' }): JSX.Element => {
if (value == null) {
return getOrEmptyTagFromValue(value);
}
const maybeDate = getMaybeDate(value);
return maybeDate.isValid() ? (
<LocalizedDateTooltip date={maybeDate.toDate()} fieldName={fieldName} className={className}>
<PreferenceFormattedDate value={maybeDate.toDate()} />
</LocalizedDateTooltip>
) : (
getOrEmptyTagFromValue(value)
);
}
const maybeDate = getMaybeDate(value);
return maybeDate.isValid() ? (
<LocalizedDateTooltip date={maybeDate.toDate()} fieldName={fieldName} className={className}>
<PreferenceFormattedDate value={maybeDate.toDate()} />
</LocalizedDateTooltip>
) : (
getOrEmptyTagFromValue(value)
);
});
);
FormattedDate.displayName = 'FormattedDate';

View file

@ -6,23 +6,25 @@ exports[`HeaderSection it renders 1`] = `
>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
>
<EuiFlexItem
grow={true}
>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
responsive={false}
>
<EuiFlexItem>
<EuiTitle
size="m"
>
<h2
<h4
data-test-subj="header-section-title"
>
Test title
</h2>
</h4>
</EuiTitle>
<Subtitle
data-test-subj="header-section-subtitle"

View file

@ -68,12 +68,12 @@ const HeaderSectionComponent: React.FC<HeaderSectionProps> = ({
hideSubtitle = false,
}) => (
<Header data-test-subj="header-section" border={border} height={height}>
<EuiFlexGroup alignItems="center">
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={growLeftSplit}>
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="none">
<EuiFlexItem>
<EuiTitle size={titleSize}>
<h2 data-test-subj="header-section-title">
<h4 data-test-subj="header-section-title">
{title}
{tooltip && (
<>
@ -81,7 +81,7 @@ const HeaderSectionComponent: React.FC<HeaderSectionProps> = ({
<EuiIconTip color="subdued" content={tooltip} size="l" type="iInCircle" />
</>
)}
</h2>
</h4>
</EuiTitle>
{!hideSubtitle && (

View file

@ -30,33 +30,49 @@ const SHOW_TOP = (fieldName: string) =>
});
interface Props {
className?: string;
/** When `Component` is used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality.
* When `Component` is used with `EuiContextMenu`, we pass EuiContextMenuItem to render the right style.
*/
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem;
enablePopOver?: boolean;
field: string;
flush?: 'left' | 'right' | 'both';
globalFilters?: Filter[];
iconSide?: 'left' | 'right';
iconType?: string;
isExpandable?: boolean;
onClick: () => void;
onFilterAdded?: () => void;
ownFocus: boolean;
paddingSize?: 's' | 'm' | 'l' | 'none';
showTooltip?: boolean;
showTopN: boolean;
showLegend?: boolean;
timelineId?: string | null;
title?: string;
value?: string[] | string | null;
}
export const ShowTopNButton: React.FC<Props> = React.memo(
({
className,
Component,
enablePopOver,
field,
flush,
iconSide,
iconType,
isExpandable,
onClick,
onFilterAdded,
ownFocus,
paddingSize,
showLegend,
showTooltip = true,
showTopN,
timelineId,
title,
value,
globalFilters,
}) => {
@ -70,31 +86,36 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
? SourcererScopeName.detections
: SourcererScopeName.default;
const { browserFields, indexPattern } = useSourcererScope(activeScope);
const icon = iconType ?? 'visBarVertical';
const side = iconSide ?? 'left';
const buttonTitle = title ?? SHOW_TOP(field);
const basicButton = useMemo(
() =>
Component ? (
<Component
aria-label={SHOW_TOP(field)}
aria-label={buttonTitle}
className={className}
data-test-subj="show-top-field"
icon="visBarVertical"
iconType="visBarVertical"
icon={icon}
iconType={icon}
iconSide={side}
flush={flush}
onClick={onClick}
title={SHOW_TOP(field)}
title={buttonTitle}
>
{SHOW_TOP(field)}
{buttonTitle}
</Component>
) : (
<EuiButtonIcon
aria-label={SHOW_TOP(field)}
aria-label={buttonTitle}
className="securitySolution__hoverActionButton"
data-test-subj="show-top-field"
iconSize="s"
iconType="visBarVertical"
iconType={icon}
onClick={onClick}
/>
),
[Component, field, onClick]
[Component, buttonTitle, className, flush, icon, onClick, side]
);
const button = useMemo(
@ -107,7 +128,7 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
field,
value,
})}
content={SHOW_TOP(field)}
content={buttonTitle}
shortcut={SHOW_TOP_N_KEYBOARD_SHORTCUT}
showShortcut={ownFocus}
/>
@ -118,7 +139,7 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
) : (
basicButton
),
[basicButton, field, ownFocus, showTooltip, showTopN, value]
[basicButton, buttonTitle, field, ownFocus, showTooltip, showTopN, value]
);
const topNPannel = useMemo(
@ -128,15 +149,37 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
field={field}
indexPattern={indexPattern}
onFilterAdded={onFilterAdded}
paddingSize={paddingSize}
showLegend={showLegend}
timelineId={timelineId ?? undefined}
toggleTopN={onClick}
value={value}
globalFilters={globalFilters}
/>
),
[browserFields, field, indexPattern, onClick, onFilterAdded, timelineId, value, globalFilters]
[
browserFields,
field,
indexPattern,
onFilterAdded,
paddingSize,
showLegend,
timelineId,
onClick,
value,
globalFilters,
]
);
if (isExpandable) {
return (
<>
{basicButton}
{showTopN && topNPannel}
</>
);
}
return showTopN ? (
enablePopOver ? (
<EuiPopover

View file

@ -0,0 +1,71 @@
/*
* 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, { SyntheticEvent } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonIcon,
EuiButtonProps,
EuiLink,
EuiLinkProps,
PropsForAnchor,
PropsForButton,
} from '@elastic/eui';
import styled from 'styled-components';
export const LinkButton: React.FC<PropsForButton<EuiButtonProps> | PropsForAnchor<EuiButtonProps>> =
({ children, ...props }) => <EuiButton {...props}>{children}</EuiButton>;
export const LinkAnchor: React.FC<EuiLinkProps> = ({ children, ...props }) => (
<EuiLink {...props}>{children}</EuiLink>
);
export const Comma = styled('span')`
margin-right: 5px;
margin-left: 5px;
&::after {
content: ' ,';
}
`;
Comma.displayName = 'Comma';
const GenericLinkButtonComponent: React.FC<{
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
dataTestSubj?: string;
href: string;
onClick?: (e: SyntheticEvent) => void;
title?: string;
iconType?: string;
}> = ({ children, Component, dataTestSubj, href, onClick, title, iconType = 'expand' }) => {
return Component ? (
<Component
data-test-subj={dataTestSubj}
href={href}
iconType={iconType}
onClick={onClick}
title={title}
>
{title ?? children}
</Component>
) : (
<LinkButton data-test-subj={dataTestSubj} href={href} onClick={onClick}>
{title ?? children}
</LinkButton>
);
};
export const GenericLinkButton = React.memo(GenericLinkButtonComponent);
export const PortContainer = styled.div`
& svg {
position: relative;
top: -1px;
}
`;

View file

@ -6,19 +6,15 @@
*/
import {
EuiButton,
EuiButtonProps,
EuiLink,
EuiLinkProps,
EuiToolTip,
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
PropsForAnchor,
PropsForButton,
EuiLink,
EuiToolTip,
} from '@elastic/eui';
import React, { useMemo, useCallback, SyntheticEvent } from 'react';
import { isNil } from 'lodash/fp';
import styled from 'styled-components';
import { IP_REPUTATION_LINKS_SETTING, APP_ID } from '../../../../common/constants';
import {
@ -43,23 +39,12 @@ import { isUrlInvalid } from '../../utils/validators';
import * as i18n from './translations';
import { SecurityPageName } from '../../../app/types';
import { getUebaDetailsUrl } from '../link_to/redirect_to_ueba';
import { LinkButton, LinkAnchor, GenericLinkButton, PortContainer, Comma } from './helpers';
export { LinkButton, LinkAnchor } from './helpers';
export const DEFAULT_NUMBER_OF_LINK = 5;
export const LinkButton: React.FC<PropsForButton<EuiButtonProps> | PropsForAnchor<EuiButtonProps>> =
({ children, ...props }) => <EuiButton {...props}>{children}</EuiButton>;
export const LinkAnchor: React.FC<EuiLinkProps> = ({ children, ...props }) => (
<EuiLink {...props}>{children}</EuiLink>
);
export const PortContainer = styled.div`
& svg {
position: relative;
top: -1px;
}
`;
// Internal Links
const UebaDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
@ -102,10 +87,13 @@ export const UebaDetailsLink = React.memo(UebaDetailsLinkComponent);
const HostDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
hostName: string;
isButton?: boolean;
onClick?: (e: SyntheticEvent) => void;
}> = ({ children, hostName, isButton, onClick }) => {
title?: string;
}> = ({ children, Component, hostName, isButton, onClick, title }) => {
const { formatUrl, search } = useFormatUrl(SecurityPageName.hosts);
const { navigateToApp } = useKibana().services.application;
const goToHostDetails = useCallback(
@ -118,19 +106,25 @@ const HostDetailsLinkComponent: React.FC<{
},
[hostName, navigateToApp, search]
);
const href = useMemo(
() => formatUrl(getHostDetailsUrl(encodeURIComponent(hostName))),
[formatUrl, hostName]
);
return isButton ? (
<LinkButton
<GenericLinkButton
Component={Component}
dataTestSubj="data-grid-host-details"
href={href}
iconType="expand"
onClick={onClick ?? goToHostDetails}
href={formatUrl(getHostDetailsUrl(encodeURIComponent(hostName)))}
data-test-subj="host-details-button"
title={title ?? hostName}
>
{children ? children : hostName}
</LinkButton>
{children}
</GenericLinkButton>
) : (
<LinkAnchor
onClick={onClick ?? goToHostDetails}
href={formatUrl(getHostDetailsUrl(encodeURIComponent(hostName)))}
href={href}
data-test-subj="host-details-button"
>
{children ? children : hostName}
@ -176,11 +170,14 @@ ExternalLink.displayName = 'ExternalLink';
const NetworkDetailsLinkComponent: React.FC<{
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
ip: string;
flowTarget?: FlowTarget | FlowTargetSourceDest;
isButton?: boolean;
onClick?: (e: SyntheticEvent) => void | undefined;
}> = ({ children, ip, flowTarget = FlowTarget.source, isButton, onClick }) => {
title?: string;
}> = ({ Component, children, ip, flowTarget = FlowTarget.source, isButton, onClick, title }) => {
const { formatUrl, search } = useFormatUrl(SecurityPageName.network);
const { navigateToApp } = useKibana().services.application;
const goToNetworkDetails = useCallback(
@ -193,19 +190,25 @@ const NetworkDetailsLinkComponent: React.FC<{
},
[flowTarget, ip, navigateToApp, search]
);
const href = useMemo(
() => formatUrl(getNetworkDetailsUrl(encodeURIComponent(encodeIpv6(ip)))),
[formatUrl, ip]
);
return isButton ? (
<LinkButton
href={formatUrl(getNetworkDetailsUrl(encodeURIComponent(encodeIpv6(ip))))}
<GenericLinkButton
Component={Component}
dataTestSubj="data-grid-network-details"
onClick={onClick ?? goToNetworkDetails}
data-test-subj="network-details"
href={href}
title={title ?? ip}
>
{children ? children : ip}
</LinkButton>
{children}
</GenericLinkButton>
) : (
<LinkAnchor
onClick={onClick ?? goToNetworkDetails}
href={formatUrl(getNetworkDetailsUrl(encodeURIComponent(encodeIpv6(ip))))}
href={href}
data-test-subj="network-details"
>
{children ? children : ip}
@ -272,63 +275,84 @@ CreateCaseLink.displayName = 'CreateCaseLink';
// External Links
export const GoogleLink = React.memo<{ children?: React.ReactNode; link: string }>(
({ children, link }) => (
<ExternalLink url={`https://www.google.com/search?q=${encodeURIComponent(link)}`}>
{children ? children : link}
</ExternalLink>
)
({ children, link }) => {
const url = useMemo(
() => `https://www.google.com/search?q=${encodeURIComponent(link)}`,
[link]
);
return <ExternalLink url={url}>{children ? children : link}</ExternalLink>;
}
);
GoogleLink.displayName = 'GoogleLink';
export const PortOrServiceNameLink = React.memo<{
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
portOrServiceName: number | string;
}>(({ children, portOrServiceName }) => (
<PortContainer>
<EuiLink
data-test-subj="port-or-service-name-link"
href={`https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=${encodeURIComponent(
onClick?: (e: SyntheticEvent) => void | undefined;
title?: string;
}>(({ Component, title, children, portOrServiceName }) => {
const href = useMemo(
() =>
`https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml?search=${encodeURIComponent(
String(portOrServiceName)
)}`}
target="_blank"
)}`,
[portOrServiceName]
);
return Component ? (
<Component
href={href}
data-test-subj="data-grid-port-or-service-name-link"
title={title}
iconType="link"
>
{children ? children : portOrServiceName}
</EuiLink>
</PortContainer>
));
{title ?? children ?? portOrServiceName}
</Component>
) : (
<PortContainer>
<EuiLink data-test-subj="port-or-service-name-link" href={href} target="_blank">
{children ? children : portOrServiceName}
</EuiLink>
</PortContainer>
);
});
PortOrServiceNameLink.displayName = 'PortOrServiceNameLink';
export const Ja3FingerprintLink = React.memo<{
children?: React.ReactNode;
ja3Fingerprint: string;
}>(({ children, ja3Fingerprint }) => (
<EuiLink
data-test-subj="ja3-fingerprint-link"
href={`https://sslbl.abuse.ch/ja3-fingerprints/${encodeURIComponent(ja3Fingerprint)}`}
target="_blank"
>
{children ? children : ja3Fingerprint}
</EuiLink>
));
}>(({ children, ja3Fingerprint }) => {
const href = useMemo(
() => `https://sslbl.abuse.ch/ja3-fingerprints/${encodeURIComponent(ja3Fingerprint)}`,
[ja3Fingerprint]
);
return (
<EuiLink data-test-subj="ja3-fingerprint-link" href={href} target="_blank">
{children ? children : ja3Fingerprint}
</EuiLink>
);
});
Ja3FingerprintLink.displayName = 'Ja3FingerprintLink';
export const CertificateFingerprintLink = React.memo<{
children?: React.ReactNode;
certificateFingerprint: string;
}>(({ children, certificateFingerprint }) => (
<EuiLink
data-test-subj="certificate-fingerprint-link"
href={`https://sslbl.abuse.ch/ssl-certificates/sha1/${encodeURIComponent(
certificateFingerprint
)}`}
target="_blank"
>
{children ? children : certificateFingerprint}
</EuiLink>
));
}>(({ children, certificateFingerprint }) => {
const href = useMemo(
() =>
`https://sslbl.abuse.ch/ssl-certificates/sha1/${encodeURIComponent(certificateFingerprint)}`,
[certificateFingerprint]
);
return (
<EuiLink data-test-subj="certificate-fingerprint-link" href={href} target="_blank">
{children ? children : certificateFingerprint}
</EuiLink>
);
});
CertificateFingerprintLink.displayName = 'CertificateFingerprintLink';
@ -354,16 +378,6 @@ const isReputationLink = (
(rowItem as ReputationLinkSetting).url_template !== undefined &&
(rowItem as ReputationLinkSetting).name !== undefined;
export const Comma = styled('span')`
margin-right: 5px;
margin-left: 5px;
&::after {
content: ' ,';
}
`;
Comma.displayName = 'Comma';
const defaultNameMapping: Record<DefaultReputationLink, string> = {
[DefaultReputationLink['virustotal.com']]: i18n.VIEW_VIRUS_TOTAL,
[DefaultReputationLink['talosIntelligence.com']]: i18n.VIEW_TALOS_INTELLIGENCE,
@ -463,11 +477,13 @@ ReputationLinkComponent.displayName = 'ReputationLinkComponent';
export const ReputationLink = React.memo(ReputationLinkComponent);
export const WhoIsLink = React.memo<{ children?: React.ReactNode; domain: string }>(
({ children, domain }) => (
<ExternalLink url={`https://www.iana.org/whois?q=${encodeURIComponent(domain)}`}>
{children ? children : domain}
</ExternalLink>
)
({ children, domain }) => {
const url = useMemo(
() => `https://www.iana.org/whois?q=${encodeURIComponent(domain)}`,
[domain]
);
return <ExternalLink url={url}>{children ? children : domain}</ExternalLink>;
}
);
WhoIsLink.displayName = 'WhoIsLink';

View file

@ -79,6 +79,7 @@ export const MatrixHistogramComponent: React.FC<MatrixHistogramComponentProps> =
legendPosition,
mapping,
onError,
paddingSize = 'm',
panelHeight = DEFAULT_PANEL_HEIGHT,
setAbsoluteRangeDatePickerTarget = 'global',
setQuery,
@ -200,7 +201,11 @@ export const MatrixHistogramComponent: React.FC<MatrixHistogramComponentProps> =
return (
<>
<InspectButtonContainer show={!isInitialLoading}>
<HistogramPanel data-test-subj={`${id}Panel`} height={panelHeight}>
<HistogramPanel
data-test-subj={`${id}Panel`}
height={panelHeight}
paddingSize={paddingSize}
>
{loading && !isInitialLoading && (
<EuiProgress
data-test-subj="initialLoadingPanelMatrixOverTime"

View file

@ -52,6 +52,7 @@ interface MatrixHistogramBasicProps {
legendPosition?: Position;
mapping?: MatrixHistogramMappingTypes;
panelHeight?: number;
paddingSize?: 's' | 'm' | 'l' | 'none';
setQuery: GlobalTimeArgs['setQuery'];
startDate: GlobalTimeArgs['from'];
stackByOptions: MatrixHistogramOption[];

View file

@ -31,13 +31,58 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
z-index: 9950 !important;
}
.euiDataGridRowCell__expandButton .euiDataGridRowCell__actionButtonIcon {
display: none;
&:first-child,
&:nth-child(2),
&:nth-child(3) {
display: inline-flex;
}
}
/*
overrides the default styling of EuiDataGrid expand popover footer to
make it a column of actions instead of the default actions row
*/
.euiDataGridRowCell__popover .euiPopoverFooter .euiFlexGroup {
flex-direction: column;
align-items: flex-start;
.euiDataGridRowCell__popover {
max-width: 815px !important;
max-height: none !important;
overflow: hidden;
.expandable-top-value-button {
&.euiButtonEmpty--primary:enabled:focus,
.euiButtonEmpty--primary:focus {
background-color: transparent;
}
}
&.euiPopover__panel.euiPopover__panel-isOpen {
padding: 8px 0;
min-width: 65px;
}
.euiPopoverFooter {
border: 0;
.euiFlexGroup {
flex-direction: column;
.euiButtonEmpty .euiButtonContent {
justify-content: left;
}
.euiFlexItem:first-child,
.euiFlexItem:nth-child(2) {
display: none;
}
}
}
}
/*
@ -54,10 +99,14 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
}
/* hide open draggable popovers when a modal is being displayed to prevent them from covering the modal */
body.euiBody-hasOverlayMask .withHoverActions__popover.euiPopover__panel-isOpen{
visibility: hidden !important;
body.euiBody-hasOverlayMask {
.euiDataGridRowCell__popover.euiPopover__panel-isOpen,
.withHoverActions__popover.euiPopover__panel-isOpen {
visibility: hidden !important;
}
}
/* ensure elastic charts tooltips appear above open euiPopovers */
.echTooltip {
z-index: 9950;

View file

@ -81,6 +81,8 @@ export interface OwnProps {
timelineId?: string;
toggleTopN: () => void;
onFilterAdded?: () => void;
paddingSize?: 's' | 'm' | 'l' | 'none';
showLegend?: boolean;
value?: string[] | string | null;
globalFilters?: Filter[];
}
@ -101,6 +103,8 @@ const StatefulTopNComponent: React.FC<Props> = ({
globalQuery = EMPTY_QUERY,
kqlMode,
onFilterAdded,
paddingSize,
showLegend,
timelineId,
toggleTopN,
value,
@ -160,7 +164,9 @@ const StatefulTopNComponent: React.FC<Props> = ({
from={timelineId === TimelineId.active ? activeTimelineFrom : from}
indexPattern={indexPattern}
options={options}
paddingSize={paddingSize}
query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery}
showLegend={showLegend}
setAbsoluteRangeDatePickerTarget={timelineId === TimelineId.active ? 'timeline' : 'global'}
setQuery={setQuery}
timelineId={timelineId}

View file

@ -55,8 +55,10 @@ export interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery
filters: Filter[];
indexPattern: IIndexPattern;
options: TopNOption[];
paddingSize?: 's' | 'm' | 'l' | 'none';
query: Query;
setAbsoluteRangeDatePickerTarget: InputsModelId;
showLegend?: boolean;
timelineId?: string;
toggleTopN: () => void;
onFilterAdded?: () => void;
@ -72,7 +74,9 @@ const TopNComponent: React.FC<Props> = ({
from,
indexPattern,
options,
paddingSize,
query,
showLegend,
setAbsoluteRangeDatePickerTarget,
setQuery,
timelineId,
@ -127,7 +131,9 @@ const TopNComponent: React.FC<Props> = ({
indexPattern={indexPattern}
indexNames={view === 'raw' ? rawIndices : allIndices}
onlyField={field}
paddingSize={paddingSize}
query={query}
showLegend={showLegend}
setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget}
setQuery={setQuery}
showSpacer={false}
@ -141,7 +147,9 @@ const TopNComponent: React.FC<Props> = ({
filters={filters}
headerChildren={headerChildren}
onlyField={field}
paddingSize={paddingSize}
query={query}
showLegend={showLegend}
setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget}
timelineId={timelineId}
/>

View file

@ -0,0 +1,92 @@
/*
* 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 { EuiDataGridColumn } from '@elastic/eui';
import type {
BrowserFields,
TimelineNonEcsData,
} from '../../../../../timelines/common/search_strategy';
import { TGridCellAction } from '../../../../../timelines/common/types';
import { Ecs } from '../../../../common/ecs';
import { ColumnHeaderType } from '../../../timelines/store/timeline/model';
import { defaultCellActions, EmptyComponent } from './default_cell_actions';
import { COLUMNS_WITH_LINKS } from './helpers';
describe('default cell actions', () => {
const browserFields: BrowserFields = {};
const data: TimelineNonEcsData[][] = [[]];
const ecsData: Ecs[] = [];
const timelineId = 'mockTimelineId';
const pageSize = 10;
test('columns without any link action (e.g.: signal.status) should return an empty component (not null or data grid would crash)', () => {
const columnHeaders = [
{
category: 'signal',
columnHeaderType: 'no-filtered' as ColumnHeaderType,
id: 'signal.status',
type: 'string',
aggregatable: true,
initialWidth: 105,
},
];
const columnsWithCellActions: EuiDataGridColumn[] = columnHeaders.map((header) => {
const buildAction = (tGridCellAction: TGridCellAction) =>
tGridCellAction({
browserFields,
data,
ecsData,
header: columnHeaders.find((h) => h.id === header.id),
pageSize,
timelineId,
});
return {
...header,
cellActions: defaultCellActions?.map(buildAction),
};
});
expect(columnsWithCellActions[0]?.cellActions?.length).toEqual(5);
expect(columnsWithCellActions[0]?.cellActions![4]).toEqual(EmptyComponent);
});
const columnHeadersToTest = COLUMNS_WITH_LINKS.map((c) => [
{
category: 'signal',
columnHeaderType: 'no-filtered' as ColumnHeaderType,
id: c.columnId,
type: c.fieldType,
aggregatable: true,
initialWidth: 105,
},
]);
describe.each(columnHeadersToTest)('columns with a link action', (columnHeaders) => {
test(`${columnHeaders.id ?? columnHeaders.type}`, () => {
const columnsWithCellActions: EuiDataGridColumn[] = [columnHeaders].map((header) => {
const buildAction = (tGridCellAction: TGridCellAction) =>
tGridCellAction({
browserFields,
data,
ecsData,
header: [columnHeaders].find((h) => h.id === header.id),
pageSize,
timelineId,
});
return {
...header,
cellActions: defaultCellActions?.map(buildAction),
};
});
expect(columnsWithCellActions[0]?.cellActions?.length).toEqual(5);
expect(columnsWithCellActions[0]?.cellActions![4]).not.toEqual(EmptyComponent);
});
});
});

View file

@ -5,21 +5,28 @@
* 2.0.
*/
import React, { useCallback, useState, useMemo } from 'react';
import { Filter } from '../../../../../../../src/plugins/data/public';
import { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { head, getOr, get, isEmpty } from 'lodash/fp';
import React, { useMemo } from 'react';
import type {
BrowserFields,
TimelineNonEcsData,
} from '../../../../../timelines/common/search_strategy';
import { DataProvider, TGridCellAction } from '../../../../../timelines/common/types';
import {
ColumnHeaderOptions,
DataProvider,
TGridCellAction,
} from '../../../../../timelines/common/types';
import { getPageRowIndex } from '../../../../../timelines/public';
import { Ecs } from '../../../../common/ecs';
import { getMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
import { parseValue } from '../../../timelines/components/timeline/body/renderers/parse_value';
import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider';
import { allowTopN, escapeDataProviderId } from '../../components/drag_and_drop/helpers';
import { ShowTopNButton } from '../../components/hover_actions/actions/show_top_n';
import { getAllFieldsByName } from '../../containers/source';
import { escapeDataProviderId } from '../../components/drag_and_drop/helpers';
import { useKibana } from '../kibana';
import { getLink } from './helpers';
/** a noop required by the filter in / out buttons */
const onFilterAdded = () => {};
@ -36,15 +43,77 @@ const useKibanaServices = () => {
return { timelines, filterManager };
};
/** the default actions shown in `EuiDataGrid` cells */
export const defaultCellActions: TGridCellAction[] = [
export const EmptyComponent = () => <></>;
const cellActionLink = [
({
browserFields,
data,
ecsData,
header,
timelineId,
pageSize,
}: {
browserFields: BrowserFields;
data: TimelineNonEcsData[][];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
timelineId: string;
pageSize: number;
}) =>
getLink(header?.id, header?.type, header?.linkField)
? ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => {
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const ecs = pageRowIndex < ecsData.length ? ecsData[pageRowIndex] : null;
const linkValues = header && getOr([], header.linkField ?? '', ecs);
const eventId = header && get('_id' ?? '', ecs);
if (pageRowIndex >= data.length) {
// data grid expects each cell action always return an element, it crashes if returns null
return <></>;
}
const values = getMappedNonEcsValue({
data: data[pageRowIndex],
fieldName: columnId,
});
const link = getLink(columnId, header?.type, header?.linkField);
const value = parseValue(head(values));
return link && eventId && values && !isEmpty(value) ? (
<FormattedFieldValue
Component={Component}
contextId={`expanded-value-${columnId}-row-${pageRowIndex}-${timelineId}`}
eventId={eventId}
fieldFormat={header?.format || ''}
fieldName={columnId}
fieldType={header?.type || ''}
isButton={true}
isDraggable={false}
value={value}
truncate={false}
title={values.length > 1 ? `${link?.label}: ${value}` : link?.label}
linkValue={head(linkValues)}
onClick={closePopover}
/>
) : (
// data grid expects each cell action always return an element, it crashes if returns null
<></>
);
}
: EmptyComponent,
];
export const cellActions: TGridCellAction[] = [
({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) =>
({ rowIndex, columnId, Component }) => {
({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
const { timelines, filterManager } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
if (pageRowIndex >= data.length) {
return null;
// data grid expects each cell action always return an element, it crashes if returns null
return <></>;
}
const value = getMappedNonEcsValue({
@ -72,7 +141,8 @@ export const defaultCellActions: TGridCellAction[] = [
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
if (pageRowIndex >= data.length) {
return null;
// data grid expects each cell action always return an element, it crashes if returns null
return <></>;
}
const value = getMappedNonEcsValue({
@ -100,34 +170,8 @@ export const defaultCellActions: TGridCellAction[] = [
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
if (pageRowIndex >= data.length) {
return null;
}
const value = getMappedNonEcsValue({
data: data[pageRowIndex],
fieldName: columnId,
});
return (
<>
{timelines.getHoverActions().getCopyButton({
Component,
field: columnId,
isHoverAction: false,
ownFocus: false,
showTooltip: false,
value,
})}
</>
);
},
({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) =>
({ rowIndex, columnId, Component }) => {
const { timelines } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
if (pageRowIndex >= data.length) {
return null;
// data grid expects each cell action always return an element, it crashes if returns null
return <></>;
}
const value = getMappedNonEcsValue({
@ -165,26 +209,14 @@ export const defaultCellActions: TGridCellAction[] = [
</>
);
},
({
browserFields,
data,
globalFilters,
timelineId,
pageSize,
}: {
browserFields: BrowserFields;
data: TimelineNonEcsData[][];
globalFilters?: Filter[];
timelineId: string;
pageSize: number;
}) =>
({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) =>
({ rowIndex, columnId, Component }) => {
const [showTopN, setShowTopN] = useState(false);
const onClick = useCallback(() => setShowTopN(!showTopN), [showTopN]);
const { timelines } = useKibanaServices();
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
if (pageRowIndex >= data.length) {
return null;
// data grid expects each cell action always return an element, it crashes if returns null
return <></>;
}
const value = getMappedNonEcsValue({
@ -192,31 +224,20 @@ export const defaultCellActions: TGridCellAction[] = [
fieldName: columnId,
});
const showButton = useMemo(
() =>
allowTopN({
browserField: getAllFieldsByName(browserFields)[columnId],
fieldName: columnId,
hideTopN: false,
}),
[columnId]
return (
<>
{timelines.getHoverActions().getCopyButton({
Component,
field: columnId,
isHoverAction: false,
ownFocus: false,
showTooltip: false,
value,
})}
</>
);
return showButton ? (
<ShowTopNButton
Component={Component}
enablePopOver
data-test-subj="hover-actions-show-top-n"
field={columnId}
globalFilters={globalFilters}
onClick={onClick}
onFilterAdded={onFilterAdded}
ownFocus={false}
showTopN={showTopN}
showTooltip={false}
timelineId={timelineId}
value={value}
/>
) : null;
},
];
/** the default actions shown in `EuiDataGrid` cells */
export const defaultCellActions = [...cellActions, ...cellActionLink];

View file

@ -0,0 +1,49 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { ExpandedCellValueActions } from './expanded_cell_value_actions';
jest.mock('../kibana');
describe('ExpandedCellValueActions', () => {
const props = {
browserFields: {
host: {
fields: {
'host.name': {
aggregatable: true,
category: 'host',
description:
'Name of the host. It can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.',
type: 'string',
name: 'host.name',
},
},
},
},
field: 'host.name',
globalFilters: [],
onFilterAdded: () => {},
timelineId: 'mockTimelineId',
value: ['mock value'],
};
const wrapper = shallow(<ExpandedCellValueActions {...props} />);
test('renders show topN button', () => {
expect(wrapper.find('[data-test-subj="data-grid-expanded-show-top-n"]').exists()).toBeTruthy();
});
test('renders filter in button', () => {
expect(wrapper.find('EuiFlexItem').first().html()).toContain('Filter button');
});
test('renders filter out button', () => {
expect(wrapper.find('EuiFlexItem').last().html()).toContain('Filter out button');
});
});

View file

@ -0,0 +1,125 @@
/*
* 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { useMemo, useState, useCallback } from 'react';
import styled from 'styled-components';
import { Filter } from '../../../../../../../src/plugins/data/public';
import { BrowserFields } from '../../../../../timelines/common/search_strategy';
import { allowTopN } from '../../components/drag_and_drop/helpers';
import { ShowTopNButton } from '../../components/hover_actions/actions/show_top_n';
import { getAllFieldsByName } from '../../containers/source';
import { useKibana } from '../kibana';
import { SHOW_TOP_VALUES, HIDE_TOP_VALUES } from './translations';
interface Props {
browserFields: BrowserFields;
field: string;
globalFilters?: Filter[];
timelineId: string;
value: string[] | undefined;
onFilterAdded?: () => void;
}
const StyledFlexGroup = styled(EuiFlexGroup)`
border-top: 1px solid #d3dae6;
border-bottom: 1px solid #d3dae6;
margin-top: 2px;
`;
export const StyledContent = styled.div<{ $isDetails: boolean }>`
padding: ${({ $isDetails }) => ($isDetails ? '0 8px' : undefined)};
`;
const ExpandedCellValueActionsComponent: React.FC<Props> = ({
browserFields,
field,
globalFilters,
onFilterAdded,
timelineId,
value,
}) => {
const {
timelines,
data: {
query: { filterManager },
},
} = useKibana().services;
const showButton = useMemo(
() =>
allowTopN({
browserField: getAllFieldsByName(browserFields)[field],
fieldName: field,
hideTopN: false,
}),
[browserFields, field]
);
const [showTopN, setShowTopN] = useState(false);
const onClick = useCallback(() => setShowTopN(!showTopN), [showTopN]);
return (
<>
<StyledContent $isDetails data-test-subj="data-grid-expanded-cell-value-actions">
{showButton ? (
<ShowTopNButton
className="eui-displayBlock expandable-top-value-button"
Component={EuiButtonEmpty}
data-test-subj="data-grid-expanded-show-top-n"
field={field}
flush="both"
globalFilters={globalFilters}
iconSide="right"
iconType={showTopN ? 'arrowUp' : 'arrowDown'}
isExpandable
onClick={onClick}
onFilterAdded={onFilterAdded ?? noop}
ownFocus={false}
paddingSize="none"
showLegend
showTopN={showTopN}
showTooltip={false}
timelineId={timelineId}
title={showTopN ? HIDE_TOP_VALUES : SHOW_TOP_VALUES}
value={value}
/>
) : null}
</StyledContent>
<StyledFlexGroup gutterSize="s">
<EuiFlexItem>
{timelines.getHoverActions().getFilterForValueButton({
Component: EuiButtonEmpty,
field,
filterManager,
onFilterAdded,
ownFocus: false,
size: 's',
showTooltip: false,
value,
})}
</EuiFlexItem>
<EuiFlexItem>
{timelines.getHoverActions().getFilterOutValueButton({
Component: EuiButtonEmpty,
field,
filterManager,
onFilterAdded,
ownFocus: false,
size: 's',
showTooltip: false,
value,
})}
</EuiFlexItem>
</StyledFlexGroup>
</>
);
};
ExpandedCellValueActionsComponent.displayName = 'ExpandedCellValueActionsComponent';
export const ExpandedCellValueActions = React.memo(ExpandedCellValueActionsComponent);

View file

@ -0,0 +1,65 @@
/*
* 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 * as i18n from './translations';
import {
EVENT_URL_FIELD_NAME,
HOST_NAME_FIELD_NAME,
REFERENCE_URL_FIELD_NAME,
RULE_REFERENCE_FIELD_NAME,
SIGNAL_RULE_NAME_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { INDICATOR_REFERENCE } from '../../../../common/cti/constants';
import { IP_FIELD_TYPE } from '../../../network/components/ip';
import { PORT_NAMES } from '../../../network/components/port/helpers';
export const COLUMNS_WITH_LINKS = [
{
columnId: HOST_NAME_FIELD_NAME,
label: i18n.VIEW_HOST_SUMMARY,
},
{
columnId: 'source.ip',
fieldType: IP_FIELD_TYPE,
label: i18n.EXPAND_IP_DETAILS,
},
{
columnId: 'destination.ip',
fieldType: IP_FIELD_TYPE,
label: i18n.EXPAND_IP_DETAILS,
},
{
columnId: SIGNAL_RULE_NAME_FIELD_NAME,
label: i18n.VIEW_RULE_DETAILS,
},
...PORT_NAMES.map((p) => ({
columnId: p,
label: i18n.VIEW_PORT_DETAILS,
})),
{
columnId: RULE_REFERENCE_FIELD_NAME,
label: i18n.VIEW_RULE_REFERENCE,
},
{
columnId: REFERENCE_URL_FIELD_NAME,
label: i18n.VIEW_RULE_REFERENCE,
},
{
columnId: EVENT_URL_FIELD_NAME,
label: i18n.VIEW_EVENT_REFERENCE,
},
{
columnId: INDICATOR_REFERENCE,
label: i18n.VIEW_INDICATOR_REFERENCE,
},
];
export const getLink = (cId?: string, fieldType?: string, linkField?: string) =>
cId &&
COLUMNS_WITH_LINKS.find(
(c) => c.columnId === cId || (c.fieldType && fieldType === c.fieldType && linkField != null)
);

View file

@ -0,0 +1,71 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SHOW_TOP_VALUES = i18n.translate(
'xpack.securitySolution.expandedValue.showTopN.showTopValues',
{
defaultMessage: 'Show top values',
}
);
export const HIDE_TOP_VALUES = i18n.translate(
'xpack.securitySolution.expandedValue.hideTopValues.HideTopValues',
{
defaultMessage: 'Hide top values',
}
);
export const VIEW_HOST_SUMMARY = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewHostSummary',
{
defaultMessage: 'View host summary',
}
);
export const EXPAND_IP_DETAILS = i18n.translate(
'xpack.securitySolution.expandedValue.links.expandIpDetails',
{
defaultMessage: 'Expand ip details',
}
);
export const VIEW_RULE_DETAILS = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewRuleDetails',
{
defaultMessage: 'View rule details',
}
);
export const VIEW_PORT_DETAILS = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewPortDetails',
{
defaultMessage: 'View port details',
}
);
export const VIEW_RULE_REFERENCE = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewRuleReference',
{
defaultMessage: 'View rule reference',
}
);
export const VIEW_EVENT_REFERENCE = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewEventReference',
{
defaultMessage: 'View event reference',
}
);
export const VIEW_INDICATOR_REFERENCE = i18n.translate(
'xpack.securitySolution.expandedValue.links.viewIndicatorReference',
{
defaultMessage: 'View indicator reference',
}
);

View file

@ -31,6 +31,7 @@ interface AlertsHistogramProps {
legendItems: LegendItem[];
legendPosition?: Position;
loading: boolean;
showLegend?: boolean;
to: string;
data: HistogramData[];
updateDateRange: UpdateDateRange;
@ -43,6 +44,7 @@ export const AlertsHistogram = React.memo<AlertsHistogramProps>(
legendItems,
legendPosition = 'right',
loading,
showLegend,
to,
updateDateRange,
}) => {
@ -73,8 +75,9 @@ export const AlertsHistogram = React.memo<AlertsHistogramProps>(
<Settings
legendPosition={legendPosition}
onBrushEnd={updateDateRange}
showLegend={legendItems.length === 0}
showLegendExtra
// showLegend controls the default legend coming from Elastic chart, we show them when our customised legend items doesn't exist (but we still want to show legend).
showLegend={showLegend && legendItems.length === 0}
showLegendExtra={showLegend}
theme={theme}
/>

View file

@ -63,10 +63,12 @@ interface AlertsHistogramPanelProps {
headerChildren?: React.ReactNode;
/** Override all defaults, and only display this field */
onlyField?: AlertsStackByField;
paddingSize?: 's' | 'm' | 'l' | 'none';
titleSize?: EuiTitleSize;
query?: Query;
legendPosition?: Position;
signalIndexName: string | null;
showLegend?: boolean;
showLinkToAlerts?: boolean;
showTotalAlertsCount?: boolean;
showStackBy?: boolean;
@ -85,9 +87,11 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
filters,
headerChildren,
onlyField,
paddingSize = 'm',
query,
legendPosition = 'right',
signalIndexName,
showLegend = true,
showLinkToAlerts = false,
showTotalAlertsCount = false,
showStackBy = true,
@ -154,7 +158,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
const legendItems: LegendItem[] = useMemo(
() =>
alertsData?.aggregations?.alertsByGrouping?.buckets != null
showLegend && alertsData?.aggregations?.alertsByGrouping?.buckets != null
? alertsData.aggregations.alertsByGrouping.buckets.map((bucket, i) => ({
color: i < defaultLegendColors.length ? defaultLegendColors[i] : undefined,
dataProviderId: escapeDataProviderId(
@ -165,7 +169,12 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
value: bucket.key,
}))
: NO_LEGEND_DATA,
[alertsData, selectedStackByOption, timelineId]
[
alertsData?.aggregations?.alertsByGrouping.buckets,
selectedStackByOption,
showLegend,
timelineId,
]
);
useEffect(() => {
@ -254,7 +263,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
return (
<InspectButtonContainer data-test-subj="alerts-histogram-panel" show={!isInitialLoading}>
<KpiPanel height={PANEL_HEIGHT} hasBorder>
<KpiPanel height={PANEL_HEIGHT} hasBorder paddingSize={paddingSize}>
<HeaderSection
id={uniqueQueryId}
title={titleText}
@ -288,6 +297,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
legendPosition={legendPosition}
loading={isLoadingAlerts}
to={to}
showLegend={showLegend}
updateDateRange={updateDateRange}
/>
)}

View file

@ -375,21 +375,21 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
return (
<StatefulEventsViewer
pageFilters={defaultFiltersMemo}
additionalFilters={additionalFiltersComponent}
currentFilter={filterGroup}
defaultCellActions={defaultCellActions}
defaultModel={defaultTimelineModel}
entityType="events"
end={to}
currentFilter={filterGroup}
entityType="events"
hasAlertsCrud={hasIndexWrite && hasIndexMaintenance}
id={timelineId}
onRuleChange={onRuleChange}
pageFilters={defaultFiltersMemo}
renderCellValue={RenderCellValue}
rowRenderers={defaultRowRenderers}
scopeId={SourcererScopeName.detections}
start={from}
utilityBar={utilityBarCallback}
additionalFilters={additionalFiltersComponent}
hasAlertsCrud={hasIndexWrite && hasIndexMaintenance}
/>
);
};

View file

@ -18,37 +18,41 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell
*/
export const RenderCellValue: React.FC<EuiDataGridCellValueElementProps & CellValueElementProps> =
({
browserFields,
columnId,
data,
ecsData,
eventId,
isDraggable,
globalFilters,
header,
isDetails,
isDraggable,
isExpandable,
isExpanded,
linkValues,
rowIndex,
rowRenderers,
setCellProps,
timelineId,
ecsData,
rowRenderers,
browserFields,
truncate,
}) => (
<DefaultCellRenderer
browserFields={browserFields}
columnId={columnId}
data={data}
ecsData={ecsData}
eventId={eventId}
isDraggable={isDraggable}
globalFilters={globalFilters}
header={header}
isDetails={isDetails}
isDraggable={isDraggable}
isExpandable={isExpandable}
isExpanded={isExpanded}
linkValues={linkValues}
rowIndex={rowIndex}
rowRenderers={rowRenderers}
setCellProps={setCellProps}
timelineId={timelineId}
ecsData={ecsData}
rowRenderers={rowRenderers}
browserFields={browserFields}
truncate={truncate}
/>
);

View file

@ -115,11 +115,11 @@ const EventsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> = ({
end={endDate}
entityType="events"
id={TimelineId.hostsPageEvents}
pageFilters={pageFilters}
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
scopeId={SourcererScopeName.default}
start={startDate}
pageFilters={pageFilters}
unit={unit}
/>
</>

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
export const CLIENT_PORT_FIELD_NAME = 'client.port';
export const DESTINATION_PORT_FIELD_NAME = 'destination.port';
export const SERVER_PORT_FIELD_NAME = 'server.port';
export const SOURCE_PORT_FIELD_NAME = 'source.port';
export const URL_PORT_FIELD_NAME = 'url.port';
export const PORT_NAMES = [
CLIENT_PORT_FIELD_NAME,
DESTINATION_PORT_FIELD_NAME,
SERVER_PORT_FIELD_NAME,
SOURCE_PORT_FIELD_NAME,
URL_PORT_FIELD_NAME,
];

View file

@ -5,33 +5,22 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import React from 'react';
import { DefaultDraggable } from '../../../common/components/draggables';
import { getEmptyValue } from '../../../common/components/empty_value';
import { PortOrServiceNameLink } from '../../../common/components/links';
export const CLIENT_PORT_FIELD_NAME = 'client.port';
export const DESTINATION_PORT_FIELD_NAME = 'destination.port';
export const SERVER_PORT_FIELD_NAME = 'server.port';
export const SOURCE_PORT_FIELD_NAME = 'source.port';
export const URL_PORT_FIELD_NAME = 'url.port';
export const PORT_NAMES = [
CLIENT_PORT_FIELD_NAME,
DESTINATION_PORT_FIELD_NAME,
SERVER_PORT_FIELD_NAME,
SOURCE_PORT_FIELD_NAME,
URL_PORT_FIELD_NAME,
];
export const Port = React.memo<{
contextId: string;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
eventId: string;
fieldName: string;
isDraggable?: boolean;
title?: string;
value: string | undefined | null;
}>(({ contextId, eventId, fieldName, isDraggable, value }) =>
}>(({ Component, contextId, eventId, fieldName, isDraggable, title, value }) =>
isDraggable ? (
<DefaultDraggable
data-test-subj="port"
@ -41,10 +30,18 @@ export const Port = React.memo<{
tooltipContent={fieldName}
value={value}
>
<PortOrServiceNameLink portOrServiceName={value || getEmptyValue()} />
<PortOrServiceNameLink
portOrServiceName={value || getEmptyValue()}
Component={Component}
title={title}
/>
</DefaultDraggable>
) : (
<PortOrServiceNameLink portOrServiceName={value || getEmptyValue()} />
<PortOrServiceNameLink
portOrServiceName={value || getEmptyValue()}
Component={Component}
title={title}
/>
)
);

View file

@ -18,7 +18,7 @@ import { TestProviders } from '../../../common/mock/test_providers';
import { ID_FIELD_NAME } from '../../../common/components/event_details/event_id';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../ip';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME } from '../port';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME } from '../port/helpers';
import {
DESTINATION_BYTES_FIELD_NAME,
DESTINATION_PACKETS_FIELD_NAME,

View file

@ -15,7 +15,7 @@ import '../../../common/mock/match_media';
import { TestProviders } from '../../../common/mock/test_providers';
import { ID_FIELD_NAME } from '../../../common/components/event_details/event_id';
import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../ip';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME } from '../port';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME } from '../port/helpers';
import * as i18n from '../../../timelines/components/timeline/body/renderers/translations';
import { useMountAppended } from '../../../common/utils/use_mount_appended';

View file

@ -11,7 +11,8 @@ import React from 'react';
import deepEqual from 'fast-deep-equal';
import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../ip';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME, Port } from '../port';
import { Port } from '../port';
import { DESTINATION_PORT_FIELD_NAME, SOURCE_PORT_FIELD_NAME } from '../port/helpers';
import * as i18n from '../../../timelines/components/timeline/body/renderers/translations';
import { GeoFields } from './geo_fields';

View file

@ -49,8 +49,10 @@ interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'se
indexPattern: IIndexPattern;
indexNames: string[];
onlyField?: string;
paddingSize?: 's' | 'm' | 'l' | 'none';
query: Query;
setAbsoluteRangeDatePickerTarget?: InputsModelId;
showLegend?: boolean;
showSpacer?: boolean;
timelineId?: string;
toggleTopN?: () => void;
@ -70,9 +72,11 @@ const EventsByDatasetComponent: React.FC<Props> = ({
indexPattern,
indexNames,
onlyField,
paddingSize,
query,
setAbsoluteRangeDatePickerTarget,
setQuery,
showLegend,
showSpacer = true,
timelineId,
to,
@ -177,9 +181,11 @@ const EventsByDatasetComponent: React.FC<Props> = ({
id={uniqueQueryId}
indexNames={indexNames}
onError={toggleTopN}
paddingSize={paddingSize}
setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget}
setQuery={setQuery}
showSpacer={showSpacer}
showLegend={showLegend}
skip={filterQuery === undefined}
startDate={from}
timelineId={timelineId}

View file

@ -23,8 +23,10 @@ interface Props {
headerChildren?: React.ReactNode;
/** Override all defaults, and only display this field */
onlyField?: AlertsStackByField;
paddingSize?: 's' | 'm' | 'l' | 'none';
query?: Query;
setAbsoluteRangeDatePickerTarget?: InputsModelId;
showLegend?: boolean;
timelineId?: string;
}
@ -33,7 +35,9 @@ const SignalsByCategoryComponent: React.FC<Props> = ({
filters,
headerChildren,
onlyField,
paddingSize,
query,
showLegend,
setAbsoluteRangeDatePickerTarget = 'global',
timelineId,
}) => {
@ -61,16 +65,18 @@ const SignalsByCategoryComponent: React.FC<Props> = ({
combinedQueries={combinedQueries}
filters={filters}
headerChildren={headerChildren}
legendPosition={'right'}
onlyField={onlyField}
titleSize={onlyField == null ? 'm' : 's'}
paddingSize={paddingSize}
query={query}
signalIndexName={signalIndexName}
showTotalAlertsCount={true}
showLegend={showLegend}
showLinkToAlerts={onlyField == null ? true : false}
showStackBy={onlyField == null}
legendPosition={'right'}
showTotalAlertsCount={true}
signalIndexName={signalIndexName}
timelineId={timelineId}
title={i18n.ALERT_COUNT}
titleSize={onlyField == null ? 'm' : 's'}
updateDateRange={updateDateRangeCallback}
/>
);

View file

@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useContext } from 'react';
import { useDispatch } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { FlowTarget } from '../../../../common/search_strategy/security_solution/network';
import {
DragEffects,
@ -145,11 +146,15 @@ interface AddressLinksItemProps extends Omit<AddressLinksProps, 'addresses'> {
const AddressLinksItemComponent: React.FC<AddressLinksItemProps> = ({
address,
Component,
contextId,
eventId,
fieldName,
isButton,
isDraggable,
onClick,
truncate,
title,
}) => {
const key = `address-links-draggable-wrapper-${getUniqueId({
contextId,
@ -171,6 +176,10 @@ const AddressLinksItemComponent: React.FC<AddressLinksItemProps> = ({
const openNetworkDetailsSidePanel = useCallback(
(e) => {
e.preventDefault();
if (onClick) {
onClick();
}
if (eventContext && isInTimelineContext) {
const { tabType, timelineID } = eventContext;
const updatedExpandedDetail: TimelineExpandedDetailType = {
@ -196,22 +205,41 @@ const AddressLinksItemComponent: React.FC<AddressLinksItemProps> = ({
}
}
},
[eventContext, isInTimelineContext, address, fieldName, dispatch]
[onClick, eventContext, isInTimelineContext, address, fieldName, dispatch]
);
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined
// When this component is used outside of timeline/alerts table (i.e. in the flyout) we would still like it to link to the IP Overview page
const content = useMemo(
() => (
<Content field={fieldName} tooltipContent={fieldName}>
() =>
Component ? (
<NetworkDetailsLink
Component={Component}
ip={address}
isButton={false}
isButton={isButton}
onClick={isInTimelineContext ? openNetworkDetailsSidePanel : undefined}
title={title}
/>
</Content>
),
[address, fieldName, isInTimelineContext, openNetworkDetailsSidePanel]
) : (
<Content field={fieldName} tooltipContent={fieldName}>
<NetworkDetailsLink
Component={Component}
ip={address}
isButton={isButton}
onClick={isInTimelineContext ? openNetworkDetailsSidePanel : undefined}
title={title}
/>
</Content>
),
[
Component,
address,
fieldName,
isButton,
isInTimelineContext,
openNetworkDetailsSidePanel,
title,
]
);
const render = useCallback(
@ -245,20 +273,28 @@ const AddressLinksItem = React.memo(AddressLinksItemComponent);
interface AddressLinksProps {
addresses: string[];
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
contextId: string;
eventId: string;
fieldName: string;
isButton?: boolean;
isDraggable: boolean;
onClick?: () => void;
truncate?: boolean;
title?: string;
}
const AddressLinksComponent: React.FC<AddressLinksProps> = ({
addresses,
Component,
contextId,
eventId,
fieldName,
isButton,
isDraggable,
onClick,
truncate,
title,
}) => {
const uniqAddresses = useMemo(() => uniq(addresses), [addresses]);
@ -268,14 +304,29 @@ const AddressLinksComponent: React.FC<AddressLinksProps> = ({
<AddressLinksItem
key={address}
address={address}
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
isButton={isButton}
isDraggable={isDraggable}
onClick={onClick}
truncate={truncate}
title={title}
/>
)),
[contextId, eventId, fieldName, isDraggable, truncate, uniqAddresses]
[
Component,
contextId,
eventId,
fieldName,
isButton,
isDraggable,
onClick,
title,
truncate,
uniqAddresses,
]
);
return <>{content}</>;
@ -293,13 +344,28 @@ const AddressLinks = React.memo(
);
const FormattedIpComponent: React.FC<{
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
contextId: string;
eventId: string;
fieldName: string;
isButton?: boolean;
isDraggable: boolean;
onClick?: () => void;
title?: string;
truncate?: boolean;
value: string | object | null | undefined;
}> = ({ contextId, eventId, fieldName, isDraggable, truncate, value }) => {
}> = ({
Component,
contextId,
eventId,
fieldName,
isDraggable,
isButton,
onClick,
title,
truncate,
value,
}) => {
if (isString(value) && !isEmpty(value)) {
try {
const addresses = JSON.parse(value);
@ -307,10 +373,14 @@ const FormattedIpComponent: React.FC<{
return (
<AddressLinks
addresses={addresses}
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
isButton={isButton}
isDraggable={isDraggable}
onClick={onClick}
title={title}
truncate={truncate}
/>
);
@ -323,11 +393,15 @@ const FormattedIpComponent: React.FC<{
return (
<AddressLinks
addresses={[value]}
Component={Component}
contextId={contextId}
eventId={eventId}
isButton={isButton}
isDraggable={isDraggable}
onClick={onClick}
fieldName={fieldName}
truncate={truncate}
title={title}
/>
);
} else {

View file

@ -25,7 +25,7 @@ import { JA3_HASH_FIELD_NAME } from '../ja3_fingerprint';
import {
DESTINATION_PORT_FIELD_NAME,
SOURCE_PORT_FIELD_NAME,
} from '../../../network/components/port';
} from '../../../network/components/port/helpers';
import {
DESTINATION_GEO_CITY_NAME_FIELD_NAME,
DESTINATION_GEO_CONTINENT_NAME_FIELD_NAME,

View file

@ -6,33 +6,44 @@
*/
import type React from 'react';
import { Filter } from '../../../../../../../../../src/plugins/data/public';
import { BrowserFields } from '../../../../../../../timelines/common/search_strategy';
import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common';
import { ColumnHeaderOptions, RowRenderer } from '../../../../../../common';
import { Ecs } from '../../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
export interface ColumnRenderer {
isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean;
renderColumn: ({
browserFields,
className,
columnName,
eventId,
field,
globalFilters,
isDetails,
isDraggable,
linkValues,
rowRenderers,
timelineId,
truncate,
values,
linkValues,
}: {
asPlainText?: boolean;
browserFields?: BrowserFields;
className?: string;
columnName: string;
ecsData?: Ecs;
eventId: string;
field: ColumnHeaderOptions;
globalFilters?: Filter[];
isDetails?: boolean;
isDraggable?: boolean;
linkValues?: string[] | null | undefined;
rowRenderers?: RowRenderer[];
timelineId: string;
truncate?: boolean;
values: string[] | null | undefined;
linkValues?: string[] | null | undefined;
ecsData?: Ecs;
rowRenderers?: RowRenderer[];
browserFields?: BrowserFields;
}) => React.ReactNode;
}

View file

@ -7,7 +7,7 @@
/* eslint-disable complexity */
import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { isNumber, isEmpty } from 'lodash/fp';
import React from 'react';
@ -19,7 +19,8 @@ import { getOrEmptyTagFromValue } from '../../../../../common/components/empty_v
import { FormattedDate } from '../../../../../common/components/formatted_date';
import { FormattedIp } from '../../../../components/formatted_ip';
import { Port, PORT_NAMES } from '../../../../../network/components/port';
import { Port } from '../../../../../network/components/port';
import { PORT_NAMES } from '../../../../../network/components/port/helpers';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
import {
DATE_FIELD_TYPE,
@ -44,44 +45,60 @@ import { AgentStatuses } from './agent_statuses';
const columnNamesNotDraggable = [MESSAGE_FIELD_NAME];
const FormattedFieldValueComponent: React.FC<{
asPlainText?: boolean;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
contextId: string;
eventId: string;
isObjectArray?: boolean;
fieldFormat?: string;
fieldName: string;
fieldType?: string;
isButton?: boolean;
isDraggable?: boolean;
onClick?: () => void;
title?: string;
truncate?: boolean;
value: string | number | undefined | null;
linkValue?: string | null | undefined;
}> = ({
asPlainText,
Component,
contextId,
eventId,
fieldFormat,
fieldName,
fieldType,
isButton,
isObjectArray = false,
isDraggable = true,
truncate,
onClick,
title,
truncate = true,
value,
linkValue,
}) => {
if (isObjectArray) {
return <>{value}</>;
if (isObjectArray || asPlainText) {
return <span data-test-subj={`formatted-field-${fieldName}`}>{value}</span>;
} else if (fieldType === IP_FIELD_TYPE) {
return (
<FormattedIp
Component={Component}
eventId={eventId}
contextId={contextId}
fieldName={fieldName}
isButton={isButton}
isDraggable={isDraggable}
value={!isNumber(value) ? value : String(value)}
onClick={onClick}
title={title}
truncate={truncate}
/>
);
} else if (fieldType === GEO_FIELD_TYPE) {
return <>{value}</>;
} else if (fieldType === DATE_FIELD_TYPE) {
const classNames = truncate ? 'eui-textTruncate eui-alignMiddle' : undefined;
return isDraggable ? (
<DefaultDraggable
field={fieldName}
@ -90,18 +107,20 @@ const FormattedFieldValueComponent: React.FC<{
tooltipContent={null}
value={`${value}`}
>
<FormattedDate fieldName={fieldName} value={value} />
<FormattedDate className={classNames} fieldName={fieldName} value={value} />
</DefaultDraggable>
) : (
<FormattedDate fieldName={fieldName} value={value} />
<FormattedDate className={classNames} fieldName={fieldName} value={value} />
);
} else if (PORT_NAMES.some((portName) => fieldName === portName)) {
return (
<Port
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
isDraggable={isDraggable}
title={title}
value={`${value}`}
/>
);
@ -118,10 +137,14 @@ const FormattedFieldValueComponent: React.FC<{
} else if (fieldName === HOST_NAME_FIELD_NAME) {
return (
<HostName
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
isDraggable={isDraggable}
isButton={isButton}
onClick={onClick}
title={title}
value={value}
/>
);
@ -138,11 +161,13 @@ const FormattedFieldValueComponent: React.FC<{
} else if (fieldName === SIGNAL_RULE_NAME_FIELD_NAME) {
return (
<RenderRuleName
Component={Component}
contextId={contextId}
eventId={eventId}
fieldName={fieldName}
isDraggable={isDraggable}
linkValue={linkValue}
title={title}
truncate={truncate}
value={value}
/>
@ -185,7 +210,17 @@ const FormattedFieldValueComponent: React.FC<{
INDICATOR_REFERENCE,
].includes(fieldName)
) {
return renderUrl({ contextId, eventId, fieldName, linkValue, isDraggable, truncate, value });
return renderUrl({
contextId,
Component,
eventId,
fieldName,
linkValue,
isDraggable,
truncate,
title,
value,
});
} else if (columnNamesNotDraggable.includes(fieldName) || !isDraggable) {
return truncate && !isEmpty(value) ? (
<TruncatableText data-test-subj="truncatable-message">

View file

@ -5,9 +5,17 @@
* 2.0.
*/
import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiLink,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiToolTip,
} from '@elastic/eui';
import { isString, isEmpty } from 'lodash/fp';
import React, { useCallback } from 'react';
import React, { SyntheticEvent, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { DefaultDraggable } from '../../../../../common/components/draggables';
@ -30,22 +38,26 @@ const EventModuleFlexItem = styled(EuiFlexItem)`
`;
interface RenderRuleNameProps {
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
contextId: string;
eventId: string;
fieldName: string;
isDraggable: boolean;
linkValue: string | null | undefined;
truncate?: boolean;
title?: string;
value: string | number | null | undefined;
}
export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
Component,
contextId,
eventId,
fieldName,
isDraggable,
linkValue,
truncate,
title,
value,
}) => {
const ruleName = `${value}`;
@ -69,15 +81,29 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
[navigateToApp, ruleId, search]
);
const href = useMemo(
() =>
getUrlForApp(APP_ID, {
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsUrl(ruleId ?? '', search),
}),
[getUrlForApp, ruleId, search]
);
const id = `event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}-${ruleId}`;
if (isString(value) && ruleName.length > 0 && ruleId != null) {
const link = (
<LinkAnchor
const link = Component ? (
<Component
aria-label={title}
data-test-subj={`view-${fieldName}`}
iconType="link"
onClick={goToRuleDetails}
href={getUrlForApp(APP_ID, {
deepLinkId: SecurityPageName.rules,
path: getRuleDetailsUrl(ruleId, search),
})}
title={title}
>
{title ?? value}
</Component>
) : (
<LinkAnchor onClick={goToRuleDetails} href={href}>
{content}
</LinkAnchor>
);
@ -85,7 +111,7 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
return isDraggable ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}-${ruleId}`}
id={id}
isDraggable={isDraggable}
tooltipContent={value}
value={value}
@ -99,7 +125,7 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
return isDraggable ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}-${ruleId}`}
id={id}
isDraggable={isDraggable}
tooltipContent={value}
value={`${value}`}
@ -189,35 +215,71 @@ export const renderEventModule = ({
);
};
const GenericLinkComponent: React.FC<{
children?: React.ReactNode;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
dataTestSubj?: string;
href: string;
onClick?: (e: SyntheticEvent) => void;
title?: string;
iconType?: string;
}> = ({ children, Component, dataTestSubj, href, onClick, title, iconType = 'link' }) => {
return Component ? (
<Component
data-test-subj={dataTestSubj}
href={href}
iconType={iconType}
onClick={onClick}
title={title}
>
{title ?? children}
</Component>
) : (
<EuiLink data-test-subj={dataTestSubj} target="_blank" href={href}>
{title ?? children}
</EuiLink>
);
};
const GenericLink = React.memo(GenericLinkComponent);
export const renderUrl = ({
contextId,
Component,
eventId,
fieldName,
isDraggable,
linkValue,
truncate,
title,
value,
}: {
contextId: string;
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
eventId: string;
fieldName: string;
isDraggable: boolean;
linkValue: string | null | undefined;
truncate?: boolean;
title?: string;
value: string | number | null | undefined;
}) => {
const urlName = `${value}`;
const isUrlValid = !isUrlInvalid(urlName);
const formattedValue = truncate ? <TruncatableText>{value}</TruncatableText> : value;
const content = (
<>
{!isUrlInvalid(urlName) && (
<EuiLink target="_blank" href={urlName}>
{formattedValue}
</EuiLink>
)}
{isUrlInvalid(urlName) && <>{formattedValue}</>}
</>
const content = isUrlValid ? (
<GenericLink
Component={Component}
href={urlName}
dataTestSubj="ata-grid-url"
title={title}
iconType="link"
/>
) : (
<>{formattedValue}</>
);
return isString(value) && urlName.length > 0 ? (

View file

@ -6,6 +6,7 @@
*/
import React, { useCallback, useContext, useMemo } from 'react';
import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { isString } from 'lodash/fp';
import { HostDetailsLink } from '../../../../../common/components/links';
@ -23,17 +24,25 @@ import { StatefulEventContext } from '../../../../../../../timelines/public';
interface Props {
contextId: string;
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
eventId: string;
fieldName: string;
isDraggable: boolean;
isButton?: boolean;
onClick?: () => void;
value: string | number | undefined | null;
title?: string;
}
const HostNameComponent: React.FC<Props> = ({
fieldName,
Component,
contextId,
eventId,
isDraggable,
isButton,
onClick,
title,
value,
}) => {
const dispatch = useDispatch();
@ -44,6 +53,10 @@ const HostNameComponent: React.FC<Props> = ({
const openHostDetailsSidePanel = useCallback(
(e) => {
e.preventDefault();
if (onClick) {
onClick();
}
if (eventContext && isInTimelineContext) {
const { timelineID, tabType } = eventContext;
const updatedExpandedDetail: TimelineExpandedDetailType = {
@ -66,7 +79,7 @@ const HostNameComponent: React.FC<Props> = ({
}
}
},
[dispatch, eventContext, isInTimelineContext, hostName]
[onClick, eventContext, isInTimelineContext, hostName, dispatch]
);
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined
@ -74,14 +87,16 @@ const HostNameComponent: React.FC<Props> = ({
const content = useMemo(
() => (
<HostDetailsLink
Component={Component}
hostName={hostName}
isButton={false}
isButton={isButton}
onClick={isInTimelineContext ? openHostDetailsSidePanel : undefined}
title={title}
>
<TruncatableText data-test-subj="draggable-truncatable-content">{hostName}</TruncatableText>
</HostDetailsLink>
),
[hostName, isInTimelineContext, openHostDetailsSidePanel]
[Component, hostName, isButton, isInTimelineContext, openHostDetailsSidePanel, title]
);
return isString(value) && hostName.length > 0 ? (

View file

@ -29,7 +29,7 @@ import {
import {
DESTINATION_PORT_FIELD_NAME,
SOURCE_PORT_FIELD_NAME,
} from '../../../../../network/components/port';
} from '../../../../../network/components/port/helpers';
import {
DESTINATION_GEO_CITY_NAME_FIELD_NAME,
DESTINATION_GEO_CONTINENT_NAME_FIELD_NAME,

View file

@ -34,7 +34,7 @@ import {
import {
DESTINATION_PORT_FIELD_NAME,
SOURCE_PORT_FIELD_NAME,
} from '../../../../../../network/components/port';
} from '../../../../../../network/components/port/helpers';
import {
NETWORK_BYTES_FIELD_NAME,
NETWORK_COMMUNITY_ID_FIELD_NAME,

View file

@ -7,6 +7,7 @@
import { head } from 'lodash/fp';
import React from 'react';
import { Filter } from '../../../../../../../../../src/plugins/data/public';
import { ColumnHeaderOptions } from '../../../../../../common';
import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
@ -21,8 +22,8 @@ export const dataExistsAtColumn = (columnName: string, data: TimelineNonEcsData[
export const plainColumnRenderer: ColumnRenderer = {
isInstance: (columnName: string, data: TimelineNonEcsData[]) =>
dataExistsAtColumn(columnName, data),
renderColumn: ({
asPlainText,
columnName,
eventId,
field,
@ -32,9 +33,11 @@ export const plainColumnRenderer: ColumnRenderer = {
values,
linkValues,
}: {
asPlainText?: boolean;
columnName: string;
eventId: string;
field: ColumnHeaderOptions;
globalFilters?: Filter[];
isDraggable?: boolean;
timelineId: string;
truncate?: boolean;
@ -44,16 +47,17 @@ export const plainColumnRenderer: ColumnRenderer = {
values != null
? values.map((value, i) => (
<FormattedFieldValue
key={`plain-column-renderer-formatted-field-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`}
asPlainText={asPlainText}
contextId={`plain-column-renderer-formatted-field-value-${timelineId}`}
eventId={eventId}
fieldFormat={field.format || ''}
fieldName={columnName}
fieldType={field.type || ''}
isDraggable={isDraggable}
value={parseValue(value)}
truncate={truncate}
key={`plain-column-renderer-formatted-field-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`}
linkValue={head(linkValues)}
truncate={truncate}
value={parseValue(value)}
/>
))
: getEmptyTagValue(),

View file

@ -19,7 +19,7 @@ import {
RowRenderer,
RowRendererId,
} from '../../../../../../common';
import { fireEvent, render } from '@testing-library/react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../../../../../timelines/public/mock';
import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../../timelines/public/components';
import { cloneDeep } from 'lodash';
@ -92,9 +92,10 @@ describe('reasonColumnRenderer', () => {
expect(plainColumnRenderer.renderColumn).toBeCalledTimes(1);
});
it("doesn't call `plainColumnRenderer.renderColumn` when ecsData, rowRenderers or browserFields fields are not empty", () => {
it("doesn't call `plainColumnRenderer.renderColumn` in expanded value when ecsData, rowRenderers or browserFields fields are not empty", () => {
reasonColumnRenderer.renderColumn({
...defaultProps,
isDetails: true,
ecsData: invalidEcs,
rowRenderers,
browserFields,
@ -103,9 +104,22 @@ describe('reasonColumnRenderer', () => {
expect(plainColumnRenderer.renderColumn).toBeCalledTimes(0);
});
it("doesn't render popover button when getRowRenderer doesn't find a rowRenderer", () => {
it('call `plainColumnRenderer.renderColumn` in cell value', () => {
reasonColumnRenderer.renderColumn({
...defaultProps,
isDetails: false,
ecsData: invalidEcs,
rowRenderers,
browserFields,
});
expect(plainColumnRenderer.renderColumn).toBeCalledTimes(1);
});
it("doesn't render reason renderers button when getRowRenderer doesn't find a rowRenderer", () => {
const renderedColumn = reasonColumnRenderer.renderColumn({
...defaultProps,
isDetails: true,
ecsData: invalidEcs,
rowRenderers,
browserFields,
@ -116,9 +130,10 @@ describe('reasonColumnRenderer', () => {
expect(wrapper.queryByTestId('reason-cell-button')).not.toBeInTheDocument();
});
it('render popover button when getRowRenderer finds a rowRenderer', () => {
it('render reason renderers when getRowRenderer finds a rowRenderer', () => {
const renderedColumn = reasonColumnRenderer.renderColumn({
...defaultProps,
isDetails: true,
ecsData: validEcs,
rowRenderers,
browserFields,
@ -126,39 +141,7 @@ describe('reasonColumnRenderer', () => {
const wrapper = render(<TestProviders>{renderedColumn}</TestProviders>);
expect(wrapper.queryByTestId('reason-cell-button')).toBeInTheDocument();
});
it('render rowRender inside a popover when reson field button is clicked', () => {
const renderedColumn = reasonColumnRenderer.renderColumn({
...defaultProps,
ecsData: validEcs,
rowRenderers,
browserFields,
});
const wrapper = render(<TestProviders>{renderedColumn}</TestProviders>);
fireEvent.click(wrapper.getByTestId('reason-cell-button'));
expect(wrapper.queryByTestId('test-row-render')).toBeInTheDocument();
});
it('the popover always contains a class that hides it when an overlay (e.g. the inspect modal) is displayed', () => {
const renderedColumn = reasonColumnRenderer.renderColumn({
...defaultProps,
ecsData: validEcs,
rowRenderers,
browserFields,
});
const wrapper = render(<TestProviders>{renderedColumn}</TestProviders>);
fireEvent.click(wrapper.getByTestId('reason-cell-button'));
expect(wrapper.getByRole('dialog')).toHaveClass(
'euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--right withHoverActions__popover'
);
expect(wrapper.queryByTestId('reason-cell-renderer')).toBeInTheDocument();
});
});
});

View file

@ -5,11 +5,10 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiPopover, EuiPopoverTitle } from '@elastic/eui';
import { EuiSpacer, EuiPanel } from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common';
import { Ecs } from '../../../../../../common/ecs';
import { eventRendererNames } from '../../../row_renderers_browser/catalog/constants';
@ -23,58 +22,59 @@ export const reasonColumnRenderer: ColumnRenderer = {
isInstance: isEqual(REASON_FIELD_NAME),
renderColumn: ({
browserFields,
columnName,
ecsData,
eventId,
field,
isDetails,
isDraggable = true,
linkValues,
rowRenderers = [],
timelineId,
truncate,
values,
linkValues,
ecsData,
rowRenderers = [],
browserFields,
}: {
browserFields?: BrowserFields;
columnName: string;
ecsData?: Ecs;
eventId: string;
field: ColumnHeaderOptions;
isDetails?: boolean;
isDraggable?: boolean;
linkValues?: string[] | null | undefined;
rowRenderers?: RowRenderer[];
timelineId: string;
truncate?: boolean;
values: string[] | undefined | null;
linkValues?: string[] | null | undefined;
ecsData?: Ecs;
rowRenderers?: RowRenderer[];
browserFields?: BrowserFields;
}) =>
values != null && ecsData && rowRenderers?.length > 0 && browserFields
? values.map((value, i) => (
<ReasonCell
key={`reason-column-renderer-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`}
timelineId={timelineId}
value={value}
ecsData={ecsData}
rowRenderers={rowRenderers}
browserFields={browserFields}
/>
))
: plainColumnRenderer.renderColumn({
columnName,
eventId,
field,
isDraggable,
timelineId,
truncate,
values,
linkValues,
}),
}) => {
if (isDetails && values && ecsData && rowRenderers && browserFields) {
return values.map((value, i) => (
<ReasonCell
browserFields={browserFields}
ecsData={ecsData}
key={`reason-column-renderer-value-${timelineId}-${columnName}-${eventId}-${field.id}-${value}-${i}`}
rowRenderers={rowRenderers}
timelineId={timelineId}
value={value}
/>
));
} else {
return plainColumnRenderer.renderColumn({
columnName,
eventId,
field,
isDetails,
isDraggable,
linkValues,
timelineId,
truncate,
values,
});
}
},
};
const StyledEuiButtonEmpty = styled(EuiButtonEmpty)`
font-weight: ${(props) => props.theme.eui.euiFontWeightRegular};
`;
const ReasonCell: React.FC<{
value: string | number | undefined | null;
timelineId: string;
@ -82,8 +82,6 @@ const ReasonCell: React.FC<{
rowRenderers: RowRenderer[];
browserFields: BrowserFields;
}> = ({ ecsData, rowRenderers, browserFields, timelineId, value }) => {
const [isOpen, setIsOpen] = useState(false);
const rowRenderer = useMemo(() => getRowRenderer(ecsData, rowRenderers), [ecsData, rowRenderers]);
const rowRender = useMemo(() => {
@ -98,38 +96,17 @@ const ReasonCell: React.FC<{
);
}, [rowRenderer, browserFields, ecsData, timelineId]);
const handleTogglePopOver = useCallback(() => setIsOpen(!isOpen), [setIsOpen, isOpen]);
const handleClosePopOver = useCallback(() => setIsOpen(false), [setIsOpen]);
const button = useMemo(
() => (
<StyledEuiButtonEmpty
data-test-subj="reason-cell-button"
size="xs"
flush="left"
onClick={handleTogglePopOver}
>
{value}
</StyledEuiButtonEmpty>
),
[value, handleTogglePopOver]
);
return (
<>
{rowRenderer && rowRender ? (
<EuiPopover
isOpen={isOpen}
anchorPosition="rightCenter"
closePopover={handleClosePopOver}
panelClassName="withHoverActions__popover"
button={button}
>
<EuiPopoverTitle paddingSize="s">
{i18n.EVENT_RENDERER_POPOVER_TITLE(eventRendererNames[rowRenderer.id] ?? '')}
</EuiPopoverTitle>
{rowRender}
</EuiPopover>
<>
{value}
<h4>{i18n.REASON_RENDERER_TITLE(eventRendererNames[rowRenderer.id] ?? '')}</h4>
<EuiSpacer size="xs" />
<EuiPanel color="subdued" className="eui-xScroll" data-test-subj="reason-cell-renderer">
<div className="eui-displayInlineBlock">{rowRender}</div>
</EuiPanel>
</>
) : (
value
)}

View file

@ -52,8 +52,8 @@ export const EMPTY_STATUS = i18n.translate(
}
);
export const EVENT_RENDERER_POPOVER_TITLE = (eventRendererName: string) =>
i18n.translate('xpack.securitySolution.event.reason.eventRenderPopoverTitle', {
export const REASON_RENDERER_TITLE = (eventRendererName: string) =>
i18n.translate('xpack.securitySolution.event.reason.reasonRendererTitle', {
values: { eventRendererName },
defaultMessage: 'Event renderer: {eventRendererName} ',
});

View file

@ -16,6 +16,8 @@ import { DroppableWrapper } from '../../../../common/components/drag_and_drop/dr
import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock';
import { DefaultCellRenderer } from './default_cell_renderer';
import { BrowserFields } from '../../../../../../timelines/common/search_strategy';
import { Ecs } from '../../../../../common/ecs';
jest.mock('../../../../common/lib/kibana');
@ -26,15 +28,16 @@ const mockImplementation = {
};
describe('DefaultCellRenderer', () => {
const columnId = 'signal.rule.risk_score';
const columnId = '@timestamp';
const eventId = '_id-123';
const isDetails = true;
const isExpandable = true;
const isExpanded = true;
const linkValues = ['foo', 'bar', '@baz'];
const rowIndex = 3;
const setCellProps = jest.fn();
const timelineId = 'test';
const ecsData = {} as Ecs;
const browserFields = {} as BrowserFields;
beforeEach(() => {
jest.clearAllMocks();
@ -44,14 +47,17 @@ describe('DefaultCellRenderer', () => {
test('it invokes `getColumnRenderer` with the expected arguments', () => {
const data = cloneDeep(mockTimelineData[0].data);
const header = cloneDeep(defaultHeaders[0]);
const isDetails = true;
mount(
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<DroppableWrapper droppableId="testing">
<DefaultCellRenderer
browserFields={browserFields}
columnId={columnId}
data={data}
ecsData={ecsData}
eventId={eventId}
header={header}
isDetails={isDetails}
@ -71,17 +77,69 @@ describe('DefaultCellRenderer', () => {
expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data);
});
test('it invokes `renderColumn` with the expected arguments', () => {
test('if in tgrid expanded value, it invokes `renderColumn` with the expected arguments', () => {
const data = cloneDeep(mockTimelineData[0].data);
const header = cloneDeep(defaultHeaders[0]);
const isDetails = true;
const truncate = isDetails ? false : true;
mount(
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<DroppableWrapper droppableId="testing">
<DefaultCellRenderer
browserFields={browserFields}
columnId={columnId}
data={data}
ecsData={ecsData}
eventId={eventId}
header={header}
isDetails={isDetails}
isDraggable={true}
isExpandable={isExpandable}
isExpanded={isExpanded}
linkValues={linkValues}
rowIndex={rowIndex}
setCellProps={setCellProps}
timelineId={timelineId}
truncate={truncate}
/>
</DroppableWrapper>
</DragDropContextWrapper>
</TestProviders>
);
expect(mockImplementation.renderColumn).toBeCalledWith({
asPlainText: false,
browserFields,
columnName: header.id,
ecsData,
eventId,
field: header,
isDetails,
isDraggable: true,
linkValues,
rowRenderers: undefined,
timelineId,
truncate,
values: ['2018-11-05T19:03:25.937Z'],
});
});
test('if in tgrid expanded value, it renders ExpandedCellValueActions', () => {
const data = cloneDeep(mockTimelineData[0].data);
const header = cloneDeep(defaultHeaders[1]);
const isDetails = true;
const id = 'event.severity';
const wrapper = mount(
<TestProviders>
<DragDropContextWrapper browserFields={mockBrowserFields}>
<DroppableWrapper droppableId="testing">
<DefaultCellRenderer
browserFields={browserFields}
columnId={id}
ecsData={ecsData}
data={data}
eventId={eventId}
header={header}
isDetails={isDetails}
@ -98,15 +156,8 @@ describe('DefaultCellRenderer', () => {
</TestProviders>
);
expect(mockImplementation.renderColumn).toBeCalledWith({
columnName: header.id,
eventId,
field: header,
isDraggable: true,
linkValues,
timelineId,
truncate: true,
values: ['2018-11-05T19:03:25.937Z'],
});
expect(
wrapper.find('[data-test-subj="data-grid-expanded-cell-value-actions"]').exists()
).toBeTruthy();
});
});

View file

@ -12,36 +12,70 @@ import { columnRenderers } from '../body/renderers';
import { getColumnRenderer } from '../body/renderers/get_column_renderer';
import { CellValueElementProps } from '.';
import { getLink } from '../../../../common/lib/cell_actions/helpers';
import {
ExpandedCellValueActions,
StyledContent,
} from '../../../../common/lib/cell_actions/expanded_cell_value_actions';
const FIELDS_WITHOUT_CELL_ACTIONS = ['@timestamp', 'signal.rule.risk_score', 'signal.reason'];
const hasCellActions = (columnId?: string) => {
return columnId && FIELDS_WITHOUT_CELL_ACTIONS.indexOf(columnId) < 0;
};
export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
columnId,
browserFields,
data,
ecsData,
eventId,
globalFilters,
header,
isDetails,
isDraggable,
linkValues,
rowRenderers,
setCellProps,
timelineId,
rowRenderers,
browserFields,
ecsData,
}) => (
<>
{getColumnRenderer(header.id, columnRenderers, data).renderColumn({
columnName: header.id,
eventId,
field: header,
isDraggable,
linkValues,
timelineId,
truncate: true,
values: getMappedNonEcsValue({
data,
fieldName: header.id,
}),
rowRenderers,
browserFields,
ecsData,
})}
</>
);
truncate,
}) => {
const values = getMappedNonEcsValue({
data,
fieldName: header.id,
});
const styledContentClassName = isDetails
? 'eui-textBreakWord'
: 'eui-displayInlineBlock eui-textTruncate';
return (
<>
<StyledContent className={styledContentClassName} $isDetails={isDetails}>
{getColumnRenderer(header.id, columnRenderers, data).renderColumn({
asPlainText: !!getLink(header.id, header.type), // we want to render value with links as plain text but keep other formatters like badge.
browserFields,
columnName: header.id,
ecsData,
eventId,
field: header,
isDetails,
isDraggable,
linkValues,
rowRenderers,
timelineId,
truncate,
values: getMappedNonEcsValue({
data,
fieldName: header.id,
}),
})}
</StyledContent>
{isDetails && browserFields && hasCellActions(header.id) && (
<ExpandedCellValueActions
browserFields={browserFields}
field={header.id}
globalFilters={globalFilters}
timelineId={timelineId}
value={values}
/>
)}
</>
);
};

View file

@ -7,21 +7,26 @@
import { EuiDataGridCellValueElementProps } from '@elastic/eui';
import { RowRenderer } from '../../..';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Filter } from '../../../../../../../src/plugins/data/public';
import { Ecs } from '../../../ecs';
import { BrowserFields, TimelineNonEcsData } from '../../../search_strategy';
import { ColumnHeaderOptions } from '../columns';
/** The following props are provided to the function called by `renderCellValue` */
export type CellValueElementProps = EuiDataGridCellValueElementProps & {
asPlainText?: boolean;
browserFields?: BrowserFields;
data: TimelineNonEcsData[];
ecsData?: Ecs;
eventId: string; // _id
globalFilters?: Filter[];
header: ColumnHeaderOptions;
isDraggable: boolean;
linkValues: string[] | undefined;
timelineId: string;
rowRenderers?: RowRenderer[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setFlyoutAlert?: (data: any) => void;
ecsData?: Ecs;
rowRenderers?: RowRenderer[];
browserFields?: BrowserFields;
timelineId: string;
truncate?: boolean;
};

View file

@ -8,9 +8,10 @@
import { ReactNode } from 'react';
import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { Filter, IFieldSubType } from '../../../../../../../src/plugins/data/common';
import { IFieldSubType } from '../../../../../../../src/plugins/data/common';
import { BrowserFields } from '../../../search_strategy/index_fields';
import { TimelineNonEcsData } from '../../../search_strategy/timeline';
import { Ecs } from '../../../ecs';
export type ColumnHeaderType = 'not-filtered' | 'text-filter';
@ -45,14 +46,16 @@ export type ColumnId = string;
export type TGridCellAction = ({
browserFields,
data,
globalFilters,
ecsData,
header,
pageSize,
timelineId,
}: {
browserFields: BrowserFields;
/** each row of data is represented as one TimelineNonEcsData[] */
data: TimelineNonEcsData[][];
globalFilters?: Filter[];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
pageSize: number;
timelineId: string;
}) => (props: EuiDataGridColumnCellActionProps) => ReactNode;

View file

@ -14,8 +14,8 @@ import { TooltipWithKeyboardShortcut } from '../../tooltip_with_keyboard_shortcu
import { createFilter, getAdditionalScreenReaderOnlyContext } from '../utils';
import { HoverActionComponentProps, FilterValueFnArgs } from './types';
export const FILTER_FOR_VALUE = i18n.translate('xpack.timelines.hoverActions.filterForValue', {
defaultMessage: 'Filter for value',
export const FILTER_FOR_VALUE = i18n.translate('xpack.timelines.hoverActions.filterIn', {
defaultMessage: 'Filter In',
});
export const FILTER_FOR_VALUE_KEYBOARD_SHORTCUT = 'f';
@ -31,6 +31,7 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
onFilterAdded,
ownFocus,
onClick,
size,
showTooltip = false,
value,
}) => {
@ -74,6 +75,7 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
data-test-subj="filter-for-value"
iconType="plusInCircle"
onClick={filterForValueFn}
size={size}
title={FILTER_FOR_VALUE}
>
{FILTER_FOR_VALUE}
@ -87,9 +89,10 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
iconSize="s"
iconType="plusInCircle"
onClick={filterForValueFn}
size={size}
/>
),
[Component, defaultFocusedButtonRef, filterForValueFn]
[Component, defaultFocusedButtonRef, filterForValueFn, size]
);
return showTooltip ? (

View file

@ -14,8 +14,8 @@ import { TooltipWithKeyboardShortcut } from '../../tooltip_with_keyboard_shortcu
import { createFilter, getAdditionalScreenReaderOnlyContext } from '../utils';
import { HoverActionComponentProps, FilterValueFnArgs } from './types';
export const FILTER_OUT_VALUE = i18n.translate('xpack.timelines.hoverActions.filterOutValue', {
defaultMessage: 'Filter out value',
export const FILTER_OUT_VALUE = i18n.translate('xpack.timelines.hoverActions.filterOut', {
defaultMessage: 'Filter Out',
});
export const FILTER_OUT_VALUE_KEYBOARD_SHORTCUT = 'o';
@ -30,6 +30,7 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
onFilterAdded,
ownFocus,
onClick,
size,
showTooltip = false,
value,
}) => {
@ -74,6 +75,7 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
data-test-subj="filter-out-value"
iconType="minusInCircle"
onClick={filterOutValueFn}
size={size}
title={FILTER_OUT_VALUE}
>
{FILTER_OUT_VALUE}
@ -87,9 +89,10 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
iconSize="s"
iconType="minusInCircle"
onClick={filterOutValueFn}
size={size}
/>
),
[Component, defaultFocusedButtonRef, filterOutValueFn]
[Component, defaultFocusedButtonRef, filterOutValueFn, size]
);
return showTooltip ? (

View file

@ -18,11 +18,13 @@ export interface FilterValueFnArgs {
}
export interface HoverActionComponentProps {
className?: string;
defaultFocusedButtonRef?: EuiButtonIconPropsForButton['buttonRef'];
field: string;
keyboardEvent?: React.KeyboardEvent;
ownFocus: boolean;
onClick?: () => void;
size?: 'xs' | 's' | 'm';
showTooltip?: boolean;
value?: string[] | string | null;
}

View file

@ -15,6 +15,8 @@ import { defaultHeaders, mockBrowserFields, mockTimelineData, TestProviders } fr
import { TimelineTabs } from '../../../../common/types/timeline';
import { TestCellRenderer } from '../../../mock/cell_renderer';
import { mockGlobalState } from '../../../mock/global_state';
import { EuiDataGridColumn } from '@elastic/eui';
import { defaultColumnHeaderType } from '../../../store/t_grid/defaults';
const mockSort: Sort[] = [
{
@ -151,5 +153,124 @@ describe('Body', () => {
.text()
).toEqual(mockTimelineData[0].ecs.timestamp);
});
test("timestamp column doesn't render cell actions", () => {
const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp');
const testProps = {
...props,
columnHeaders: headersJustTimestamp,
data: mockTimelineData.slice(0, 1),
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
</TestProviders>
);
wrapper.update();
expect(
wrapper
.find('[data-test-subj="body-data-grid"]')
.first()
.prop<EuiDataGridColumn[]>('columns')
.find((c) => c.id === '@timestamp')?.cellActions
).toBeUndefined();
});
test("signal.rule.risk_score column doesn't render cell actions", () => {
const columnHeaders = [
{
category: 'signal',
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.risk_score',
type: 'number',
aggregatable: true,
initialWidth: 105,
},
];
const testProps = {
...props,
columnHeaders,
data: mockTimelineData.slice(0, 1),
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
</TestProviders>
);
wrapper.update();
expect(
wrapper
.find('[data-test-subj="body-data-grid"]')
.first()
.prop<EuiDataGridColumn[]>('columns')
.find((c) => c.id === 'signal.rule.risk_score')?.cellActions
).toBeUndefined();
});
test("signal.reason column doesn't render cell actions", () => {
const columnHeaders = [
{
category: 'signal',
columnHeaderType: defaultColumnHeaderType,
id: 'signal.reason',
type: 'string',
aggregatable: true,
initialWidth: 450,
},
];
const testProps = {
...props,
columnHeaders,
data: mockTimelineData.slice(0, 1),
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
</TestProviders>
);
wrapper.update();
expect(
wrapper
.find('[data-test-subj="body-data-grid"]')
.first()
.prop<EuiDataGridColumn[]>('columns')
.find((c) => c.id === 'signal.reason')?.cellActions
).toBeUndefined();
});
});
test("signal.rule.risk_score column doesn't render cell actions", () => {
const columnHeaders = [
{
category: 'signal',
columnHeaderType: defaultColumnHeaderType,
id: 'signal.rule.risk_score',
type: 'number',
aggregatable: true,
initialWidth: 105,
},
];
const testProps = {
...props,
columnHeaders,
data: mockTimelineData.slice(0, 1),
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
</TestProviders>
);
wrapper.update();
expect(
wrapper
.find('[data-test-subj="body-data-grid"]')
.first()
.prop<EuiDataGridColumn[]>('columns')
.find((c) => c.id === 'signal.rule.risk_score')?.cellActions
).toBeUndefined();
});
});

View file

@ -145,6 +145,9 @@ const EuiDataGridContainer = styled.div<{ hideLastPage: boolean }>`
}
`;
const FIELDS_WITHOUT_CELL_ACTIONS = ['@timestamp', 'signal.rule.risk_score', 'signal.reason'];
const hasCellActions = (columnId?: string) =>
columnId && FIELDS_WITHOUT_CELL_ACTIONS.indexOf(columnId) < 0;
const transformControlColumns = ({
actionColumnsWidth,
columnHeaders,
@ -636,7 +639,6 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
setEventsDeleted,
hasAlertsCrudPermissions,
]);
const columnsWithCellActions: EuiDataGridColumn[] = useMemo(
() =>
columnHeaders.map((header) => {
@ -644,18 +646,24 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
tGridCellAction({
browserFields,
data: data.map((row) => row.data),
globalFilters: filters,
ecsData: data.map((row) => row.ecs),
header: columnHeaders.find((h) => h.id === header.id),
pageSize,
timelineId: id,
});
return {
...header,
cellActions:
header.tGridCellActions?.map(buildAction) ?? defaultCellActions?.map(buildAction),
...(hasCellActions(header.id)
? {
cellActions:
header.tGridCellActions?.map(buildAction) ??
defaultCellActions?.map(buildAction),
}
: {}),
};
}),
[browserFields, columnHeaders, data, defaultCellActions, id, pageSize, filters]
[columnHeaders, defaultCellActions, browserFields, data, pageSize, id]
);
const renderTGridCellValue = useMemo(() => {
@ -663,9 +671,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
columnId,
rowIndex,
setCellProps,
isDetails,
}): React.ReactElement | null => {
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const rowData = pageRowIndex < data.length ? data[pageRowIndex].data : null;
const header = columnHeaders.find((h) => h.id === columnId);
const eventId = pageRowIndex < data.length ? data[pageRowIndex]._id : null;
@ -687,34 +695,36 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
}
return renderCellValue({
browserFields,
columnId: header.id,
eventId,
data: rowData,
ecsData: ecs,
eventId,
globalFilters: filters,
header,
isDetails,
isDraggable: false,
isExpandable: true,
isExpanded: false,
isDetails: false,
linkValues: getOr([], header.linkField ?? '', ecs),
rowIndex,
setCellProps,
timelineId: tabType != null ? `${id}-${tabType}` : id,
ecsData: ecs,
browserFields,
rowRenderers,
setCellProps,
timelineId: id,
truncate: isDetails ? false : true,
}) as React.ReactElement;
};
return Cell;
}, [
browserFields,
columnHeaders,
data,
filters,
id,
renderCellValue,
tabType,
theme,
browserFields,
rowRenderers,
pageSize,
renderCellValue,
rowRenderers,
theme,
]);
const onChangeItemsPerPage = useCallback(

View file

@ -180,19 +180,21 @@ const EventRenderedViewComponent = ({
rowRenderer.isInstance(ecsData)
);
return (
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexGroup gutterSize="none" direction="column" className="eui-fullWidth">
{reason && <EuiFlexItem>{reason}</EuiFlexItem>}
{rowRenderersValid.length > 0 &&
rowRenderersValid.map((rowRenderer) => (
<>
<EuiHorizontalRule size="half" margin="xs" />
<EventRenderedFlexItem>
{rowRenderer.renderRow({
browserFields,
data: ecsData,
isDraggable: false,
timelineId: 'NONE',
})}
<EventRenderedFlexItem className="eui-xScroll">
<div className="eui-displayInlineBlock">
{rowRenderer.renderRow({
browserFields,
data: ecsData,
isDraggable: false,
timelineId: 'NONE',
})}
</div>
</EventRenderedFlexItem>
</>
))}

View file

@ -22164,7 +22164,6 @@
"xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate": "無効な日付",
"xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved": "タイムスタンプが取得されていません",
"xpack.securitySolution.event.module.linkToElasticEndpointSecurityDescription": "Endpoint Securityで開く",
"xpack.securitySolution.event.reason.eventRenderPopoverTitle": "イベントレンダラー:{eventRendererName} ",
"xpack.securitySolution.eventDetails.blank": " ",
"xpack.securitySolution.eventDetails.copyToClipboard": "クリップボードにコピー",
"xpack.securitySolution.eventDetails.copyToClipboardTooltip": "クリップボードにコピー",
@ -24427,8 +24426,6 @@
"xpack.timelines.hoverActions.addToTimeline.addedFieldMessage": "{fieldOrValue}をタイムラインに追加しました",
"xpack.timelines.hoverActions.columnToggleLabel": "表の{field}列を切り替える",
"xpack.timelines.hoverActions.fieldLabel": "フィールド",
"xpack.timelines.hoverActions.filterForValue": "値でフィルター",
"xpack.timelines.hoverActions.filterOutValue": "値を除外",
"xpack.timelines.hoverActions.moreActions": "さらにアクションを表示",
"xpack.timelines.hoverActions.nestedColumnToggleLabel": "{field}フィールドはオブジェクトであり、列として追加できるネストされたフィールドに分解されます",
"xpack.timelines.hoverActions.tooltipWithKeyboardShortcut.pressTooltipLabel": "プレス",

View file

@ -22512,7 +22512,6 @@
"xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate": "日期无效",
"xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved": "未检索时间戳",
"xpack.securitySolution.event.module.linkToElasticEndpointSecurityDescription": "在 Endpoint Security 中打开",
"xpack.securitySolution.event.reason.eventRenderPopoverTitle": "事件渲染器:{eventRendererName} ",
"xpack.securitySolution.eventDetails.blank": " ",
"xpack.securitySolution.eventDetails.copyToClipboard": "复制到剪贴板",
"xpack.securitySolution.eventDetails.copyToClipboardTooltip": "复制到剪贴板",
@ -24840,8 +24839,6 @@
"xpack.timelines.hoverActions.addToTimeline.addedFieldMessage": "已将 {fieldOrValue} 添加到时间线",
"xpack.timelines.hoverActions.columnToggleLabel": "在表中切换 {field} 列",
"xpack.timelines.hoverActions.fieldLabel": "字段",
"xpack.timelines.hoverActions.filterForValue": "筛留值",
"xpack.timelines.hoverActions.filterOutValue": "筛除值",
"xpack.timelines.hoverActions.moreActions": "更多操作",
"xpack.timelines.hoverActions.nestedColumnToggleLabel": "{field} 字段是对象,并分解为可以添加为列的嵌套字段",
"xpack.timelines.hoverActions.tooltipWithKeyboardShortcut.pressTooltipLabel": "按",