Show event context (#10513)

Backports PR #9198
This commit is contained in:
jasper 2017-02-22 13:06:02 -05:00 committed by Felix Stürmer
parent 3d6285548c
commit 134ece5c12
58 changed files with 1819 additions and 47 deletions

View file

@ -78,7 +78,6 @@ Markdown.
`notifications:lifetime:error`:: Specifies the duration in milliseconds for error notification displays. The default value is 300000. Set this field to `Infinity` to disable error notifications.
`notifications:lifetime:warning`:: Specifies the duration in milliseconds for warning notification displays. The default value is 10000. Set this field to `Infinity` to disable warning notifications.
`notifications:lifetime:info`:: Specifies the duration in milliseconds for information notification displays. The default value is 5000. Set this field to `Infinity` to disable information notifications.
`timelion:showTutorial`:: Set this property to `true` to show the Timelion tutorial to users when they first open Timelion.
`timelion:es.timefield`:: Default field containing a timestamp when using the `.es()` query.
`timelion:es.default_index`:: Default index when using the `.es()` query.
@ -89,3 +88,5 @@ Markdown.
`timelion:graphite.url`:: [experimental] Used with graphite queries, this it the URL of your host
`timelion:quandl.key`:: [experimental] Used with quandl queries, this is your API key from www.quandl.com
`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.

View file

@ -0,0 +1,97 @@
# Discover Context App Implementation Notes
The implementation of this app is intended to exhibit certain desirable
properties by adhering to a set of *principles*. This document aims to explain
those and the *concepts* employed to achieve that.
## Principles
**Single Source of Truth**: A good user experience depends on the UI displaying
consistent information across the whole page. To achieve this, there should
always be a single source of truth for the application's state. In this
application this is the `ContextAppController::state` object.
**Unidirectional Data Flow**: While a single state promotes rendering
consistency, it does little to make the state changes easier to reason about.
To avoid having state mutations scattered all over the code, this app
implements a unidirectional data flow architecture. That means that the state
is treated as immutable throughout the application except for actions, which
may modify it to cause angular to re-render and watches to trigger.
**Unit-Testability**: Creating unit tests for large parts of the UI code is
made easy by expressing the as much of the logic as possible as
side-effect-free functions. The only place where side-effects are allowed are
actions. Due to the nature of AngularJS a certain amount of impure code must be
employed in some cases, e.g. when dealing with the isolate scope bindings in
`ContextAppController`.
**Loose Coupling**: An attempt was made to couple the parts that make up this
app as loosely as possible. This means using pure functions whenever possible
and isolating the angular directives diligently. To that end, the app has been
implemented as the independent `ContextApp` directive in [app.js](./app.js). It
does not access the Kibana `AppState` directly but communicates only via its
directive properties. The binding of these attributes to the state and thereby
to the route is performed by the `CreateAppRouteController`in
[index.js](./index.js). Similarly, the `SizePicker` directive only communicates
with its parent via the passed properties.
## Concepts
To adhere to the principles mentioned above, this app borrows some concepts
from the redux architecture that forms a ciruclar unidirectional data flow:
```
|* create initial state
v
+->+
| v
| |* state
| v
| |* angular templates render state
| v
| |* angular calls actions in response to user action/system events
| v
| |* actions modify state
| v
+--+
```
**State**: The state is the single source of truth at
`ContextAppController::state` and may only be modified by actions.
**Action**: Actions are functions that are called inreponse user or system
actions and may modified the state the are bound to via their closure.
## Directory Structure
**index.js**: Defines the route and renders the `<context-app>` directive,
binding it to the `AppState`.
**app.js**: Defines the `<context-app>` directive, that is at the root of the
application. Creates the store, reducer and bound actions/selectors.
**query**: Exports the actions, reducers and selectors related to the
query status and results.
**query_parameters**: Exports the actions, reducers and selectors related to
the parameters used to construct the query.
**components/loading_button**: Defines the `<context-loading-button>`
directive including its respective styles.
**components/size_picker**: Defines the `<context-size-picker>`
directive including its respective styles.
**api/anchor.js**: Exports `fetchAnchor()` that creates and executes the
query for the anchor document.
**api/context.js**: Exports `fetchPredecessors()` and `fetchSuccessors()` that
create and execute the queries for the preceeding and succeeding documents.
**api/utils**: Exports various functions used to create and transform
queries.

View file

@ -0,0 +1,80 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { fetchAnchor } from 'plugins/kibana/context/api/anchor';
describe('context app', function () {
describe('function fetchAnchor', function () {
it('should use the `search` api to query the given index', function () {
const indexPatternStub = createIndexPatternStub('index1');
const esStub = createEsStub(['hit1']);
return fetchAnchor(esStub, indexPatternStub, 'UID', { '@timestamp': 'desc' })
.then(() => {
expect(esStub.search.calledOnce).to.be(true);
expect(esStub.search.firstCall.args[0]).to.have.property('index', 'index1');
});
});
it('should include computed fields in the query', function () {
const indexPatternStub = createIndexPatternStub('index1');
const esStub = createEsStub(['hit1']);
return fetchAnchor(esStub, indexPatternStub, 'UID', { '@timestamp': 'desc' })
.then(() => {
expect(esStub.search.calledOnce).to.be(true);
expect(esStub.search.firstCall.args[0].body).to.have.keys([
'script_fields', 'docvalue_fields', 'stored_fields']);
});
});
it('should reject with an error when no hits were found', function () {
const indexPatternStub = createIndexPatternStub('index1');
const esStub = createEsStub([]);
return fetchAnchor(esStub, indexPatternStub, 'UID', { '@timestamp': 'desc' })
.then(
() => {
expect().fail('expected the promise to be rejected');
},
(error) => {
expect(error).to.be.an(Error);
}
);
});
it('should return the first hit after adding an anchor marker', function () {
const indexPatternStub = createIndexPatternStub('index1');
const esStub = createEsStub([{ property1: 'value1' }, {}]);
return fetchAnchor(esStub, indexPatternStub, 'UID', { '@timestamp': 'desc' })
.then((anchorDocument) => {
expect(anchorDocument).to.have.property('property1', 'value1');
expect(anchorDocument).to.have.property('$$_isAnchor', true);
});
});
});
});
function createIndexPatternStub(indices) {
return {
getComputedFields: sinon.stub()
.returns({}),
toIndexList: sinon.stub()
.returns(indices),
};
}
function createEsStub(hits) {
return {
search: sinon.stub()
.returns({
hits: {
hits,
total: hits.length,
},
}),
};
}

View file

@ -0,0 +1,31 @@
import _ from 'lodash';
import { addComputedFields } from './utils/fields';
import { createAnchorQueryBody } from './utils/queries';
async function fetchAnchor(es, indexPattern, uid, sort) {
const indices = await indexPattern.toIndexList();
const queryBody = addComputedFields(indexPattern, createAnchorQueryBody(uid, sort));
const response = await es.search({
index: indices,
body: queryBody,
});
if (_.get(response, ['hits', 'total'], 0) < 1) {
throw new Error('Failed to load anchor document.');
}
return Object.assign(
{},
response.hits.hits[0],
{
$$_isAnchor: true,
},
);
}
export {
fetchAnchor,
};

View file

@ -0,0 +1,47 @@
import _ from 'lodash';
import { addComputedFields } from './utils/fields';
import { getDocumentUid } from './utils/ids';
import { createSuccessorsQueryBody } from './utils/queries.js';
import { reverseQuerySort } from './utils/sorting';
async function fetchSuccessors(es, indexPattern, anchorDocument, sort, size) {
const successorsQueryBody = prepareQueryBody(indexPattern, anchorDocument, sort, size);
const results = await performQuery(es, indexPattern, successorsQueryBody);
return results;
}
async function fetchPredecessors(es, indexPattern, anchorDocument, sort, size) {
const successorsQueryBody = prepareQueryBody(indexPattern, anchorDocument, sort, size);
const predecessorsQueryBody = reverseQuerySort(successorsQueryBody);
const reversedResults = await performQuery(es, indexPattern, predecessorsQueryBody);
const results = reversedResults.slice().reverse();
return results;
}
function prepareQueryBody(indexPattern, anchorDocument, sort, size) {
const successorsQueryBody = addComputedFields(
indexPattern,
createSuccessorsQueryBody(anchorDocument.sort, sort, size)
);
return successorsQueryBody;
}
async function performQuery(es, indexPattern, queryBody) {
const indices = await indexPattern.toIndexList();
const response = await es.search({
index: indices,
body: queryBody,
});
return _.get(response, ['hits', 'hits'], []);
}
export {
fetchPredecessors,
fetchSuccessors,
};

View file

@ -0,0 +1,49 @@
import expect from 'expect.js';
import { addComputedFields } from 'plugins/kibana/context/api/utils/fields';
describe('context app', function () {
describe('function addComputedFields', function () {
it('should add the `script_fields` property defined in the given index pattern', function () {
const getComputedFields = () => ({
scriptFields: {
sourcefield1: {
script: '_source.field1',
},
}
});
const query = addComputedFields({ getComputedFields }, {});
expect(query).to.have.property('script_fields');
expect(query.script_fields).to.eql(getComputedFields().scriptFields);
});
it('should add the `docvalue_fields` property defined in the given index pattern', function () {
const getComputedFields = () => ({
docvalueFields: ['field1'],
});
const query = addComputedFields({ getComputedFields }, {});
expect(query).to.have.property('docvalue_fields');
expect(query.docvalue_fields).to.eql(getComputedFields().docvalueFields);
});
it('should add the `stored_fields` property defined in the given index pattern', function () {
const getComputedFields = () => ({
storedFields: ['field1'],
});
const query = addComputedFields({ getComputedFields }, {});
expect(query).to.have.property('stored_fields');
expect(query.stored_fields).to.eql(getComputedFields().storedFields);
});
it('should preserve other properties of the query', function () {
const getComputedFields = () => ({});
const query = addComputedFields({ getComputedFields }, { property1: 'value1' });
expect(query).to.have.property('property1', 'value1');
});
});
});

View file

@ -0,0 +1,46 @@
import expect from 'expect.js';
import {
createAnchorQueryBody,
createSuccessorsQueryBody,
} from 'plugins/kibana/context/api/utils/queries';
describe('context app', function () {
describe('function createAnchorQueryBody', function () {
it('should return a search definition that searches the given uid', function () {
const query = createAnchorQueryBody('UID', { '@timestamp': 'desc' });
expect(query.query.terms._uid[0]).to.eql('UID');
});
it('should return a search definition that sorts by the given criteria and uid', function () {
const query = createAnchorQueryBody('UID', { '@timestamp': 'desc' });
expect(query.sort).to.eql([
{ '@timestamp': 'desc' },
{ _uid: 'asc' },
]);
});
});
describe('function createSuccessorsQueryBody', function () {
it('should return a search definition that includes the given size', function () {
const query = createSuccessorsQueryBody([0, 'UID'], { '@timestamp' : 'desc' }, 10);
expect(query).to.have.property('size', 10);
});
it('should return a search definition that sorts by the given criteria and uid', function () {
const query = createSuccessorsQueryBody([0, 'UID'], { '@timestamp' : 'desc' }, 10);
expect(query).to.have.property('sort');
expect(query.sort).to.eql([
{ '@timestamp': 'desc' },
{ _uid: 'asc' },
]);
});
it('should return a search definition that searches after the given uid', function () {
const query = createSuccessorsQueryBody([0, 'UID'], { '@timestamp' : 'desc' }, 10);
expect(query).to.have.property('search_after');
expect(query.search_after).to.eql([0, 'UID']);
});
});
});

View file

@ -0,0 +1,63 @@
import expect from 'expect.js';
import {
reverseQuerySort,
reverseSortDirection,
reverseSortDirective
} from 'plugins/kibana/context/api/utils/sorting';
describe('context app', function () {
describe('function reverseQuerySort', function () {
it('should reverse all the `sort` property values', function () {
expect(reverseQuerySort({
sort: [
{ field1: { order: 'desc', mode: 'max' } },
{ field2: 'asc' },
'field3',
'_score',
],
})).to.eql({
sort: [
{ field1: { order: 'asc', mode: 'max' } },
{ field2: 'desc' },
{ field3: 'desc' },
{ _score: 'asc' },
],
});
});
});
describe('function reverseSortDirection', function () {
it('should reverse a direction given as a string', function () {
expect(reverseSortDirection('asc')).to.eql('desc');
expect(reverseSortDirection('desc')).to.eql('asc');
});
it('should reverse a direction given in an option object', function () {
expect(reverseSortDirection({ order: 'asc' })).to.eql({ order: 'desc' });
expect(reverseSortDirection({ order: 'desc' })).to.eql({ order: 'asc' });
});
it('should preserve other properties than `order` in an option object', function () {
expect(reverseSortDirection({
order: 'asc',
other: 'field',
})).to.have.property('other', 'field');
});
});
describe('function reverseSortDirective', function () {
it('should return direction `asc` when given just `_score`', function () {
expect(reverseSortDirective('_score')).to.eql({ _score: 'asc' });
});
it('should return direction `desc` when given just a field name', function () {
expect(reverseSortDirective('field1')).to.eql({ field1: 'desc' });
});
it('should reverse direction when given an object', function () {
expect(reverseSortDirective({ field1: 'asc' })).to.eql({ field1: 'desc' });
});
});
});

View file

@ -0,0 +1,21 @@
import _ from 'lodash';
const addComputedFields = _.curry(function addComputedFields(indexPattern, queryBody) {
const computedFields = indexPattern.getComputedFields();
return Object.assign(
{},
queryBody,
{
script_fields: computedFields.scriptFields,
docvalue_fields: computedFields.docvalueFields,
stored_fields: computedFields.storedFields,
},
);
});
export {
addComputedFields,
};

View file

@ -0,0 +1,8 @@
function getDocumentUid(type, id) {
return `${type}#${id}`;
}
export {
getDocumentUid,
};

View file

@ -0,0 +1,29 @@
function createAnchorQueryBody(uid, contextSort) {
return {
_source: true,
query: {
terms: {
_uid: [uid],
},
},
sort: [ contextSort, { _uid: 'asc' } ],
};
}
function createSuccessorsQueryBody(anchorSortValues, contextSort, size) {
return {
_source: true,
query: {
match_all: {},
},
size,
sort: [ contextSort, { _uid: 'asc' } ],
search_after: anchorSortValues,
};
}
export {
createAnchorQueryBody,
createSuccessorsQueryBody,
};

View file

@ -0,0 +1,96 @@
import _ from 'lodash';
/**
* A sort directive in object or string form.
*
* @typedef {(SortDirectiveString|SortDirectiveObject)} SortDirective
*/
/**
* A sort directive in object form.
*
* @typedef {Object.<FieldName, (SortDirection|SortOptions)>} SortDirectiveObject
*/
/**
* A sort order string.
*
* @typedef {('asc'|'desc')} SortDirection
*/
/**
* A field name.
*
* @typedef {string} FieldName
*/
/**
* A sort options object
*
* @typedef {Object} SortOptions
* @property {SortDirection} order
*/
/**
* Return a copy of a query with the sort direction reversed.
*
* @param {Object} query - The query to reverse the sort direction of.
*
* @returns {Object}
*/
function reverseQuerySort(query) {
return Object.assign(
{},
query,
{
sort: _.get(query, 'sort', []).map(reverseSortDirective),
}
);
}
/**
* Return a copy of the directive with the sort direction reversed. If the
* field name is '_score', it inverts the default sort direction in the same
* way as Elasticsearch itself.
*
* @param {SortDirective} sortDirective - The directive to reverse the
* sort direction of
*
* @returns {SortDirective}
*/
function reverseSortDirective(sortDirective) {
if (_.isString(sortDirective)) {
return {
[sortDirective]: (sortDirective === '_score' ? 'asc' : 'desc'),
};
} else if (_.isPlainObject(sortDirective)) {
return _.mapValues(sortDirective, reverseSortDirection);
} else {
return sortDirective;
}
}
/**
* Return the reversed sort direction.
*
* @param {(SortDirection|SortOptions)} sortDirection
*
* @returns {(SortDirection|SortOptions)}
*/
function reverseSortDirection(sortDirection) {
if (_.isPlainObject(sortDirection)) {
return _.assign({}, sortDirection, {
order: reverseSortDirection(sortDirection.order),
});
} else {
return (sortDirection === 'asc' ? 'desc' : 'asc');
}
}
export {
reverseQuerySort,
reverseSortDirection,
reverseSortDirective,
};

View file

@ -0,0 +1,142 @@
<div class="kuiLocalNav">
<div class="kuiLocalNavRow">
<div class="kuiLocalNavRow__section">
<div class="kuiLocalTitle">
Surrounding Documents in {{ contextApp.state.queryParameters.indexPattern.id }}
</div>
</div>
</div>
<div class="kuiLocalNavRow kuiLocalNavRow--secondary">
<div class="kuiLocalTabs">
<div class="kuiLocalTab kuiLocalTab-isSelected" ng-bind="contextApp.state.queryParameters.anchorUid"></div>
</div>
</div>
</div>
<!-- Error feedback -->
<div
class="kuiViewContent kuiViewContentItem"
ng-if="contextApp.state.loadingStatus.anchor === contextApp.constants.LOADING_STATUS.FAILED"
>
<div class="kuiInfoPanel kuiInfoPanel--error kuiVerticalRhythm">
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--error fa-warning"></span>
<span class="kuiInfoPanelHeader__title">
Problem with query
</span>
</div>
<div class="kuiInfoPanelBody">
<div class="kuiInfoPanelBody__message">
Failed to load the anchor document. Please reload or visit
<a ng-href="{{ contextApp.state.navigation.discover.url }}">Discover</a>
to select a valid anchor document.
</div>
</div>
</div>
</div>
<div
class="kuiViewContent kuiViewContentItem"
ng-if="contextApp.state.loadingStatus.anchor !== contextApp.constants.LOADING_STATUS.FAILED"
role="main"
>
<!-- Controls -->
<div class="kuiBar kuiVerticalRhythm">
<div class="kuiBarSection">
<context-loading-button
data-test-subj="predecessorLoadMoreButton"
is-disabled="![
contextApp.constants.LOADING_STATUS.LOADED,
contextApp.constants.LOADING_STATUS.FAILED,
].includes(contextApp.state.loadingStatus.predecessors)"
icon="'fa-chevron-up'"
ng-click="contextApp.actions.fetchMorePredecessorRows()"
>
Load {{ contextApp.state.queryParameters.defaultStepSize }} more
</context-loading-button>
<context-size-picker
count="contextApp.state.queryParameters.predecessorCount"
data-test-subj="predecessorCountPicker"
is-disabled="contextApp.state.loadingStatus.anchor !== contextApp.constants.LOADING_STATUS.LOADED"
on-change-count="contextApp.actions.fetchGivenPredecessorRows"
></context-size-picker>
<span>newer documents</span>
<span
class="kuiStatusText kuiStatusText--warning"
ng-if="(contextApp.state.loadingStatus.predecessors === contextApp.constants.LOADING_STATUS.LOADED)
&& (contextApp.state.queryParameters.predecessorCount > contextApp.state.rows.predecessors.length)"
>
<span class="kuiStatusText__icon kuiIcon fa-bolt"></span>
<span ng-bind-template="Only {{ contextApp.state.rows.predecessors.length }} documents newer than the anchor could be found."></span>
</span>
</div>
<div class="kuiBarSection">
</div>
</div>
<!-- Loading feedback -->
<div
ng-if="[
contextApp.constants.LOADING_STATUS.UNINITIALIZED,
contextApp.constants.LOADING_STATUS.LOADING,
].includes(contextApp.state.loadingStatus.anchor)"
class="kuiPanel kuiPanel--centered kuiVerticalRhythm"
>
<div class="kuiLoadingItems">
Loading&hellip;
</div>
</div>
<!-- Table -->
<div
ng-if="contextApp.state.loadingStatus.anchor === contextApp.constants.LOADING_STATUS.LOADED"
class="kuiPanel kuiVerticalRhythm"
>
<div class="discover-table" fixed-scroll>
<doc-table
hits="contextApp.state.rows.all"
index-pattern="contextApp.state.queryParameters.indexPattern"
sorting="contextApp.state.queryParameters.sort"
columns="contextApp.state.queryParameters.columns"
infinite-scroll="true"
></doc-table>
</div>
</div>
<!-- Controls -->
<div class="kuiBar kuiVerticalRhythm">
<div class="kuiBarSection">
<context-loading-button
data-test-subj="successorLoadMoreButton"
is-disabled="![
contextApp.constants.LOADING_STATUS.LOADED,
contextApp.constants.LOADING_STATUS.FAILED,
].includes(contextApp.state.loadingStatus.successors)"
icon="'fa-chevron-down'"
ng-click="contextApp.actions.fetchMoreSuccessorRows()"
>
Load {{ contextApp.state.queryParameters.defaultStepSize }} more
</context-loading-button>
<context-size-picker
count="contextApp.state.queryParameters.successorCount"
data-test-subj="successorCountPicker"
is-disabled="contextApp.state.loadingStatus.anchor !== contextApp.constants.LOADING_STATUS.LOADED"
on-change-count="contextApp.actions.fetchGivenSuccessorRows"
></context-size-picker>
<div>older documents</div>
<span
class="kuiStatusText kuiStatusText--warning"
ng-if="(contextApp.state.loadingStatus.successors === contextApp.constants.LOADING_STATUS.LOADED)
&& (contextApp.state.queryParameters.successorCount > contextApp.state.rows.successors.length)"
>
<span class="kuiStatusText__icon kuiIcon fa-bolt"></span>
<span ng-bind-template="Only {{ contextApp.state.rows.successors.length }} documents older than the anchor could be found."></span>
</span>
</div>
<div class="kuiBarSection">
</div>
</div>
</div>

View file

@ -0,0 +1,107 @@
import _ from 'lodash';
import uiModules from 'ui/modules';
import contextAppTemplate from './app.html';
import './components/loading_button';
import './components/size_picker/size_picker';
import {
createInitialQueryParametersState,
QueryParameterActionsProvider,
QUERY_PARAMETER_KEYS,
} from './query_parameters';
import {
createInitialLoadingStatusState,
LOADING_STATUS,
QueryActionsProvider,
} from './query';
const module = uiModules.get('apps/context', [
'elasticsearch',
'kibana',
'kibana/config',
'kibana/notify',
'ngRoute',
]);
module.directive('contextApp', function ContextApp() {
return {
bindToController: true,
controller: ContextAppController,
controllerAs: 'contextApp',
restrict: 'E',
scope: {
anchorUid: '=',
columns: '=',
indexPattern: '=',
predecessorCount: '=',
successorCount: '=',
sort: '=',
discoverUrl: '=',
},
template: contextAppTemplate,
};
});
function ContextAppController($scope, config, Private) {
const queryParameterActions = Private(QueryParameterActionsProvider);
const queryActions = Private(QueryActionsProvider);
this.state = createInitialState(
parseInt(config.get('context:step'), 10),
this.discoverUrl,
);
this.actions = _.mapValues(Object.assign(
{},
queryParameterActions,
queryActions,
), (action) => (...args) => action(this.state)(...args));
this.constants = {
LOADING_STATUS,
};
$scope.$watchGroup([
() => this.state.rows.predecessors,
() => this.state.rows.anchor,
() => this.state.rows.successors,
], (newValues) => this.actions.setAllRows(...newValues));
/**
* Sync query parameters to arguments
*/
$scope.$watchCollection(
() => _.pick(this, QUERY_PARAMETER_KEYS),
(newValues) => {
// break the watch cycle
if (!_.isEqual(newValues, this.state.queryParameters)) {
this.actions.fetchAllRowsWithNewQueryParameters(newValues);
}
},
);
$scope.$watchCollection(
() => this.state.queryParameters,
(newValues) => {
_.assign(this, newValues);
},
);
}
function createInitialState(defaultStepSize, discoverUrl) {
return {
queryParameters: createInitialQueryParametersState(defaultStepSize),
rows: {
all: [],
anchor: null,
predecessors: [],
successors: [],
},
loadingStatus: createInitialLoadingStatusState(),
navigation: {
discover: {
url: discoverUrl,
},
},
};
}

View file

@ -0,0 +1,10 @@
<button
class="kuiButton kuiButton--basic kuiButton--iconText"
ng-disabled="isDisabled"
>
<span
class="kuiButton__icon kuiIcon fa-spinner fa-spin"
ng-class="isDisabled ? ['fa-spinner', 'fa-spin'] : [icon]"
/>
<span ng-transclude />
</button>

View file

@ -0,0 +1,21 @@
import uiModules from 'ui/modules';
import contextLoadingButtonTemplate from './loading_button.html';
const module = uiModules.get('apps/context', [
'kibana',
'ngRoute',
]);
module.directive('contextLoadingButton', function ContextLoadingButton() {
return {
replace: true,
restrict: 'E',
scope: {
isDisabled: '=',
icon: '=',
},
template: contextLoadingButtonTemplate,
transclude: true,
};
});

View file

@ -0,0 +1,7 @@
<input
class="kuiTextInput contextSizePicker"
ng-disabled="contextSizePicker.isDisabled"
ng-model-options="{updateOn: 'change', getterSetter: true, debounce: 200}"
ng-model="contextSizePicker.getOrSetCount"
type="number"
>

View file

@ -0,0 +1,45 @@
import _ from 'lodash';
import uiModules from 'ui/modules';
import contextSizePickerTemplate from './size_picker.html';
import './size_picker.less';
const module = uiModules.get('apps/context', [
'kibana',
]);
module.directive('contextSizePicker', function ContextSizePicker() {
return {
bindToController: true,
controller: ContextSizePickerController,
controllerAs: 'contextSizePicker',
link: linkContextSizePicker,
replace: true,
restrict: 'E',
require: 'ngModel',
scope: {
count: '=',
isDisabled: '=',
onChangeCount: '=', // To avoid inconsistent ngModel states this action
// should make sure the new value is propagated back
// to the `count` property. If that propagation
// fails, the user input will be reset to the value
// of `count`.
},
template: contextSizePickerTemplate,
};
});
function linkContextSizePicker(scope, element, attrs, ngModel) {
scope.countModel = ngModel;
}
function ContextSizePickerController($scope) {
$scope.$watch(
() => this.count,
() => $scope.countModel.$rollbackViewValue(),
);
this.getOrSetCount = (count) => (
_.isUndefined(count) ? this.count : this.onChangeCount(count)
);
}

View file

@ -0,0 +1,14 @@
/**
* 1. Hide increment and decrement buttons for type="number" input.
*/
.contextSizePicker {
appearance: textfield;
text-align: center;
width: 5em;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none; /* 1 */
margin: 0; /* 1 */
}
}

View file

@ -0,0 +1,9 @@
<context-app
anchor-uid="contextAppRoute.anchorUid"
columns="contextAppRoute.state.columns"
discover-url="contextAppRoute.discoverUrl"
index-pattern="contextAppRoute.indexPattern"
predecessor-count="contextAppRoute.state.predecessorCount"
successor-count="contextAppRoute.state.successorCount"
sort="[contextAppRoute.indexPattern.timeFieldName, 'desc']"
></context-app>

View file

@ -0,0 +1,48 @@
import uiRoutes from 'ui/routes';
import './app';
import { getDocumentUid } from './api/utils/ids';
import contextAppRouteTemplate from './index.html';
uiRoutes
.when('/context/:indexPattern/:type/:id', {
controller: ContextAppRouteController,
controllerAs: 'contextAppRoute',
resolve: {
indexPattern: function ($route, courier, savedSearches) {
return courier.indexPatterns.get($route.current.params.indexPattern);
},
},
template: contextAppRouteTemplate,
});
function ContextAppRouteController(
$routeParams,
$scope,
AppState,
chrome,
config,
indexPattern,
) {
this.state = new AppState(createDefaultAppState(config));
this.state.save(true);
$scope.$watchGroup([
'contextAppRoute.state.columns',
'contextAppRoute.state.predecessorCount',
'contextAppRoute.state.successorCount',
], () => this.state.save(true));
this.anchorUid = getDocumentUid($routeParams.type, $routeParams.id);
this.indexPattern = indexPattern;
this.discoverUrl = chrome.getNavLinkById('kibana:discover').lastSubUrl;
}
function createDefaultAppState(config) {
return {
columns: ['_source'],
predecessorCount: parseInt(config.get('context:defaultSize'), 10),
successorCount: parseInt(config.get('context:defaultSize'), 10),
};
}

View file

@ -0,0 +1,151 @@
import _ from 'lodash';
import { fetchAnchor } from '../api/anchor';
import { fetchPredecessors, fetchSuccessors } from '../api/context';
import { QueryParameterActionsProvider } from '../query_parameters';
import { LOADING_STATUS } from './constants';
export function QueryActionsProvider(es, Notifier, Private, Promise) {
const {
increasePredecessorCount,
increaseSuccessorCount,
setPredecessorCount,
setQueryParameters,
setSuccessorCount,
} = Private(QueryParameterActionsProvider);
const notifier = new Notifier({
location: 'Context',
});
const setLoadingStatus = (state) => (subject, status) => (
state.loadingStatus[subject] = status
);
const fetchAnchorRow = (state) => () => {
const { queryParameters: { indexPattern, anchorUid, sort } } = state;
setLoadingStatus(state)('anchor', LOADING_STATUS.LOADING);
return Promise.try(() => (
fetchAnchor(es, indexPattern, anchorUid, _.zipObject([sort]))
))
.then(
(anchorDocument) => {
setLoadingStatus(state)('anchor', LOADING_STATUS.LOADED);
state.rows.anchor = anchorDocument;
return anchorDocument;
},
(error) => {
setLoadingStatus(state)('anchor', LOADING_STATUS.FAILED);
notifier.error(error);
throw error;
}
);
};
const fetchPredecessorRows = (state) => () => {
const {
queryParameters: { indexPattern, predecessorCount, sort },
rows: { anchor },
} = state;
setLoadingStatus(state)('predecessors', LOADING_STATUS.LOADING);
return Promise.try(() => (
fetchPredecessors(es, indexPattern, anchor, _.zipObject([sort]), predecessorCount)
))
.then(
(predecessorDocuments) => {
setLoadingStatus(state)('predecessors', LOADING_STATUS.LOADED);
state.rows.predecessors = predecessorDocuments;
return predecessorDocuments;
},
(error) => {
setLoadingStatus(state)('predecessors', LOADING_STATUS.FAILED);
notifier.error(error);
throw error;
},
);
};
const fetchSuccessorRows = (state) => () => {
const {
queryParameters: { indexPattern, sort, successorCount },
rows: { anchor },
} = state;
setLoadingStatus(state)('successors', LOADING_STATUS.LOADING);
return Promise.try(() => (
fetchSuccessors(es, indexPattern, anchor, _.zipObject([sort]), successorCount)
))
.then(
(successorDocuments) => {
setLoadingStatus(state)('successors', LOADING_STATUS.LOADED);
state.rows.successors = successorDocuments;
return successorDocuments;
},
(error) => {
setLoadingStatus(state)('successors', LOADING_STATUS.FAILED);
notifier.error(error);
throw error;
},
);
};
const fetchAllRows = (state) => () => (
Promise.try(fetchAnchorRow(state))
.then(() => Promise.all([
fetchPredecessorRows(state)(),
fetchSuccessorRows(state)(),
]))
);
const fetchAllRowsWithNewQueryParameters = (state) => (queryParameters) => {
setQueryParameters(state)(queryParameters);
return fetchAllRows(state)();
};
const fetchGivenPredecessorRows = (state) => (count) => {
setPredecessorCount(state)(count);
return fetchPredecessorRows(state)();
};
const fetchGivenSuccessorRows = (state) => (count) => {
setSuccessorCount(state)(count);
return fetchSuccessorRows(state)();
};
const fetchMorePredecessorRows = (state) => () => {
increasePredecessorCount(state)();
return fetchPredecessorRows(state)();
};
const fetchMoreSuccessorRows = (state) => () => {
increaseSuccessorCount(state)();
return fetchSuccessorRows(state)();
};
const setAllRows = (state) => (predecessorRows, anchorRow, successorRows) => (
state.rows.all = [
...(predecessorRows || []),
...(anchorRow ? [anchorRow] : []),
...(successorRows || []),
]
);
return {
fetchAllRows,
fetchAllRowsWithNewQueryParameters,
fetchAnchorRow,
fetchGivenPredecessorRows,
fetchGivenSuccessorRows,
fetchMorePredecessorRows,
fetchMoreSuccessorRows,
fetchPredecessorRows,
fetchSuccessorRows,
setAllRows,
};
}

View file

@ -0,0 +1,6 @@
export const LOADING_STATUS = {
FAILED: 'failed',
LOADED: 'loaded',
LOADING: 'loading',
UNINITIALIZED: 'uninitialized',
};

View file

@ -0,0 +1,3 @@
export { QueryActionsProvider } from './actions';
export { LOADING_STATUS } from './constants';
export { createInitialLoadingStatusState } from './state';

View file

@ -0,0 +1,10 @@
import { LOADING_STATUS } from './constants';
export function createInitialLoadingStatusState() {
return {
anchor: LOADING_STATUS.UNINITIALIZED,
predecessors: LOADING_STATUS.UNINITIALIZED,
successors: LOADING_STATUS.UNINITIALIZED,
};
}

View file

@ -0,0 +1,12 @@
import _ from 'lodash';
export function createStateStub(overrides) {
return _.merge({
queryParameters: {
defaultStepSize: 3,
predecessorCount: 10,
successorCount: 10,
},
}, overrides);
}

View file

@ -0,0 +1,45 @@
import expect from 'expect.js';
import { createStateStub } from './_utils';
import { QueryParameterActionsProvider } from '../actions';
describe('context app', function () {
describe('action increasePredecessorCount', function () {
it('should increase the predecessorCount by the given value', function () {
const { increasePredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increasePredecessorCount(state)(20);
expect(state.queryParameters.predecessorCount).to.equal(30);
});
it('should increase the predecessorCount by the default step size if not value is given', function () {
const { increasePredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increasePredecessorCount(state)();
expect(state.queryParameters.predecessorCount).to.equal(13);
});
it('should limit the predecessorCount to 0 as a lower bound', function () {
const { increasePredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increasePredecessorCount(state)(-20);
expect(state.queryParameters.predecessorCount).to.equal(0);
});
it('should limit the predecessorCount to 10000 as an upper bound', function () {
const { increasePredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increasePredecessorCount(state)(20000);
expect(state.queryParameters.predecessorCount).to.equal(10000);
});
});
});

View file

@ -0,0 +1,45 @@
import expect from 'expect.js';
import { createStateStub } from './_utils';
import { QueryParameterActionsProvider } from '../actions';
describe('context app', function () {
describe('action increaseSuccessorCount', function () {
it('should increase the successorCount by the given value', function () {
const { increaseSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increaseSuccessorCount(state)(20);
expect(state.queryParameters.successorCount).to.equal(30);
});
it('should increase the successorCount by the default step size if not value is given', function () {
const { increaseSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increaseSuccessorCount(state)();
expect(state.queryParameters.successorCount).to.equal(13);
});
it('should limit the successorCount to 0 as a lower bound', function () {
const { increaseSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increaseSuccessorCount(state)(-20);
expect(state.queryParameters.successorCount).to.equal(0);
});
it('should limit the successorCount to 10000 as an upper bound', function () {
const { increaseSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
increaseSuccessorCount(state)(20000);
expect(state.queryParameters.successorCount).to.equal(10000);
});
});
});

View file

@ -0,0 +1,36 @@
import expect from 'expect.js';
import { createStateStub } from './_utils';
import { QueryParameterActionsProvider } from '../actions';
describe('context app', function () {
describe('action setPredecessorCount', function () {
it('should set the predecessorCount to the given value', function () {
const { setPredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setPredecessorCount(state)(20);
expect(state.queryParameters.predecessorCount).to.equal(20);
});
it('should limit the predecessorCount to 0 as a lower bound', function () {
const { setPredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setPredecessorCount(state)(-1);
expect(state.queryParameters.predecessorCount).to.equal(0);
});
it('should limit the predecessorCount to 10000 as an upper bound', function () {
const { setPredecessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setPredecessorCount(state)(20000);
expect(state.queryParameters.predecessorCount).to.equal(10000);
});
});
});

View file

@ -0,0 +1,50 @@
import expect from 'expect.js';
import { createStateStub } from './_utils';
import { QueryParameterActionsProvider } from '../actions';
describe('context app', function () {
describe('action setQueryParameters', function () {
it('should update the queryParameters with valid properties from the given object', function () {
const { setQueryParameters } = new QueryParameterActionsProvider();
const state = createStateStub({
queryParameters: {
additionalParameter: 'ADDITIONAL_PARAMETER',
}
});
setQueryParameters(state)({
anchorUid: 'ANCHOR_UID',
columns: ['column'],
defaultStepSize: 3,
indexPattern: 'INDEX_PATTERN',
predecessorCount: 100,
successorCount: 100,
sort: ['field'],
});
expect(state.queryParameters).to.eql({
additionalParameter: 'ADDITIONAL_PARAMETER',
anchorUid: 'ANCHOR_UID',
columns: ['column'],
defaultStepSize: 3,
indexPattern: 'INDEX_PATTERN',
predecessorCount: 100,
successorCount: 100,
sort: ['field'],
});
});
it('should ignore invalid properties', function () {
const { setQueryParameters } = new QueryParameterActionsProvider();
const state = createStateStub();
setQueryParameters(state)({
additionalParameter: 'ADDITIONAL_PARAMETER',
});
expect(state.queryParameters).to.eql(createStateStub().queryParameters);
});
});
});

View file

@ -0,0 +1,36 @@
import expect from 'expect.js';
import { createStateStub } from './_utils';
import { QueryParameterActionsProvider } from '../actions';
describe('context app', function () {
describe('action setSuccessorCount', function () {
it('should set the successorCount to the given value', function () {
const { setSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setSuccessorCount(state)(20);
expect(state.queryParameters.successorCount).to.equal(20);
});
it('should limit the successorCount to 0 as a lower bound', function () {
const { setSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setSuccessorCount(state)(-1);
expect(state.queryParameters.successorCount).to.equal(0);
});
it('should limit the successorCount to 10000 as an upper bound', function () {
const { setSuccessorCount } = new QueryParameterActionsProvider();
const state = createStateStub();
setSuccessorCount(state)(20000);
expect(state.queryParameters.successorCount).to.equal(10000);
});
});
});

View file

@ -0,0 +1,57 @@
import _ from 'lodash';
import {
MAX_CONTEXT_SIZE,
MIN_CONTEXT_SIZE,
QUERY_PARAMETER_KEYS,
} from './constants';
export function QueryParameterActionsProvider() {
const setPredecessorCount = (state) => (predecessorCount) => (
state.queryParameters.predecessorCount = clamp(
MIN_CONTEXT_SIZE,
MAX_CONTEXT_SIZE,
predecessorCount,
)
);
const increasePredecessorCount = (state) => (
value = state.queryParameters.defaultStepSize,
) => (
setPredecessorCount(state)(state.queryParameters.predecessorCount + value)
);
const setSuccessorCount = (state) => (successorCount) => (
state.queryParameters.successorCount = clamp(
MIN_CONTEXT_SIZE,
MAX_CONTEXT_SIZE,
successorCount,
)
);
const increaseSuccessorCount = (state) => (
value = state.queryParameters.defaultStepSize,
) => (
setSuccessorCount(state)(state.queryParameters.successorCount + value)
);
const setQueryParameters = (state) => (queryParameters) => (
Object.assign(
state.queryParameters,
_.pick(queryParameters, QUERY_PARAMETER_KEYS),
)
);
return {
increasePredecessorCount,
increaseSuccessorCount,
setPredecessorCount,
setQueryParameters,
setSuccessorCount,
};
}
function clamp(minimum, maximum, value) {
return Math.max(Math.min(maximum, value), minimum);
}

View file

@ -0,0 +1,6 @@
import { createInitialQueryParametersState } from './state';
export const MAX_CONTEXT_SIZE = 10000; // Elasticsearch's default maximum size limit
export const MIN_CONTEXT_SIZE = 0;
export const QUERY_PARAMETER_KEYS = Object.keys(createInitialQueryParametersState());

View file

@ -0,0 +1,7 @@
export { QueryParameterActionsProvider } from './actions';
export {
MAX_CONTEXT_SIZE,
MIN_CONTEXT_SIZE,
QUERY_PARAMETER_KEYS,
} from './constants';
export { createInitialQueryParametersState } from './state';

View file

@ -0,0 +1,11 @@
export function createInitialQueryParametersState(defaultStepSize) {
return {
anchorUid: null,
columns: [],
defaultStepSize,
indexPattern: null,
predecessorCount: 0,
successorCount: 0,
sort: [],
};
}

View file

@ -1,5 +1,9 @@
<li class="sidebar-item" attr-field="{{::field.name}}">
<li
class="sidebar-item"
attr-field="{{::field.name}}"
data-test-subj="field-{{::field.name}}"
>
<div ng-click="toggleDetails(field)" class="sidebar-item-title">
<field-name field="field"></field-name>
<button

View file

@ -128,6 +128,10 @@
font-size: 9px;
}
.discover-table-row--highlight td {
background-color: #E2F1F6;
}
.shard-failures {
color: @discover-shard-failures-color;
background-color: @discover-shard-failures-bg !important;

View file

@ -14,6 +14,7 @@ import 'plugins/kibana/dashboard/index';
import 'plugins/kibana/management/index';
import 'plugins/kibana/doc';
import 'plugins/kibana/dev_tools';
import 'plugins/kibana/context';
import 'ui/vislib';
import 'ui/agg_response';
import 'ui/agg_types';

View file

@ -1,9 +1,9 @@
<tr>
<th width="1%"></th>
<th ng-if="indexPattern.timeFieldName">
<th ng-if="indexPattern.timeFieldName" data-test-subj="docTableHeaderField">
<span>Time <i ng-class="headerClass(indexPattern.timeFieldName)" ng-click="sort(indexPattern.timeFieldName)" tooltip="Sort by time"></i></span>
</th>
<th ng-repeat="name in columns">
<th ng-repeat="name in columns" data-test-subj="docTableHeaderField">
<span class="table-header-name">
{{name | shortDots}} <i ng-class="headerClass(name)" ng-click="sort(name)" tooltip="{{tooltip(name)}}" tooltip-append-to-body="1"></i>
</span>
@ -13,4 +13,4 @@
<i ng-click="moveRight(name)" class="fa fa-angle-double-right" ng-show="!$last" tooltip="Move column to the right" tooltip-append-to-body="1"></i>
</span>
</th>
</tr>
</tr>

View file

@ -1,10 +1,12 @@
import _ from 'lodash';
import $ from 'jquery';
import rison from 'rison-node';
import 'ui/highlight';
import 'ui/highlight/highlight_tags';
import 'ui/doc_viewer';
import 'ui/filters/trust_as_html';
import 'ui/filters/short_dots';
import './table_row.less';
import noWhiteSpace from 'ui/utils/no_white_space';
import openRowHtml from 'ui/doc_table/components/table_row/open.html';
import detailsHtml from 'ui/doc_table/components/table_row/details.html';
@ -24,7 +26,7 @@ const MIN_LINE_LENGTH = 20;
* <tr ng-repeat="row in rows" kbn-table-row="row"></tr>
* ```
*/
module.directive('kbnTableRow', ['$compile', function ($compile) {
module.directive('kbnTableRow', function ($compile, $httpParamSerializer, kbnUrl) {
const cellTemplate = _.template(noWhiteSpace(require('ui/doc_table/components/table_row/cell.html')));
const truncateByHeightTemplate = _.template(noWhiteSpace(require('ui/partials/truncate_by_height.html')));
@ -89,6 +91,20 @@ module.directive('kbnTableRow', ['$compile', function ($compile) {
$scope.filter(field, $scope.flattenedRow[column], type);
};
$scope.getContextAppHref = () => {
const path = kbnUrl.eval('#/context/{{ indexPattern }}/{{ anchorType }}/{{ anchorId }}', {
anchorId: $scope.row._id,
anchorType: $scope.row._type,
indexPattern: $scope.indexPattern.id,
});
const hash = $httpParamSerializer({
_a: rison.encode({
columns: $scope.columns,
}),
});
return `${path}?${hash}`;
};
// create a tr element that lists the value for each *column*
function createSummaryRow(row) {
const indexPattern = $scope.indexPattern;
@ -180,4 +196,4 @@ module.directive('kbnTableRow', ['$compile', function ($compile) {
}
}
};
}]);
});

View file

@ -0,0 +1,19 @@
@import (reference) "~ui/styles/variables";
/**
* 1. Visually align the actions with the tabs. We can improve this by using flexbox instead, at a later point.
*/
.documentTableRow__actions {
float: right;
font-family: @font-family-base;
padding-top: 8px; /* 1 */
}
/**
* 1. Put some space between the actions.
*/
.documentTableRow__action {
& + & {
margin-left: 10px; /* 1 */
}
}

View file

@ -8,7 +8,7 @@ if (timefield) {
attributes='class="discover-table-datafield"';
}
%>
<td <%= attributes %>>
<td <%= attributes %> data-test-subj="docTableField">
<%= formatted %>
<span class="table-cell-filter">
<% if (filterable) { %>

View file

@ -1,6 +1,20 @@
<td colspan="{{ columns.length + 2 }}">
<a class="pull-right" ng-href="#/doc/{{indexPattern.id}}/{{row._index}}/{{row._type}}/?id={{row._id | uriescape}}">
<small>Link to /{{row._index}}/{{row._type}}/{{row._id | uriescape}}</small></i>
</a>
<div class="documentTableRow__actions">
<a
class="kuiLink documentTableRow__action"
data-test-subj="docTableRowAction"
ng-href="{{ getContextAppHref() }}"
ng-if="indexPattern.hasTimeField()"
>
View surrounding documents
</a>
<a
class="kuiLink documentTableRow__action"
data-test-subj="docTableRowAction"
ng-href="#/doc/{{indexPattern.id}}/{{row._index}}/{{row._type}}/?id={{row._id | uriescape}}"
>
View single document
</a>
</div>
<doc-viewer hit="row" filter="filter" columns="columns" index-pattern="indexPattern"></doc-viewer>
</td>
</td>

View file

@ -1,6 +1,6 @@
<td ng-click="toggleRow()">
<td ng-click="toggleRow()" data-test-subj="docTableExpandToggleColumn">
<i
class="fa discover-table-open-icon"
ng-class="{ 'fa-caret-down': open, 'fa-caret-right': !open }">
</i>
</td>
</td>

View file

@ -21,7 +21,7 @@
</table>
</paginate>
<table ng-if="infiniteScroll" class="kbn-table table" ng-if="indexPattern">
<table ng-if="infiniteScroll" class="kbn-table table" ng-if="indexPattern" data-test-subj="docTable">
<thead
kbn-table-header
columns="columns"
@ -35,7 +35,10 @@
sorting="sorting"
index-pattern="indexPattern"
filter="filter"
class="discover-table-row"></tr>
class="discover-table-row"
ng-class="{'discover-table-row--highlight': row['$$_isAnchor']}"
data-test-subj="docTableRow{{ row['$$_isAnchor'] ? ' docTableAnchorRow' : ''}}"
></tr>
</tbody>
</table>
<kbn-infinite-scroll ng-if="infiniteScroll" more="addRows"></kbn-infinite-scroll>

View file

@ -21,6 +21,10 @@ kbn-table, .kbn-table, tbody[kbn-rows] {
}
}
/**
* 1. Use alpha so this will stand out against non-white backgrounds, e.g. the highlighted
* row in the Context Log.
*/
dl.source {
margin-bottom: 0;
line-height: 2em;
@ -31,7 +35,7 @@ kbn-table, .kbn-table, tbody[kbn-rows] {
}
dt {
background: @table-dt-bg;
background-color: rgba(108, 135, 142, 0.15); /* 1 */
color: @table-dt-color;
padding: @padding-xs-vertical @padding-xs-horizontal;
margin-right: @padding-xs-horizontal;

View file

@ -1,20 +1,3 @@
@import (reference) "~ui/styles/variables";
@truncate1: fade(@truncate-color, 0%);
@truncate2: fade(@truncate-color, 1%);
@truncate3: fade(@truncate-color, 99%);
@truncate4: fade(@truncate-color, 100%);
.truncate-by-height {
position: relative;
overflow: hidden;
&:before {
content: " ";
width: 100%;
height: 15px; // copied into index.html!
position: absolute;
left: 0;
background: linear-gradient(to bottom, @truncate1 0%, @truncate2 1%, @truncate3 99%, @truncate4 100%);
}
}

View file

@ -52,6 +52,8 @@
@discover-field-details-bg: @kibanaGray5;
@discover-field-details-progress-bar-bg: @gray;
@discover-table-highlight-bg: #faebcc;
// Field name ==================================================================
@field-name-color: @kibanaGray3;
@ -80,7 +82,6 @@
// table =======================================================================
@table-dt-bg: @gray-lighter;
@table-dt-color: @brand-primary;
@table-cell-hover-bg: white;
@table-cell-hover-hover-bg: @gray-lighter;

View file

@ -310,6 +310,14 @@ export default function defaultSettingsProvider() {
'indexPattern:placeholder': {
value: 'logstash-*',
description: 'The placeholder for the field "Index name or pattern" in the "Settings > Indices" tab.',
}
},
'context:defaultSize': {
value: 5,
description: 'The number of surrounding entries to show in the context view',
},
'context:step': {
value: 5,
description: 'The step size to increment or decrement the context size by',
},
};
}

View file

@ -0,0 +1,54 @@
import expect from 'expect.js';
import { bdd, esClient } from '../../../support';
import PageObjects from '../../../support/page_objects';
const TEST_DISCOVER_START_TIME = '2015-09-19 06:31:44.000';
const TEST_DISCOVER_END_TIME = '2015-09-23 18:31:44.000';
const TEST_COLUMN_NAMES = ['@message'];
bdd.describe('context link in discover', function contextSize() {
bdd.before(async function() {
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.setAbsoluteRange(TEST_DISCOVER_START_TIME, TEST_DISCOVER_END_TIME);
await Promise.all(TEST_COLUMN_NAMES.map((columnName) => (
PageObjects.discover.clickFieldListItemAdd(columnName)
)));
});
bdd.it('should open the context view with the selected document as anchor', async function () {
const discoverDocTable = await PageObjects.docTable.getTable();
const firstRow = (await PageObjects.docTable.getBodyRows(discoverDocTable))[0];
const firstTimestamp = await (await PageObjects.docTable.getFields(firstRow))[0]
.getVisibleText();
// add a column in Discover
await (await PageObjects.docTable.getRowExpandToggle(firstRow)).click();
const firstDetailsRow = (await PageObjects.docTable.getDetailsRows(discoverDocTable))[0];
await (await PageObjects.docTable.getRowActions(firstDetailsRow))[0].click();
// check the column in the Context View
await PageObjects.common.try(async () => {
const contextDocTable = await PageObjects.docTable.getTable();
const anchorRow = await PageObjects.docTable.getAnchorRow(contextDocTable);
const anchorTimestamp = await (await PageObjects.docTable.getFields(anchorRow))[0]
.getVisibleText();
expect(anchorTimestamp).to.equal(firstTimestamp);
});
});
bdd.it('should open the context view with the same columns', async function () {
const docTable = await PageObjects.docTable.getTable();
await PageObjects.common.try(async () => {
const headerFields = await PageObjects.docTable.getHeaderFields(docTable);
const columnNames = await Promise.all(headerFields.map((headerField) => (
headerField.getVisibleText()
)));
expect(columnNames).to.eql([
'Time',
...TEST_COLUMN_NAMES,
]);
});
});
});

View file

@ -0,0 +1,63 @@
import expect from 'expect.js';
import { bdd, esClient } from '../../../support';
import PageObjects from '../../../support/page_objects';
const TEST_INDEX_PATTERN = 'logstash-*';
const TEST_ANCHOR_TYPE = 'apache';
const TEST_ANCHOR_ID = 'AU_x3_BrGFA8no6QjjaI';
const TEST_DEFAULT_CONTEXT_SIZE = 7;
const TEST_STEP_SIZE = 3;
bdd.describe('context size', function contextSize() {
bdd.before(async function() {
await esClient.updateConfigDoc({
'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`,
'context:step': `${TEST_STEP_SIZE}`,
});
});
bdd.it('should default to the `context:defaultSize` setting', async function () {
await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID);
const docTable = await PageObjects.docTable.getTable();
await PageObjects.common.try(async function () {
expect(await PageObjects.docTable.getBodyRows(docTable)).to.have.length(2 * TEST_DEFAULT_CONTEXT_SIZE + 1);
});
await PageObjects.common.try(async function() {
const predecessorCountPicker = await PageObjects.context.getPredecessorCountPicker();
expect(await predecessorCountPicker.getProperty('value')).to.equal(`${TEST_DEFAULT_CONTEXT_SIZE}`);
});
await PageObjects.common.try(async function() {
const successorCountPicker = await PageObjects.context.getSuccessorCountPicker();
expect(await successorCountPicker.getProperty('value')).to.equal(`${TEST_DEFAULT_CONTEXT_SIZE}`);
});
});
bdd.it('should increase according to the `context:step` setting when clicking the `load newer` button', async function() {
await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID);
const docTable = await PageObjects.docTable.getTable();
await (await PageObjects.context.getPredecessorLoadMoreButton()).click();
await PageObjects.common.try(async function () {
expect(await PageObjects.docTable.getBodyRows(docTable)).to.have.length(
2 * TEST_DEFAULT_CONTEXT_SIZE + TEST_STEP_SIZE + 1
);
});
});
bdd.it('should increase according to the `context:step` setting when clicking the `load older` button', async function() {
await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID);
const docTable = await PageObjects.docTable.getTable();
const successorLoadMoreButton = await PageObjects.context.getSuccessorLoadMoreButton();
await this.remote.moveMouseTo(successorLoadMoreButton); // possibly scroll until the button is visible
await successorLoadMoreButton.click();
await PageObjects.common.try(async function () {
expect(await PageObjects.docTable.getBodyRows(docTable)).to.have.length(
2 * TEST_DEFAULT_CONTEXT_SIZE + TEST_STEP_SIZE + 1
);
});
});
});

View file

@ -0,0 +1,28 @@
import {
bdd,
defaultTimeout,
elasticDump,
remote,
scenarioManager,
} from '../../../support';
import PageObjects from '../../../support/page_objects';
bdd.describe('context app', function () {
this.timeout = defaultTimeout;
bdd.before(async function () {
await PageObjects.remote.setWindowSize(1200,800);
await scenarioManager.loadIfEmpty('logstashFunctional');
await elasticDump.elasticLoad('visualize','.kibana');
await PageObjects.common.navigateToApp('discover');
});
bdd.after(function unloadMakelogs() {
return scenarioManager.unload('logstashFunctional');
});
require('./_discover_navigation');
require('./_size');
});

View file

@ -35,6 +35,7 @@ define(function (require) {
'intern/dojo/node!./apps/visualize',
'intern/dojo/node!./apps/console',
'intern/dojo/node!./apps/dashboard',
'intern/dojo/node!./apps/context',
'intern/dojo/node!./status_page'
].filter((suite) => {
if (!requestedApps) return true;

View file

@ -34,6 +34,10 @@ module.exports = {
pathname: kibanaURL,
hash: '/discover',
},
context: {
pathname: kibanaURL,
hash: '/context',
},
visualize: {
pathname: kibanaURL,
hash: '/visualize',

View file

@ -0,0 +1,58 @@
import rison from 'rison-node';
import { config } from '../';
import PageObjects from './';
import getUrl from '../../utils/get_url';
const DEFAULT_INITIAL_STATE = {
columns: ['@message'],
};
export default class ContextPage {
init(remote) {
this.remote = remote;
}
async navigateTo(indexPattern, anchorType, anchorId, overrideInitialState = {}) {
const initialState = rison.encode({
...DEFAULT_INITIAL_STATE,
...overrideInitialState,
});
const appUrl = getUrl.noAuth(config.servers.kibana, {
...config.apps.context,
hash: `${config.apps.context.hash}/${indexPattern}/${anchorType}/${anchorId}?_a=${initialState}`,
});
await this.remote.get(appUrl);
await this.remote.refresh();
await this.waitUntilContextLoadingHasFinished();
}
getPredecessorCountPicker() {
return PageObjects.common.findTestSubject('predecessorCountPicker');
}
getSuccessorCountPicker() {
return PageObjects.common.findTestSubject('successorCountPicker');
}
getPredecessorLoadMoreButton() {
return PageObjects.common.findTestSubject('predecessorLoadMoreButton');
}
getSuccessorLoadMoreButton() {
return PageObjects.common.findTestSubject('predecessorLoadMoreButton');
}
waitUntilContextLoadingHasFinished() {
return PageObjects.common.try(async () => {
if (
!(await this.getSuccessorLoadMoreButton().isEnabled())
|| !(await this.getPredecessorLoadMoreButton().isEnabled())
) {
throw new Error('loading context rows');
}
});
}
}

View file

@ -291,7 +291,10 @@ export default class DiscoverPage {
}
async clickFieldListItemAdd(field) {
await PageObjects.common.findTestSubject('fieldToggle-' + field).click();
const listEntry = await PageObjects.common.findTestSubject(`field-${field}`);
await this.remote.moveMouseTo(listEntry);
await PageObjects.common.findTestSubject(`fieldToggle-${field}`)
.click();
}
async clickFieldListItemVisualize(field) {

View file

@ -0,0 +1,40 @@
import PageObjects from './';
export default class DocTable {
init(remote) {
this.remote = remote;
}
getTable() {
return PageObjects.common.findTestSubject('docTable');
}
async getBodyRows(table) {
return await table.findAllByCssSelector('[data-test-subj~="docTableRow"]');
}
async getAnchorRow(table) {
return await table.findByCssSelector('[data-test-subj~="docTableAnchorRow"]');
}
async getRowExpandToggle(row) {
return await row.findByCssSelector('[data-test-subj~="docTableExpandToggleColumn"]');
}
async getDetailsRows(table) {
return await table.findAllByCssSelector('[data-test-subj~="docTableRow"] + tr');
}
async getRowActions(row) {
return await row.findAllByCssSelector('[data-test-subj~="docTableRowAction"]');
}
async getFields(row) {
return await row.findAllByCssSelector('[data-test-subj~="docTableField"]');
}
async getHeaderFields(table) {
return await table.findAllByCssSelector('[data-test-subj~="docTableHeaderField"]');
}
}

View file

@ -1,6 +1,7 @@
import Common from './common';
import ConsolePage from './console_page';
import ContextPage from './context_page';
import DashboardPage from './dashboard_page';
import DiscoverPage from './discover_page';
import HeaderPage from './header_page';
@ -9,9 +10,11 @@ import ShieldPage from './shield_page';
import VisualizePage from './visualize_page';
import VisualizePointSeriesOptions from './visualize_point_series_options';
import MonitoringPage from './monitoring_page';
import DocTable from './doc_table';
const common = new Common();
const consolePage = new ConsolePage();
const contextPage = new ContextPage();
const dashboardPage = new DashboardPage();
const discoverPage = new DiscoverPage();
const headerPage = new HeaderPage();
@ -21,26 +24,33 @@ const visualizePage = new VisualizePage();
const visualizePointSeriesOptions = new VisualizePointSeriesOptions();
const monitoringPage = new MonitoringPage();
const docTable = new DocTable();
class PageObjects {
constructor() {
this.isInitialized = false;
this.remote = undefined;
this.pageObjects = [
common,
consolePage,
contextPage,
dashboardPage,
discoverPage,
headerPage,
settingsPage,
shieldPage,
visualizePage,
visualizePointSeriesOptions,
monitoringPage,
docTable,
];
}
init(remote) {
this.isInitialized = true;
this.remote = remote;
common.init(remote);
consolePage.init(remote);
dashboardPage.init(remote);
discoverPage.init(remote);
headerPage.init(remote);
settingsPage.init(remote);
shieldPage.init(remote);
visualizePage.init(remote);
visualizePointSeriesOptions.init(remote);
monitoringPage.init(remote);
this.pageObjects.map((pageObject) => pageObject.init(remote));
}
assertInitialized() {
@ -58,6 +68,10 @@ class PageObjects {
return this.assertInitialized() && consolePage;
}
get context() {
return this.assertInitialized() && contextPage;
}
get dashboard() {
return this.assertInitialized() && dashboardPage;
}
@ -90,6 +104,10 @@ class PageObjects {
return this.assertInitialized() && monitoringPage;
}
get docTable() {
return this.assertInitialized() && docTable;
}
}
export default new PageObjects();