diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc
index 12397d3b7bdd2..146d98353beae 100644
--- a/docs/management/advanced-options.asciidoc
+++ b/docs/management/advanced-options.asciidoc
@@ -92,3 +92,4 @@ Markdown.
`state:storeInSessionStorage`:: [experimental] Kibana tracks UI state in the URL, which can lead to problems when there is a lot of information there and the URL gets very long. Enabling this will store parts of the state in your browser session instead, to keep the URL shorter.
`context:defaultSize`:: Specifies the initial number of surrounding entries to display in the context view. The default value is 5.
`context:step`:: Specifies the number to increment or decrement the context size by when using the buttons in the context view. The default value is 5.
+`context:tieBreakerFields`:: A comma-separated list of fields to use for tiebreaking between documents that have the same timestamp value. From this list the first field that is present and sortable in the current index pattern is used.
diff --git a/src/core_plugins/kibana/public/context/api/__tests__/anchor.js b/src/core_plugins/kibana/public/context/api/__tests__/anchor.js
index 47fff9f20c015..2d4d11dfe42de 100644
--- a/src/core_plugins/kibana/public/context/api/__tests__/anchor.js
+++ b/src/core_plugins/kibana/public/context/api/__tests__/anchor.js
@@ -30,7 +30,7 @@ describe('context app', function () {
it('should use the `fetch` method of the SearchSource', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
expect(searchSourceStub.fetch.calledOnce).to.be(true);
});
@@ -39,7 +39,7 @@ describe('context app', function () {
it('should configure the SearchSource to not inherit from the implicit root', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const inheritsSpy = searchSourceStub.inherits;
expect(inheritsSpy.calledOnce).to.be(true);
@@ -50,7 +50,7 @@ describe('context app', function () {
it('should set the SearchSource index pattern', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const setIndexSpy = searchSourceStub.set.withArgs('index');
expect(setIndexSpy.calledOnce).to.be(true);
@@ -61,7 +61,7 @@ describe('context app', function () {
it('should set the SearchSource version flag to true', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const setVersionSpy = searchSourceStub.set.withArgs('version');
expect(setVersionSpy.calledOnce).to.be(true);
@@ -72,7 +72,7 @@ describe('context app', function () {
it('should set the SearchSource size to 1', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const setSizeSpy = searchSourceStub.set.withArgs('size');
expect(setSizeSpy.calledOnce).to.be(true);
@@ -83,7 +83,7 @@ describe('context app', function () {
it('should set the SearchSource query to a _uid terms query', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const setQuerySpy = searchSourceStub.set.withArgs('query');
expect(setQuerySpy.calledOnce).to.be(true);
@@ -98,13 +98,13 @@ describe('context app', function () {
it('should set the SearchSource sort order', function () {
const searchSourceStub = new SearchSourceStub();
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(() => {
const setSortSpy = searchSourceStub.set.withArgs('sort');
expect(setSortSpy.calledOnce).to.be(true);
expect(setSortSpy.firstCall.args[1]).to.eql([
{ '@timestamp': 'desc' },
- { '_uid': 'asc' },
+ { '_doc': 'asc' },
]);
});
});
@@ -113,7 +113,7 @@ describe('context app', function () {
const searchSourceStub = new SearchSourceStub();
searchSourceStub._stubHits = [];
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then(
() => {
expect().fail('expected the promise to be rejected');
@@ -131,7 +131,7 @@ describe('context app', function () {
{ property2: 'value2' },
];
- return fetchAnchor('INDEX_PATTERN_ID', 'UID', { '@timestamp': 'desc' })
+ return fetchAnchor('INDEX_PATTERN_ID', 'UID', [{ '@timestamp': 'desc' }, { '_doc': 'asc' }])
.then((anchorDocument) => {
expect(anchorDocument).to.have.property('property1', 'value1');
expect(anchorDocument).to.have.property('$$_isAnchor', true);
diff --git a/src/core_plugins/kibana/public/context/api/anchor.js b/src/core_plugins/kibana/public/context/api/anchor.js
index 66c6784674753..7ac0d12507bd0 100644
--- a/src/core_plugins/kibana/public/context/api/anchor.js
+++ b/src/core_plugins/kibana/public/context/api/anchor.js
@@ -19,7 +19,7 @@ function fetchAnchorProvider(courier, Private) {
_uid: [uid],
},
})
- .set('sort', [sort, { _uid: 'asc' }]);
+ .set('sort', sort);
const response = await searchSource.fetch();
diff --git a/src/core_plugins/kibana/public/context/api/context.js b/src/core_plugins/kibana/public/context/api/context.js
index bd5d678d768dd..1d3b6b87e32ac 100644
--- a/src/core_plugins/kibana/public/context/api/context.js
+++ b/src/core_plugins/kibana/public/context/api/context.js
@@ -14,11 +14,10 @@ function fetchContextProvider(courier, Private) {
};
async function fetchSuccessors(indexPatternId, anchorDocument, contextSort, size, filters) {
- const successorsSort = [contextSort, { _uid: 'asc' }];
const successorsSearchSource = await createSearchSource(
indexPatternId,
anchorDocument,
- successorsSort,
+ contextSort,
size,
filters,
);
@@ -27,7 +26,7 @@ function fetchContextProvider(courier, Private) {
}
async function fetchPredecessors(indexPatternId, anchorDocument, contextSort, size, filters) {
- const predecessorsSort = [reverseSortDirective(contextSort), { _uid: 'desc' }];
+ const predecessorsSort = contextSort.map(reverseSortDirective);
const predecessorsSearchSource = await createSearchSource(
indexPatternId,
anchorDocument,
diff --git a/src/core_plugins/kibana/public/context/api/utils/sorting.js b/src/core_plugins/kibana/public/context/api/utils/sorting.js
index a75a52dc9460e..85cf7016beb09 100644
--- a/src/core_plugins/kibana/public/context/api/utils/sorting.js
+++ b/src/core_plugins/kibana/public/context/api/utils/sorting.js
@@ -1,5 +1,33 @@
import _ from 'lodash';
+
+/**
+ * The list of field names that are allowed for sorting, but not included in
+ * index pattern fields.
+ *
+ * @constant
+ * @type {string[]}
+ */
+const META_FIELD_NAMES = ['_seq_no', '_doc', '_uid'];
+
+/**
+ * Returns a field from the intersection of the set of sortable fields in the
+ * given index pattern and a given set of candidate field names.
+ *
+ * @param {IndexPattern} indexPattern - The index pattern to search for
+ * sortable fields
+ * @param {string[]} fields - The list of candidate field names
+ *
+ * @returns {string[]}
+ */
+function getFirstSortableField(indexPattern, fieldNames) {
+ const sortableFields = fieldNames.filter((fieldName) => (
+ META_FIELD_NAMES.includes(fieldName)
+ || (indexPattern.fields.byName[fieldName] || { sortable: false }).sortable
+ ));
+ return sortableFields[0];
+}
+
/**
* A sort directive in object or string form.
*
@@ -72,6 +100,7 @@ function reverseSortDirection(sortDirection) {
export {
+ getFirstSortableField,
reverseSortDirection,
reverseSortDirective,
};
diff --git a/src/core_plugins/kibana/public/context/app.html b/src/core_plugins/kibana/public/context/app.html
index 655a4588ba9cc..6d0235435e7bf 100644
--- a/src/core_plugins/kibana/public/context/app.html
+++ b/src/core_plugins/kibana/public/context/app.html
@@ -18,19 +18,33 @@
-
- Failed to load the anchor document. Please reload or visit
+
+ No searchable tiebreaker field could be found in the index pattern
+ {{ contextApp.state.queryParameters.indexPatternId}}.
+
+ Please change the advanced setting
+ context:tieBreakerFields
to include a valid field for this
+ index pattern.
+
+
+ Please reload or visit
Discover
to select a valid anchor document.
@@ -40,7 +54,7 @@
@@ -51,7 +65,7 @@
is-disabled="![
contextApp.constants.LOADING_STATUS.LOADED,
contextApp.constants.LOADING_STATUS.FAILED,
- ].includes(contextApp.state.loadingStatus.predecessors)"
+ ].includes(contextApp.state.loadingStatus.predecessors.status)"
icon="'fa-chevron-up'"
ng-click="contextApp.actions.fetchMorePredecessorRows()"
>
@@ -60,13 +74,13 @@
newer documents
contextApp.state.rows.predecessors.length)"
>
@@ -83,7 +97,7 @@
ng-if="[
contextApp.constants.LOADING_STATUS.UNINITIALIZED,
contextApp.constants.LOADING_STATUS.LOADING,
- ].includes(contextApp.state.loadingStatus.anchor)"
+ ].includes(contextApp.state.loadingStatus.anchor.status)"
class="kuiPanel kuiPanel--centered kuiVerticalRhythm"
>
@@ -93,7 +107,7 @@
@@ -116,7 +130,7 @@
is-disabled="![
contextApp.constants.LOADING_STATUS.LOADED,
contextApp.constants.LOADING_STATUS.FAILED,
- ].includes(contextApp.state.loadingStatus.successors)"
+ ].includes(contextApp.state.loadingStatus.successors.status)"
icon="'fa-chevron-down'"
ng-click="contextApp.actions.fetchMoreSuccessorRows()"
>
@@ -125,13 +139,13 @@
older documents
contextApp.state.rows.successors.length)"
>
diff --git a/src/core_plugins/kibana/public/context/app.js b/src/core_plugins/kibana/public/context/app.js
index 0612d4f300ee4..538c1d7909496 100644
--- a/src/core_plugins/kibana/public/context/app.js
+++ b/src/core_plugins/kibana/public/context/app.js
@@ -4,6 +4,7 @@ import { uiModules } from 'ui/modules';
import contextAppTemplate from './app.html';
import './components/loading_button';
import './components/size_picker/size_picker';
+import { getFirstSortableField } from './api/utils/sorting';
import {
createInitialQueryParametersState,
QueryParameterActionsProvider,
@@ -11,6 +12,7 @@ import {
} from './query_parameters';
import {
createInitialLoadingStatusState,
+ FAILURE_REASONS,
LOADING_STATUS,
QueryActionsProvider,
} from './query';
@@ -52,6 +54,7 @@ function ContextAppController($scope, config, Private, timefilter) {
this.state = createInitialState(
parseInt(config.get('context:step'), 10),
+ getFirstSortableField(this.indexPattern, config.get('context:tieBreakerFields')),
this.discoverUrl,
);
@@ -62,6 +65,7 @@ function ContextAppController($scope, config, Private, timefilter) {
), (action) => (...args) => action(this.state)(...args));
this.constants = {
+ FAILURE_REASONS,
LOADING_STATUS,
};
@@ -111,9 +115,9 @@ function ContextAppController($scope, config, Private, timefilter) {
);
}
-function createInitialState(defaultStepSize, discoverUrl) {
+function createInitialState(defaultStepSize, tieBreakerField, discoverUrl) {
return {
- queryParameters: createInitialQueryParametersState(defaultStepSize),
+ queryParameters: createInitialQueryParametersState(defaultStepSize, tieBreakerField),
rows: {
all: [],
anchor: null,
diff --git a/src/core_plugins/kibana/public/context/query/actions.js b/src/core_plugins/kibana/public/context/query/actions.js
index e05b6c00c75c6..d0660bbc77838 100644
--- a/src/core_plugins/kibana/public/context/query/actions.js
+++ b/src/core_plugins/kibana/public/context/query/actions.js
@@ -3,7 +3,7 @@ import _ from 'lodash';
import { fetchAnchorProvider } from '../api/anchor';
import { fetchContextProvider } from '../api/context';
import { QueryParameterActionsProvider } from '../query_parameters';
-import { LOADING_STATUS } from './constants';
+import { FAILURE_REASONS, LOADING_STATUS } from './constants';
export function QueryActionsProvider(courier, Notifier, Private, Promise) {
@@ -21,26 +21,48 @@ export function QueryActionsProvider(courier, Notifier, Private, Promise) {
location: 'Context',
});
- const setLoadingStatus = (state) => (subject, status) => (
- state.loadingStatus[subject] = status
+ const setFailedStatus = (state) => (subject, details = {}) => (
+ state.loadingStatus[subject] = {
+ status: LOADING_STATUS.FAILED,
+ reason: FAILURE_REASONS.UNKNOWN,
+ ...details,
+ }
+ );
+
+ const setLoadedStatus = (state) => (subject) => (
+ state.loadingStatus[subject] = {
+ status: LOADING_STATUS.LOADED,
+ }
+ );
+
+ const setLoadingStatus = (state) => (subject) => (
+ state.loadingStatus[subject] = {
+ status: LOADING_STATUS.LOADING,
+ }
);
const fetchAnchorRow = (state) => () => {
- const { queryParameters: { indexPatternId, anchorUid, sort } } = state;
+ const { queryParameters: { indexPatternId, anchorUid, sort, tieBreakerField } } = state;
- setLoadingStatus(state)('anchor', LOADING_STATUS.LOADING);
+ if (!tieBreakerField) {
+ return Promise.reject(setFailedStatus(state)('anchor', {
+ reason: FAILURE_REASONS.INVALID_TIEBREAKER
+ }));
+ }
+
+ setLoadingStatus(state)('anchor');
return Promise.try(() => (
- fetchAnchor(indexPatternId, anchorUid, _.zipObject([sort]))
+ fetchAnchor(indexPatternId, anchorUid, [_.zipObject([sort]), { [tieBreakerField]: 'asc' }])
))
.then(
(anchorDocument) => {
- setLoadingStatus(state)('anchor', LOADING_STATUS.LOADED);
+ setLoadedStatus(state)('anchor');
state.rows.anchor = anchorDocument;
return anchorDocument;
},
(error) => {
- setLoadingStatus(state)('anchor', LOADING_STATUS.FAILED);
+ setFailedStatus(state)('anchor', { error });
notifier.error(error);
throw error;
}
@@ -49,23 +71,29 @@ export function QueryActionsProvider(courier, Notifier, Private, Promise) {
const fetchPredecessorRows = (state) => () => {
const {
- queryParameters: { indexPatternId, filters, predecessorCount, sort },
+ queryParameters: { indexPatternId, filters, predecessorCount, sort, tieBreakerField },
rows: { anchor },
} = state;
- setLoadingStatus(state)('predecessors', LOADING_STATUS.LOADING);
+ if (!tieBreakerField) {
+ return Promise.reject(setFailedStatus(state)('predecessors', {
+ reason: FAILURE_REASONS.INVALID_TIEBREAKER
+ }));
+ }
+
+ setLoadingStatus(state)('predecessors');
return Promise.try(() => (
- fetchPredecessors(indexPatternId, anchor, _.zipObject([sort]), predecessorCount, filters)
+ fetchPredecessors(indexPatternId, anchor, [_.zipObject([sort]), { [tieBreakerField]: 'asc' }], predecessorCount, filters)
))
.then(
(predecessorDocuments) => {
- setLoadingStatus(state)('predecessors', LOADING_STATUS.LOADED);
+ setLoadedStatus(state)('predecessors');
state.rows.predecessors = predecessorDocuments;
return predecessorDocuments;
},
(error) => {
- setLoadingStatus(state)('predecessors', LOADING_STATUS.FAILED);
+ setFailedStatus(state)('predecessors', { error });
notifier.error(error);
throw error;
},
@@ -74,23 +102,29 @@ export function QueryActionsProvider(courier, Notifier, Private, Promise) {
const fetchSuccessorRows = (state) => () => {
const {
- queryParameters: { indexPatternId, filters, sort, successorCount },
+ queryParameters: { indexPatternId, filters, sort, successorCount, tieBreakerField },
rows: { anchor },
} = state;
- setLoadingStatus(state)('successors', LOADING_STATUS.LOADING);
+ if (!tieBreakerField) {
+ return Promise.reject(setFailedStatus(state)('successors', {
+ reason: FAILURE_REASONS.INVALID_TIEBREAKER
+ }));
+ }
+
+ setLoadingStatus(state)('successors');
return Promise.try(() => (
- fetchSuccessors(indexPatternId, anchor, _.zipObject([sort]), successorCount, filters)
+ fetchSuccessors(indexPatternId, anchor, [_.zipObject([sort]), { [tieBreakerField]: 'asc' }], successorCount, filters)
))
.then(
(successorDocuments) => {
- setLoadingStatus(state)('successors', LOADING_STATUS.LOADED);
+ setLoadedStatus(state)('successors');
state.rows.successors = successorDocuments;
return successorDocuments;
},
(error) => {
- setLoadingStatus(state)('successors', LOADING_STATUS.FAILED);
+ setFailedStatus(state)('successors', { error });
notifier.error(error);
throw error;
},
diff --git a/src/core_plugins/kibana/public/context/query/constants.js b/src/core_plugins/kibana/public/context/query/constants.js
index 4d63357f105ba..e2f51765d2e9f 100644
--- a/src/core_plugins/kibana/public/context/query/constants.js
+++ b/src/core_plugins/kibana/public/context/query/constants.js
@@ -1,3 +1,8 @@
+export const FAILURE_REASONS = {
+ UNKNOWN: 'unknown',
+ INVALID_TIEBREAKER: 'invalid_tiebreaker',
+};
+
export const LOADING_STATUS = {
FAILED: 'failed',
LOADED: 'loaded',
diff --git a/src/core_plugins/kibana/public/context/query/index.js b/src/core_plugins/kibana/public/context/query/index.js
index e0231e236fae4..ced9dfb6b0796 100644
--- a/src/core_plugins/kibana/public/context/query/index.js
+++ b/src/core_plugins/kibana/public/context/query/index.js
@@ -1,3 +1,3 @@
export { QueryActionsProvider } from './actions';
-export { LOADING_STATUS } from './constants';
+export { FAILURE_REASONS, LOADING_STATUS } from './constants';
export { createInitialLoadingStatusState } from './state';
diff --git a/src/core_plugins/kibana/public/context/query_parameters/state.js b/src/core_plugins/kibana/public/context/query_parameters/state.js
index 5b9023e209a30..78b6819ba4626 100644
--- a/src/core_plugins/kibana/public/context/query_parameters/state.js
+++ b/src/core_plugins/kibana/public/context/query_parameters/state.js
@@ -1,4 +1,4 @@
-export function createInitialQueryParametersState(defaultStepSize) {
+export function createInitialQueryParametersState(defaultStepSize, tieBreakerField) {
return {
anchorUid: null,
columns: [],
@@ -8,5 +8,6 @@ export function createInitialQueryParametersState(defaultStepSize) {
predecessorCount: 0,
successorCount: 0,
sort: [],
+ tieBreakerField,
};
}
diff --git a/src/ui/ui_settings/defaults.js b/src/ui/ui_settings/defaults.js
index 20095c9e26aa7..10f383a1603d2 100644
--- a/src/ui/ui_settings/defaults.js
+++ b/src/ui/ui_settings/defaults.js
@@ -341,6 +341,12 @@ export function getDefaultSettings() {
'context:step': {
value: 5,
description: 'The step size to increment or decrement the context size by',
- }
+ },
+ 'context:tieBreakerFields': {
+ value: ['_doc'],
+ description: 'A comma-separated list of fields to use for tiebreaking between documents ' +
+ 'that have the same timestamp value. From this list the first field that ' +
+ 'is present and sortable in the current index pattern is used.',
+ },
};
}