[maps][alerting] fix ES query rule boundary field changed when editing the rule (#165155)

Fixes https://github.com/elastic/kibana/issues/163959

While digging into the original issue, it was determined that the
existing components were unsalvageable. Fixing all of the issues would
have required more work than just starting over. Problems with original
components include:
1) updating rule state on component load. This is the cause of the
reported bug.
2) lack of loading state when performing async tasks, like loading data
views.
3) not displaying validation errors. When users clicked "save" with
missing configuration, no UI notifications were displayed
4) Heavy use of EuiExpression made it impossible to view all
configuration in a single time

Now, geo containment form:
1) Only updates rule state when users interact with inputs.
2) Displays loading state when performing async tasks, like loading data
views.
3) Displays validation errors
4) Has a simpler UI that allows users to see all configuration
information at the same time.

<img width="300" alt="Screen Shot 2023-08-30 at 5 34 00 PM"
src="65abfa5d-6c8e-45a9-b69f-cc07f5be7184">

<img width="300" alt="Screen Shot 2023-08-30 at 5 34 48 PM"
src="63b5af12-7104-43ae-a836-0236cf9d1e98">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-09-06 12:18:54 -06:00 committed by GitHub
parent 633aebe5fc
commit 118ea87a08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 944 additions and 1722 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

View file

@ -2,51 +2,25 @@
[[geo-alerting]]
== Tracking containment
<<maps, Maps>> offers the tracking containment rule type which runs an {es} query over indices to determine whether any
documents are currently contained within any boundaries from the specified boundary index.
In the event that an entity is contained within a boundary, an alert may be generated.
The tracking containment rule alerts when an entity is contained or no longer contained within a boundary.
[float]
=== Requirements
To create a tracking containment rule, the following requirements must be present:
- *Tracks index or data view*: An index containing a `geo_point` or `geo_shape` field, `date` field,
and some form of entity identifier. An entity identifier is a `keyword` or `number`
field that consistently identifies the entity to be tracked. The data in this index should be dynamically
updating so that there are entity movements to alert upon.
- *Boundaries index or data view*: An index containing `geo_shape` data, such as boundary data and bounding box data.
This data is presumed to be static (not updating). Shape data matching the query is
harvested once when the rule is created and anytime after when the rule is re-enabled
after disablement.
- *Entities index*: An index containing a `geo_point` or `geo_shape` field, `date` field, and entity identifier. An entity identifier is a `keyword`, `number`, or `ip` field that identifies the entity. Entity data is expected to be updating so that there are entity movements to alert upon.
- *Boundaries index*: An index containing `geo_shape` data.
Boundaries data is expected to be static (not updating). Boundaries are collected once when the rule is created and anytime after when boundary configuration is modified.
By design, current interval entity locations (_current_ is determined by `date` in
the *Tracked index or data view*) are queried to determine if they are contained
within any monitored boundaries. Entity
data should be somewhat "real time", meaning the dates of new documents arent older
Entity locations are queried to determine if they are contained within any monitored boundaries.
Entity data should be somewhat "real time", meaning the dates of new documents arent older
than the current time minus the amount of the interval. If data older than
`now - <current interval>` is ingested, it won't trigger a rule.
[float]
=== Rule conditions
Tracking containment rules have three clauses that define the condition to detect,
as well as two Kuery bars used to provide additional filtering context for each of the indices.
[role="screenshot"]
image::user/alerting/images/alert-types-tracking-containment-conditions.png[Define the condition to detect,width=75%]
// NOTE: This is an autogenerated screenshot. Do not edit it directly.
Index (entity):: This clause requires an *index or data view*, a *time field* that will be used for the *time window*, and a *`geo_point` or `geo_shape` field* for tracking.
Index (Boundary):: This clause requires an *index or data view*, a *`geo_shape` field*
identifying boundaries, and an optional *Human-readable boundary name* for better alerting
messages.
[float]
=== Actions
Conditions for how a rule is tracked can be specified uniquely for each individual action.
A rule can be triggered either when a containment condition is met or when an entity
is no longer contained.
A rule can be triggered either when a containment condition is met or when an entity is no longer contained.
[role="screenshot"]
image::user/alerting/images/alert-types-tracking-containment-action-options.png[Action frequency options for an action,width=75%]

View file

@ -14,10 +14,7 @@ import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui';
import type { DataViewsContract } from '@kbn/data-views-plugin/public';
export type IndexPatternSelectProps = Required<
Omit<
EuiComboBoxProps<any>,
'isLoading' | 'onSearchChange' | 'options' | 'selectedOptions' | 'onChange'
>,
Omit<EuiComboBoxProps<any>, 'onSearchChange' | 'options' | 'selectedOptions' | 'onChange'>,
'placeholder'
> & {
onChange: (indexPatternId?: string) => void;
@ -155,7 +152,7 @@ export default class IndexPatternSelect extends Component<IndexPatternSelectInte
{...rest}
placeholder={placeholder}
singleSelection={true}
isLoading={this.state.isLoading}
isLoading={this.state.isLoading || this.props.isLoading}
onSearchChange={this.fetchOptions}
options={this.state.options}
selectedOptions={this.state.selectedIndexPattern ? [this.state.selectedIndexPattern] : []}

View file

@ -15,11 +15,11 @@ export function getRuleType(): RuleTypeModel<GeoContainmentAlertParams> {
return {
id: '.geo-containment',
description: i18n.translate('xpack.stackAlerts.geoContainment.descriptionText', {
defaultMessage: 'Alert when an entity is contained within a geo boundary.',
defaultMessage: 'Alert when an entity is contained or no longer contained within a boundary.',
}),
iconClass: 'globe',
documentationUrl: null,
ruleParamsExpression: lazy(() => import('./query_builder')),
ruleParamsExpression: lazy(() => import('./rule_form')),
validate: validateExpression,
requiresAppContext: false,
};

View file

@ -1,278 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render BoundaryIndexExpression 1`] = `
<ExpressionWithPopover
defaultValue="Select a data view and geo shape field"
expressionDescription="index"
popoverContent={
<React.Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoIndexPatternSelect"
labelType="label"
>
<GeoIndexPatternSelect
IndexPatternSelectComponent={[MockFunction]}
includedGeoTypes={
Array [
"geo_shape",
]
}
indexPatternService={
Object {
"clearCache": [MockFunction],
"create": [MockFunction],
"createField": [MockFunction],
"createFieldList": [MockFunction],
"ensureDefaultDataView": [MockFunction],
"ensureDefaultIndexPattern": [MockFunction],
"find": [MockFunction],
"get": [MockFunction],
"getCanSaveSync": [MockFunction],
"getDefaultDataView": [MockFunction],
"getDefaultId": [MockFunction],
"getFieldsForIndexPattern": [MockFunction],
"getIdsWithTitle": [MockFunction],
"hasData": Object {
"hasDataView": [MockFunction],
"hasESData": [MockFunction],
"hasUserDataView": [MockFunction],
},
"make": [Function],
}
}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoField"
label="Geospatial field"
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select geo field"
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="boundaryNameFieldSelect"
label="Human-readable boundary name (optional)"
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select boundary name"
value="testNameField"
/>
</EuiFormRow>
</React.Fragment>
}
/>
`;
exports[`should render EntityIndexExpression 1`] = `
<ExpressionWithPopover
defaultValue="Select a data view and geospatial field"
expressionDescription="index"
isInvalid={false}
popoverContent={
<React.Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoIndexPatternSelect"
labelType="label"
>
<GeoIndexPatternSelect
IndexPatternSelectComponent={[MockFunction]}
includedGeoTypes={
Array [
"geo_point",
"geo_shape",
]
}
indexPatternService={
Object {
"clearCache": [MockFunction],
"create": [MockFunction],
"createField": [MockFunction],
"createFieldList": [MockFunction],
"ensureDefaultDataView": [MockFunction],
"ensureDefaultIndexPattern": [MockFunction],
"find": [MockFunction],
"get": [MockFunction],
"getCanSaveSync": [MockFunction],
"getDefaultDataView": [MockFunction],
"getDefaultId": [MockFunction],
"getFieldsForIndexPattern": [MockFunction],
"getIdsWithTitle": [MockFunction],
"hasData": Object {
"hasDataView": [MockFunction],
"hasESData": [MockFunction],
"hasUserDataView": [MockFunction],
},
"make": [Function],
}
}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="containmentTimeField"
label={
<FormattedMessage
defaultMessage="Time field"
id="xpack.stackAlerts.geoContainment.timeFieldLabel"
values={Object {}}
/>
}
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select time field"
value="testDateField"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoField"
label="Geospatial field"
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select geo field"
value="testGeoField"
/>
</EuiFormRow>
</React.Fragment>
}
/>
`;
exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = `
<ExpressionWithPopover
defaultValue="Select a data view and geospatial field"
expressionDescription="index"
isInvalid={true}
popoverContent={
<React.Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoIndexPatternSelect"
labelType="label"
>
<GeoIndexPatternSelect
IndexPatternSelectComponent={[MockFunction]}
includedGeoTypes={
Array [
"geo_point",
"geo_shape",
]
}
indexPatternService={
Object {
"clearCache": [MockFunction],
"create": [MockFunction],
"createField": [MockFunction],
"createFieldList": [MockFunction],
"ensureDefaultDataView": [MockFunction],
"ensureDefaultIndexPattern": [MockFunction],
"find": [MockFunction],
"get": [MockFunction],
"getCanSaveSync": [MockFunction],
"getDefaultDataView": [MockFunction],
"getDefaultId": [MockFunction],
"getFieldsForIndexPattern": [MockFunction],
"getIdsWithTitle": [MockFunction],
"hasData": Object {
"hasDataView": [MockFunction],
"hasESData": [MockFunction],
"hasUserDataView": [MockFunction],
},
"make": [Function],
}
}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="containmentTimeField"
label={
<FormattedMessage
defaultMessage="Time field"
id="xpack.stackAlerts.geoContainment.timeFieldLabel"
values={Object {}}
/>
}
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select time field"
value="testDateField"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
id="geoField"
label="Geospatial field"
labelType="label"
>
<SingleFieldSelect
fields={Array []}
onChange={[Function]}
placeholder="Select geo field"
value="testGeoField"
/>
</EuiFormRow>
</React.Fragment>
}
/>
`;

View file

@ -1,30 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render entity by expression with aggregatable field options for entity 1`] = `
<div
class="euiPopover emotion-euiPopover"
id="popoverForExpression"
>
<div
class="euiPopover__anchor css-zih94u-render"
>
<button
class="euiExpression emotion-euiExpression-isClickable-success-columns"
data-test-subj="selectIndexExpression"
>
<span
class="euiExpression__description emotion-euiExpression__description-success-isUppercase-columns"
style="flex-basis: 20%;"
>
by
</span>
<span
class="euiExpression__value emotion-euiExpression__value-columns"
>
FlightNum
</span>
</button>
</div>
</div>
`;

View file

@ -1,176 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { HttpSetup } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { DataViewField, DataView } from '@kbn/data-plugin/common';
import { ES_GEO_SHAPE_TYPES, GeoContainmentAlertParams } from '../../types';
import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select';
import { SingleFieldSelect } from '../util_components/single_field_select';
import { ExpressionWithPopover } from '../util_components/expression_with_popover';
interface Props {
ruleParams: GeoContainmentAlertParams;
errors: IErrorObject;
boundaryIndexPattern: DataView;
boundaryNameField?: string;
setBoundaryIndexPattern: (boundaryIndexPattern?: DataView) => void;
setBoundaryGeoField: (boundaryGeoField?: string) => void;
setBoundaryNameField: (boundaryNameField?: string) => void;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
interface KibanaDeps {
http: HttpSetup;
}
export const BoundaryIndexExpression: FunctionComponent<Props> = ({
ruleParams,
errors,
boundaryIndexPattern,
boundaryNameField,
setBoundaryIndexPattern,
setBoundaryGeoField,
setBoundaryNameField,
data,
unifiedSearch,
}) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip'];
const { http } = useKibana<KibanaDeps>().services;
const IndexPatternSelect = (unifiedSearch.ui && unifiedSearch.ui.IndexPatternSelect) || null;
const { boundaryGeoField } = ruleParams;
// eslint-disable-next-line react-hooks/exhaustive-deps
const nothingSelected: DataViewField = {
name: '<nothing selected>',
type: 'string',
} as DataViewField;
const usePrevious = <T extends unknown>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
const oldIndexPattern = usePrevious(boundaryIndexPattern);
const fields = useRef<{
geoFields: DataViewField[];
boundaryNameFields: DataViewField[];
}>({
geoFields: [],
boundaryNameFields: [],
});
useEffect(() => {
if (oldIndexPattern !== boundaryIndexPattern) {
fields.current.geoFields =
(boundaryIndexPattern.fields &&
boundaryIndexPattern.fields.length &&
boundaryIndexPattern.fields.filter((field: DataViewField) =>
ES_GEO_SHAPE_TYPES.includes(field.type)
)) ||
[];
if (fields.current.geoFields.length) {
setBoundaryGeoField(fields.current.geoFields[0].name);
}
fields.current.boundaryNameFields = [
...(boundaryIndexPattern.fields ?? []).filter((field: DataViewField) => {
return (
BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) &&
!field.name.startsWith('_') &&
!field.name.endsWith('keyword')
);
}),
nothingSelected,
];
if (fields.current.boundaryNameFields.length) {
setBoundaryNameField(fields.current.boundaryNameFields[0].name);
}
}
}, [
BOUNDARY_NAME_ENTITY_TYPES,
boundaryIndexPattern,
nothingSelected,
oldIndexPattern,
setBoundaryGeoField,
setBoundaryNameField,
]);
const indexPopover = (
<Fragment>
<EuiFormRow id="geoIndexPatternSelect" fullWidth error={errors.index}>
<GeoIndexPatternSelect
onChange={(_indexPattern) => {
if (!_indexPattern) {
return;
}
setBoundaryIndexPattern(_indexPattern);
}}
value={boundaryIndexPattern.id}
IndexPatternSelectComponent={IndexPatternSelect}
indexPatternService={data.indexPatterns}
http={http}
includedGeoTypes={ES_GEO_SHAPE_TYPES}
/>
</EuiFormRow>
<EuiFormRow
id="geoField"
fullWidth
label={i18n.translate('xpack.stackAlerts.geoContainment.geofieldLabel', {
defaultMessage: 'Geospatial field',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectLabel', {
defaultMessage: 'Select geo field',
})}
value={boundaryGeoField}
onChange={setBoundaryGeoField}
fields={fields.current.geoFields}
/>
</EuiFormRow>
<EuiFormRow
id="boundaryNameFieldSelect"
fullWidth
label={i18n.translate('xpack.stackAlerts.geoContainment.boundaryNameSelectLabel', {
defaultMessage: 'Human-readable boundary name (optional)',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.boundaryNameSelect', {
defaultMessage: 'Select boundary name',
})}
value={boundaryNameField || null}
onChange={(name) => {
setBoundaryNameField(name === nothingSelected.name ? undefined : name);
}}
fields={fields.current.boundaryNameFields}
/>
</EuiFormRow>
</Fragment>
);
return (
<ExpressionWithPopover
defaultValue={'Select a data view and geo shape field'}
value={boundaryIndexPattern.title}
popoverContent={indexPopover}
expressionDescription={i18n.translate('xpack.stackAlerts.geoContainment.indexLabel', {
defaultMessage: 'index',
})}
/>
);
};

View file

@ -1,95 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { EntityByExpression, getValidIndexPatternFields } from './entity_by_expression';
import { DataViewField } from '@kbn/data-views-plugin/public';
const defaultProps = {
errors: {
index: [],
indexId: [],
geoField: [],
entity: [],
dateField: [],
boundaryType: [],
boundaryIndexTitle: [],
boundaryIndexId: [],
boundaryGeoField: [],
name: ['Name is required.'],
interval: [],
alertTypeId: [],
actionConnectors: [],
},
entity: 'FlightNum',
setAlertParamsEntity: (arg: string) => {},
indexFields: [
{
count: 0,
name: 'DestLocation',
type: 'geo_point',
esTypes: ['geo_point'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
count: 0,
name: 'FlightNum',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
count: 0,
name: 'OriginLocation',
type: 'geo_point',
esTypes: ['geo_point'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
] as DataViewField[],
isInvalid: false,
};
test('should render entity by expression with aggregatable field options for entity', async () => {
const component = mount(<EntityByExpression {...defaultProps} />);
expect(component.render()).toMatchSnapshot();
});
//
test('should only use valid index fields', async () => {
// Only the string index field should match
const indexFields = getValidIndexPatternFields(defaultProps.indexFields);
expect(indexFields.length).toEqual(1);
// Set all agg fields to false, invalidating them for use
const invalidIndexFields = defaultProps.indexFields.map((field) => ({
...field,
aggregatable: false,
}));
const noIndexFields = getValidIndexPatternFields(invalidIndexFields as DataViewField[]);
expect(noIndexFields.length).toEqual(0);
});

View file

@ -1,92 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent, useEffect, useRef } from 'react';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import { DataViewField } from '@kbn/data-views-plugin/public';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { SingleFieldSelect } from '../util_components/single_field_select';
import { ExpressionWithPopover } from '../util_components/expression_with_popover';
interface Props {
errors: IErrorObject;
entity: string;
setAlertParamsEntity: (entity: string) => void;
indexFields: DataViewField[];
isInvalid: boolean;
}
const ENTITY_TYPES = ['string', 'number', 'ip'];
export function getValidIndexPatternFields(fields: DataViewField[]): DataViewField[] {
return fields.filter((field) => {
const isSpecifiedSupportedField = ENTITY_TYPES.includes(field.type);
const hasLeadingUnderscore = field.name.startsWith('_');
const isAggregatable = !!field.aggregatable;
return isSpecifiedSupportedField && isAggregatable && !hasLeadingUnderscore;
});
}
export const EntityByExpression: FunctionComponent<Props> = ({
errors,
entity,
setAlertParamsEntity,
indexFields,
isInvalid,
}) => {
const usePrevious = <T extends unknown>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
const oldIndexFields = usePrevious(indexFields);
const fields = useRef<{
indexFields: DataViewField[];
}>({
indexFields: [],
});
useEffect(() => {
if (!_.isEqual(oldIndexFields, indexFields)) {
fields.current.indexFields = getValidIndexPatternFields(indexFields);
if (!entity && fields.current.indexFields.length) {
setAlertParamsEntity(fields.current.indexFields[0].name);
}
}
}, [indexFields, oldIndexFields, setAlertParamsEntity, entity]);
const indexPopover = (
<EuiFormRow id="entitySelect" fullWidth error={errors.index}>
<SingleFieldSelect
placeholder={i18n.translate(
'xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder',
{
defaultMessage: 'Select entity field',
}
)}
value={entity}
onChange={(_entity) => _entity && setAlertParamsEntity(_entity)}
fields={fields.current.indexFields}
/>
</EuiFormRow>
);
return (
<ExpressionWithPopover
isInvalid={isInvalid}
value={entity}
defaultValue={'Select entity field'}
popoverContent={indexPopover}
expressionDescription={i18n.translate('xpack.stackAlerts.geoContainment.entityByLabel', {
defaultMessage: 'by',
})}
/>
);
};

View file

@ -1,172 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react';
import { EuiFormRow } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { HttpSetup } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
IErrorObject,
RuleTypeParamsExpressionProps,
} from '@kbn/triggers-actions-ui-plugin/public';
import { DataViewField, DataView } from '@kbn/data-plugin/common';
import { ES_GEO_FIELD_TYPES } from '../../types';
import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select';
import { SingleFieldSelect } from '../util_components/single_field_select';
import { ExpressionWithPopover } from '../util_components/expression_with_popover';
interface Props {
dateField: string;
geoField: string;
errors: IErrorObject;
setAlertParamsDate: (date: string) => void;
setAlertParamsGeoField: (geoField: string) => void;
setRuleProperty: RuleTypeParamsExpressionProps['setRuleProperty'];
setIndexPattern: (indexPattern: DataView) => void;
indexPattern: DataView;
isInvalid: boolean;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
interface KibanaDeps {
http: HttpSetup;
}
export const EntityIndexExpression: FunctionComponent<Props> = ({
setAlertParamsDate,
setAlertParamsGeoField,
errors,
setIndexPattern,
indexPattern,
isInvalid,
dateField: timeField,
geoField,
data,
unifiedSearch,
}) => {
const { http } = useKibana<KibanaDeps>().services;
const IndexPatternSelect = (unifiedSearch.ui && unifiedSearch.ui.IndexPatternSelect) || null;
const usePrevious = <T extends unknown>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
const oldIndexPattern = usePrevious(indexPattern);
const fields = useRef<{
dateFields: DataViewField[];
geoFields: DataViewField[];
}>({
dateFields: [],
geoFields: [],
});
useEffect(() => {
if (oldIndexPattern !== indexPattern) {
fields.current.geoFields =
(indexPattern.fields &&
indexPattern.fields.length &&
indexPattern.fields.filter((field: DataViewField) =>
ES_GEO_FIELD_TYPES.includes(field.type)
)) ||
[];
if (fields.current.geoFields.length) {
setAlertParamsGeoField(fields.current.geoFields[0].name);
}
fields.current.dateFields =
(indexPattern.fields &&
indexPattern.fields.length &&
indexPattern.fields.filter((field: DataViewField) => field.type === 'date')) ||
[];
if (fields.current.dateFields.length) {
setAlertParamsDate(fields.current.dateFields[0].name);
}
}
}, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]);
const indexPopover = (
<Fragment>
<EuiFormRow id="geoIndexPatternSelect" fullWidth error={errors.index}>
<GeoIndexPatternSelect
onChange={(_indexPattern) => {
// reset time field and expression fields if indices are deleted
if (!_indexPattern) {
return;
}
setIndexPattern(_indexPattern);
}}
value={indexPattern.id}
IndexPatternSelectComponent={IndexPatternSelect}
indexPatternService={data.indexPatterns}
http={http}
includedGeoTypes={ES_GEO_FIELD_TYPES}
/>
</EuiFormRow>
<EuiFormRow
id="containmentTimeField"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.geoContainment.timeFieldLabel"
defaultMessage="Time field"
/>
}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectTimeLabel', {
defaultMessage: 'Select time field',
})}
value={timeField}
onChange={(_timeField: string | undefined) =>
_timeField && setAlertParamsDate(_timeField)
}
fields={fields.current.dateFields}
/>
</EuiFormRow>
<EuiFormRow
id="geoField"
fullWidth
label={i18n.translate('xpack.stackAlerts.geoContainment.geofieldLabel', {
defaultMessage: 'Geospatial field',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectGeoLabel', {
defaultMessage: 'Select geo field',
})}
value={geoField}
onChange={(_geoField: string | undefined) =>
_geoField && setAlertParamsGeoField(_geoField)
}
fields={fields.current.geoFields}
/>
</EuiFormRow>
</Fragment>
);
return (
<ExpressionWithPopover
isInvalid={isInvalid}
value={indexPattern.title}
defaultValue={i18n.translate('xpack.stackAlerts.geoContainment.entityIndexSelect', {
defaultMessage: 'Select a data view and geospatial field',
})}
popoverContent={indexPopover}
expressionDescription={i18n.translate('xpack.stackAlerts.geoContainment.entityIndexLabel', {
defaultMessage: 'index',
})}
/>
);
};

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EntityIndexExpression } from './expressions/entity_index_expression';
import { BoundaryIndexExpression } from './expressions/boundary_index_expression';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { DataView } from '@kbn/data-plugin/common';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
const dataStartMock = dataPluginMock.createStartContract();
const unifiedSearchStartMock = unifiedSearchPluginMock.createStartContract();
const alertParams = {
index: '',
indexId: '',
geoField: '',
entity: '',
dateField: '',
boundaryType: '',
boundaryIndexTitle: '',
boundaryIndexId: '',
boundaryGeoField: '',
};
test('should render EntityIndexExpression', async () => {
const component = shallow(
<EntityIndexExpression
dateField={'testDateField'}
geoField={'testGeoField'}
errors={{} as IErrorObject}
setAlertParamsDate={() => {}}
setAlertParamsGeoField={() => {}}
setRuleProperty={() => {}}
setIndexPattern={() => {}}
indexPattern={'' as unknown as DataView}
isInvalid={false}
data={dataStartMock}
unifiedSearch={unifiedSearchStartMock}
/>
);
expect(component).toMatchSnapshot();
});
test('should render EntityIndexExpression w/ invalid flag if invalid', async () => {
const component = shallow(
<EntityIndexExpression
dateField={'testDateField'}
geoField={'testGeoField'}
errors={{} as IErrorObject}
setAlertParamsDate={() => {}}
setAlertParamsGeoField={() => {}}
setRuleProperty={() => {}}
setIndexPattern={() => {}}
indexPattern={'' as unknown as DataView}
isInvalid={true}
data={dataStartMock}
unifiedSearch={unifiedSearchStartMock}
/>
);
expect(component).toMatchSnapshot();
});
test('should render BoundaryIndexExpression', async () => {
const component = shallow(
<BoundaryIndexExpression
ruleParams={alertParams}
errors={{} as IErrorObject}
boundaryIndexPattern={'' as unknown as DataView}
setBoundaryIndexPattern={() => {}}
setBoundaryGeoField={() => {}}
setBoundaryNameField={() => {}}
boundaryNameField={'testNameField'}
data={dataStartMock}
unifiedSearch={unifiedSearchStartMock}
/>
);
expect(component).toMatchSnapshot();
});

View file

@ -1,298 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, useEffect, useState } from 'react';
import { EuiCallOut, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { fromKueryExpression, luceneStringToDsl } from '@kbn/es-query';
import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { Query } from '@kbn/es-query';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { CoreStart } from '@kbn/core/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { STACK_ALERTS_FEATURE_ID } from '../../../../common/constants';
import { BoundaryIndexExpression } from './expressions/boundary_index_expression';
import { EntityByExpression } from './expressions/entity_by_expression';
import { EntityIndexExpression } from './expressions/entity_index_expression';
import type { GeoContainmentAlertParams } from '../types';
const DEFAULT_VALUES = {
TRACKING_EVENT: '',
ENTITY: '',
INDEX: '',
INDEX_ID: '',
DATE_FIELD: '',
BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more
GEO_FIELD: '',
BOUNDARY_INDEX: '',
BOUNDARY_INDEX_ID: '',
BOUNDARY_GEO_FIELD: '',
BOUNDARY_NAME_FIELD: '',
DELAY_OFFSET_WITH_UNITS: '0m',
};
interface KibanaDeps {
http: HttpSetup;
docLinks: DocLinksStart;
dataViews: DataViewsPublicPluginStart;
uiSettings: IUiSettingsClient;
notifications: CoreStart['notifications'];
storage: IStorageWrapper;
usageCollection: UsageCollectionStart;
}
function validateQuery(query: Query) {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
query.language === 'kuery' ? fromKueryExpression(query.query) : luceneStringToDsl(query.query);
} catch (err) {
return false;
}
return true;
}
export const GeoContainmentAlertTypeExpression: React.FunctionComponent<
RuleTypeParamsExpressionProps<GeoContainmentAlertParams>
> = ({ ruleParams, ruleInterval, setRuleParams, setRuleProperty, errors, data, unifiedSearch }) => {
const {
index,
indexId,
indexQuery,
geoField,
entity,
dateField,
boundaryType,
boundaryIndexTitle,
boundaryIndexId,
boundaryIndexQuery,
boundaryGeoField,
boundaryNameField,
} = ruleParams;
const { http, docLinks, uiSettings, notifications, storage, usageCollection, dataViews } =
useKibana<KibanaDeps>().services;
const [indexPattern, _setIndexPattern] = useState<DataView>({
id: '',
title: '',
} as DataView);
const setIndexPattern = (_indexPattern?: DataView) => {
if (_indexPattern) {
_setIndexPattern(_indexPattern);
if (_indexPattern.title) {
setRuleParams('index', _indexPattern.title);
}
if (_indexPattern.id) {
setRuleParams('indexId', _indexPattern.id);
}
}
};
const [indexQueryInput, setIndexQueryInput] = useState<Query>(
indexQuery || {
query: '',
language: 'kuery',
}
);
const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState<DataView>({
id: '',
title: '',
} as DataView);
const setBoundaryIndexPattern = (_indexPattern?: DataView) => {
if (_indexPattern) {
_setBoundaryIndexPattern(_indexPattern);
if (_indexPattern.title) {
setRuleParams('boundaryIndexTitle', _indexPattern.title);
}
if (_indexPattern.id) {
setRuleParams('boundaryIndexId', _indexPattern.id);
}
}
};
const [boundaryIndexQueryInput, setBoundaryIndexQueryInput] = useState<Query>(
boundaryIndexQuery || {
query: '',
language: 'kuery',
}
);
const hasExpressionErrors = false;
const expressionErrorMessage = i18n.translate(
'xpack.stackAlerts.geoContainment.fixErrorInExpressionBelowValidationMessage',
{
defaultMessage: 'Expression contains errors.',
}
);
useEffect(() => {
const initToDefaultParams = async () => {
setRuleProperty('params', {
...ruleParams,
index: index ?? DEFAULT_VALUES.INDEX,
indexId: indexId ?? DEFAULT_VALUES.INDEX_ID,
entity: entity ?? DEFAULT_VALUES.ENTITY,
dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD,
boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE,
geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD,
boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX,
boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID,
boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD,
boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD,
});
if (!data.indexPatterns) {
return;
}
if (indexId) {
const _indexPattern = await data.indexPatterns.get(indexId);
setIndexPattern(_indexPattern);
}
if (boundaryIndexId) {
const _boundaryIndexPattern = await data.indexPatterns.get(boundaryIndexId);
setBoundaryIndexPattern(_boundaryIndexPattern);
}
};
initToDefaultParams();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Fragment>
{hasExpressionErrors ? (
<Fragment>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
<EuiSpacer />
</Fragment>
) : null}
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.selectEntity"
defaultMessage="Select entity"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EntityIndexExpression
dateField={dateField}
geoField={geoField}
errors={errors}
setAlertParamsDate={(_date) => setRuleParams('dateField', _date)}
setAlertParamsGeoField={(_geoField) => setRuleParams('geoField', _geoField)}
setRuleProperty={setRuleProperty}
setIndexPattern={setIndexPattern}
indexPattern={indexPattern}
isInvalid={!indexId || !dateField || !geoField}
data={data}
unifiedSearch={unifiedSearch}
/>
<EntityByExpression
errors={errors}
entity={entity}
setAlertParamsEntity={(entityName) => setRuleParams('entity', entityName)}
indexFields={indexPattern.fields}
isInvalid={indexId && dateField && geoField ? !entity : false}
/>
<EuiSpacer size="s" />
<EuiFlexItem>
<QueryStringInput
disableAutoFocus
bubbleSubmitEvent
indexPatterns={indexPattern ? [indexPattern] : []}
query={indexQueryInput}
onChange={(query) => {
if (query.language) {
if (validateQuery(query)) {
setRuleParams('indexQuery', query);
}
setIndexQueryInput(query);
}
}}
appName={STACK_ALERTS_FEATURE_ID}
deps={{
unifiedSearch,
notifications,
http,
docLinks,
uiSettings,
data,
dataViews,
storage,
usageCollection,
}}
/>
</EuiFlexItem>
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.selectBoundaryIndex"
defaultMessage="Select boundary"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<BoundaryIndexExpression
ruleParams={ruleParams}
errors={errors}
boundaryIndexPattern={boundaryIndexPattern}
setBoundaryIndexPattern={setBoundaryIndexPattern}
setBoundaryGeoField={(_geoField: string | undefined) =>
_geoField && setRuleParams('boundaryGeoField', _geoField)
}
setBoundaryNameField={(_boundaryNameField: string | undefined) =>
_boundaryNameField
? setRuleParams('boundaryNameField', _boundaryNameField)
: setRuleParams('boundaryNameField', '')
}
boundaryNameField={boundaryNameField}
data={data}
unifiedSearch={unifiedSearch}
/>
<EuiSpacer size="s" />
<EuiFlexItem>
<QueryStringInput
disableAutoFocus
bubbleSubmitEvent
indexPatterns={boundaryIndexPattern ? [boundaryIndexPattern] : []}
query={boundaryIndexQueryInput}
onChange={(query) => {
if (query.language) {
if (validateQuery(query)) {
setRuleParams('boundaryIndexQuery', query);
}
setBoundaryIndexQueryInput(query);
}
}}
appName={STACK_ALERTS_FEATURE_ID}
deps={{
unifiedSearch,
notifications,
http,
docLinks,
uiSettings,
data,
dataViews,
storage,
usageCollection,
}}
/>
</EuiFlexItem>
<EuiSpacer size="l" />
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { GeoContainmentAlertTypeExpression as default };

View file

@ -1,61 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render with error when data view does not have geo_point field 1`] = `
<Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
error="Data view does not contain any allowed geospatial fields. Must have one of type geo_point."
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={true}
label="Data view"
labelType="label"
>
<MockIndexPatternSelectComponent
fieldTypes={
Array [
"geo_point",
]
}
indexPatternId="foobar_without_geopoint"
isClearable={false}
isDisabled={false}
isInvalid={true}
onChange={[Function]}
onNoIndexPatterns={[Function]}
placeholder="Select data view"
/>
</EuiFormRow>
</Fragment>
`;
exports[`should render without error after mounting 1`] = `
<Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
error=""
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Data view"
labelType="label"
>
<MockIndexPatternSelectComponent
fieldTypes={
Array [
"geo_point",
]
}
indexPatternId="foobar_with_geopoint"
isClearable={false}
isDisabled={false}
isInvalid={false}
onChange={[Function]}
onNoIndexPatterns={[Function]}
placeholder="Select data view"
/>
</EuiFormRow>
</Fragment>
`;

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ReactNode, useState } from 'react';
import {
EuiButtonIcon,
EuiExpression,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiPopoverTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const ExpressionWithPopover: ({
popoverContent,
expressionDescription,
defaultValue,
value,
isInvalid,
}: {
popoverContent: ReactNode;
expressionDescription: ReactNode;
defaultValue?: ReactNode;
value?: ReactNode;
isInvalid?: boolean;
}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => {
const [popoverOpen, setPopoverOpen] = useState(false);
return (
<EuiPopover
id="popoverForExpression"
button={
<EuiExpression
display="columns"
data-test-subj="selectIndexExpression"
description={expressionDescription}
value={value || defaultValue}
isActive={popoverOpen}
onClick={() => setPopoverOpen(true)}
isInvalid={isInvalid}
/>
}
isOpen={popoverOpen}
closePopover={() => setPopoverOpen(false)}
ownFocus
anchorPosition="downLeft"
zIndex={8000}
display="block"
>
<div style={{ width: '450px' }}>
<EuiPopoverTitle>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>{expressionDescription}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="closePopover"
iconType="cross"
color="danger"
aria-label={i18n.translate(
'xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel',
{
defaultMessage: 'Close',
}
)}
onClick={() => setPopoverOpen(false)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
{popoverContent}
</div>
</EuiPopover>
);
};

View file

@ -1,73 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { GeoIndexPatternSelect } from './geo_index_pattern_select';
import { DataViewsContract } from '@kbn/data-plugin/public';
import { HttpSetup } from '@kbn/core/public';
class MockIndexPatternSelectComponent extends React.Component {
render() {
return 'MockIndexPatternSelectComponent';
}
}
function makeMockIndexPattern(id: string, fields: unknown) {
return {
id,
fields,
};
}
const mockIndexPatternService: DataViewsContract = {
get(id: string) {
if (id === 'foobar_with_geopoint') {
return makeMockIndexPattern(id, [{ type: 'geo_point' }]);
} else if (id === 'foobar_without_geopoint') {
return makeMockIndexPattern(id, [{ type: 'string' }]);
}
},
} as unknown as DataViewsContract;
test('should render without error after mounting', async () => {
const component = shallow(
<GeoIndexPatternSelect
http={{} as unknown as HttpSetup}
onChange={() => {}}
value={'foobar_with_geopoint'}
includedGeoTypes={['geo_point']}
indexPatternService={mockIndexPatternService}
IndexPatternSelectComponent={MockIndexPatternSelectComponent}
/>
);
// Ensure all promises resolve
await new Promise((resolve) => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('should render with error when data view does not have geo_point field', async () => {
const component = shallow(
<GeoIndexPatternSelect
http={{} as unknown as HttpSetup}
onChange={() => {}}
value={'foobar_without_geopoint'}
includedGeoTypes={['geo_point']}
indexPatternService={mockIndexPatternService}
IndexPatternSelectComponent={MockIndexPatternSelectComponent}
/>
);
// Ensure all promises resolve
await new Promise((resolve) => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});

View file

@ -1,178 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component } from 'react';
import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewsContract } from '@kbn/data-plugin/public';
import { HttpSetup } from '@kbn/core/public';
import { DataView } from '@kbn/data-plugin/common';
interface Props {
onChange: (indexPattern: DataView) => void;
value: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
IndexPatternSelectComponent: any;
indexPatternService: DataViewsContract | undefined;
http: HttpSetup;
includedGeoTypes: string[];
}
interface State {
doesIndexPatternHaveGeoField: boolean;
noIndexPatternsExist: boolean;
}
export class GeoIndexPatternSelect extends Component<Props, State> {
private _isMounted: boolean = false;
state = {
doesIndexPatternHaveGeoField: false,
noIndexPatternsExist: false,
};
componentWillUnmount() {
this._isMounted = false;
}
componentDidMount() {
this._isMounted = true;
if (this.props.value) {
this._loadIndexPattern(this.props.value);
}
}
_loadIndexPattern = async (indexPatternId: string) => {
if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) {
return;
}
let indexPattern;
try {
indexPattern = await this.props.indexPatternService.get(indexPatternId);
} catch (err) {
return;
}
if (!this._isMounted || indexPattern.id !== indexPatternId) {
return;
}
this.setState({
doesIndexPatternHaveGeoField: indexPattern.fields.some((field) => {
return this.props.includedGeoTypes.includes(field.type);
}),
});
return indexPattern;
};
_onIndexPatternSelect = async (indexPatternId: string) => {
const indexPattern = await this._loadIndexPattern(indexPatternId);
if (indexPattern) {
this.props.onChange(indexPattern);
}
};
_onNoIndexPatterns = () => {
this.setState({ noIndexPatternsExist: true });
};
_renderNoIndexPatternWarning() {
if (!this.state.noIndexPatternsExist) {
return null;
}
return (
<>
<EuiCallOut
title={i18n.translate('xpack.stackAlerts.geoContainment.noIndexPattern.messageTitle', {
defaultMessage: `Couldn't find any data views`,
})}
color="warning"
>
<p>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.noIndexPattern.doThisPrefixDescription"
defaultMessage="You'll need to "
/>
<EuiLink href={this.props.http.basePath.prepend(`/app/management/kibana/dataViews`)}>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.noIndexPattern.doThisLinkTextDescription"
defaultMessage="Create a data view."
/>
</EuiLink>
</p>
<p>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.noIndexPattern.hintDescription"
defaultMessage="Don't have any data? "
/>
<EuiLink
href={this.props.http.basePath.prepend('/app/home#/tutorial_directory/sampleData')}
>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.noIndexPattern.getStartedLinkText"
defaultMessage="Get started with some sample data sets."
/>
</EuiLink>
</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
}
render() {
const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent;
const isIndexPatternInvalid = !!this.props.value && !this.state.doesIndexPatternHaveGeoField;
const error = isIndexPatternInvalid
? i18n.translate('xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message', {
defaultMessage:
'Data view does not contain any allowed geospatial fields. Must have one of type {geoFields}.',
values: {
geoFields: this.props.includedGeoTypes.join(', '),
},
})
: '';
return (
<>
{this._renderNoIndexPatternWarning()}
<EuiFormRow
label={i18n.translate('xpack.stackAlerts.geoContainment.indexPatternSelectLabel', {
defaultMessage: 'Data view',
})}
isInvalid={isIndexPatternInvalid}
error={error}
>
{IndexPatternSelectComponent ? (
<IndexPatternSelectComponent
isInvalid={isIndexPatternInvalid}
isDisabled={this.state.noIndexPatternsExist}
indexPatternId={this.props.value}
onChange={this._onIndexPatternSelect}
placeholder={i18n.translate(
'xpack.stackAlerts.geoContainment.indexPatternSelectPlaceholder',
{
defaultMessage: 'Select data view',
}
)}
fieldTypes={this.props.includedGeoTypes}
onNoIndexPatterns={this._onNoIndexPatterns}
isClearable={false}
/>
) : (
<div />
)}
</EuiFormRow>
</>
);
}
}

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 React from 'react';
import { act } from 'react-dom/test-utils';
import { BoundaryForm } from './boundary_form';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { GeoContainmentAlertParams } from '../types';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
jest.mock('./query_input', () => {
return {
QueryInput: () => <div>mock query input</div>,
};
});
test('should not call prop callbacks on render', async () => {
const DATA_VIEW_TITLE = 'my-boundaries*';
const DATA_VIEW_ID = '1234';
const mockDataView = {
id: DATA_VIEW_ID,
fields: [
{
name: 'location',
type: 'geo_shape',
},
],
title: DATA_VIEW_TITLE,
};
const props = {
data: {
indexPatterns: {
get: async () => mockDataView,
},
} as unknown as DataPublicPluginStart,
getValidationError: () => null,
ruleParams: {
boundaryIndexTitle: DATA_VIEW_TITLE,
boundaryIndexId: DATA_VIEW_ID,
boundaryGeoField: 'location',
boundaryNameField: 'name',
boundaryIndexQuery: {
query: 'population > 1000',
language: 'kuery',
},
} as unknown as GeoContainmentAlertParams,
setDataViewId: jest.fn(),
setDataViewTitle: jest.fn(),
setGeoField: jest.fn(),
setNameField: jest.fn(),
setQuery: jest.fn(),
unifiedSearch: {
ui: {
IndexPatternSelect: () => {
return '<div>mock IndexPatternSelect</div>';
},
},
} as unknown as UnifiedSearchPublicPluginStart,
};
const wrapper = mountWithIntl(<BoundaryForm {...props} />);
await act(async () => {
await nextTick();
wrapper.update();
});
// Assert that geospatial dataView fields are loaded
// to ensure test is properly awaiting async useEffect
let geoFieldsLoaded = false;
wrapper.findWhere((n) => {
if (
n.name() === 'SingleFieldSelect' &&
n.props().value === 'location' &&
n.props().fields.length === 1
) {
geoFieldsLoaded = true;
}
return false;
});
expect(geoFieldsLoaded).toBe(true);
expect(props.setDataViewId).not.toHaveBeenCalled();
expect(props.setDataViewTitle).not.toHaveBeenCalled();
expect(props.setGeoField).not.toHaveBeenCalled();
expect(props.setNameField).not.toHaveBeenCalled();
expect(props.setQuery).not.toHaveBeenCalled();
});

View file

@ -0,0 +1,233 @@
/*
* 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, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFormRow, EuiPanel, EuiSkeletonText, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { Query } from '@kbn/es-query';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { GeoContainmentAlertParams } from '../types';
import { DataViewSelect } from './data_view_select';
import { SingleFieldSelect } from './single_field_select';
import { QueryInput } from './query_input';
export const BOUNDARY_GEO_FIELD_TYPES = ['geo_shape'];
function getGeoFields(fields: DataViewField[]) {
return fields.filter((field: DataViewField) => BOUNDARY_GEO_FIELD_TYPES.includes(field.type));
}
function getNameFields(fields: DataViewField[]) {
return fields.filter(
(field: DataViewField) =>
['string', 'number', 'ip'].includes(field.type) &&
!field.name.startsWith('_') &&
!field.name.endsWith('keyword')
);
}
interface Props {
data: DataPublicPluginStart;
getValidationError: (key: string) => string | null;
ruleParams: GeoContainmentAlertParams;
setDataViewId: (id: string) => void;
setDataViewTitle: (title: string) => void;
setGeoField: (fieldName: string) => void;
setNameField: (fieldName: string | undefined) => void;
setQuery: (query: Query) => void;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
export const BoundaryForm = (props: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [dataView, setDataView] = useState<undefined | DataView>();
const [dataViewNotFound, setDataViewNotFound] = useState(false);
const [geoFields, setGeoFields] = useState<DataViewField[]>([]);
const [nameFields, setNameFields] = useState<DataViewField[]>([]);
useEffect(() => {
if (!props.ruleParams.boundaryIndexId || props.ruleParams.boundaryIndexId === dataView?.id) {
return;
}
let ignore = false;
setIsLoading(true);
setDataViewNotFound(false);
props.data.indexPatterns
.get(props.ruleParams.boundaryIndexId)
.then((nextDataView) => {
if (!ignore) {
setDataView(nextDataView);
setGeoFields(getGeoFields(nextDataView.fields));
setNameFields(getNameFields(nextDataView.fields));
setIsLoading(false);
}
})
.catch(() => {
if (!ignore) {
setDataViewNotFound(true);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [props.ruleParams.boundaryIndexId, dataView?.id, props.data.indexPatterns]);
function getDataViewError() {
const validationError = props.getValidationError('boundaryIndexTitle');
if (validationError) {
return validationError;
}
if (dataView && geoFields.length === 0) {
return i18n.translate('xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message', {
defaultMessage:
'Data view does not contain geospatial fields. Must have one of type: {geoFieldTypes}.',
values: {
geoFieldTypes: BOUNDARY_GEO_FIELD_TYPES.join(', '),
},
});
}
if (dataViewNotFound) {
return i18n.translate('xpack.stackAlerts.geoContainment.dataViewNotFound', {
defaultMessage: `Unable to find data view '{id}'`,
values: { id: props.ruleParams.indexId },
});
}
return null;
}
const dataViewError = getDataViewError();
const geoFieldError = props.getValidationError('boundaryGeoField');
return (
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.boundariesFormLabel"
defaultMessage="Boundaries"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiSkeletonText lines={3} size="s" isLoading={isLoading}>
<EuiFormRow
error={dataViewError}
isInvalid={Boolean(dataViewError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.dataViewLabel', {
defaultMessage: 'Data view',
})}
>
<DataViewSelect
dataViewId={props.ruleParams.boundaryIndexId}
data={props.data}
isInvalid={Boolean(dataViewError)}
onChange={(nextDataView: DataView) => {
if (!nextDataView.id) {
return;
}
props.setDataViewId(nextDataView.id);
props.setDataViewTitle(nextDataView.title);
const nextGeoFields = getGeoFields(nextDataView.fields);
if (nextGeoFields.length) {
props.setGeoField(nextGeoFields[0].name);
} else if ('boundaryGeoField' in props.ruleParams) {
props.setGeoField('');
}
// do not attempt to auto select name field
// its optional plus there can be many matches so auto selecting the correct field is improbable
if ('boundaryNameField' in props.ruleParams) {
props.setNameField(undefined);
}
}}
unifiedSearch={props.unifiedSearch}
/>
</EuiFormRow>
{props.ruleParams.boundaryIndexId && (
<>
<EuiFormRow
error={geoFieldError}
isInvalid={Boolean(geoFieldError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.geofieldLabel', {
defaultMessage: 'Location',
})}
>
<SingleFieldSelect
isInvalid={Boolean(geoFieldError)}
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectGeoLabel', {
defaultMessage: 'Select location field',
})}
value={props.ruleParams.boundaryGeoField}
onChange={(fieldName?: string) => {
if (fieldName) {
props.setGeoField(fieldName);
}
}}
fields={geoFields}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.stackAlerts.geoContainment.boundaryNameSelectLabel', {
defaultMessage: 'Display name (optional)',
})}
>
<SingleFieldSelect
placeholder={i18n.translate(
'xpack.stackAlerts.geoContainment.selectBoundaryNameLabel',
{
defaultMessage: 'Select name field',
}
)}
value={props.ruleParams.boundaryNameField}
onChange={(fieldName?: string) => {
if (fieldName) {
props.setNameField(fieldName);
}
}}
fields={nameFields}
/>
</EuiFormRow>
<EuiFormRow
helpText={i18n.translate(
'xpack.stackAlerts.geoContainment.boundariesFilterHelpText',
{
defaultMessage: 'Add a filter to narrow boundaries.',
}
)}
label={i18n.translate('xpack.stackAlerts.geoContainment.filterLabel', {
defaultMessage: 'Filter',
})}
>
<QueryInput
dataView={dataView}
onChange={(query: Query) => {
props.setQuery(query);
}}
query={props.ruleParams.boundaryIndexQuery}
/>
</EuiFormRow>
</>
)}
</EuiSkeletonText>
</EuiPanel>
);
};

View file

@ -0,0 +1,58 @@
/*
* 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, { useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { i18n } from '@kbn/i18n';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
interface Props {
data: DataPublicPluginStart;
dataViewId?: string;
isInvalid: boolean;
onChange: (dataview: DataView) => void;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
export const DataViewSelect = (props: Props) => {
const [isLoading, setIsLoading] = useState(false);
const isMounted = useMountedState();
return (
<props.unifiedSearch.ui.IndexPatternSelect
isClearable={false}
isDisabled={isLoading}
isInvalid={props.isInvalid}
isLoading={isLoading}
indexPatternId={props.dataViewId ? props.dataViewId : ''}
onChange={async (dataViewId?: string) => {
if (!dataViewId) {
return;
}
try {
setIsLoading(true);
const dataView = await props.data.indexPatterns.get(dataViewId);
if (isMounted()) {
props.onChange(dataView);
setIsLoading(false);
}
} catch (error) {
// ignore indexPatterns.get error,
// if data view does not exist, select will not update rule params
if (isMounted()) {
setIsLoading(false);
}
}
}}
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.dataViewSelectPlaceholder', {
defaultMessage: 'Select data view',
})}
/>
);
};

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { EntityForm } from './entity_form';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { GeoContainmentAlertParams } from '../types';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
jest.mock('./query_input', () => {
return {
QueryInput: () => <div>mock query input</div>,
};
});
test('should not call prop callbacks on render', async () => {
const DATA_VIEW_TITLE = 'my-entities*';
const DATA_VIEW_ID = '1234';
const mockDataView = {
id: DATA_VIEW_ID,
fields: [
{
name: 'location',
type: 'geo_point',
},
],
title: DATA_VIEW_TITLE,
};
const props = {
data: {
indexPatterns: {
get: async () => mockDataView,
},
} as unknown as DataPublicPluginStart,
getValidationError: () => null,
ruleParams: {
index: DATA_VIEW_TITLE,
indexId: DATA_VIEW_ID,
geoField: 'location',
entity: 'entity_id',
dateField: 'time',
indexQuery: {
query: 'population > 1000',
language: 'kuery',
},
} as unknown as GeoContainmentAlertParams,
setDataViewId: jest.fn(),
setDataViewTitle: jest.fn(),
setDateField: jest.fn(),
setEntityField: jest.fn(),
setGeoField: jest.fn(),
setQuery: jest.fn(),
unifiedSearch: {
ui: {
IndexPatternSelect: () => {
return '<div>mock IndexPatternSelect</div>';
},
},
} as unknown as UnifiedSearchPublicPluginStart,
};
const wrapper = mountWithIntl(<EntityForm {...props} />);
await act(async () => {
await nextTick();
wrapper.update();
});
// Assert that geospatial dataView fields are loaded
// to ensure test is properly awaiting async useEffect
let geoFieldsLoaded = false;
wrapper.findWhere((n) => {
if (
n.name() === 'SingleFieldSelect' &&
n.props().value === 'location' &&
n.props().fields.length === 1
) {
geoFieldsLoaded = true;
}
return false;
});
expect(geoFieldsLoaded).toBe(true);
expect(props.setDataViewId).not.toHaveBeenCalled();
expect(props.setDataViewTitle).not.toHaveBeenCalled();
expect(props.setDateField).not.toHaveBeenCalled();
expect(props.setEntityField).not.toHaveBeenCalled();
expect(props.setGeoField).not.toHaveBeenCalled();
expect(props.setQuery).not.toHaveBeenCalled();
});

View file

@ -0,0 +1,277 @@
/*
* 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, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFormRow, EuiPanel, EuiSkeletonText, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { Query } from '@kbn/es-query';
import type { GeoContainmentAlertParams } from '../types';
import { DataViewSelect } from './data_view_select';
import { SingleFieldSelect } from './single_field_select';
import { QueryInput } from './query_input';
export const ENTITY_GEO_FIELD_TYPES = ['geo_point', 'geo_shape'];
function getDateFields(fields: DataViewField[]) {
return fields.filter((field: DataViewField) => field.type === 'date');
}
function getEntityFields(fields: DataViewField[]) {
return fields.filter(
(field: DataViewField) =>
field.aggregatable &&
['string', 'number', 'ip'].includes(field.type) &&
!field.name.startsWith('_')
);
}
function getGeoFields(fields: DataViewField[]) {
return fields.filter((field: DataViewField) => ENTITY_GEO_FIELD_TYPES.includes(field.type));
}
interface Props {
data: DataPublicPluginStart;
getValidationError: (key: string) => string | null;
ruleParams: GeoContainmentAlertParams;
setDataViewId: (id: string) => void;
setDataViewTitle: (title: string) => void;
setDateField: (fieldName: string) => void;
setEntityField: (fieldName: string) => void;
setGeoField: (fieldName: string) => void;
setQuery: (query: Query) => void;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
export const EntityForm = (props: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [dataView, setDataView] = useState<undefined | DataView>();
const [dataViewNotFound, setDataViewNotFound] = useState(false);
const [dateFields, setDateFields] = useState<DataViewField[]>([]);
const [entityFields, setEntityFields] = useState<DataViewField[]>([]);
const [geoFields, setGeoFields] = useState<DataViewField[]>([]);
useEffect(() => {
if (!props.ruleParams.indexId || props.ruleParams.indexId === dataView?.id) {
return;
}
let ignore = false;
setIsLoading(true);
setDataViewNotFound(false);
props.data.indexPatterns
.get(props.ruleParams.indexId)
.then((nextDataView) => {
if (!ignore) {
setDataView(nextDataView);
setDateFields(getDateFields(nextDataView.fields));
setEntityFields(getEntityFields(nextDataView.fields));
setGeoFields(getGeoFields(nextDataView.fields));
setIsLoading(false);
}
})
.catch(() => {
if (!ignore) {
setDataViewNotFound(true);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [props.ruleParams.indexId, dataView?.id, props.data.indexPatterns]);
function getDataViewError() {
const validationError = props.getValidationError('index');
if (validationError) {
return validationError;
}
if (dataView && dateFields.length === 0) {
return i18n.translate('xpack.stackAlerts.geoContainment.noDateFieldInIndexPattern.message', {
defaultMessage: 'Data view does not contain date fields.',
});
}
if (dataView && geoFields.length === 0) {
return i18n.translate('xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message', {
defaultMessage:
'Data view does not contain geospatial fields. Must have one of type: {geoFieldTypes}.',
values: {
geoFieldTypes: ENTITY_GEO_FIELD_TYPES.join(', '),
},
});
}
if (dataViewNotFound) {
return i18n.translate('xpack.stackAlerts.geoContainment.dataViewNotFound', {
defaultMessage: `Unable to find data view '{id}'`,
values: { id: props.ruleParams.indexId },
});
}
return null;
}
const dataViewError = getDataViewError();
const dateFieldError = props.getValidationError('dateField');
const geoFieldError = props.getValidationError('geoField');
const entityFieldError = props.getValidationError('entity');
return (
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.geoContainment.entitiesFormLabel"
defaultMessage="Entities"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiSkeletonText lines={3} size="s" isLoading={isLoading}>
<EuiFormRow
error={dataViewError}
isInvalid={Boolean(dataViewError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.dataViewLabel', {
defaultMessage: 'Data view',
})}
>
<DataViewSelect
dataViewId={props.ruleParams.indexId}
data={props.data}
isInvalid={Boolean(dataViewError)}
onChange={(nextDataView: DataView) => {
if (!nextDataView.id) {
return;
}
props.setDataViewId(nextDataView.id);
props.setDataViewTitle(nextDataView.title);
const nextDateFields = getDateFields(nextDataView.fields);
if (nextDateFields.length) {
props.setDateField(nextDateFields[0].name);
} else if ('dateField' in props.ruleParams) {
props.setDateField('');
}
// do not attempt to auto select entity field
// there can be many matches so auto selecting the correct field is improbable
if ('entity' in props.ruleParams) {
props.setEntityField('');
}
const nextGeoFields = getGeoFields(nextDataView.fields);
if (nextGeoFields.length) {
props.setGeoField(nextGeoFields[0].name);
} else if ('geoField' in props.ruleParams) {
props.setGeoField('');
}
}}
unifiedSearch={props.unifiedSearch}
/>
</EuiFormRow>
{props.ruleParams.indexId && (
<>
<EuiFormRow
error={dateFieldError}
isInvalid={Boolean(dateFieldError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.timeFieldLabel', {
defaultMessage: 'Time',
})}
>
<SingleFieldSelect
isInvalid={Boolean(dateFieldError)}
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectTimeLabel', {
defaultMessage: 'Select time field',
})}
value={props.ruleParams.dateField}
onChange={(fieldName?: string) => {
if (fieldName) {
props.setDateField(fieldName);
}
}}
fields={dateFields}
/>
</EuiFormRow>
<EuiFormRow
error={geoFieldError}
isInvalid={Boolean(geoFieldError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.geofieldLabel', {
defaultMessage: 'Location',
})}
>
<SingleFieldSelect
isInvalid={Boolean(geoFieldError)}
placeholder={i18n.translate('xpack.stackAlerts.geoContainment.selectGeoLabel', {
defaultMessage: 'Select location field',
})}
value={props.ruleParams.geoField}
onChange={(fieldName?: string) => {
if (fieldName) {
props.setGeoField(fieldName);
}
}}
fields={geoFields}
/>
</EuiFormRow>
<EuiFormRow
error={entityFieldError}
isInvalid={Boolean(entityFieldError)}
label={i18n.translate('xpack.stackAlerts.geoContainment.entityfieldLabel', {
defaultMessage: 'Entity',
})}
>
<SingleFieldSelect
isInvalid={Boolean(entityFieldError)}
placeholder={i18n.translate(
'xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder',
{
defaultMessage: 'Select entity field',
}
)}
value={props.ruleParams.entity}
onChange={(fieldName?: string) => {
if (fieldName) {
props.setEntityField(fieldName);
}
}}
fields={entityFields}
/>
</EuiFormRow>
<EuiFormRow
helpText={i18n.translate('xpack.stackAlerts.geoContainment.entityFilterHelpText', {
defaultMessage: 'Add a filter to narrow entities.',
})}
label={i18n.translate('xpack.stackAlerts.geoContainment.filterLabel', {
defaultMessage: 'Filter',
})}
>
<QueryInput
dataView={dataView}
onChange={(query: Query) => {
props.setQuery(query);
}}
query={props.ruleParams.indexQuery}
/>
</EuiFormRow>
</>
)}
</EuiSkeletonText>
</EuiPanel>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 { RuleForm } from './rule_form';
// eslint-disable-next-line import/no-default-export
export default RuleForm;

View file

@ -0,0 +1,98 @@
/*
* 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, { useState } from 'react';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { Query } from '@kbn/es-query';
import { fromKueryExpression, luceneStringToDsl } from '@kbn/es-query';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { CoreStart } from '@kbn/core/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { STACK_ALERTS_FEATURE_ID } from '../../../../common/constants';
function validateQuery(query: Query) {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
query.language === 'kuery' ? fromKueryExpression(query.query) : luceneStringToDsl(query.query);
} catch (err) {
return false;
}
return true;
}
interface Props {
dataView?: DataView;
onChange: (query: Query) => void;
query?: Query;
}
export const QueryInput = (props: Props) => {
const {
data,
dataViews,
docLinks,
http,
notifications,
storage,
uiSettings,
unifiedSearch,
usageCollection,
} = useKibana<{
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
docLinks: DocLinksStart;
http: HttpSetup;
notifications: CoreStart['notifications'];
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
unifiedSearch: UnifiedSearchPublicPluginStart;
usageCollection: UsageCollectionStart;
}>().services;
const [localQuery, setLocalQuery] = useState<Query>(
props.query || {
query: '',
language: 'kuery',
}
);
return (
<QueryStringInput
disableAutoFocus
bubbleSubmitEvent
indexPatterns={props.dataView ? [props.dataView] : []}
query={localQuery}
onChange={(query) => {
if (query.language) {
setLocalQuery(query);
if (validateQuery(query)) {
props.onChange(query);
}
}
}}
appName={STACK_ALERTS_FEATURE_ID}
deps={{
unifiedSearch,
notifications,
http,
docLinks,
uiSettings,
data,
dataViews,
storage,
usageCollection,
}}
/>
);
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import type { Query } from '@kbn/es-query';
import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import type { GeoContainmentAlertParams } from '../types';
import { BoundaryForm } from './boundary_form';
import { EntityForm } from './entity_form';
export const RuleForm: React.FunctionComponent<
RuleTypeParamsExpressionProps<GeoContainmentAlertParams>
> = (props) => {
function getValidationError(key: string) {
return props.errors[key]?.length > 0 && key in props.ruleParams
? (props.errors[key] as string[])[0]
: null;
}
return (
<>
<EntityForm
data={props.data}
getValidationError={getValidationError}
ruleParams={props.ruleParams}
setDataViewId={(id: string) => props.setRuleParams('indexId', id)}
setDataViewTitle={(title: string) => props.setRuleParams('index', title)}
setDateField={(fieldName: string) => props.setRuleParams('dateField', fieldName)}
setEntityField={(fieldName: string) => props.setRuleParams('entity', fieldName)}
setGeoField={(fieldName: string) => props.setRuleParams('geoField', fieldName)}
setQuery={(query: Query) => props.setRuleParams('indexQuery', query)}
unifiedSearch={props.unifiedSearch}
/>
<EuiSpacer size="s" />
<BoundaryForm
data={props.data}
getValidationError={getValidationError}
ruleParams={props.ruleParams}
setDataViewId={(id: string) => {
props.setRuleParams('boundaryIndexId', id);
// TODO remove unused param 'boundaryType'
props.setRuleParams('boundaryType', 'entireIndex');
}}
setDataViewTitle={(title: string) => props.setRuleParams('boundaryIndexTitle', title)}
setGeoField={(fieldName: string) => props.setRuleParams('boundaryGeoField', fieldName)}
setNameField={(fieldName: string | undefined) =>
props.setRuleParams('boundaryNameField', fieldName)
}
setQuery={(query: Query) => props.setRuleParams('boundaryIndexQuery', query)}
unifiedSearch={props.unifiedSearch}
/>
<EuiSpacer size="l" />
</>
);
};

View file

@ -33,13 +33,14 @@ function fieldsToOptions(fields?: DataViewField[]): Array<EuiComboBoxOptionOptio
}
interface Props {
isInvalid?: boolean;
placeholder: string;
value: string | null; // data view field name
value: string | null | undefined;
onChange: (fieldName?: string) => void;
fields: DataViewField[];
}
export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) {
export function SingleFieldSelect({ isInvalid, placeholder, value, onChange, fields }: Props) {
function renderOption(
option: EuiComboBoxOptionOption<DataViewField>,
searchValue: string,
@ -71,6 +72,7 @@ export function SingleFieldSelect({ placeholder, value, onChange, fields }: Prop
return (
<EuiComboBox
isInvalid={isInvalid}
singleSelection={true}
options={fieldsToOptions(fields)}
selectedOptions={selectedOptions}
@ -79,7 +81,6 @@ export function SingleFieldSelect({ placeholder, value, onChange, fields }: Prop
renderOption={renderOption}
isClearable={false}
placeholder={placeholder}
compressed
/>
);
}

View file

@ -22,6 +22,3 @@ export interface GeoContainmentAlertParams extends RuleTypeParams {
indexQuery?: Query;
boundaryIndexQuery?: Query;
}
export const ES_GEO_FIELD_TYPES = ['geo_point', 'geo_shape'];
export const ES_GEO_SHAPE_TYPES = ['geo_shape'];

View file

@ -105,7 +105,7 @@ describe('expression params validation', () => {
};
expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe(
'Boundary data view title is required.'
'Boundary data view is required.'
);
});

View file

@ -69,7 +69,7 @@ export const validateExpression = (alertParams: GeoContainmentAlertParams): Vali
if (!boundaryIndexTitle) {
errors.boundaryIndexTitle.push(
i18n.translate('xpack.stackAlerts.geoContainment.error.requiredBoundaryIndexTitleText', {
defaultMessage: 'Boundary data view title is required.',
defaultMessage: 'Boundary data view is required.',
})
);
}

View file

@ -35656,7 +35656,6 @@
"xpack.stackAlerts.geoContainment.boundariesFetchError": "Impossible de récupérer les limites du suivi de lendiguement, erreur : {error}",
"xpack.stackAlerts.geoContainment.entityContainmentFetchError": "Impossible de récupérer lendiguement des entités, erreur : {error}",
"xpack.stackAlerts.geoContainment.noBoundariesError": "Aucune limite de suivi de lendiguement trouvée. Assurez-vous que lindex \"{index}\" a des documents.",
"xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message": "La vue de données ne contient aucun champ géospatial autorisé. Il doit en contenir un de type {geoFields}.",
"xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "le groupe {group} de l'alerte {name} a atteint le seuil",
"xpack.stackAlerts.indexThreshold.alertTypeRecoveryContextSubjectTitle": "groupe {group} de l'alerte {name} récupéré",
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "thresholdComparator spécifié non valide : {comparator}",
@ -35747,12 +35746,8 @@
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityDocumentIdLabel": "ID du document de l'entité contenue",
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityLocationLabel": "Emplacement de l'entité",
"xpack.stackAlerts.geoContainment.alertTypeTitle": "Suivi de l'endiguement",
"xpack.stackAlerts.geoContainment.boundaryNameSelect": "Sélectionner un nom de limite",
"xpack.stackAlerts.geoContainment.boundaryNameSelectLabel": "Nom de limite lisible par l'utilisateur (facultatif)",
"xpack.stackAlerts.geoContainment.descriptionText": "Alerte lorsqu'une entité est contenue dans une limite géographique.",
"xpack.stackAlerts.geoContainment.entityByLabel": "par",
"xpack.stackAlerts.geoContainment.entityIndexLabel": "index",
"xpack.stackAlerts.geoContainment.entityIndexSelect": "Sélectionner une vue de données et un champ de point géographique",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryGeoFieldText": "Le champ de limite géographique est requis.",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryIndexTitleText": "Le titre de la vue de données de limite est requis.",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryTypeText": "Le type de limite est requis.",
@ -35760,25 +35755,12 @@
"xpack.stackAlerts.geoContainment.error.requiredEntityText": "L'entité est requise.",
"xpack.stackAlerts.geoContainment.error.requiredGeoFieldText": "Le champ géographique est requis.",
"xpack.stackAlerts.geoContainment.error.requiredIndexTitleText": "La vue de données est requise.",
"xpack.stackAlerts.geoContainment.fixErrorInExpressionBelowValidationMessage": "L'expression contient des erreurs.",
"xpack.stackAlerts.geoContainment.geofieldLabel": "Champ géospatial",
"xpack.stackAlerts.geoContainment.indexLabel": "index",
"xpack.stackAlerts.geoContainment.indexPatternSelectLabel": "Vue de données",
"xpack.stackAlerts.geoContainment.indexPatternSelectPlaceholder": "Sélectionner la vue de données",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisLinkTextDescription": "Créez une vue de données.",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisPrefixDescription": "Vous devrez ",
"xpack.stackAlerts.geoContainment.noIndexPattern.getStartedLinkText": "Commencez avec des échantillons d'ensembles de données.",
"xpack.stackAlerts.geoContainment.noIndexPattern.hintDescription": "Vous n'avez aucune donnée ? ",
"xpack.stackAlerts.geoContainment.noIndexPattern.messageTitle": "Aucune vue de données n'a été trouvée",
"xpack.stackAlerts.geoContainment.notGeoContained": "Plus contenu",
"xpack.stackAlerts.geoContainment.selectBoundaryIndex": "Sélectionner une limite",
"xpack.stackAlerts.geoContainment.selectEntity": "Sélectionner une entité",
"xpack.stackAlerts.geoContainment.selectGeoLabel": "Sélectionner un champ géographique",
"xpack.stackAlerts.geoContainment.selectLabel": "Sélectionner un champ géographique",
"xpack.stackAlerts.geoContainment.selectTimeLabel": "Sélectionner un champ temporel",
"xpack.stackAlerts.geoContainment.timeFieldLabel": "Champ temporel",
"xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "Sélectionner un champ d'entité",
"xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "Fermer",
"xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "Seuil atteint",
"xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "Chaîne décrivant le comparateur de seuil et le seuil",
"xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "Date à laquelle l'alerte a dépassé le seuil.",

View file

@ -35655,7 +35655,6 @@
"xpack.stackAlerts.geoContainment.boundariesFetchError": "追跡包含境界を取得できません。エラー:{error}",
"xpack.stackAlerts.geoContainment.entityContainmentFetchError": "エンティティコンテインメントを取得できません。エラー:{error}",
"xpack.stackAlerts.geoContainment.noBoundariesError": "追跡包含境界が見つかりません。インデックス\"{index}\"にドキュメントがあることを確認します。",
"xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message": "データビューには許可された地理空間フィールドが含まれていません。{geoFields}型のいずれかが必要です。",
"xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "アラート{name}グループ{group}がしきい値を満たしました",
"xpack.stackAlerts.indexThreshold.alertTypeRecoveryContextSubjectTitle": "アラート{name}グループ{group}が回復されました",
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効なthresholdComparatorが指定されました{comparator}",
@ -35746,12 +35745,8 @@
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityDocumentIdLabel": "含まれるエンティティドキュメントの ID",
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityLocationLabel": "エンティティの場所",
"xpack.stackAlerts.geoContainment.alertTypeTitle": "追跡包含",
"xpack.stackAlerts.geoContainment.boundaryNameSelect": "境界名を選択",
"xpack.stackAlerts.geoContainment.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)",
"xpack.stackAlerts.geoContainment.descriptionText": "エンティティが地理的境界に含まれるときにアラートを発行します。",
"xpack.stackAlerts.geoContainment.entityByLabel": "グループ基準",
"xpack.stackAlerts.geoContainment.entityIndexLabel": "インデックス",
"xpack.stackAlerts.geoContainment.entityIndexSelect": "データビューと地理ポイントフィールドを選択",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryIndexTitleText": "境界データビュータイトルが必要です。",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryTypeText": "境界タイプは必須です。",
@ -35759,25 +35754,12 @@
"xpack.stackAlerts.geoContainment.error.requiredEntityText": "エンティティは必須です。",
"xpack.stackAlerts.geoContainment.error.requiredGeoFieldText": "地理フィールドは必須です。",
"xpack.stackAlerts.geoContainment.error.requiredIndexTitleText": "データビューが必要です。",
"xpack.stackAlerts.geoContainment.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。",
"xpack.stackAlerts.geoContainment.geofieldLabel": "地理空間フィールド",
"xpack.stackAlerts.geoContainment.indexLabel": "インデックス",
"xpack.stackAlerts.geoContainment.indexPatternSelectLabel": "データビュー",
"xpack.stackAlerts.geoContainment.indexPatternSelectPlaceholder": "データビューを選択",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisLinkTextDescription": "データビューを作成します。",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisPrefixDescription": "次のことが必要です ",
"xpack.stackAlerts.geoContainment.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。",
"xpack.stackAlerts.geoContainment.noIndexPattern.hintDescription": "データがない場合",
"xpack.stackAlerts.geoContainment.noIndexPattern.messageTitle": "データビューが見つかりませんでした",
"xpack.stackAlerts.geoContainment.notGeoContained": "含まれていません",
"xpack.stackAlerts.geoContainment.selectBoundaryIndex": "境界を選択",
"xpack.stackAlerts.geoContainment.selectEntity": "エンティティを選択",
"xpack.stackAlerts.geoContainment.selectGeoLabel": "ジオフィールドを選択",
"xpack.stackAlerts.geoContainment.selectLabel": "ジオフィールドを選択",
"xpack.stackAlerts.geoContainment.selectTimeLabel": "時刻フィールドを選択",
"xpack.stackAlerts.geoContainment.timeFieldLabel": "時間フィールド",
"xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択",
"xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "閉じる",
"xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "しきい値一致",
"xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "しきい値比較基準としきい値を説明する文字列",
"xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "アラートがしきい値を超えた日付。",

View file

@ -35649,7 +35649,6 @@
"xpack.stackAlerts.geoContainment.boundariesFetchError": "无法提取跟踪限制边界,错误:{error}",
"xpack.stackAlerts.geoContainment.entityContainmentFetchError": "无法提取实体限制,错误:{error}",
"xpack.stackAlerts.geoContainment.noBoundariesError": "找不到跟踪限制边界。确保索引“{index}”包含文档。",
"xpack.stackAlerts.geoContainment.noGeoFieldInIndexPattern.message": "数据视图不包含任何允许的地理空间字段。必须具有一个类型 {geoFields}。",
"xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "告警 {name} 组 {group} 达到阈值",
"xpack.stackAlerts.indexThreshold.alertTypeRecoveryContextSubjectTitle": "告警 {name} 组 {group} 已恢复",
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}",
@ -35740,12 +35739,8 @@
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityDocumentIdLabel": "所包含实体文档的 ID",
"xpack.stackAlerts.geoContainment.actionVariableContextFromEntityLocationLabel": "实体的位置",
"xpack.stackAlerts.geoContainment.alertTypeTitle": "跟踪限制",
"xpack.stackAlerts.geoContainment.boundaryNameSelect": "选择边界名称",
"xpack.stackAlerts.geoContainment.boundaryNameSelectLabel": "可人工读取的边界名称(可选)",
"xpack.stackAlerts.geoContainment.descriptionText": "实体包含在地理边界内时告警。",
"xpack.stackAlerts.geoContainment.entityByLabel": "依据",
"xpack.stackAlerts.geoContainment.entityIndexLabel": "索引",
"xpack.stackAlerts.geoContainment.entityIndexSelect": "选择数据视图和地理点字段",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryGeoFieldText": "边界地理字段必填。",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryIndexTitleText": "边界数据视图标题必填。",
"xpack.stackAlerts.geoContainment.error.requiredBoundaryTypeText": "“边界类型”必填。",
@ -35753,25 +35748,12 @@
"xpack.stackAlerts.geoContainment.error.requiredEntityText": "“实体”必填。",
"xpack.stackAlerts.geoContainment.error.requiredGeoFieldText": "“地理”字段必填。",
"xpack.stackAlerts.geoContainment.error.requiredIndexTitleText": "需要数据视图。",
"xpack.stackAlerts.geoContainment.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。",
"xpack.stackAlerts.geoContainment.geofieldLabel": "地理空间字段",
"xpack.stackAlerts.geoContainment.indexLabel": "索引",
"xpack.stackAlerts.geoContainment.indexPatternSelectLabel": "数据视图",
"xpack.stackAlerts.geoContainment.indexPatternSelectPlaceholder": "选择数据视图",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisLinkTextDescription": "创建数据视图。",
"xpack.stackAlerts.geoContainment.noIndexPattern.doThisPrefixDescription": "您将需要 ",
"xpack.stackAlerts.geoContainment.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。",
"xpack.stackAlerts.geoContainment.noIndexPattern.hintDescription": "没有任何数据?",
"xpack.stackAlerts.geoContainment.noIndexPattern.messageTitle": "找不到任何数据视图",
"xpack.stackAlerts.geoContainment.notGeoContained": "不再包含",
"xpack.stackAlerts.geoContainment.selectBoundaryIndex": "选择边界",
"xpack.stackAlerts.geoContainment.selectEntity": "选择实体",
"xpack.stackAlerts.geoContainment.selectGeoLabel": "选择地理字段",
"xpack.stackAlerts.geoContainment.selectLabel": "选择地理字段",
"xpack.stackAlerts.geoContainment.selectTimeLabel": "选择时间字段",
"xpack.stackAlerts.geoContainment.timeFieldLabel": "时间字段",
"xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "选择实体字段",
"xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "关闭",
"xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "已达到阈值",
"xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "描述阈值比较运算符和阈值的字符串",
"xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "告警超过阈值的日期。",