Merge remote-tracking branch 'origin/master' into feature/merge-code

This commit is contained in:
Fuyao Zhao 2019-04-09 18:08:00 -07:00
commit ef0e01e8a9
159 changed files with 5406 additions and 2222 deletions

View file

@ -100,7 +100,7 @@
"@babel/polyfill": "^7.2.5",
"@babel/register": "^7.0.0",
"@elastic/datemath": "5.0.2",
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"@elastic/filesaver": "1.1.2",
"@elastic/good": "8.1.1-kibana2",
"@elastic/numeral": "2.3.2",

View file

@ -11,7 +11,8 @@
"kbn:watch": "node scripts/build --source-maps --watch"
},
"dependencies": {
"lodash": "npm:@elastic/lodash@3.10.1-kibana1"
"lodash": "npm:@elastic/lodash@3.10.1-kibana1",
"moment-timezone": "^0.5.14"
},
"devDependencies": {
"@babel/cli": "^7.2.3",

View file

@ -103,6 +103,36 @@ describe('build query', function () {
expect(result).to.eql(expectedResult);
});
it('should use the default time zone set in the Advanced Settings in queries and filters', function () {
const queries = [
{ query: '@timestamp:"2019-03-23T13:18:00"', language: 'kuery' },
{ query: '@timestamp:"2019-03-23T13:18:00"', language: 'lucene' }
];
const filters = [
{ match_all: {}, meta: { type: 'match_all' } }
];
const config = {
allowLeadingWildcards: true,
queryStringOptions: {},
ignoreFilterIfFieldNotInIndex: false,
dateFormatTZ: 'Africa/Johannesburg',
};
const expectedResult = {
bool: {
must: [
decorateQuery(luceneStringToDsl('@timestamp:"2019-03-23T13:18:00"'), config.queryStringOptions, config.dateFormatTZ),
{ match_all: {} }
],
filter: [toElasticsearchQuery(fromKueryExpression('@timestamp:"2019-03-23T13:18:00"'), indexPattern, config)],
should: [],
must_not: [],
}
};
const result = buildEsQuery(indexPattern, queries, filters, config);
expect(result).to.eql(expectedResult);
});
});
});

View file

@ -29,4 +29,9 @@ describe('Query decorator', function () {
const decoratedQuery = decorateQuery({ query_string: { query: '*' } }, { analyze_wildcard: true });
expect(decoratedQuery).to.eql({ query_string: { query: '*', analyze_wildcard: true } });
});
it('should add a default of a time_zone parameter if one is provided', function () {
const decoratedQuery = decorateQuery({ query_string: { query: '*' } }, { analyze_wildcard: true }, 'America/Phoenix');
expect(decoratedQuery).to.eql({ query_string: { query: '*', analyze_wildcard: true, time_zone: 'America/Phoenix' } });
});
});

View file

@ -60,6 +60,31 @@ describe('build query', function () {
);
});
it('should accept a specific date format for a kuery query into an ES query in the bool\'s filter clause', function () {
const queries = [{ query: '@timestamp:"2018-04-03T19:04:17"', language: 'kuery' }];
const expectedESQueries = queries.map(query => {
return toElasticsearchQuery(fromKueryExpression(query.query), indexPattern, { dateFormatTZ: 'America/Phoenix' });
});
const result = buildQueryFromKuery(indexPattern, queries, true, 'America/Phoenix');
expect(result.filter).to.eql(expectedESQueries);
});
it('should gracefully handle date queries when no date format is provided', function () {
const queries = [{ query: '@timestamp:"2018-04-03T19:04:17Z"', language: 'kuery' }];
const expectedESQueries = queries.map(query => {
return toElasticsearchQuery(fromKueryExpression(query.query), indexPattern);
});
const result = buildQueryFromKuery(indexPattern, queries, true);
expect(result.filter).to.eql(expectedESQueries);
});
});
});

View file

@ -66,4 +66,22 @@ describe('build query', function () {
});
it('should accept a date format in the decorated queries and combine that into the bool\'s must clause', function () {
const queries = [
{ query: 'foo:bar', language: 'lucene' },
{ query: 'bar:baz', language: 'lucene' },
];
const dateFormatTZ = 'America/Phoenix';
const expectedESQueries = queries.map(
(query) => {
return decorateQuery(luceneStringToDsl(query.query), {}, dateFormatTZ);
}
);
const result = buildQueryFromLucene(queries, {}, dateFormatTZ);
expect(result.must).to.eql(expectedESQueries);
});
});

View file

@ -0,0 +1,66 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { getEsQueryConfig } from '../get_es_query_config';
const config = {
get(item) {
return config[item];
},
'query:allowLeadingWildcards': {
allowLeadingWildcards: true,
},
'query:queryString:options': {
queryStringOptions: {},
},
'courier:ignoreFilterIfFieldNotInIndex': {
ignoreFilterIfFieldNotInIndex: true,
},
'dateFormat:tz': {
dateFormatTZ: 'Browser',
},
};
describe('getEsQueryConfig', function () {
it('should return the parameters of an Elasticsearch query config requested', function () {
const result = getEsQueryConfig(config);
const expected = {
allowLeadingWildcards: {
allowLeadingWildcards: true,
},
dateFormatTZ: {
dateFormatTZ: 'Browser',
},
ignoreFilterIfFieldNotInIndex: {
ignoreFilterIfFieldNotInIndex: true,
},
queryStringOptions: {
queryStringOptions: {},
},
};
expect(result).to.eql(expected);
expect(result).to.have.keys(
'allowLeadingWildcards',
'dateFormatTZ',
'ignoreFilterIfFieldNotInIndex',
'queryStringOptions'
);
});
});

View file

@ -28,6 +28,7 @@ import { buildQueryFromLucene } from './from_lucene';
* @param filters - a filter object or array of filter objects
* @param config - an objects with query:allowLeadingWildcards and query:queryString:options UI
* settings in form of { allowLeadingWildcards, queryStringOptions }
* config contains dateformat:tz
*/
export function buildEsQuery(
indexPattern,
@ -37,15 +38,15 @@ export function buildEsQuery(
allowLeadingWildcards: false,
queryStringOptions: {},
ignoreFilterIfFieldNotInIndex: false,
dateFormatTZ: null,
}) {
queries = Array.isArray(queries) ? queries : [queries];
filters = Array.isArray(filters) ? filters : [filters];
const validQueries = queries.filter((query) => has(query, 'query'));
const queriesByLanguage = groupBy(validQueries, 'language');
const kueryQuery = buildQueryFromKuery(indexPattern, queriesByLanguage.kuery, config.allowLeadingWildcards);
const luceneQuery = buildQueryFromLucene(queriesByLanguage.lucene, config.queryStringOptions);
const kueryQuery = buildQueryFromKuery(indexPattern, queriesByLanguage.kuery, config.allowLeadingWildcards, config.dateFormatTZ);
const luceneQuery = buildQueryFromLucene(queriesByLanguage.lucene, config.queryStringOptions, config.dateFormatTZ);
const filterQuery = buildQueryFromFilters(filters, indexPattern, config.ignoreFilterIfFieldNotInIndex);
return {

View file

@ -18,16 +18,22 @@
*/
import _ from 'lodash';
import { getTimeZoneFromSettings } from '../utils/get_time_zone_from_settings';
/**
* Decorate queries with default parameters
* @param query object
* @param queryStringOptions query:queryString:options from UI settings
* @param dateFormatTZ dateFormat:tz from UI settings
* @returns {object}
*/
export function decorateQuery(query, queryStringOptions) {
export function decorateQuery(query, queryStringOptions, dateFormatTZ = null) {
if (_.has(query, 'query_string.query')) {
_.extend(query.query_string, queryStringOptions);
if (dateFormatTZ) {
_.defaults(query.query_string, { time_zone: getTimeZoneFromSettings(dateFormatTZ) });
}
}
return query;

View file

@ -19,7 +19,7 @@
import { fromLegacyKueryExpression, fromKueryExpression, toElasticsearchQuery, nodeTypes } from '../kuery';
export function buildQueryFromKuery(indexPattern, queries = [], allowLeadingWildcards) {
export function buildQueryFromKuery(indexPattern, queries = [], allowLeadingWildcards, dateFormatTZ = null) {
const queryASTs = queries.map(query => {
try {
return fromKueryExpression(query.query, { allowLeadingWildcards });
@ -32,12 +32,12 @@ export function buildQueryFromKuery(indexPattern, queries = [], allowLeadingWild
throw Error('OutdatedKuerySyntaxError');
}
});
return buildQuery(indexPattern, queryASTs);
return buildQuery(indexPattern, queryASTs, { dateFormatTZ });
}
function buildQuery(indexPattern, queryASTs) {
function buildQuery(indexPattern, queryASTs, config = null) {
const compoundQueryAST = nodeTypes.function.buildNode('and', queryASTs);
const kueryQuery = toElasticsearchQuery(compoundQueryAST, indexPattern);
const kueryQuery = toElasticsearchQuery(compoundQueryAST, indexPattern, config);
return {
must: [],
filter: [],

View file

@ -21,10 +21,10 @@ import _ from 'lodash';
import { decorateQuery } from './decorate_query';
import { luceneStringToDsl } from './lucene_string_to_dsl';
export function buildQueryFromLucene(queries, queryStringOptions) {
export function buildQueryFromLucene(queries, queryStringOptions, dateFormatTZ = null) {
const combinedQueries = _.map(queries, (query) => {
const queryDsl = luceneStringToDsl(query.query);
return decorateQuery(queryDsl, queryStringOptions);
return decorateQuery(queryDsl, queryStringOptions, dateFormatTZ);
});
return {

View file

@ -21,5 +21,6 @@ export function getEsQueryConfig(config) {
const allowLeadingWildcards = config.get('query:allowLeadingWildcards');
const queryStringOptions = config.get('query:queryString:options');
const ignoreFilterIfFieldNotInIndex = config.get('courier:ignoreFilterIfFieldNotInIndex');
return { allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex };
const dateFormatTZ = config.get('dateFormat:tz');
return { allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex, dateFormatTZ };
}

View file

@ -24,6 +24,7 @@ import indexPatternResponse from '../../../__fixtures__/index_pattern_response.j
// Helpful utility allowing us to test the PEG parser by simply checking for deep equality between
// the nodes the parser generates and the nodes our constructor functions generate.
function fromLegacyKueryExpressionNoMeta(text) {
return ast.fromLegacyKueryExpression(text, { includeMetadata: false });
}
@ -416,6 +417,14 @@ describe('kuery AST API', function () {
expect(ast.toElasticsearchQuery(unknownTypeNode)).to.eql(expected);
});
it('should return the given node type\'s ES query representation including a time zone parameter when one is provided', function () {
const config = { dateFormatTZ: 'America/Phoenix' };
const node = nodeTypes.function.buildNode('is', '@timestamp', '"2018-04-03T19:04:17"');
const expected = nodeTypes.function.toElasticsearchQuery(node, indexPattern, config);
const result = ast.toElasticsearchQuery(node, indexPattern, config);
expect(result).to.eql(expected);
});
});
describe('doesKueryExpressionHaveLuceneSyntaxError', function () {

View file

@ -51,15 +51,19 @@ function fromExpression(expression, parseOptions = {}, parse = parseKuery) {
return parse(expression, parseOptions);
}
// indexPattern isn't required, but if you pass one in, we can be more intelligent
// about how we craft the queries (e.g. scripted fields)
export function toElasticsearchQuery(node, indexPattern) {
/**
* @params {String} indexPattern
* @params {Object} config - contains the dateFormatTZ
*
* IndexPattern isn't required, but if you pass one in, we can be more intelligent
* about how we craft the queries (e.g. scripted fields)
*/
export function toElasticsearchQuery(node, indexPattern, config = {}) {
if (!node || !node.type || !nodeTypes[node.type]) {
return toElasticsearchQuery(nodeTypes.function.buildNode('and', []));
}
return nodeTypes[node.type].toElasticsearchQuery(node, indexPattern);
return nodeTypes[node.type].toElasticsearchQuery(node, indexPattern, config);
}
export function doesKueryExpressionHaveLuceneSyntaxError(expression) {

View file

@ -23,7 +23,6 @@ import { nodeTypes } from '../../node_types';
import * as ast from '../../ast';
import indexPatternResponse from '../../../__fixtures__/index_pattern_response.json';
let indexPattern;
const childNode1 = nodeTypes.function.buildNode('is', 'machine.os', 'osx');
@ -57,8 +56,6 @@ describe('kuery functions', function () {
[childNode1, childNode2].map((childNode) => ast.toElasticsearchQuery(childNode, indexPattern))
);
});
});
});
});

View file

@ -199,6 +199,52 @@ describe('kuery functions', function () {
expect(result.bool.should[0]).to.have.key('script');
});
it('should support date fields without a dateFormat provided', function () {
const expected = {
bool: {
should: [
{
range: {
'@timestamp': {
gte: '2018-04-03T19:04:17',
lte: '2018-04-03T19:04:17',
}
}
}
],
minimum_should_match: 1
}
};
const node = nodeTypes.function.buildNode('is', '@timestamp', '"2018-04-03T19:04:17"');
const result = is.toElasticsearchQuery(node, indexPattern);
expect(result).to.eql(expected);
});
it('should support date fields with a dateFormat provided', function () {
const config = { dateFormatTZ: 'America/Phoenix' };
const expected = {
bool: {
should: [
{
range: {
'@timestamp': {
gte: '2018-04-03T19:04:17',
lte: '2018-04-03T19:04:17',
time_zone: 'America/Phoenix',
}
}
}
],
minimum_should_match: 1
}
};
const node = nodeTypes.function.buildNode('is', '@timestamp', '"2018-04-03T19:04:17"');
const result = is.toElasticsearchQuery(node, indexPattern, config);
expect(result).to.eql(expected);
});
});
});
});

View file

@ -23,7 +23,6 @@ import { nodeTypes } from '../../node_types';
import * as ast from '../../ast';
import indexPatternResponse from '../../../__fixtures__/index_pattern_response.json';
let indexPattern;
const childNode = nodeTypes.function.buildNode('is', 'extension', 'jpg');
@ -32,7 +31,6 @@ describe('kuery functions', function () {
describe('not', function () {
beforeEach(() => {
indexPattern = indexPatternResponse;
});
@ -56,6 +54,7 @@ describe('kuery functions', function () {
expect(result.bool).to.only.have.keys('must_not');
expect(result.bool.must_not).to.eql(ast.toElasticsearchQuery(childNode, indexPattern));
});
});
});
});

View file

@ -23,7 +23,6 @@ import { nodeTypes } from '../../node_types';
import * as ast from '../../ast';
import indexPatternResponse from '../../../__fixtures__/index_pattern_response.json';
let indexPattern;
const childNode1 = nodeTypes.function.buildNode('is', 'machine.os', 'osx');
@ -33,11 +32,11 @@ describe('kuery functions', function () {
describe('or', function () {
beforeEach(() => {
indexPattern = indexPatternResponse;
});
describe('buildNodeParams', function () {
it('arguments should contain the unmodified child nodes', function () {

View file

@ -22,7 +22,6 @@ import * as range from '../range';
import { nodeTypes } from '../../node_types';
import indexPatternResponse from '../../../__fixtures__/index_pattern_response.json';
let indexPattern;
describe('kuery functions', function () {
@ -136,6 +135,52 @@ describe('kuery functions', function () {
expect(result.bool.should[0]).to.have.key('script');
});
it('should support date fields without a dateFormat provided', function () {
const expected = {
bool: {
should: [
{
range: {
'@timestamp': {
gt: '2018-01-03T19:04:17',
lt: '2018-04-03T19:04:17',
}
}
}
],
minimum_should_match: 1
}
};
const node = nodeTypes.function.buildNode('range', '@timestamp', { gt: '2018-01-03T19:04:17', lt: '2018-04-03T19:04:17' });
const result = range.toElasticsearchQuery(node, indexPattern);
expect(result).to.eql(expected);
});
it('should support date fields with a dateFormat provided', function () {
const config = { dateFormatTZ: 'America/Phoenix' };
const expected = {
bool: {
should: [
{
range: {
'@timestamp': {
gt: '2018-01-03T19:04:17',
lt: '2018-04-03T19:04:17',
time_zone: 'America/Phoenix',
}
}
}
],
minimum_should_match: 1
}
};
const node = nodeTypes.function.buildNode('range', '@timestamp', { gt: '2018-01-03T19:04:17', lt: '2018-04-03T19:04:17' });
const result = range.toElasticsearchQuery(node, indexPattern, config);
expect(result).to.eql(expected);
});
});
});
});

View file

@ -25,13 +25,13 @@ export function buildNodeParams(children) {
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern, config) {
const children = node.arguments || [];
return {
bool: {
filter: children.map((child) => {
return ast.toElasticsearchQuery(child, indexPattern);
return ast.toElasticsearchQuery(child, indexPattern, config);
})
}
};

View file

@ -23,6 +23,7 @@ import * as literal from '../node_types/literal';
import * as wildcard from '../node_types/wildcard';
import { getPhraseScript } from '../../filters';
import { getFields } from './utils/get_fields';
import { getTimeZoneFromSettings } from '../../utils/get_time_zone_from_settings';
export function buildNodeParams(fieldName, value, isPhrase = false) {
if (_.isUndefined(fieldName)) {
@ -35,19 +36,16 @@ export function buildNodeParams(fieldName, value, isPhrase = false) {
const fieldNode = typeof fieldName === 'string' ? ast.fromLiteralExpression(fieldName) : literal.buildNode(fieldName);
const valueNode = typeof value === 'string' ? ast.fromLiteralExpression(value) : literal.buildNode(value);
const isPhraseNode = literal.buildNode(isPhrase);
return {
arguments: [fieldNode, valueNode, isPhraseNode],
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern = null, config = {}) {
const { arguments: [ fieldNameArg, valueArg, isPhraseArg ] } = node;
const fieldName = ast.toElasticsearchQuery(fieldNameArg);
const value = !_.isUndefined(valueArg) ? ast.toElasticsearchQuery(valueArg) : valueArg;
const type = isPhraseArg.value ? 'phrase' : 'best_fields';
if (fieldNameArg.value === null) {
if (valueArg.type === 'wildcard') {
return {
@ -67,7 +65,6 @@ export function toElasticsearchQuery(node, indexPattern) {
}
const fields = indexPattern ? getFields(fieldNameArg, indexPattern) : [];
// If no fields are found in the index pattern we send through the given field name as-is. We do this to preserve
// the behaviour of lucene on dashboards where there are panels based on different index patterns that have different
// fields. If a user queries on a field that exists in one pattern but not the other, the index pattern without the
@ -116,6 +113,22 @@ export function toElasticsearchQuery(node, indexPattern) {
}
}];
}
/*
If we detect that it's a date field and the user wants an exact date, we need to convert the query to both >= and <= the value provided to force a range query. This is because match and match_phrase queries do not accept a timezone parameter.
dateFormatTZ can have the value of 'Browser', in which case we guess the timezone using moment.tz.guess.
*/
else if (field.type === 'date') {
const timeZoneParam = config.dateFormatTZ ? { time_zone: getTimeZoneFromSettings(config.dateFormatTZ) } : {};
return [...accumulator, {
range: {
[field.name]: {
gte: value,
lte: value,
...timeZoneParam,
},
}
}];
}
else {
const queryType = type === 'phrase' ? 'match_phrase' : 'match';
return [...accumulator, {
@ -134,3 +147,4 @@ export function toElasticsearchQuery(node, indexPattern) {
};
}

View file

@ -25,12 +25,12 @@ export function buildNodeParams(child) {
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern, config) {
const [ argument ] = node.arguments;
return {
bool: {
must_not: ast.toElasticsearchQuery(argument, indexPattern)
must_not: ast.toElasticsearchQuery(argument, indexPattern, config)
}
};
}

View file

@ -25,13 +25,13 @@ export function buildNodeParams(children) {
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern, config) {
const children = node.arguments || [];
return {
bool: {
should: children.map((child) => {
return ast.toElasticsearchQuery(child, indexPattern);
return ast.toElasticsearchQuery(child, indexPattern, config);
}),
minimum_should_match: 1,
},

View file

@ -22,6 +22,7 @@ import { nodeTypes } from '../node_types';
import * as ast from '../ast';
import { getRangeScript } from '../../filters';
import { getFields } from './utils/get_fields';
import { getTimeZoneFromSettings } from '../../utils/get_time_zone_from_settings';
export function buildNodeParams(fieldName, params) {
params = _.pick(params, 'gt', 'lt', 'gte', 'lte', 'format');
@ -35,7 +36,7 @@ export function buildNodeParams(fieldName, params) {
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern = null, config = {}) {
const [ fieldNameArg, ...args ] = node.arguments;
const fields = indexPattern ? getFields(fieldNameArg, indexPattern) : [];
const namedArgs = extractArguments(args);
@ -60,7 +61,17 @@ export function toElasticsearchQuery(node, indexPattern) {
script: getRangeScript(field, queryParams),
};
}
else if (field.type === 'date') {
const timeZoneParam = config.dateFormatTZ ? { time_zone: getTimeZoneFromSettings(config.dateFormatTZ) } : {};
return {
range: {
[field.name]: {
...queryParams,
...timeZoneParam,
}
}
};
}
return {
range: {
[field.name]: queryParams

View file

@ -31,7 +31,6 @@ describe('kuery node types', function () {
let indexPattern;
beforeEach(() => {
indexPattern = indexPatternResponse;
});

View file

@ -21,8 +21,8 @@ import _ from 'lodash';
import { functions } from '../functions';
export function buildNode(functionName, ...functionArgs) {
const kueryFunction = functions[functionName];
const kueryFunction = functions[functionName];
if (_.isUndefined(kueryFunction)) {
throw new Error(`Unknown function "${functionName}"`);
}
@ -47,8 +47,8 @@ export function buildNodeWithArgumentNodes(functionName, argumentNodes) {
};
}
export function toElasticsearchQuery(node, indexPattern) {
export function toElasticsearchQuery(node, indexPattern, config = {}) {
const kueryFunction = functions[node.function];
return kueryFunction.toElasticsearchQuery(node, indexPattern);
return kueryFunction.toElasticsearchQuery(node, indexPattern, config);
}

View file

@ -17,20 +17,20 @@
* under the License.
*/
import { scanAllTypes } from '../scan_all_types';
import expect from '@kbn/expect';
import { getTimeZoneFromSettings } from '../get_time_zone_from_settings';
jest.mock('ui/chrome', () => ({
addBasePath: () => 'apiUrl',
}));
describe('get timezone from settings', function () {
describe('scanAllTypes', () => {
it('should call the api', async () => {
const $http = {
post: jest.fn().mockImplementation(() => ([]))
};
const typesToInclude = ['index-pattern', 'dashboard'];
await scanAllTypes($http, typesToInclude);
expect($http.post).toBeCalledWith('apiUrl/export', { typesToInclude });
it('should return the config timezone if the time zone is set', function () {
const result = getTimeZoneFromSettings('America/Chicago');
expect(result).to.eql('America/Chicago');
});
it('should return the system timezone if the time zone is set to "Browser"', function () {
const result = getTimeZoneFromSettings('Browser');
expect(result).to.not.equal('Browser');
});
});

View file

@ -17,10 +17,12 @@
* under the License.
*/
import chrome from 'ui/chrome';
import moment from 'moment-timezone';
const detectedTimezone = moment.tz.guess();
const apiBase = chrome.addBasePath('/api/kibana/management/saved_objects/scroll');
export async function scanAllTypes($http, typesToInclude) {
const results = await $http.post(`${apiBase}/export`, { typesToInclude });
return results.data;
export function getTimeZoneFromSettings(dateFormatTZ) {
if (dateFormatTZ === 'Browser') {
return detectedTimezone;
}
return dateFormatTZ;
}

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './get_time_zone_from_settings';

View file

@ -77,76 +77,129 @@ exports[`ObjectsTable delete should show a confirm modal 1`] = `
`;
exports[`ObjectsTable export should allow the user to choose when exporting all 1`] = `
<EuiConfirmModal
buttonColor="primary"
cancelButtonText={
<FormattedMessage
defaultMessage="Cancel"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel"
values={Object {}}
/>
}
confirmButtonText={
<FormattedMessage
defaultMessage="Export All"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel"
values={Object {}}
/>
}
defaultFocusedButton="confirm"
onCancel={[Function]}
onConfirm={[Function]}
title={
<FormattedMessage
defaultMessage="Export {filteredItemCount, plural, one{# object} other {# objects}}"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle"
values={
Object {
"filteredItemCount": 4,
}
}
/>
}
<EuiModal
maxWidth={true}
onClose={[Function]}
>
<p>
<FormattedMessage
defaultMessage="Select which types to export. The number in parentheses indicates how many of this type are available to export."
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription"
values={Object {}}
/>
</p>
<EuiCheckboxGroup
idToSelectedMap={
Object {
"dashboard": true,
"index-pattern": true,
"search": true,
"visualization": true,
}
}
onChange={[Function]}
options={
Array [
Object {
"id": "index-pattern",
"label": "index-pattern (0)",
},
Object {
"id": "visualization",
"label": "visualization (0)",
},
Object {
"id": "dashboard",
"label": "dashboard (0)",
},
Object {
"id": "search",
"label": "search (0)",
},
]
}
/>
</EuiConfirmModal>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
defaultMessage="Export {filteredItemCount, plural, one{# object} other {# objects}}"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle"
values={
Object {
"filteredItemCount": 4,
}
}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText
grow={true}
size="m"
>
<p>
<FormattedMessage
defaultMessage="Select which types to export."
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription"
values={Object {}}
/>
</p>
<EuiCheckboxGroup
idToSelectedMap={
Object {
"dashboard": true,
"index-pattern": true,
"search": true,
"visualization": true,
}
}
onChange={[Function]}
options={
Array [
Object {
"id": "index-pattern",
"label": "index-pattern (0)",
},
Object {
"id": "visualization",
"label": "visualization (0)",
},
Object {
"id": "dashboard",
"label": "dashboard (0)",
},
Object {
"id": "search",
"label": "search (0)",
},
]
}
/>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup
justifyContent="spaceBetween"
>
<EuiFlexItem>
<EuiSwitch
checked={true}
label={
<FormattedMessage
defaultMessage="Include related objects"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel"
values={Object {}}
/>
}
name="includeReferencesDeep"
onChange={[Function]}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiFlexGroup>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
color="primary"
iconSide="left"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Cancel"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
fill={true}
iconSide="left"
onClick={[Function]}
size="m"
type="button"
>
<FormattedMessage
defaultMessage="Export all"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel"
values={Object {}}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;
exports[`ObjectsTable import should show the flyout 1`] = `

View file

@ -24,6 +24,8 @@ import { ObjectsTable, INCLUDED_TYPES } from '../objects_table';
import { Flyout } from '../components/flyout/';
import { Relationships } from '../components/relationships/';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
jest.mock('../components/header', () => ({
Header: () => 'Header',
}));
@ -45,12 +47,12 @@ jest.mock('ui/chrome', () => ({
addBasePath: () => ''
}));
jest.mock('../../../lib/retrieve_and_export_docs', () => ({
retrieveAndExportDocs: jest.fn(),
jest.mock('../../../lib/fetch_export_objects', () => ({
fetchExportObjects: jest.fn(),
}));
jest.mock('../../../lib/scan_all_types', () => ({
scanAllTypes: jest.fn(),
jest.mock('../../../lib/fetch_export_by_type', () => ({
fetchExportByType: jest.fn(),
}));
jest.mock('../../../lib/get_saved_object_counts', () => ({
@ -64,8 +66,8 @@ jest.mock('../../../lib/get_saved_object_counts', () => ({
})
}));
jest.mock('../../../lib/save_to_file', () => ({
saveToFile: jest.fn(),
jest.mock('@elastic/filesaver', () => ({
saveAs: jest.fn(),
}));
jest.mock('../../../lib/get_relationships', () => ({
@ -147,6 +149,7 @@ const defaultProps = {
};
let addDangerMock;
let addSuccessMock;
describe('ObjectsTable', () => {
beforeEach(() => {
@ -159,8 +162,10 @@ describe('ObjectsTable', () => {
return debounced;
};
addDangerMock = jest.fn();
addSuccessMock = jest.fn();
require('ui/notify').toastNotifications = {
addDanger: addDangerMock,
addSuccess: addSuccessMock,
};
});
@ -222,7 +227,7 @@ describe('ObjectsTable', () => {
}))
};
const { retrieveAndExportDocs } = require('../../../lib/retrieve_and_export_docs');
const { fetchExportObjects } = require('../../../lib/fetch_export_objects');
const component = shallowWithIntl(
<ObjectsTable.WrappedComponent
@ -239,10 +244,10 @@ describe('ObjectsTable', () => {
// Set some as selected
component.instance().onSelectionChanged(mockSelectedSavedObjects);
await component.instance().onExport();
await component.instance().onExport(true);
expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects);
expect(retrieveAndExportDocs).toHaveBeenCalledWith(mockSavedObjects, mockSavedObjectsClient);
expect(fetchExportObjects).toHaveBeenCalledWith(mockSelectedSavedObjects, true);
expect(addSuccessMock).toHaveBeenCalledWith({ title: 'Your file is downloading in the background' });
});
it('should allow the user to choose when exporting all', async () => {
@ -260,12 +265,12 @@ describe('ObjectsTable', () => {
component.find('Header').prop('onExportAll')();
component.update();
expect(component.find('EuiConfirmModal')).toMatchSnapshot();
expect(component.find('EuiModal')).toMatchSnapshot();
});
it('should export all', async () => {
const { scanAllTypes } = require('../../../lib/scan_all_types');
const { saveToFile } = require('../../../lib/save_to_file');
const { fetchExportByType } = require('../../../lib/fetch_export_by_type');
const { saveAs } = require('@elastic/filesaver');
const component = shallowWithIntl(
<ObjectsTable.WrappedComponent
{...defaultProps}
@ -278,12 +283,14 @@ describe('ObjectsTable', () => {
component.update();
// Set up mocks
scanAllTypes.mockImplementation(() => allSavedObjects);
const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' });
fetchExportByType.mockImplementation(() => blob);
await component.instance().onExportAll();
expect(scanAllTypes).toHaveBeenCalledWith(defaultProps.$http, INCLUDED_TYPES);
expect(saveToFile).toHaveBeenCalledWith(JSON.stringify(allSavedObjects, null, 2));
expect(fetchExportByType).toHaveBeenCalledWith(INCLUDED_TYPES, true);
expect(saveAs).toHaveBeenCalledWith(blob, 'export.ndjson');
expect(addSuccessMock).toHaveBeenCalledWith({ title: 'Your file is downloading in the background' });
});
});

View file

@ -22,43 +22,342 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = `
/>
</h2>
</EuiTitle>
<EuiSpacer
size="s"
<span>
<EuiSpacer
size="s"
/>
<EuiCallOut
color="warning"
iconType="help"
size="m"
title={
<FormattedMessage
defaultMessage="Index Pattern Conflicts"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="The following saved objects use index patterns that do not exist. Please select the index patterns you'd like re-associated with them. You can {indexPatternLink} if necessary."
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsDescription"
values={
Object {
"indexPatternLink": <EuiLink
color="primary"
href=""
type="button"
>
<FormattedMessage
defaultMessage="create a new index pattern"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsCalloutLinkText"
values={Object {}}
/>
</EuiLink>,
}
}
/>
</p>
</EuiCallOut>
</span>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiInMemoryTable
columns={
Array [
Object {
"description": "ID of the index pattern",
"field": "existingIndexPatternId",
"name": "ID",
"sortable": true,
},
Object {
"description": "How many affected objects",
"field": "list",
"name": "Count",
"render": [Function],
},
Object {
"description": "Sample of affected objects",
"field": "list",
"name": "Sample of affected objects",
"render": [Function],
},
Object {
"field": "existingIndexPatternId",
"name": "New index pattern",
"render": [Function],
},
]
}
executeQueryOptions={Object {}}
items={
Array [
Object {
"existingIndexPatternId": "MyIndexPattern*",
"list": Array [
Object {
"id": "1",
"title": "My Visualization",
"type": "visualization",
},
],
"newIndexPatternId": undefined,
},
]
}
pagination={
Object {
"pageSizeOptions": Array [
5,
10,
25,
],
}
}
responsive={true}
sorting={false}
/>
<EuiCallOut
color="warning"
iconType="help"
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
color="primary"
iconSide="left"
onClick={[MockFunction]}
size="s"
type="button"
>
<FormattedMessage
defaultMessage="Cancel"
id="kbn.management.objects.objectsTable.flyout.import.cancelButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="primary"
data-test-subj="importSavedObjectsConfirmBtn"
fill={true}
iconSide="left"
isLoading={false}
onClick={[Function]}
size="s"
type="button"
>
<FormattedMessage
defaultMessage="Confirm all changes"
id="kbn.management.objects.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel"
values={Object {}}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
`;
exports[`Flyout conflicts should allow conflict resolution 2`] = `
[MockFunction] {
"calls": Array [
Array [
Object {
"getConflictResolutions": [Function],
"state": Object {
"conflictedIndexPatterns": undefined,
"conflictedSavedObjectsLinkedToSavedSearches": undefined,
"conflictedSearchDocs": undefined,
"conflictingRecord": undefined,
"error": undefined,
"failedImports": Array [
Object {
"error": Object {
"references": Array [
Object {
"id": "MyIndexPattern*",
"type": "index-pattern",
},
],
"type": "missing_references",
},
"obj": Object {
"id": "1",
"title": "My Visualization",
"type": "visualization",
},
},
],
"file": Object {
"name": "foo.ndjson",
"path": "/home/foo.ndjson",
},
"importCount": 0,
"indexPatterns": Array [
Object {
"id": "1",
},
Object {
"id": "2",
},
],
"isLegacyFile": false,
"isOverwriteAllChecked": true,
"loadingMessage": undefined,
"status": "loading",
"unmatchedReferences": Array [
Object {
"existingIndexPatternId": "MyIndexPattern*",
"list": Array [
Object {
"id": "1",
"title": "My Visualization",
"type": "visualization",
},
],
"newIndexPatternId": "2",
},
],
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"failedImports": Array [],
"importCount": 1,
"status": "success",
},
},
],
}
`;
exports[`Flyout conflicts should handle errors 1`] = `
<EuiCallOut
color="warning"
iconType="help"
size="m"
title={
<FormattedMessage
defaultMessage="Import failed"
id="kbn.management.objects.objectsTable.flyout.importFailedTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Failed to import {failedImportCount} of {totalImportCount} objects. Import failed"
id="kbn.management.objects.objectsTable.flyout.importFailedDescription"
values={
Object {
"failedImportCount": 1,
"totalImportCount": 1,
}
}
/>
</p>
<p />
</EuiCallOut>
`;
exports[`Flyout legacy conflicts should allow conflict resolution 1`] = `
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[MockFunction]}
ownFocus={false}
size="s"
>
<EuiFlyoutHeader>
<EuiTitle
size="m"
title={
textTransform="none"
>
<h2>
<FormattedMessage
defaultMessage="Index Pattern Conflicts"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsTitle"
defaultMessage="Import saved objects"
id="kbn.management.objects.objectsTable.flyout.importSavedObjectTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="The following saved objects use index patterns that do not exist. Please select the index patterns you'd like re-associated with them. You can {indexPatternLink} if necessary."
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsDescription"
values={
Object {
"indexPatternLink": <EuiLink
color="primary"
href=""
type="button"
>
<FormattedMessage
defaultMessage="create a new index pattern"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsCalloutLinkText"
values={Object {}}
/>
</EuiLink>,
</h2>
</EuiTitle>
<span>
<EuiSpacer
size="s"
/>
<EuiCallOut
color="warning"
iconType="help"
size="m"
title={
<FormattedMessage
defaultMessage="Support for JSON files is going away"
id="kbn.management.objects.objectsTable.flyout.legacyFileUsedTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Use our updated export to generate NDJSON files, and you'll be all set."
id="kbn.management.objects.objectsTable.flyout.legacyFileUsedBody"
values={Object {}}
/>
</p>
</EuiCallOut>
</span>
<span>
<EuiSpacer
size="s"
/>
<EuiCallOut
color="warning"
iconType="help"
size="m"
title={
<FormattedMessage
defaultMessage="Index Pattern Conflicts"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="The following saved objects use index patterns that do not exist. Please select the index patterns you'd like re-associated with them. You can {indexPatternLink} if necessary."
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsDescription"
values={
Object {
"indexPatternLink": <EuiLink
color="primary"
href=""
type="button"
>
<FormattedMessage
defaultMessage="create a new index pattern"
id="kbn.management.objects.objectsTable.flyout.indexPatternConflictsCalloutLinkText"
values={Object {}}
/>
</EuiLink>,
}
}
}
/>
</p>
</EuiCallOut>
/>
</p>
</EuiCallOut>
</span>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiInMemoryTable
@ -97,7 +396,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = `
"list": Array [
Object {
"id": "MyIndexPattern*",
"name": "MyIndexPattern*",
"title": "MyIndexPattern*",
"type": "index-pattern",
},
],
@ -164,7 +463,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = `
</EuiFlyout>
`;
exports[`Flyout conflicts should handle errors 1`] = `
exports[`Flyout legacy conflicts should handle errors 1`] = `
<EuiCallOut
color="danger"
iconType="cross"
@ -214,7 +513,7 @@ exports[`Flyout should render import step 1`] = `
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Please select a JSON file to import"
defaultMessage="Please select a file to import"
id="kbn.management.objects.objectsTable.flyout.selectFileToImportFormRowLabel"
values={Object {}}
/>

View file

@ -22,6 +22,8 @@ import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { Flyout } from '../flyout';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
jest.mock('ui/errors', () => ({
SavedObjectNotFound: class SavedObjectNotFound extends Error {
constructor(options) {
@ -35,12 +37,20 @@ jest.mock('ui/errors', () => ({
},
}));
jest.mock('../../../../../lib/import_file', () => ({
importFile: jest.fn(),
}));
jest.mock('../../../../../lib/resolve_import_errors', () => ({
resolveImportErrors: jest.fn(),
}));
jest.mock('ui/chrome', () => ({
addBasePath: () => {},
}));
jest.mock('../../../../../lib/import_file', () => ({
importFile: jest.fn(),
jest.mock('../../../../../lib/import_legacy_file', () => ({
importLegacyFile: jest.fn(),
}));
jest.mock('../../../../../lib/resolve_saved_objects', () => ({
@ -57,13 +67,19 @@ const defaultProps = {
done: jest.fn(),
services: [],
newIndexPatternUrl: '',
getConflictResolutions: jest.fn(),
indexPatterns: {
getFields: jest.fn().mockImplementation(() => [{ id: '1' }, { id: '2' }]),
},
};
const mockFile = {
path: '/home/foo.txt',
name: 'foo.ndjson',
path: '/home/foo.ndjson',
};
const legacyMockFile = {
name: 'foo.json',
path: '/home/foo.json',
};
describe('Flyout', () => {
@ -104,8 +120,23 @@ describe('Flyout', () => {
expect(component.state('file')).toBe(mockFile);
});
it('should allow removing a file', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
await Promise.resolve();
// Ensure the state changes are reflected
component.update();
expect(component.state('file')).toBe(undefined);
component.find('EuiFilePicker').simulate('change', [mockFile]);
expect(component.state('file')).toBe(mockFile);
component.find('EuiFilePicker').simulate('change', []);
expect(component.state('file')).toBe(undefined);
});
it('should handle invalid files', async () => {
const { importFile } = require('../../../../../lib/import_file');
const { importLegacyFile } = require('../../../../../lib/import_legacy_file');
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
@ -113,18 +144,18 @@ describe('Flyout', () => {
// Ensure the state changes are reflected
component.update();
importFile.mockImplementation(() => {
importLegacyFile.mockImplementation(() => {
throw new Error('foobar');
});
await component.instance().import();
await component.instance().legacyImport();
expect(component.state('error')).toBe('The file could not be processed.');
importFile.mockImplementation(() => ({
importLegacyFile.mockImplementation(() => ({
invalid: true,
}));
await component.instance().import();
await component.instance().legacyImport();
expect(component.state('error')).toBe(
'Saved objects file format is invalid and cannot be imported.'
);
@ -132,6 +163,157 @@ describe('Flyout', () => {
describe('conflicts', () => {
const { importFile } = require('../../../../../lib/import_file');
const { resolveImportErrors } = require('../../../../../lib/resolve_import_errors');
beforeEach(() => {
importFile.mockImplementation(() => ({
success: false,
successCount: 0,
errors: [
{
id: '1',
type: 'visualization',
title: 'My Visualization',
error: {
type: 'missing_references',
references: [
{
id: 'MyIndexPattern*',
type: 'index-pattern',
},
],
}
},
],
}));
resolveImportErrors.mockImplementation(() => ({
status: 'success',
importCount: 1,
failedImports: [],
}));
});
it('should figure out unmatchedReferences', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
component.setState({ file: mockFile, isLegacyFile: false });
await component.instance().import();
expect(importFile).toHaveBeenCalledWith(mockFile, true);
expect(component.state()).toMatchObject({
conflictedIndexPatterns: undefined,
conflictedSavedObjectsLinkedToSavedSearches: undefined,
conflictedSearchDocs: undefined,
importCount: 0,
status: 'idle',
error: undefined,
unmatchedReferences: [
{
existingIndexPatternId: 'MyIndexPattern*',
newIndexPatternId: undefined,
list: [
{
id: '1',
type: 'visualization',
title: 'My Visualization',
},
],
},
],
});
});
it('should allow conflict resolution', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
component.setState({ file: mockFile, isLegacyFile: false });
await component.instance().import();
// Ensure it looks right
component.update();
expect(component).toMatchSnapshot();
// Ensure we can change the resolution
component
.instance()
.onIndexChanged('MyIndexPattern*', { target: { value: '2' } });
expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2');
// Let's resolve now
await component
.find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]')
.simulate('click');
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
expect(resolveImportErrors).toMatchSnapshot();
});
it('should handle errors', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
resolveImportErrors.mockImplementation(() => ({
status: 'success',
importCount: 0,
failedImports: [
{
obj: {
type: 'visualization',
id: '1',
},
error: {
type: 'unknown',
},
},
],
}));
component.setState({ file: mockFile, isLegacyFile: false });
// Go through the import flow
await component.instance().import();
component.update();
// Set a resolution
component
.instance()
.onIndexChanged('MyIndexPattern*', { target: { value: '2' } });
await component
.find('EuiButton[data-test-subj="importSavedObjectsConfirmBtn"]')
.simulate('click');
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
expect(component.state('failedImports')).toEqual([
{
error: {
type: 'unknown',
},
obj: {
id: '1',
type: 'visualization',
},
},
]);
expect(component.find('EuiFlyoutBody EuiCallOut')).toMatchSnapshot();
});
});
describe('legacy conflicts', () => {
const { importLegacyFile } = require('../../../../../lib/import_legacy_file');
const {
resolveSavedObjects,
resolveSavedSearches,
@ -175,7 +357,7 @@ describe('Flyout', () => {
const mockConflictedSearchDocs = [3];
beforeEach(() => {
importFile.mockImplementation(() => mockData);
importLegacyFile.mockImplementation(() => mockData);
resolveSavedObjects.mockImplementation(() => ({
conflictedIndexPatterns: mockConflictedIndexPatterns,
conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches,
@ -184,7 +366,7 @@ describe('Flyout', () => {
}));
});
it('should figure out conflicts', async () => {
it('should figure out unmatchedReferences', async () => {
const component = shallowWithIntl(<Flyout.WrappedComponent {...defaultProps} />);
// Ensure all promises resolve
@ -192,10 +374,10 @@ describe('Flyout', () => {
// Ensure the state changes are reflected
component.update();
component.setState({ file: mockFile });
await component.instance().import();
component.setState({ file: legacyMockFile, isLegacyFile: true });
await component.instance().legacyImport();
expect(importFile).toHaveBeenCalledWith(mockFile);
expect(importLegacyFile).toHaveBeenCalledWith(legacyMockFile);
// Remove the last element from data since it should be filtered out
expect(resolveSavedObjects).toHaveBeenCalledWith(
mockData.slice(0, 2).map((doc) => ({ ...doc, _migrationVersion: {} })),
@ -209,16 +391,16 @@ describe('Flyout', () => {
conflictedSavedObjectsLinkedToSavedSearches: mockConflictedSavedObjectsLinkedToSavedSearches,
conflictedSearchDocs: mockConflictedSearchDocs,
importCount: 2,
isLoading: false,
wasImportSuccessful: false,
conflicts: [
status: 'idle',
error: undefined,
unmatchedReferences: [
{
existingIndexPatternId: 'MyIndexPattern*',
newIndexPatternId: undefined,
list: [
{
id: 'MyIndexPattern*',
name: 'MyIndexPattern*',
title: 'MyIndexPattern*',
type: 'index-pattern',
},
],
@ -235,8 +417,8 @@ describe('Flyout', () => {
// Ensure the state changes are reflected
component.update();
component.setState({ file: mockFile });
await component.instance().import();
component.setState({ file: legacyMockFile, isLegacyFile: true });
await component.instance().legacyImport();
// Ensure it looks right
component.update();
@ -246,7 +428,7 @@ describe('Flyout', () => {
component
.instance()
.onIndexChanged('MyIndexPattern*', { target: { value: '2' } });
expect(component.state('conflicts')[0].newIndexPatternId).toBe('2');
expect(component.state('unmatchedReferences')[0].newIndexPatternId).toBe('2');
// Let's resolve now
await component
@ -283,10 +465,10 @@ describe('Flyout', () => {
throw new Error('foobar');
});
component.setState({ file: mockFile });
component.setState({ file: legacyMockFile, isLegacyFile: true });
// Go through the import flow
await component.instance().import();
await component.instance().legacyImport();
component.update();
// Set a resolution
component

View file

@ -41,8 +41,17 @@ import {
EuiCallOut,
EuiSpacer,
EuiLink,
EuiConfirmModal,
EuiOverlayMask,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { importFile } from '../../../../lib/import_file';
import {
importFile,
importLegacyFile,
resolveImportErrors,
logLegacyImport,
processImportResponse,
} from '../../../../lib';
import {
resolveSavedObjects,
resolveSavedSearches,
@ -68,15 +77,16 @@ class FlyoutUI extends Component {
conflictedIndexPatterns: undefined,
conflictedSavedObjectsLinkedToSavedSearches: undefined,
conflictedSearchDocs: undefined,
conflicts: undefined,
unmatchedReferences: undefined,
conflictingRecord: undefined,
error: undefined,
file: undefined,
importCount: 0,
indexPatterns: undefined,
isOverwriteAllChecked: true,
isLoading: false,
loadingMessage: undefined,
wasImportSuccessful: false,
isLegacyFile: false,
status: 'idle',
};
}
@ -99,22 +109,33 @@ class FlyoutUI extends Component {
};
setImportFile = ([file]) => {
this.setState({ file });
if (!file) {
this.setState({ file: undefined, isLegacyFile: false });
return;
}
this.setState({
file,
isLegacyFile: /\.json$/i.test(file.name) || file.type === 'application/json',
});
};
/**
* Import
*
* Does the initial import of a file, resolveImportErrors then handles errors and retries
*/
import = async () => {
const { services, indexPatterns, intl } = this.props;
const { intl } = this.props;
const { file, isOverwriteAllChecked } = this.state;
this.setState({ status: 'loading', error: undefined });
this.setState({ isLoading: true, error: undefined });
let contents;
// Import the file
let response;
try {
contents = await importFile(file);
response = await importFile(file, isOverwriteAllChecked);
} catch (e) {
this.setState({
isLoading: false,
status: 'error',
error: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.importFileErrorMessage',
defaultMessage: 'The file could not be processed.',
@ -123,9 +144,99 @@ class FlyoutUI extends Component {
return;
}
this.setState(processImportResponse(response), () => {
// Resolve import errors right away if there's no index patterns to match
// This will ask about overwriting each object, etc
if (this.state.unmatchedReferences.length === 0) {
this.resolveImportErrors();
}
});
}
/**
* Get Conflict Resolutions
*
* Function iterates through the objects, displays a modal for each asking the user if they wish to overwrite it or not.
*
* @param {array} objects List of objects to request the user if they wish to overwrite it
* @return {Promise<array>} An object with the key being "type:id" and value the resolution chosen by the user
*/
getConflictResolutions = async (objects) => {
const resolutions = {};
for (const { type, id, title } of objects) {
const overwrite = await new Promise((resolve) => {
this.setState({
conflictingRecord: {
id,
type,
title,
done: resolve,
},
});
});
resolutions[`${type}:${id}`] = overwrite;
this.setState({ conflictingRecord: undefined });
}
return resolutions;
}
/**
* Resolve Import Errors
*
* Function goes through the failedImports and tries to resolve the issues.
*/
resolveImportErrors = async () => {
const { intl } = this.props;
this.setState({
error: undefined,
status: 'loading',
loadingMessage: undefined,
});
try {
const updatedState = await resolveImportErrors({
state: this.state,
getConflictResolutions: this.getConflictResolutions,
});
this.setState(updatedState);
} catch (e) {
this.setState({
status: 'error',
error: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.resolveImportErrorsFileErrorMessage',
defaultMessage: 'The file could not be processed.',
}),
});
}
}
legacyImport = async () => {
const { services, indexPatterns, intl } = this.props;
const { file, isOverwriteAllChecked } = this.state;
this.setState({ status: 'loading', error: undefined });
// Log warning on server, don't wait for response
logLegacyImport();
let contents;
try {
contents = await importLegacyFile(file);
} catch (e) {
this.setState({
status: 'error',
error: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.importLegacyFileErrorMessage',
defaultMessage: 'The file could not be processed.',
}),
});
return;
}
if (!Array.isArray(contents)) {
this.setState({
isLoading: false,
status: 'error',
error: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage',
defaultMessage: 'Saved objects file format is invalid and cannot be imported.',
@ -162,7 +273,7 @@ class FlyoutUI extends Component {
const byId = groupBy(conflictedIndexPatterns, ({ obj }) =>
obj.searchSource.getOwnField('index')
);
const conflicts = Object.entries(byId).reduce(
const unmatchedReferences = Object.entries(byId).reduce(
(accum, [existingIndexPatternId, list]) => {
accum.push({
existingIndexPatternId,
@ -170,7 +281,7 @@ class FlyoutUI extends Component {
list: list.map(({ doc }) => ({
id: existingIndexPatternId,
type: doc._type,
name: doc._source.title,
title: doc._source.title,
})),
});
return accum;
@ -183,19 +294,18 @@ class FlyoutUI extends Component {
conflictedSavedObjectsLinkedToSavedSearches,
conflictedSearchDocs,
failedImports,
conflicts,
unmatchedReferences,
importCount: importedObjectCount,
isLoading: false,
wasImportSuccessful: conflicts.length === 0,
status: unmatchedReferences.length === 0 ? 'success' : 'idle',
});
};
get hasConflicts() {
return this.state.conflicts && this.state.conflicts.length > 0;
get hasUnmatchedReferences() {
return this.state.unmatchedReferences && this.state.unmatchedReferences.length > 0;
}
get resolutions() {
return this.state.conflicts.reduce(
return this.state.unmatchedReferences.reduce(
(accum, { existingIndexPatternId, newIndexPatternId }) => {
if (newIndexPatternId) {
accum.push({
@ -209,7 +319,7 @@ class FlyoutUI extends Component {
);
}
confirmImport = async () => {
confirmLegacyImport = async () => {
const {
conflictedIndexPatterns,
isOverwriteAllChecked,
@ -222,20 +332,20 @@ class FlyoutUI extends Component {
this.setState({
error: undefined,
isLoading: true,
status: 'loading',
loadingMessage: undefined,
});
let importCount = this.state.importCount;
if (this.hasConflicts) {
if (this.hasUnmatchedReferences) {
try {
const resolutions = this.resolutions;
// Do not Promise.all these calls as the order matters
this.setState({
loadingMessage: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.confirmImport.resolvingConflictsLoadingMessage',
id: 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.resolvingConflictsLoadingMessage',
defaultMessage: 'Resolving conflicts…',
}),
});
@ -248,7 +358,7 @@ class FlyoutUI extends Component {
}
this.setState({
loadingMessage: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.confirmImport.savingConflictsLoadingMessage',
id: 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage',
defaultMessage: 'Saving conflicts…',
}),
});
@ -258,7 +368,7 @@ class FlyoutUI extends Component {
);
this.setState({
loadingMessage: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.confirmImport.savedSearchAreLinkedProperlyLoadingMessage',
id: 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage',
defaultMessage: 'Ensure saved searches are linked properly…',
}),
});
@ -270,7 +380,7 @@ class FlyoutUI extends Component {
);
this.setState({
loadingMessage: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.flyout.confirmImport.retryingFailedObjectsLoadingMessage',
id: 'kbn.management.objects.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage',
defaultMessage: 'Retrying failed objects…',
}),
});
@ -281,20 +391,20 @@ class FlyoutUI extends Component {
} catch (e) {
this.setState({
error: e.message,
isLoading: false,
status: 'error',
loadingMessage: undefined,
});
return;
}
}
this.setState({ isLoading: false, wasImportSuccessful: true, importCount });
this.setState({ status: 'success', importCount });
};
onIndexChanged = (id, e) => {
const value = e.target.value;
this.setState(state => {
const conflictIndex = state.conflicts.findIndex(
const conflictIndex = state.unmatchedReferences.findIndex(
conflict => conflict.existingIndexPatternId === id
);
if (conflictIndex === -1) {
@ -302,23 +412,23 @@ class FlyoutUI extends Component {
}
return {
conflicts: [
...state.conflicts.slice(0, conflictIndex),
unmatchedReferences: [
...state.unmatchedReferences.slice(0, conflictIndex),
{
...state.conflicts[conflictIndex],
...state.unmatchedReferences[conflictIndex],
newIndexPatternId: value,
},
...state.conflicts.slice(conflictIndex + 1),
...state.unmatchedReferences.slice(conflictIndex + 1),
],
};
});
};
renderConflicts() {
const { conflicts } = this.state;
renderUnmatchedReferences() {
const { unmatchedReferences } = this.state;
const { intl } = this.props;
if (!conflicts) {
if (!unmatchedReferences) {
return null;
}
@ -362,7 +472,7 @@ class FlyoutUI extends Component {
render: list => {
return (
<ul style={{ listStyle: 'none' }}>
{take(list, 3).map((obj, key) => <li key={key}>{obj.name}</li>)}
{take(list, 3).map((obj, key) => <li key={key}>{obj.title}</li>)}
</ul>
);
},
@ -402,7 +512,7 @@ class FlyoutUI extends Component {
return (
<EuiInMemoryTable
items={conflicts}
items={unmatchedReferences}
columns={columns}
pagination={pagination}
/>
@ -410,9 +520,9 @@ class FlyoutUI extends Component {
}
renderError() {
const { error } = this.state;
const { error, status } = this.state;
if (!error) {
if (status !== 'error') {
return null;
}
@ -433,16 +543,17 @@ class FlyoutUI extends Component {
}
renderBody() {
const { intl } = this.props;
const {
isLoading,
status,
loadingMessage,
isOverwriteAllChecked,
wasImportSuccessful,
importCount,
failedImports = [],
isLegacyFile,
} = this.state;
if (isLoading) {
if (status === 'loading') {
return (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
@ -456,7 +567,8 @@ class FlyoutUI extends Component {
);
}
if (failedImports.length && !this.hasConflicts) {
// Kept backwards compatible logic
if (failedImports.length && (!this.hasUnmatchedReferences || (isLegacyFile === false && status === 'success'))) {
return (
<EuiCallOut
title={(
@ -468,18 +580,36 @@ class FlyoutUI extends Component {
<p>
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.importFailedDescription"
defaultMessage="Failed to import {failedImportCount} of {totalImportCount} objects.Import failed"
defaultMessage="Failed to import {failedImportCount} of {totalImportCount} objects. Import failed"
values={{ failedImportCount: failedImports.length, totalImportCount: importCount + failedImports.length, }}
/>
</p>
<p>
{failedImports.map(({ error }) => getField(error, 'body.message', error.message || '')).join(' ')}
{failedImports.map(({ error, obj }) => {
if (error.type === 'missing_references') {
return error.references.map((reference) => {
return intl.formatMessage(
{
id: 'kbn.management.objects.objectsTable.flyout.importFailedMissingReference',
defaultMessage: '{type} [id={id}] could not locate {refType} [id={refId}]',
},
{
id: obj.id,
type: obj.type,
refId: reference.id,
refType: reference.type,
}
);
});
}
return getField(error, 'body.message', error.message || '');
}).join(' ')}
</p>
</EuiCallOut>
);
}
if (wasImportSuccessful) {
if (status === 'success') {
if (importCount === 0) {
return (
<EuiCallOut
@ -518,8 +648,8 @@ class FlyoutUI extends Component {
);
}
if (this.hasConflicts) {
return this.renderConflicts();
if (this.hasUnmatchedReferences) {
return this.renderUnmatchedReferences();
}
return (
@ -528,7 +658,7 @@ class FlyoutUI extends Component {
label={(
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.selectFileToImportFormRowLabel"
defaultMessage="Please select a JSON file to import"
defaultMessage="Please select a file to import"
/>
)}
>
@ -561,12 +691,12 @@ class FlyoutUI extends Component {
}
renderFooter() {
const { isLoading, wasImportSuccessful } = this.state;
const { status } = this.state;
const { done, close } = this.props;
let confirmButton;
if (wasImportSuccessful) {
if (status === 'success') {
confirmButton = (
<EuiButton
onClick={done}
@ -580,13 +710,13 @@ class FlyoutUI extends Component {
/>
</EuiButton>
);
} else if (this.hasConflicts) {
} else if (this.hasUnmatchedReferences) {
confirmButton = (
<EuiButton
onClick={this.confirmImport}
onClick={this.state.isLegacyFile ? this.confirmLegacyImport : this.resolveImportErrors}
size="s"
fill
isLoading={isLoading}
isLoading={status === 'loading'}
data-test-subj="importSavedObjectsConfirmBtn"
>
<FormattedMessage
@ -598,10 +728,10 @@ class FlyoutUI extends Component {
} else {
confirmButton = (
<EuiButton
onClick={this.import}
onClick={this.state.isLegacyFile ? this.legacyImport : this.import}
size="s"
fill
isLoading={isLoading}
isLoading={status === 'loading'}
data-test-subj="importSavedObjectsImportBtn"
>
<FormattedMessage
@ -629,16 +759,38 @@ class FlyoutUI extends Component {
renderSubheader() {
if (
!this.hasConflicts ||
this.state.isLoading ||
this.state.wasImportSuccessful
this.state.status === 'loading' ||
this.state.status === 'success'
) {
return null;
}
return (
<Fragment>
<EuiSpacer size="s" />
let legacyFileWarning;
if (this.state.isLegacyFile) {
legacyFileWarning = (
<EuiCallOut
title={(
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.legacyFileUsedTitle"
defaultMessage="Support for JSON files is going away"
/>
)}
color="warning"
iconType="help"
>
<p>
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.legacyFileUsedBody"
defaultMessage="Use our updated export to generate NDJSON files, and you'll be all set."
/>
</p>
</EuiCallOut>
);
}
let indexPatternConflictsWarning;
if (this.hasUnmatchedReferences) {
indexPatternConflictsWarning = (
<EuiCallOut
title={(
<FormattedMessage
@ -667,13 +819,80 @@ class FlyoutUI extends Component {
}}
/>
</p>
</EuiCallOut>
</EuiCallOut>);
}
if (!legacyFileWarning && !indexPatternConflictsWarning) {
return null;
}
return (
<Fragment>
{legacyFileWarning &&
<span>
<EuiSpacer size="s" />
{legacyFileWarning}
</span>
}
{indexPatternConflictsWarning &&
<span>
<EuiSpacer size="s" />
{indexPatternConflictsWarning}
</span>
}
</Fragment>
);
}
overwriteConfirmed() {
this.state.conflictingRecord.done(true);
}
overwriteSkipped() {
this.state.conflictingRecord.done(false);
}
render() {
const { close } = this.props;
const { close, intl } = this.props;
let confirmOverwriteModal;
if (this.state.conflictingRecord) {
confirmOverwriteModal = (
<EuiOverlayMask>
<EuiConfirmModal
title={intl.formatMessage(
{
id: 'kbn.management.objects.objectsTable.flyout.confirmOverwriteTitle',
defaultMessage: 'Overwrite {type}?'
},
{ type: this.state.conflictingRecord.type }
)}
cancelButtonText={intl.formatMessage(
{
id: 'kbn.management.objects.objectsTable.flyout.confirmOverwriteCancelButtonText',
defaultMessage: 'Cancel',
},
)}
confirmButtonText={intl.formatMessage(
{
id: 'kbn.management.objects.objectsTable.flyout.confirmOverwriteOverwriteButtonText',
defaultMessage: 'Overwrite',
},
)}
onCancel={this.overwriteSkipped.bind(this)}
onConfirm={this.overwriteConfirmed.bind(this)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
>
<p>
<FormattedMessage
id="kbn.management.objects.objectsTable.flyout.confirmOverwriteBody"
defaultMessage="Are you sure you want to overwrite {title}?"
values={{ title: this.state.conflictingRecord.title }}
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>);
}
return (
<EuiFlyout onClose={close} size="s">
@ -695,6 +914,7 @@ class FlyoutUI extends Component {
</EuiFlyoutBody>
<EuiFlyoutFooter>{this.renderFooter()}</EuiFlyoutFooter>
{confirmOverwriteModal}
</EuiFlyout>
);
}

View file

@ -20,6 +20,8 @@
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
jest.mock('ui/errors', () => ({
SavedObjectNotFound: class SavedObjectNotFound extends Error {
constructor(options) {
@ -37,6 +39,14 @@ jest.mock('ui/chrome', () => ({
addBasePath: () => ''
}));
jest.mock('../../../../../lib/fetch_export_by_type', () => ({
fetchExportByType: jest.fn(),
}));
jest.mock('../../../../../lib/fetch_export_objects', () => ({
fetchExportObjects: jest.fn(),
}));
import { Relationships } from '../relationships';
describe('Relationships', () => {

View file

@ -42,22 +42,81 @@ exports[`Table should render normally 1`] = `
values={Object {}}
/>
</EuiButton>,
<EuiButton
color="primary"
fill={false}
iconSide="left"
iconType="exportAction"
isDisabled={false}
onClick={[Function]}
size="m"
type="button"
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButton
color="primary"
fill={false}
iconSide="right"
iconType="arrowDown"
isDisabled={false}
onClick={[Function]}
size="m"
type="button"
>
<FormattedMessage
defaultMessage="Export"
id="kbn.management.objects.objectsTable.table.exportPopoverButtonLabel"
values={Object {}}
/>
</EuiButton>
}
closePopover={[Function]}
hasArrow={true}
isOpen={false}
ownFocus={false}
panelPaddingSize="m"
>
<FormattedMessage
defaultMessage="Export"
id="kbn.management.objects.objectsTable.table.exportButtonLabel"
values={Object {}}
/>
</EuiButton>,
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Options"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportOptionsLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiSwitch
checked={true}
label={
<FormattedMessage
defaultMessage="Include related objects"
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel"
values={Object {}}
/>
}
name="includeReferencesDeep"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiButton
color="primary"
fill={true}
iconSide="left"
iconType="exportAction"
onClick={[Function]}
size="m"
type="button"
>
<FormattedMessage
defaultMessage="Export"
id="kbn.management.objects.objectsTable.table.exportButtonLabel"
values={Object {}}
/>
</EuiButton>
</EuiFormRow>
</EuiPopover>,
]
}
/>

View file

@ -22,6 +22,8 @@ import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { keyCodes } from '@elastic/eui/lib/services';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
jest.mock('ui/errors', () => ({
SavedObjectNotFound: class SavedObjectNotFound extends Error {
constructor(options) {

View file

@ -28,7 +28,10 @@ import {
EuiLink,
EuiSpacer,
EuiToolTip,
EuiFormErrorText
EuiFormErrorText,
EuiPopover,
EuiSwitch,
EuiFormRow
} from '@elastic/eui';
import { getSavedObjectLabel, getSavedObjectIcon } from '../../../../lib';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
@ -65,6 +68,8 @@ class TableUI extends PureComponent {
state = {
isSearchTextValid: true,
parseErrorMessage: null,
isExportPopoverOpen: false,
isIncludeReferencesDeepChecked: true,
}
onChange = ({ query, error }) => {
@ -83,6 +88,29 @@ class TableUI extends PureComponent {
this.props.onQueryChange({ query });
}
closeExportPopover = () => {
this.setState({ isExportPopoverOpen: false });
}
toggleExportPopoverVisibility = () => {
this.setState(state => ({
isExportPopoverOpen: !state.isExportPopoverOpen
}));
}
toggleIsIncludeReferencesDeepChecked = () => {
this.setState(state => ({
isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked,
}));
}
onExportClick = () => {
const { onExport } = this.props;
const { isIncludeReferencesDeepChecked } = this.state;
onExport(isIncludeReferencesDeepChecked);
this.setState({ isExportPopoverOpen: false });
}
render() {
const {
pageIndex,
@ -94,7 +122,6 @@ class TableUI extends PureComponent {
filterOptions,
selectionConfig: selection,
onDelete,
onExport,
selectedSavedObjects,
onTableChange,
goInApp,
@ -216,6 +243,20 @@ class TableUI extends PureComponent {
);
}
const button = (
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={this.toggleExportPopoverVisibility}
isDisabled={selectedSavedObjects.length === 0}
>
<FormattedMessage
id="kbn.management.objects.objectsTable.table.exportPopoverButtonLabel"
defaultMessage="Export"
/>
</EuiButton>
);
return (
<Fragment>
<EuiSearchBar
@ -235,17 +276,46 @@ class TableUI extends PureComponent {
defaultMessage="Delete"
/>
</EuiButton>,
<EuiButton
key="exportSO"
iconType="exportAction"
onClick={onExport}
isDisabled={selectedSavedObjects.length === 0}
<EuiPopover
key="exportSOOptions"
button={button}
isOpen={this.state.isExportPopoverOpen}
closePopover={this.closeExportPopover}
>
<FormattedMessage
id="kbn.management.objects.objectsTable.table.exportButtonLabel"
defaultMessage="Export"
/>
</EuiButton>,
<EuiFormRow
label={(
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportOptionsLabel"
defaultMessage="Options"
/>
)}
>
<EuiSwitch
name="includeReferencesDeep"
label={(
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel"
defaultMessage="Include related objects"
/>
)}
checked={this.state.isIncludeReferencesDeepChecked}
onChange={this.toggleIsIncludeReferencesDeepChecked}
/>
</EuiFormRow>
<EuiFormRow>
<EuiButton
key="exportSO"
iconType="exportAction"
onClick={this.onExportClick}
fill
>
<FormattedMessage
id="kbn.management.objects.objectsTable.table.exportButtonLabel"
defaultMessage="Export"
/>
</EuiButton>
</EuiFormRow>
</EuiPopover>,
]}
/>
{queryParseError}

View file

@ -17,9 +17,10 @@
* under the License.
*/
import { saveAs } from '@elastic/filesaver';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { debounce, flattenDeep } from 'lodash';
import { debounce } from 'lodash';
import { Header } from './components/header';
import { Flyout } from './components/flyout';
import { Relationships } from './components/relationships';
@ -38,16 +39,26 @@ import {
EuiCheckboxGroup,
EuiToolTip,
EuiPageContent,
EuiSwitch,
EuiModal,
EuiModalHeader,
EuiModalBody,
EuiModalFooter,
EuiButtonEmpty,
EuiButton,
EuiModalHeaderTitle,
EuiText,
EuiFlexGroup,
EuiFlexItem
} from '@elastic/eui';
import {
retrieveAndExportDocs,
scanAllTypes,
saveToFile,
parseQuery,
getSavedObjectIcon,
getSavedObjectCounts,
getRelationships,
getSavedObjectLabel,
fetchExportObjects,
fetchExportByType,
} from '../../lib';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
@ -97,6 +108,7 @@ class ObjectsTableUI extends Component {
isDeleting: false,
exportAllOptions: [],
exportAllSelectedOptions: {},
isIncludeReferencesDeepChecked: true,
};
}
@ -278,17 +290,23 @@ class ObjectsTableUI extends Component {
});
};
onExport = async () => {
const { savedObjectsClient } = this.props;
onExport = async (includeReferencesDeep) => {
const { intl } = this.props;
const { selectedSavedObjects } = this.state;
const objects = await savedObjectsClient.bulkGet(selectedSavedObjects);
await retrieveAndExportDocs(objects.savedObjects, savedObjectsClient);
const objectsToExport = selectedSavedObjects.map(obj => ({ id: obj.id, type: obj.type }));
const blob = await fetchExportObjects(objectsToExport, includeReferencesDeep);
saveAs(blob, 'export.ndjson');
toastNotifications.addSuccess({
title: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.export.successNotification',
defaultMessage: 'Your file is downloading in the background',
}),
});
};
onExportAll = async () => {
const { $http } = this.props;
const { exportAllSelectedOptions } = this.state;
const { intl } = this.props;
const { exportAllSelectedOptions, isIncludeReferencesDeepChecked } = this.state;
const exportTypes = Object.entries(exportAllSelectedOptions).reduce(
(accum, [id, selected]) => {
if (selected) {
@ -298,8 +316,15 @@ class ObjectsTableUI extends Component {
},
[]
);
const results = await scanAllTypes($http, exportTypes);
saveToFile(JSON.stringify(flattenDeep(results), null, 2));
const blob = await fetchExportByType(exportTypes, isIncludeReferencesDeepChecked);
saveAs(blob, 'export.ndjson');
toastNotifications.addSuccess({
title: intl.formatMessage({
id: 'kbn.management.objects.objectsTable.exportAll.successNotification',
defaultMessage: 'Your file is downloading in the background',
}),
});
this.setState({ isShowingExportAllOptionsModal: false });
};
finishImport = () => {
@ -512,12 +537,23 @@ class ObjectsTableUI extends Component {
);
}
changeIncludeReferencesDeep = () => {
this.setState(state => ({
isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked,
}));
}
closeExportAllModal = () => {
this.setState({ isShowingExportAllOptionsModal: false });
}
renderExportAllOptionsModal() {
const {
isShowingExportAllOptionsModal,
filteredItemCount,
exportAllOptions,
exportAllSelectedOptions,
isIncludeReferencesDeepChecked,
} = this.state;
if (!isShowingExportAllOptionsModal) {
@ -526,53 +562,84 @@ class ObjectsTableUI extends Component {
return (
<EuiOverlayMask>
<EuiConfirmModal
title={(<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle"
defaultMessage="Export {filteredItemCount, plural, one{# object} other {# objects}}"
values={{
filteredItemCount
}}
/>)}
onCancel={() =>
this.setState({ isShowingExportAllOptionsModal: false })
}
onConfirm={this.onExportAll}
cancelButtonText={(
<FormattedMessage id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel" defaultMessage="Cancel"/>
)}
confirmButtonText={(
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel"
defaultMessage="Export All"
/>
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
<EuiModal
onClose={this.closeExportAllModal}
>
<p>
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription"
defaultMessage="Select which types to export. The number in parentheses indicates
how many of this type are available to export."
/>
</p>
<EuiCheckboxGroup
options={exportAllOptions}
idToSelectedMap={exportAllSelectedOptions}
onChange={optionId => {
const newExportAllSelectedOptions = {
...exportAllSelectedOptions,
...{
[optionId]: !exportAllSelectedOptions[optionId],
},
};
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalTitle"
defaultMessage="Export {filteredItemCount, plural, one{# object} other {# objects}}"
values={{
filteredItemCount
}}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
<p>
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModalDescription"
defaultMessage="Select which types to export."
/>
</p>
<EuiCheckboxGroup
options={exportAllOptions}
idToSelectedMap={exportAllSelectedOptions}
onChange={optionId => {
const newExportAllSelectedOptions = {
...exportAllSelectedOptions,
...{
[optionId]: !exportAllSelectedOptions[optionId],
},
};
this.setState({
exportAllSelectedOptions: newExportAllSelectedOptions,
});
}}
/>
</EuiConfirmModal>
this.setState({
exportAllSelectedOptions: newExportAllSelectedOptions,
});
}}
/>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiSwitch
name="includeReferencesDeep"
label={(
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.includeReferencesDeepLabel"
defaultMessage="Include related objects"
/>
)}
checked={isIncludeReferencesDeepChecked}
onChange={this.changeIncludeReferencesDeep}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={this.closeExportAllModal}>
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill onClick={this.onExportAll}>
<FormattedMessage
id="kbn.management.objects.objectsTable.exportObjectsConfirmModal.exportAllButtonLabel"
defaultMessage="Export all"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
}

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { importFile } from '../import_file';
import { importLegacyFile } from '../import_legacy_file';
describe('importFile', () => {
it('should import a file', async () => {
@ -33,7 +33,7 @@ describe('importFile', () => {
const file = 'foo';
const imported = await importFile(file, FileReader);
const imported = await importLegacyFile(file, FileReader);
expect(imported).toEqual({ text: file });
});
@ -51,7 +51,7 @@ describe('importFile', () => {
const file = 'foo';
try {
await importFile(file, FileReader);
await importLegacyFile(file, FileReader);
} catch (e) {
// There isn't a great way to handle throwing exceptions
// with async/await but this seems to work :shrug:

View file

@ -0,0 +1,146 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { processImportResponse } from '../process_import_response';
describe('processImportResponse()', () => {
test('works when no errors exist in the response', () => {
const response = {
success: true,
successCount: 0,
};
const result = processImportResponse(response);
expect(result.status).toBe('success');
expect(result.importCount).toBe(0);
});
test('conflict errors get added to failedImports', () => {
const response = {
success: false,
successCount: 0,
errors: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'conflict',
},
},
],
};
const result = processImportResponse(response);
expect(result.failedImports).toMatchInlineSnapshot(`
Array [
Object {
"error": Object {
"type": "conflict",
},
"obj": Object {
"obj": Object {
"id": "1",
"type": "a",
},
},
},
]
`);
});
test('unknown errors get added to failedImports', () => {
const response = {
success: false,
successCount: 0,
errors: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'unknown',
},
},
],
};
const result = processImportResponse(response);
expect(result.failedImports).toMatchInlineSnapshot(`
Array [
Object {
"error": Object {
"type": "unknown",
},
"obj": Object {
"obj": Object {
"id": "1",
"type": "a",
},
},
},
]
`);
});
test('missing references get added to failedImports', () => {
const response = {
success: false,
successCount: 0,
errors: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'missing_references',
references: [
{
type: 'index-pattern',
id: '2',
},
],
},
},
],
};
const result = processImportResponse(response);
expect(result.failedImports).toMatchInlineSnapshot(`
Array [
Object {
"error": Object {
"references": Array [
Object {
"id": "2",
"type": "index-pattern",
},
],
"type": "missing_references",
},
"obj": Object {
"obj": Object {
"id": "1",
"type": "a",
},
},
},
]
`);
});
});

View file

@ -0,0 +1,377 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { resolveImportErrors } from '../resolve_import_errors';
jest.mock('ui/kfetch', () => ({ kfetch: jest.fn() }));
function getFormData(form) {
const formData = {};
for (const [key, val] of form.entries()) {
if (key === 'retries') {
formData[key] = JSON.parse(val);
continue;
}
formData[key] = val;
}
return formData;
}
describe('resolveImportErrors', () => {
const getConflictResolutions = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
test('works with empty import failures', async () => {
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [],
"importCount": 0,
"status": "success",
}
`);
});
test(`doesn't retry if only unknown failures are passed in`, async () => {
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
failedImports: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'unknown',
},
},
],
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [
Object {
"error": Object {
"type": "unknown",
},
"obj": Object {
"id": "1",
"type": "a",
},
},
],
"importCount": 0,
"status": "success",
}
`);
});
test('resolves conflicts', async () => {
const { kfetch } = require('ui/kfetch');
kfetch.mockResolvedValueOnce({
success: true,
successCount: 1,
});
getConflictResolutions.mockReturnValueOnce({
'a:1': true,
'a:2': false,
});
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
failedImports: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'conflict',
},
},
{
obj: {
type: 'a',
id: '2',
},
error: {
type: 'conflict',
},
},
],
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [],
"importCount": 1,
"status": "success",
}
`);
const formData = getFormData(kfetch.mock.calls[0][0].body);
expect(formData).toMatchInlineSnapshot(`
Object {
"file": "undefined",
"retries": Array [
Object {
"id": "1",
"overwrite": true,
"replaceReferences": Array [],
"type": "a",
},
],
}
`);
});
test('resolves missing references', async () => {
const { kfetch } = require('ui/kfetch');
kfetch.mockResolvedValueOnce({
success: true,
successCount: 2,
});
getConflictResolutions.mockResolvedValueOnce({});
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
unmatchedReferences: [
{
existingIndexPatternId: '2',
newIndexPatternId: '3',
},
],
failedImports: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'missing_references',
references: [
{
type: 'index-pattern',
id: '2',
},
],
blocking: [
{
type: 'a',
id: '2',
},
],
},
},
],
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [],
"importCount": 2,
"status": "success",
}
`);
const formData = getFormData(kfetch.mock.calls[0][0].body);
expect(formData).toMatchInlineSnapshot(`
Object {
"file": "undefined",
"retries": Array [
Object {
"id": "1",
"overwrite": false,
"replaceReferences": Array [
Object {
"from": "2",
"to": "3",
"type": "index-pattern",
},
],
"type": "a",
},
Object {
"id": "2",
"type": "a",
},
],
}
`);
});
test(`doesn't resolve missing references if newIndexPatternId isn't defined`, async () => {
getConflictResolutions.mockResolvedValueOnce({});
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
unmatchedReferences: [
{
existingIndexPatternId: '2',
newIndexPatternId: undefined,
},
],
failedImports: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'missing_references',
references: [
{
type: 'index-pattern',
id: '2',
},
],
blocking: [
{
type: 'a',
id: '2',
},
],
},
},
],
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [],
"importCount": 0,
"status": "success",
}
`);
});
test('handles missing references then conflicts on the same errored objects', async () => {
const { kfetch } = require('ui/kfetch');
kfetch.mockResolvedValueOnce({
success: false,
successCount: 0,
errors: [
{
type: 'a',
id: '1',
error: {
type: 'conflict',
},
},
],
});
kfetch.mockResolvedValueOnce({
success: true,
successCount: 1,
});
getConflictResolutions.mockResolvedValueOnce({});
getConflictResolutions.mockResolvedValueOnce({
'a:1': true,
});
const result = await resolveImportErrors({
getConflictResolutions,
state: {
importCount: 0,
unmatchedReferences: [
{
existingIndexPatternId: '2',
newIndexPatternId: '3',
},
],
failedImports: [
{
obj: {
type: 'a',
id: '1',
},
error: {
type: 'missing_references',
references: [
{
type: 'index-pattern',
id: '2',
},
],
blocking: [],
},
},
],
},
});
expect(result).toMatchInlineSnapshot(`
Object {
"failedImports": Array [],
"importCount": 1,
"status": "success",
}
`);
const formData1 = getFormData(kfetch.mock.calls[0][0].body);
expect(formData1).toMatchInlineSnapshot(`
Object {
"file": "undefined",
"retries": Array [
Object {
"id": "1",
"overwrite": false,
"replaceReferences": Array [
Object {
"from": "2",
"to": "3",
"type": "index-pattern",
},
],
"type": "a",
},
],
}
`);
const formData2 = getFormData(kfetch.mock.calls[1][0].body);
expect(formData2).toMatchInlineSnapshot(`
Object {
"file": "undefined",
"retries": Array [
Object {
"id": "1",
"overwrite": true,
"replaceReferences": Array [
Object {
"from": "2",
"to": "3",
"type": "index-pattern",
},
],
"type": "a",
},
],
}
`);
});
});

View file

@ -1,108 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { retrieveAndExportDocs } from '../retrieve_and_export_docs';
jest.mock('../save_to_file', () => ({
saveToFile: jest.fn(),
}));
jest.mock('ui/errors', () => ({
SavedObjectNotFound: class SavedObjectNotFound extends Error {
constructor(options) {
super();
for (const option in options) {
if (options.hasOwnProperty(option)) {
this[option] = options[option];
}
}
}
},
}));
jest.mock('ui/chrome', () => ({
addBasePath: () => {},
}));
describe('retrieveAndExportDocs', () => {
let saveToFile;
beforeEach(() => {
saveToFile = require('../save_to_file').saveToFile;
saveToFile.mockClear();
});
it('should fetch all', async () => {
const savedObjectsClient = {
bulkGet: jest.fn().mockImplementation(() => ({
savedObjects: [],
})),
};
const objs = [1, 2, 3];
await retrieveAndExportDocs(objs, savedObjectsClient);
expect(savedObjectsClient.bulkGet.mock.calls.length).toBe(1);
expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith(objs);
});
it('should use the saveToFile utility', async () => {
const savedObjectsClient = {
bulkGet: jest.fn().mockImplementation(() => ({
savedObjects: [
{
id: 1,
type: 'index-pattern',
attributes: {
title: 'foobar',
},
},
{
id: 2,
type: 'search',
attributes: {
title: 'just the foo',
},
},
],
})),
};
const objs = [1, 2, 3];
await retrieveAndExportDocs(objs, savedObjectsClient);
expect(saveToFile.mock.calls.length).toBe(1);
expect(saveToFile).toHaveBeenCalledWith(
JSON.stringify(
[
{
_id: 1,
_type: 'index-pattern',
_source: { title: 'foobar' },
},
{
_id: 2,
_type: 'search',
_source: { title: 'just the foo' },
},
],
null,
2
)
);
});
});

View file

@ -17,22 +17,15 @@
* under the License.
*/
import { saveToFile } from '../save_to_file';
import { kfetch } from 'ui/kfetch';
jest.mock('@elastic/filesaver', () => ({
saveAs: jest.fn(),
}));
describe('saveToFile', () => {
let saveAs;
beforeEach(() => {
saveAs = require('@elastic/filesaver').saveAs;
saveAs.mockClear();
export async function fetchExportByType(types, includeReferencesDeep = false) {
return await kfetch({
method: 'POST',
pathname: '/api/saved_objects/_export',
body: JSON.stringify({
type: types,
includeReferencesDeep,
}),
});
it('should use the file saver utility', async () => {
saveToFile(JSON.stringify({ foo: 1 }));
expect(saveAs.mock.calls.length).toBe(1);
});
});
}

View file

@ -0,0 +1,31 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { kfetch } from 'ui/kfetch';
export async function fetchExportObjects(objects, includeReferencesDeep = false) {
return await kfetch({
method: 'POST',
pathname: '/api/saved_objects/_export',
body: JSON.stringify({
objects,
includeReferencesDeep,
}),
});
}

View file

@ -17,16 +17,21 @@
* under the License.
*/
export async function importFile(file, FileReader = window.FileReader) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = ({ target: { result } }) => {
try {
resolve(JSON.parse(result));
} catch (e) {
reject(e);
}
};
fr.readAsText(file);
import { kfetch } from 'ui/kfetch';
export async function importFile(file, overwriteAll = false) {
const formData = new FormData();
formData.append('file', file);
return await kfetch({
method: 'POST',
pathname: '/api/saved_objects/_import',
body: formData,
headers: {
// Important to be undefined, it forces proper headers to be set for FormData
'Content-Type': undefined,
},
query: {
overwrite: overwriteAll
},
});
}

View file

@ -17,19 +17,16 @@
* under the License.
*/
import { saveToFile } from './';
export async function retrieveAndExportDocs(objs, savedObjectsClient) {
const response = await savedObjectsClient.bulkGet(objs);
const objects = response.savedObjects.map(obj => {
return {
_id: obj.id,
_type: obj.type,
_source: obj.attributes,
_migrationVersion: obj.migrationVersion,
_references: obj.references,
export async function importLegacyFile(file, FileReader = window.FileReader) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = ({ target: { result } }) => {
try {
resolve(JSON.parse(result));
} catch (e) {
reject(e);
}
};
fr.readAsText(file);
});
saveToFile(JSON.stringify(objects, null, 2));
}

View file

@ -17,14 +17,17 @@
* under the License.
*/
export * from './fetch_export_by_type';
export * from './fetch_export_objects';
export * from './get_in_app_url';
export * from './get_relationships';
export * from './get_saved_object_counts';
export * from './get_saved_object_icon';
export * from './get_saved_object_label';
export * from './import_file';
export * from './import_legacy_file';
export * from './parse_query';
export * from './resolve_import_errors';
export * from './resolve_saved_objects';
export * from './retrieve_and_export_docs';
export * from './save_to_file';
export * from './scan_all_types';
export * from './log_legacy_import';
export * from './process_import_response';

View file

@ -17,9 +17,11 @@
* under the License.
*/
import { saveAs } from '@elastic/filesaver';
import { kfetch } from 'ui/kfetch';
export function saveToFile(resultsJson) {
const blob = new Blob([resultsJson], { type: 'application/json' });
saveAs(blob, 'export.json');
export async function logLegacyImport() {
return await kfetch({
method: 'POST',
pathname: '/api/saved_objects/_log_legacy_import',
});
}

View file

@ -0,0 +1,54 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export function processImportResponse(response) {
// Go through the failures and split between unmatchedReferences and failedImports
const failedImports = [];
const unmatchedReferences = new Map();
for (const { error, ...obj } of response.errors || []) {
failedImports.push({ obj, error });
if (error.type !== 'missing_references') {
continue;
}
// Currently only supports resolving references on index patterns
const indexPatternRefs = error.references.filter(ref => ref.type === 'index-pattern');
for (const missingReference of indexPatternRefs) {
const conflict = unmatchedReferences.get(`${missingReference.type}:${missingReference.id}`) || {
existingIndexPatternId: missingReference.id,
list: [],
newIndexPatternId: undefined,
};
conflict.list.push(obj);
unmatchedReferences.set(`${missingReference.type}:${missingReference.id}`, conflict);
}
}
return {
failedImports,
unmatchedReferences: Array.from(unmatchedReferences.values()),
// Import won't be successful in the scenario unmatched references exist, import API returned errors of type unknown or import API
// returned errors of type missing_references.
status: unmatchedReferences.size === 0 && !failedImports.some(issue => issue.error.type === 'conflict')
? 'success'
: 'idle',
importCount: response.successCount,
conflictedSavedObjectsLinkedToSavedSearches: undefined,
conflictedSearchDocs: undefined,
};
}

View file

@ -0,0 +1,151 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { kfetch } from 'ui/kfetch';
async function callResolveImportErrorsApi(file, retries) {
const formData = new FormData();
formData.append('file', file);
formData.append('retries', JSON.stringify(retries));
return await kfetch({
method: 'POST',
pathname: '/api/saved_objects/_resolve_import_errors',
headers: {
// Important to be undefined, it forces proper headers to be set for FormData
'Content-Type': undefined,
},
body: formData,
});
}
function mapImportFailureToRetryObject({ failure, overwriteDecisionCache, replaceReferencesCache, state }) {
const { isOverwriteAllChecked, unmatchedReferences } = state;
const isOverwriteGranted = isOverwriteAllChecked || overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true;
// Conflicts wihtout overwrite granted are skipped
if (!isOverwriteGranted && failure.error.type === 'conflict') {
return;
}
// Replace references if user chose a new reference
if (failure.error.type === 'missing_references') {
const objReplaceReferences = replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || [];
const indexPatternRefs = failure.error.references.filter(obj => obj.type === 'index-pattern');
for (const reference of indexPatternRefs) {
for (const unmatchedReference of unmatchedReferences) {
const hasNewValue = !!unmatchedReference.newIndexPatternId;
const matchesIndexPatternId = unmatchedReference.existingIndexPatternId === reference.id;
if (!hasNewValue || !matchesIndexPatternId) {
continue;
}
objReplaceReferences.push({
type: 'index-pattern',
from: unmatchedReference.existingIndexPatternId,
to: unmatchedReference.newIndexPatternId,
});
}
}
replaceReferencesCache.set(`${failure.obj.type}:${failure.obj.id}`, objReplaceReferences);
// Skip if nothing to replace, the UI option selected would be --Skip Import--
if (objReplaceReferences.length === 0) {
return;
}
}
return {
id: failure.obj.id,
type: failure.obj.type,
overwrite: isOverwriteAllChecked || overwriteDecisionCache.get(`${failure.obj.type}:${failure.obj.id}`) === true,
replaceReferences: replaceReferencesCache.get(`${failure.obj.type}:${failure.obj.id}`) || [],
};
}
export async function resolveImportErrors({ getConflictResolutions, state }) {
const overwriteDecisionCache = new Map();
const replaceReferencesCache = new Map();
let { importCount: successImportCount, failedImports: importFailures = [] } = state;
const { file, isOverwriteAllChecked } = state;
const doesntHaveOverwriteDecision = ({ obj }) => {
return !overwriteDecisionCache.has(`${obj.type}:${obj.id}`);
};
const getOverwriteDecision = ({ obj }) => {
return overwriteDecisionCache.get(`${obj.type}:${obj.id}`);
};
const callMapImportFailure = (failure) => {
return mapImportFailureToRetryObject({ failure, overwriteDecisionCache, replaceReferencesCache, state });
};
const isNotSkipped = (failure) => {
return (failure.error.type !== 'conflict' && failure.error.type !== 'missing_references') ||
getOverwriteDecision(failure);
};
// Loop until all issues are resolved
while (importFailures.some(failure => ['conflict', 'missing_references'].includes(failure.error.type))) {
// Ask for overwrites
if (!isOverwriteAllChecked) {
const result = await getConflictResolutions(
importFailures
.filter(({ error }) => error.type === 'conflict')
.filter(doesntHaveOverwriteDecision)
.map(({ obj }) => obj)
);
for (const key of Object.keys(result)) {
overwriteDecisionCache.set(key, result[key]);
}
}
// Build retries array
const retries = importFailures
.map(callMapImportFailure)
.filter(obj => !!obj);
for (const { error, obj } of importFailures) {
if (error.type !== 'missing_references') {
continue;
}
if (!retries.some(retryObj => retryObj.type === obj.type && retryObj.id === obj.id)) {
continue;
}
for (const { type, id } of error.blocking || []) {
retries.push({ type, id });
}
}
// Scenario where everything is skipped and nothing to retry
if (retries.length === 0) {
// Cancelled overwrites aren't failures anymore
importFailures = importFailures.filter(isNotSkipped);
break;
}
// Call API
const response = await callResolveImportErrorsApi(file, retries);
successImportCount += response.successCount;
importFailures = [];
for (const { error, ...obj } of response.errors || []) {
importFailures.push({ error, obj });
}
}
return {
status: 'success',
importCount: successImportCount,
failedImports: importFailures,
};
}

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const FIELD_TYPES = {
BOOLEAN: 'boolean',
DATE: 'date',
GEO: 'geo_point',
NUMBER: 'number',
STRING: 'string',
};

View file

@ -0,0 +1,221 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js <SplitByTermsUI /> should render and match a snapshot 1`] = `
<div>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Group by"
id="tsvb.splits.terms.groupByLabel"
values={Object {}}
/>
}
labelType="label"
>
<InjectIntl(GroupBySelectUi)
onChange={[Function]}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="By"
description="This labels a field selector allowing the user to chose 'by' which field to group."
id="tsvb.splits.terms.byLabel"
values={Object {}}
/>
}
labelType="label"
>
<InjectIntl(FieldSelectUi)
fields={
Object {
"kibana_sample_data_flights": Array [
Object {
"aggregatable": true,
"name": "OriginCityName",
"readFromDocValues": true,
"searchable": true,
"type": "string",
},
],
}
}
indexPattern="kibana_sample_data_flights"
onChange={[Function]}
value="OriginCityName"
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Include"
id="tsvb.splits.terms.includeLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
compressed={false}
fullWidth={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Exclude"
id="tsvb.splits.terms.excludeLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
compressed={false}
fullWidth={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Top"
id="tsvb.splits.terms.topLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldNumber
compressed={false}
fullWidth={false}
isLoading={false}
onChange={[Function]}
value={10}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Order by"
id="tsvb.splits.terms.orderByLabel"
values={Object {}}
/>
}
labelType="label"
>
<InjectIntl(MetricSelectUi)
additionalOptions={
Array [
Object {
"label": undefined,
"value": "_count",
},
Object {
"label": undefined,
"value": "_key",
},
]
}
clearable={false}
onChange={[Function]}
restrict="basic"
value="_count"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="42"
label={
<FormattedMessage
defaultMessage="Direction"
id="tsvb.splits.terms.directionLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiComboBox
compressed={false}
fullWidth={false}
isClearable={false}
onChange={[Function]}
options={
Array [
Object {
"label": undefined,
"value": "desc",
},
Object {
"label": undefined,
"value": "asc",
},
]
}
selectedOptions={
Array [
Object {
"label": undefined,
"value": "desc",
},
]
}
singleSelection={
Object {
"asPlainText": true,
}
}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;

View file

@ -19,21 +19,23 @@
import PropTypes from 'prop-types';
import React from 'react';
import { get, find } from 'lodash';
import GroupBySelect from './group_by_select';
import createTextHandler from '../lib/create_text_handler';
import createSelectHandler from '../lib/create_select_handler';
import FieldSelect from '../aggs/field_select';
import MetricSelect from '../aggs/metric_select';
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldNumber, EuiComboBox, EuiSpacer } from '@elastic/eui';
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldNumber, EuiComboBox, EuiFieldText } from '@elastic/eui';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { FIELD_TYPES } from '../../../common/field_types';
const SplitByTermsUi = props => {
const DEFAULTS = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' };
export const SplitByTermsUI = ({ onChange, indexPattern, intl, model: seriesModel, fields }) => {
const htmlId = htmlIdGenerator();
const handleTextChange = createTextHandler(props.onChange);
const handleSelectChange = createSelectHandler(props.onChange);
const { indexPattern, intl } = props;
const defaults = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' };
const model = { ...defaults, ...props.model };
const handleTextChange = createTextHandler(onChange);
const handleSelectChange = createSelectHandler(onChange);
const model = { ...DEFAULTS, ...seriesModel };
const { metrics } = model;
const defaultCount = {
value: '_count',
@ -57,10 +59,12 @@ const SplitByTermsUi = props => {
const selectedDirectionOption = dirOptions.find(option => {
return model.terms_direction === option.value;
});
const selectedField = find(fields[indexPattern], ({ name }) => name === model.terms_field);
const selectedFieldType = get(selectedField, 'type');
return (
<div>
<EuiFlexGroup alignItems="center">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id={htmlId('group')}
@ -88,15 +92,40 @@ const SplitByTermsUi = props => {
indexPattern={indexPattern}
onChange={handleSelectChange('terms_field')}
value={model.terms_field}
fields={props.fields}
fields={fields}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{selectedFieldType === FIELD_TYPES.STRING && (
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id={htmlId('include')}
label={(<FormattedMessage
id="tsvb.splits.terms.includeLabel"
defaultMessage="Include"
/>)}
>
<EuiFieldText value={model.terms_include} onChange={handleTextChange('terms_include')} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
id={htmlId('exclude')}
label={(<FormattedMessage
id="tsvb.splits.terms.excludeLabel"
defaultMessage="Exclude"
/>)}
>
<EuiFieldText value={model.terms_exclude} onChange={handleTextChange('terms_exclude')} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
)}
<EuiFlexGroup alignItems="center">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id={htmlId('top')}
@ -152,11 +181,12 @@ const SplitByTermsUi = props => {
);
};
SplitByTermsUi.propTypes = {
SplitByTermsUI.propTypes = {
intl: PropTypes.object,
model: PropTypes.object,
onChange: PropTypes.func,
indexPattern: PropTypes.string,
fields: PropTypes.object
};
export const SplitByTerms = injectI18n(SplitByTermsUi);
export const SplitByTerms = injectI18n(SplitByTermsUI);

View file

@ -0,0 +1,68 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { SplitByTermsUI } from './terms';
jest.mock('@elastic/eui', () => ({
htmlIdGenerator: jest.fn(() => () => '42'),
EuiFlexGroup: require.requireActual('@elastic/eui').EuiFlexGroup,
EuiFlexItem: require.requireActual('@elastic/eui').EuiFlexItem,
EuiFormRow: require.requireActual('@elastic/eui').EuiFormRow,
EuiFieldNumber: require.requireActual('@elastic/eui').EuiFieldNumber,
EuiComboBox: require.requireActual('@elastic/eui').EuiComboBox,
EuiFieldText: require.requireActual('@elastic/eui').EuiFieldText,
}));
describe('src/legacy/core_plugins/metrics/public/components/splits/terms.test.js', () => {
let props;
beforeEach(() => {
props = {
intl: {
formatMessage: jest.fn(),
},
model: {
terms_field: 'OriginCityName'
},
onChange: jest.fn(),
indexPattern: 'kibana_sample_data_flights',
fields: {
'kibana_sample_data_flights': [
{
aggregatable: true,
name: 'OriginCityName',
readFromDocValues: true,
searchable: true,
type: 'string'
}
]
},
};
});
describe('<SplitByTermsUI />', () => {
test('should render and match a snapshot', () => {
const wrapper = shallow(<SplitByTermsUI {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
});

View file

@ -21,12 +21,14 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as Rx from 'rxjs';
import { share } from 'rxjs/operators';
import { isEqual, isEmpty } from 'lodash';
import VisEditorVisualization from './vis_editor_visualization';
import Visualization from './visualization';
import VisPicker from './vis_picker';
import PanelConfig from './panel_config';
import brushHandler from '../lib/create_brush_handler';
import { fetchIndexPatternFields } from '../lib/fetch_fields';
import { fetchFields } from '../lib/fetch_fields';
import { extractIndexPatterns } from '../lib/extract_index_patterns';
class VisEditor extends Component {
constructor(props) {
@ -37,7 +39,8 @@ class VisEditor extends Component {
model: props.visParams,
dirty: false,
autoApply: true,
visFields: props.visFields
visFields: props.visFields,
extractedIndexPatterns: [''],
};
this.onBrush = brushHandler(props.vis.API.timeFilter);
this.visDataSubject = new Rx.Subject();
@ -52,23 +55,45 @@ class VisEditor extends Component {
return this.props.config.get(...args);
};
handleUiState = (field, value) => {
handleUiState = (field, value) => {
this.props.vis.uiStateVal(field, value);
};
handleChange = async (partialModel) => {
const nextModel = { ...this.state.model, ...partialModel };
this.props.vis.params = nextModel;
if (this.state.autoApply) {
this.props.vis.updateState();
if (isEmpty(partialModel)) {
return;
}
const hasTypeChanged = partialModel.type && this.state.model.type !== partialModel.type;
const nextModel = {
...this.state.model,
...partialModel,
};
let dirty = true;
this.props.vis.params = nextModel;
if (this.state.autoApply || hasTypeChanged) {
this.props.vis.updateState();
dirty = false;
}
if (this.props.isEditorMode) {
const { params, fields } = this.props.vis;
const extractedIndexPatterns = extractIndexPatterns(params, fields);
if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) {
fetchFields(extractedIndexPatterns)
.then(visFields => this.setState({
visFields,
extractedIndexPatterns,
}));
}
}
this.setState({
dirty,
model: nextModel,
dirty: !this.state.autoApply
});
const { params, fields } = this.props.vis;
fetchIndexPatternFields(params, fields).then(visFields => {
this.setState({ visFields });
});
};
@ -109,7 +134,7 @@ class VisEditor extends Component {
return (
<div className="tvbEditor">
<div className="tvbEditor--hideForReporting">
<VisPicker model={model} onChange={this.handleChange} />
<VisPicker model={model} onChange={this.handleChange}/>
</div>
<VisEditorVisualization
dirty={this.state.dirty}
@ -152,7 +177,7 @@ class VisEditor extends Component {
}
VisEditor.defaultProps = {
visData: {}
visData: {},
};
VisEditor.propTypes = {

View file

@ -1,77 +1,89 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nContext } from 'ui/i18n';
import chrome from 'ui/chrome';
import { fetchIndexPatternFields } from '../lib/fetch_fields';
function ReactEditorControllerProvider(Private, config) {
class ReactEditorController {
constructor(el, savedObj) {
this.el = el;
this.savedObj = savedObj;
this.vis = savedObj.vis;
this.vis.fields = {};
}
setDefaultIndexPattern = async () => {
const savedObjectsClient = chrome.getSavedObjectsClient();
const indexPattern = await savedObjectsClient.get('index-pattern', config.get('defaultIndex'));
this.vis.params.default_index_pattern = indexPattern.attributes.title;
};
async render(params) {
const Component = this.vis.type.editorConfig.component;
await this.setDefaultIndexPattern();
const visFields = await fetchIndexPatternFields(this.vis.params, this.vis.fields);
render(
<I18nContext>
<Component
config={config}
vis={this.vis}
visFields={visFields}
visParams={this.vis.params}
savedObj={this.savedObj}
timeRange={params.timeRange}
renderComplete={() => {}}
isEditorMode={true}
appState={params.appState}
/>
</I18nContext>,
this.el);
}
resize() {}
destroy() {
unmountComponentAtNode(this.el);
}
}
return {
name: 'react_editor',
handler: ReactEditorController
};
}
export { ReactEditorControllerProvider };
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nContext } from 'ui/i18n';
import chrome from 'ui/chrome';
import { fetchIndexPatternFields } from '../lib/fetch_fields';
function ReactEditorControllerProvider(Private, config) {
class ReactEditorController {
constructor(el, savedObj) {
this.el = el;
this.state = {
savedObj: savedObj,
vis: savedObj.vis,
isLoaded: false,
};
}
fetchDefaultIndexPattern = async () => {
const savedObjectsClient = chrome.getSavedObjectsClient();
const indexPattern = await savedObjectsClient.get('index-pattern', config.get('defaultIndex'));
return indexPattern.attributes.title;
};
fetchDefaultParams = async () => {
this.state.vis.params.default_index_pattern = await this.fetchDefaultIndexPattern();
this.state.vis.fields = await fetchIndexPatternFields(this.state.vis);
this.state.isLoaded = true;
};
getComponent = () => {
return this.state.vis.type.editorConfig.component;
};
async render(params) {
const Component = this.getComponent();
!this.state.isLoaded && await this.fetchDefaultParams();
render(
<I18nContext>
<Component
config={config}
vis={this.state.vis}
visFields={this.state.vis.fields}
visParams={this.state.vis.params}
savedObj={this.state.savedObj}
timeRange={params.timeRange}
renderComplete={() => {}}
isEditorMode={true}
appState={params.appState}
/>
</I18nContext>,
this.el);
}
destroy() {
unmountComponentAtNode(this.el);
}
}
return {
name: 'react_editor',
handler: ReactEditorController,
};
}
export { ReactEditorControllerProvider };

View file

@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { uniq } from 'lodash';
export function extractIndexPatterns(params, fetchedFields) {
const patternsToFetch = [];
@ -41,6 +41,9 @@ export function extractIndexPatterns(params, fetchedFields) {
});
}
return uniq(patternsToFetch);
if (patternsToFetch.length === 0) {
patternsToFetch.push('');
}
return uniq(patternsToFetch).sort();
}

View file

@ -30,27 +30,28 @@ export async function fetchFields(indexPatterns = ['*']) {
pathname: '/api/metrics/fields',
query: {
index: pattern,
}
},
});
}));
const fields = patterns.reduce((cumulatedFields, currentPattern, index) => {
return {
...cumulatedFields,
[currentPattern]: indexFields[index]
[currentPattern]: indexFields[index],
};
}, {});
return fields;
} catch(error) {
} catch (error) {
toastNotifications.addDanger({
title: i18n.translate('tsvb.fetchFields.loadIndexPatternFieldsErrorMessage', {
defaultMessage: 'Unable to load index_pattern fields'
defaultMessage: 'Unable to load index_pattern fields',
}),
text: error.message,
});
}
}
export async function fetchIndexPatternFields(params, fields) {
export async function fetchIndexPatternFields({ params, fields = {} }) {
const indexPatterns = extractIndexPatterns(params, fields);
return await fetchFields(indexPatterns);
}

View file

@ -17,7 +17,7 @@
* under the License.
*/
import _ from 'lodash';
import { set } from 'lodash';
import basicAggs from '../../../../../common/basic_aggs';
import getBucketsPath from '../../helpers/get_buckets_path';
import bucketTransform from '../../helpers/bucket_transform';
@ -26,20 +26,26 @@ export default function splitByTerm(req, panel, series) {
return next => doc => {
if (series.split_mode === 'terms' && series.terms_field) {
const direction = series.terms_direction || 'desc';
_.set(doc, `aggs.${series.id}.terms.field`, series.terms_field);
_.set(doc, `aggs.${series.id}.terms.size`, series.terms_size);
const metric = series.metrics.find(item => item.id === series.terms_order_by);
set(doc, `aggs.${series.id}.terms.field`, series.terms_field);
set(doc, `aggs.${series.id}.terms.size`, series.terms_size);
if (series.terms_include) {
set(doc, `aggs.${series.id}.terms.include`, series.terms_include);
}
if (series.terms_exclude) {
set(doc, `aggs.${series.id}.terms.exclude`, series.terms_exclude);
}
if (metric && metric.type !== 'count' && ~basicAggs.indexOf(metric.type)) {
const sortAggKey = `${series.terms_order_by}-SORT`;
const fn = bucketTransform[metric.type];
const bucketPath = getBucketsPath(series.terms_order_by, series.metrics)
.replace(series.terms_order_by, sortAggKey);
_.set(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction });
_.set(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) });
set(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction });
set(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) });
} else if (['_key', '_count'].includes(series.terms_order_by)) {
_.set(doc, `aggs.${series.id}.terms.order`, { [series.terms_order_by]: direction });
set(doc, `aggs.${series.id}.terms.order`, { [series.terms_order_by]: direction });
} else {
_.set(doc, `aggs.${series.id}.terms.order`, { _count: direction });
set(doc, `aggs.${series.id}.terms.order`, { _count: direction });
}
}
return next(doc);

View file

@ -23,16 +23,23 @@ export default function timeShift(resp, panel, series) {
return next => results => {
if (/^([+-]?[\d]+)([shmdwMy]|ms)$/.test(series.offset_time)) {
const matches = series.offset_time.match(/^([+-]?[\d]+)([shmdwMy]|ms)$/);
if (matches) {
const offsetValue = matches[1];
const offsetValue = Number(matches[1]);
const offsetUnit = matches[2];
const offset = moment.duration(offsetValue, offsetUnit).valueOf();
results.forEach(item => {
if (_.startsWith(item.id, series.id)) {
item.data = item.data.map(row => [moment(row[0]).add(offsetValue, offsetUnit).valueOf(), row[1]]);
item.data = item.data.map(([time, value]) => [
time + offset,
value
]);
}
});
}
}
return next(results);
};
}

View file

@ -43,6 +43,7 @@ describe('collectSavedObjects()', () => {
Array [
Object {
"foo": true,
"migrationVersion": Object {},
},
]
`);
@ -60,6 +61,7 @@ Array [
Array [
Object {
"foo": true,
"migrationVersion": Object {},
},
]
`);

View file

@ -44,6 +44,10 @@ export async function collectSavedObjects(
createFilterStream<SavedObject>(obj => !!obj),
createLimitStream(objectLimit),
createFilterStream<SavedObject>(obj => (filter ? filter(obj) : true)),
createMapStream((obj: SavedObject) => {
// Ensure migrations execute on every saved object
return Object.assign({ migrationVersion: {} }, obj);
}),
createConcatStream([]),
])) as SavedObject[];
}

View file

@ -124,6 +124,7 @@ Object {
"title": "My Index Pattern",
},
"id": "1",
"migrationVersion": Object {},
"references": Array [],
"type": "index-pattern",
},
@ -132,6 +133,7 @@ Object {
"title": "My Search",
},
"id": "2",
"migrationVersion": Object {},
"references": Array [],
"type": "search",
},
@ -140,6 +142,7 @@ Object {
"title": "My Visualization",
},
"id": "3",
"migrationVersion": Object {},
"references": Array [],
"type": "visualization",
},
@ -148,6 +151,7 @@ Object {
"title": "My Dashboard",
},
"id": "4",
"migrationVersion": Object {},
"references": Array [],
"type": "dashboard",
},
@ -200,6 +204,7 @@ Object {
"title": "My Index Pattern",
},
"id": "1",
"migrationVersion": Object {},
"references": Array [],
"type": "index-pattern",
},
@ -208,6 +213,7 @@ Object {
"title": "My Search",
},
"id": "2",
"migrationVersion": Object {},
"references": Array [],
"type": "search",
},
@ -216,6 +222,7 @@ Object {
"title": "My Visualization",
},
"id": "3",
"migrationVersion": Object {},
"references": Array [],
"type": "visualization",
},
@ -224,6 +231,7 @@ Object {
"title": "My Dashboard",
},
"id": "4",
"migrationVersion": Object {},
"references": Array [],
"type": "dashboard",
},

View file

@ -141,6 +141,7 @@ Object {
"title": "My Visualization",
},
"id": "3",
"migrationVersion": Object {},
"references": Array [],
"type": "visualization",
},
@ -196,6 +197,7 @@ Object {
"title": "My Index Pattern",
},
"id": "1",
"migrationVersion": Object {},
"references": Array [],
"type": "index-pattern",
},
@ -260,6 +262,7 @@ Object {
"title": "My Dashboard",
},
"id": "4",
"migrationVersion": Object {},
"references": Array [
Object {
"id": "13",

View file

@ -77,6 +77,47 @@ describe('POST /api/saved_objects/_import', () => {
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0);
});
test('defaults migrationVersion to empty object', async () => {
const request = {
method: 'POST',
url: '/api/saved_objects/_import',
payload: [
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"index-pattern","id":"my-pattern","attributes":{"title":"my-pattern-*"}}',
'--EXAMPLE--',
].join('\r\n'),
headers: {
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
},
};
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
type: 'index-pattern',
id: 'my-pattern',
attributes: {
title: 'my-pattern-*',
},
},
],
});
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);
expect(statusCode).toBe(200);
expect(response).toEqual({
success: true,
successCount: 1,
});
expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1);
const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0];
expect(firstBulkCreateCallArray).toHaveLength(1);
expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({});
});
test('imports an index pattern and dashboard', async () => {
// NOTE: changes to this scenario should be reflected in the docs
const request = {

View file

@ -24,6 +24,7 @@ export { createDeleteRoute } from './delete';
export { createFindRoute } from './find';
export { createGetRoute } from './get';
export { createImportRoute } from './import';
export { createLogLegacyImportRoute } from './log_legacy_import';
export { createResolveImportErrorsRoute } from './resolve_import_errors';
export { createUpdateRoute } from './update';
export { createExportRoute } from './export';

View file

@ -0,0 +1,34 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
export const createLogLegacyImportRoute = () => ({
path: '/api/saved_objects/_log_legacy_import',
method: 'POST',
options: {
handler(request: Hapi.Request) {
request.server.log(
['warning'],
'Importing saved objects from a .json file has been deprecated'
);
return { success: true };
},
},
});

View file

@ -77,7 +77,48 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0);
});
test('retries importin a dashboard', async () => {
test('defaults migrationVersion to empty object', async () => {
const request = {
method: 'POST',
url: '/api/saved_objects/_resolve_import_errors',
payload: [
'--EXAMPLE',
'Content-Disposition: form-data; name="file"; filename="export.ndjson"',
'Content-Type: application/ndjson',
'',
'{"type":"dashboard","id":"my-dashboard","attributes":{"title":"Look at my dashboard"}}',
'--EXAMPLE',
'Content-Disposition: form-data; name="retries"',
'',
'[{"type":"dashboard","id":"my-dashboard"}]',
'--EXAMPLE--',
].join('\r\n'),
headers: {
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
},
};
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
type: 'dashboard',
id: 'my-dashboard',
attributes: {
title: 'Look at my dashboard',
},
},
],
});
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);
expect(statusCode).toBe(200);
expect(response).toEqual({ success: true, successCount: 1 });
expect(savedObjectsClient.bulkCreate.mock.calls).toHaveLength(1);
const firstBulkCreateCallArray = savedObjectsClient.bulkCreate.mock.calls[0][0];
expect(firstBulkCreateCallArray).toHaveLength(1);
expect(firstBulkCreateCallArray[0].migrationVersion).toEqual({});
});
test('retries importing a dashboard', async () => {
// NOTE: changes to this scenario should be reflected in the docs
const request = {
method: 'POST',
@ -123,6 +164,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
"title": "Look at my dashboard",
},
"id": "my-dashboard",
"migrationVersion": Object {},
"type": "dashboard",
},
],
@ -185,6 +227,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
"title": "Look at my dashboard",
},
"id": "my-dashboard",
"migrationVersion": Object {},
"type": "dashboard",
},
],
@ -266,6 +309,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
"title": "Look at my visualization",
},
"id": "my-vis",
"migrationVersion": Object {},
"references": Array [
Object {
"id": "existing",

View file

@ -38,6 +38,7 @@ import {
createExportRoute,
createImportRoute,
createResolveImportErrorsRoute,
createLogLegacyImportRoute,
} from './routes';
export function savedObjectsMixin(kbnServer, server) {
@ -71,6 +72,7 @@ export function savedObjectsMixin(kbnServer, server) {
server.route(createExportRoute(prereqs, server));
server.route(createImportRoute(prereqs, server));
server.route(createResolveImportErrorsRoute(prereqs, server));
server.route(createLogLegacyImportRoute());
const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas);
const serializer = new SavedObjectsSerializer(schema);

View file

@ -110,9 +110,9 @@ describe('Saved Objects Mixin', () => {
});
describe('Routes', () => {
it('should create 10 routes', () => {
it('should create 11 routes', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledTimes(10);
expect(mockServer.route).toHaveBeenCalledTimes(11);
});
it('should add POST /api/saved_objects/_bulk_create', () => {
savedObjectsMixin(mockKbnServer, mockServer);
@ -177,6 +177,12 @@ describe('Saved Objects Mixin', () => {
})
);
});
it('should add POST /api/saved_objects/_log_legacy_import', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/saved_objects/_log_legacy_import', method: 'POST' })
);
});
});
describe('Saved object service', () => {

View file

@ -61,11 +61,14 @@ export async function kfetch(
});
return window.fetch(fullUrl, restOptions).then(async res => {
const body = await getBodyAsJson(res);
if (res.ok) {
return body;
if (!res.ok) {
throw new KFetchError(res, await getBodyAsJson(res));
}
throw new KFetchError(res, body);
const contentType = res.headers.get('content-type');
if (contentType && contentType.split(';')[0] === 'application/ndjson') {
return await getBodyAsBlob(res);
}
return await getBodyAsJson(res);
});
}
);
@ -96,13 +99,25 @@ async function getBodyAsJson(res: Response) {
}
}
async function getBodyAsBlob(res: Response) {
try {
return await res.blob();
} catch (e) {
return null;
}
}
export function withDefaultOptions(options?: KFetchOptions): KFetchOptions {
return merge(
{
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...(options && options.headers && options.headers.hasOwnProperty('Content-Type')
? {}
: {
'Content-Type': 'application/json',
}),
'kbn-version': metadata.version,
},
},

View file

@ -113,6 +113,7 @@ export default function ({ getService }) {
type: 'visualization',
attributes: {
title: 'My favorite vis',
visState: '{}',
},
references: [
{
@ -230,6 +231,7 @@ export default function ({ getService }) {
type: 'visualization',
attributes: {
title: 'My favorite vis',
visState: '{}',
},
references: [
{

View file

@ -27,157 +27,331 @@ export default function ({ getService, getPageObjects }) {
const testSubjects = getService('testSubjects');
describe('import objects', function describeIndexTests() {
beforeEach(async function () {
// delete .kibana index and then wait for Kibana to re-create it
await kibanaServer.uiSettings.replace({});
await PageObjects.settings.navigateTo();
await esArchiver.load('management');
describe('.ndjson file', () => {
beforeEach(async function () {
// delete .kibana index and then wait for Kibana to re-create it
await kibanaServer.uiSettings.replace({});
await PageObjects.settings.navigateTo();
await esArchiver.load('management');
});
afterEach(async function () {
await esArchiver.unload('management');
});
it('should import saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('Log Agents');
expect(isSavedObjectImported).to.be(true);
});
it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_conflicts.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('d1e4c910-a2e6-11e7-bb30-233be9be6a15', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object with index pattern conflict');
expect(isSavedObjectImported).to.be(true);
});
it('should allow the user to override duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.ndjson'), false);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
// Override the visualization.
await PageObjects.common.clickConfirmOnModal();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccess');
expect(isSuccessful).to.be(true);
});
it('should allow the user to cancel overriding duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can be prompted to override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.ndjson'), false);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
// *Don't* override the visualization.
await PageObjects.common.clickCancelOnModal();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccessNoneImported');
expect(isSuccessful).to.be(true);
});
it('should import saved objects linked to saved searches', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(true);
});
it('should not import saved objects linked to saved searches when saved search does not exist', async function () {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function () {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_saved_search.ndjson'));
// Wait for all the saves to happen
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickConfirmChanges();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
it('should import saved objects with index patterns when index patterns already exists', async () => {
// First, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
});
it('should import saved objects with index patterns when index patterns does not exists', async () => {
// First, we need to delete the index pattern
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
// Then, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
});
});
afterEach(async function () {
await esArchiver.unload('management');
});
describe('.json file', () => {
beforeEach(async function () {
// delete .kibana index and then wait for Kibana to re-create it
await kibanaServer.uiSettings.replace({});
await PageObjects.settings.navigateTo();
await esArchiver.load('management');
});
it('should import saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects.json'));
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('Log Agents');
expect(isSavedObjectImported).to.be(true);
});
afterEach(async function () {
await esArchiver.unload('management');
});
it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects-conflicts.json'));
await PageObjects.settings.associateIndexPattern('d1e4c910-a2e6-11e7-bb30-233be9be6a15', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object with index pattern conflict');
expect(isSavedObjectImported).to.be(true);
});
it('should import saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('Log Agents');
expect(isSavedObjectImported).to.be(true);
});
it('should allow the user to override duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects-conflicts.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('d1e4c910-a2e6-11e7-bb30-233be9be6a15', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object with index pattern conflict');
expect(isSavedObjectImported).to.be(true);
});
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
it('should allow the user to override duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
// Override the visualization.
await PageObjects.common.clickConfirmOnModal();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccess');
expect(isSuccessful).to.be(true);
});
// Override the visualization.
await PageObjects.common.clickConfirmOnModal();
it('should allow the user to cancel overriding duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccess');
expect(isSuccessful).to.be(true);
});
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can be prompted to override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
it('should allow the user to cancel overriding duplicate saved objects', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
// This data has already been loaded by the "visualize" esArchive. We'll load it again
// so that we can be prompted to override the existing visualization.
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_exists.json'), false);
// *Don't* override the visualization.
await PageObjects.common.clickCancelOnModal();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.associateIndexPattern('logstash-*', 'logstash-*');
await PageObjects.settings.clickConfirmChanges();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccessNoneImported');
expect(isSuccessful).to.be(true);
});
// *Don't* override the visualization.
await PageObjects.common.clickCancelOnModal();
it('should import saved objects linked to saved searches', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.json'));
await PageObjects.settings.clickImportDone();
const isSuccessful = await testSubjects.exists('importSavedObjectsSuccessNoneImported');
expect(isSuccessful).to.be(true);
});
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should import saved objects linked to saved searches', async function () {
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(true);
});
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should not import saved objects linked to saved searches when saved search does not exist', async function () {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(true);
});
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
it('should not import saved objects linked to saved searches when saved search does not exist', async function () {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function () {
// First, import the saved search
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.json'));
await PageObjects.settings.clickImportDone();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
// Second, we need to delete the index pattern
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function () {
// First, import the saved search
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_saved_search.json'));
// Wait for all the saves to happen
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
// Last, import a saved object connected to the saved search
// This should NOT show the conflicts
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
// Second, we need to delete the index pattern
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
// Last, import a saved object connected to the saved search
// This should NOT show the conflicts
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json'));
// Wait for all the saves to happen
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should import saved objects with index patterns when index patterns already exists', async () => {
// First, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object connected to saved search');
expect(isSavedObjectImported).to.be(false);
});
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
});
it('should import saved objects with index patterns when index patterns already exists', async () => {
// First, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should import saved objects with index patterns when index patterns does not exists', async () => {
// First, we need to delete the index pattern
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
});
// Then, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
it('should import saved objects with index patterns when index patterns does not exists', async () => {
// First, we need to delete the index pattern
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaIndexPatterns();
await PageObjects.settings.clickIndexPatternLogstash();
await PageObjects.settings.removeIndexPattern();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
// Then, import the objects
await PageObjects.settings.clickKibanaSavedObjects();
await PageObjects.settings.importFile(path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json'));
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.settings.clickImportDone();
// Wait for all the saves to happen
await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading();
const objects = await PageObjects.settings.getSavedObjectsInTable();
const isSavedObjectImported = objects.includes('saved object imported with index pattern');
expect(isSavedObjectImported).to.be(true);
});
});
});
}

View file

@ -0,0 +1 @@
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Log Agents","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"082f1d60-a2e7-11e7-bb30-233be9be6a15","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1}

View file

@ -0,0 +1 @@
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"saved object with index pattern conflict","uiStateJSON":"{}","visState":"{\"title\":\"Log Agents\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"agent.raw: Descending\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"agent.raw\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}]}"},"id":"saved_object_with_index_pattern_conflict","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"d1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1}

View file

@ -0,0 +1 @@
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"},"savedSearchRefName":"search_0","title":"saved object connected to saved search","uiStateJSON":"{}","visState":"{\"title\":\"PHP Viz\",\"type\":\"horizontal_bar\",\"params\":{\"type\":\"histogram\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":200},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":75,\"filter\":true,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"histogram\",\"mode\":\"normal\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}"},"id":"saved_object_connected_to_saved_search","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"c45e6c50-ba72-11e7-a8f9-ad70f02e633d","name":"search_0","type":"search"}],"type":"visualization","version":1}

View file

@ -0,0 +1 @@
{"attributes":{"description":"AreaChart","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"title":"Shared-Item Visualization AreaChart","uiStateJSON":"{}","visState":"{\"title\":\"New Visualization\",\"type\":\"area\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"smoothLines\":false,\"scale\":\"linear\",\"interpolate\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}"},"id":"Shared-Item-Visualization-AreaChart","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"logstash-*","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"visualization","version":1}

View file

@ -0,0 +1 @@
{"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"php\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":["@timestamp","desc"],"title":"PHP saved search"},"id":"c45e6c50-ba72-11e7-a8f9-ad70f02e633d","migrationVersion":{"search":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","version":1}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
{"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"language\":\"lucene\",\"query\":\"php\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":["@timestamp","desc"],"title":"PHP saved search"},"id":"c45e6c50-ba72-11e7-a8f9-ad70f02e633d","migrationVersion":{"search":"7.0.0"},"references":[{"id":"f1e4c910-a2e6-11e7-bb30-233be9be6a15","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","version":1}
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"},"savedSearchRefName":"search_0","title":"saved object connected to saved search","uiStateJSON":"{}","visState":"{\"title\":\"PHP Viz\",\"type\":\"horizontal_bar\",\"params\":{\"type\":\"histogram\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":200},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":75,\"filter\":true,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":true,\"type\":\"histogram\",\"mode\":\"normal\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"valueAxis\":\"ValueAxis-1\",\"drawLinesBetweenPoints\":true,\"showCircles\":true}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}"},"id":"saved_object_connected_to_saved_search","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"c45e6c50-ba72-11e7-a8f9-ad70f02e633d","name":"search_0","type":"search"}],"type":"visualization","version":1}

View file

@ -0,0 +1,2 @@
{"attributes":{"columns":["_source"],"description":"","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":["@timestamp","desc"],"title":"mysavedsearch"},"id":"6aea5700-ac94-11e8-a651-614b2788174a","migrationVersion":{"search":"7.0.0"},"references":[{"id":"4c3f3c30-ac94-11e8-a651-614b2788174a","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","version":1}
{"attributes":{"description":"","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}"},"savedSearchRefName":"search_0","title":"mysavedviz","uiStateJSON":"{}","visState":"{\"title\":\"mysavedviz\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}"},"id":"8411daa0-ac94-11e8-a651-614b2788174a","migrationVersion":{"visualization":"7.0.0"},"references":[{"id":"6aea5700-ac94-11e8-a651-614b2788174a","name":"search_0","type":"search"}],"type":"visualization","version":1}

View file

@ -7,7 +7,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"react": "^16.8.0",
"react-dom": "^16.8.0"
}

View file

@ -7,7 +7,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"react": "^16.8.0"
}
}

View file

@ -8,7 +8,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"react": "^16.8.0"
},
"scripts": {

View file

@ -7,7 +7,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"react": "^16.8.0",
"react-dom": "^16.8.0"
}

View file

@ -161,7 +161,7 @@
"@babel/register": "^7.0.0",
"@babel/runtime": "^7.3.4",
"@elastic/datemath": "5.0.2",
"@elastic/eui": "9.8.0",
"@elastic/eui": "9.9.0",
"@elastic/javascript-typescript-langserver": "^0.1.21",
"@elastic/lsp-extension": "^0.1.1",
"@elastic/node-crypto": "0.1.2",

View file

@ -56,7 +56,7 @@ Array [
"min": 100,
},
"field": "@timestamp",
"interval": "1s",
"interval": "30s",
"min_doc_count": 0,
},
},

View file

@ -34,7 +34,7 @@ interface Aggs {
export type ESResponse = PromiseReturnType<typeof fetch>;
export async function fetch({ serviceName, setup }: MetricsRequestArgs) {
const { start, end, esFilterQuery, client, config } = setup;
const { intervalString } = getBucketSize(start, end, 'auto');
const { bucketSize } = getBucketSize(start, end, 'auto');
const filters: ESFilter[] = [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [PROCESSOR_EVENT]: 'metric' } },
@ -56,7 +56,9 @@ export async function fetch({ serviceName, setup }: MetricsRequestArgs) {
timeseriesData: {
date_histogram: {
field: '@timestamp',
interval: intervalString,
// ensure minimum bucket size of 30s since this is the default resolution for metric data
interval: `${Math.max(bucketSize, 30)}s`,
min_doc_count: 0,
extended_bounds: { min: start, max: end }
},

View file

@ -48,7 +48,7 @@ Array [
"min": 100,
},
"field": "@timestamp",
"interval": "1s",
"interval": "30s",
"min_doc_count": 0,
},
},

View file

@ -30,7 +30,7 @@ interface Aggs {
export type ESResponse = PromiseReturnType<typeof fetch>;
export async function fetch({ serviceName, setup }: MetricsRequestArgs) {
const { start, end, esFilterQuery, client, config } = setup;
const { intervalString } = getBucketSize(start, end, 'auto');
const { bucketSize } = getBucketSize(start, end, 'auto');
const filters: ESFilter[] = [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [PROCESSOR_EVENT]: 'metric' } },
@ -59,7 +59,9 @@ export async function fetch({ serviceName, setup }: MetricsRequestArgs) {
timeseriesData: {
date_histogram: {
field: '@timestamp',
interval: intervalString,
// ensure minimum bucket size of 30s since this is the default resolution for metric data
interval: `${Math.max(bucketSize, 30)}s`,
min_doc_count: 0,
extended_bounds: { min: start, max: end }
},

View file

@ -9,42 +9,53 @@ import PropTypes from 'prop-types';
import { debounce } from 'lodash';
export class DomPreview extends React.Component {
static container = null;
static content = null;
static observer = null;
static propTypes = {
elementId: PropTypes.string.isRequired,
height: PropTypes.number.isRequired,
};
constructor(props) {
super(props);
this.container = null;
this.content = null;
this.observer = null;
this.original = null;
this.updateTimeout = null;
}
componentDidMount() {
const original = document.querySelector(`#${this.props.elementId}`);
const update = this.update(original);
update();
const slowUpdate = debounce(update, 250);
this.observer = new MutationObserver(slowUpdate);
// configuration of the observer
const config = { attributes: true, childList: true, subtree: true };
// pass in the target node, as well as the observer options
this.observer.observe(original, config);
this.update();
}
componentWillUnmount() {
this.observer.disconnect();
clearTimeout(this.updateTimeout);
this.observer && this.observer.disconnect(); // observer not guaranteed to exist
}
update = original => () => {
update = () => {
if (!this.content || !this.container) {
return;
}
const thumb = original.cloneNode(true);
if (!this.observer) {
this.original = this.original || document.querySelector(`#${this.props.elementId}`);
if (this.original) {
const slowUpdate = debounce(this.update, 100);
this.observer = new MutationObserver(slowUpdate);
// configuration of the observer
const config = { attributes: true, childList: true, subtree: true };
// pass in the target node, as well as the observer options
this.observer.observe(this.original, config);
} else {
clearTimeout(this.updateTimeout); // to avoid the assumption that we fully control when `update` is called
this.updateTimeout = setTimeout(this.update, 30);
return;
}
}
const originalStyle = window.getComputedStyle(original, null);
const thumb = this.original.cloneNode(true);
const originalStyle = window.getComputedStyle(this.original, null);
const originalWidth = parseInt(originalStyle.getPropertyValue('width'), 10);
const originalHeight = parseInt(originalStyle.getPropertyValue('height'), 10);
@ -61,7 +72,7 @@ export class DomPreview extends React.Component {
this.container.style.cssText = `width: ${thumbWidth}px; height: ${thumbHeight}px;`;
// Copy canvas data
const originalCanvas = original.querySelectorAll('canvas');
const originalCanvas = this.original.querySelectorAll('canvas');
const thumbCanvas = thumb.querySelectorAll('canvas');
// Cloned canvas elements are blank and need to be explicitly redrawn

View file

@ -8,13 +8,12 @@ import { connect } from 'react-redux';
import { fetchAllRenderables } from '../../state/actions/elements';
import { setRefreshInterval } from '../../state/actions/workpad';
import { getInFlight } from '../../state/selectors/resolved_args';
import { getRefreshInterval, getElementStats } from '../../state/selectors/workpad';
import { getRefreshInterval } from '../../state/selectors/workpad';
import { RefreshControl as Component } from './refresh_control';
const mapStateToProps = state => ({
inFlight: getInFlight(state),
refreshInterval: getRefreshInterval(state),
elementStats: getElementStats(state),
});
const mapDispatchToProps = {

View file

@ -8,7 +8,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import { EuiButtonEmpty } from '@elastic/eui';
import { Popover } from '../popover';
import { loadingIndicator } from '../../lib/loading_indicator';
import { AutoRefreshControls } from './auto_refresh_controls';
const getRefreshInterval = (val = '') => {
@ -37,21 +36,7 @@ const getRefreshInterval = (val = '') => {
}
};
export const RefreshControl = ({
inFlight,
elementStats,
setRefreshInterval,
refreshInterval,
doRefresh,
}) => {
const { pending } = elementStats;
if (inFlight || pending > 0) {
loadingIndicator.show();
} else {
loadingIndicator.hide();
}
export const RefreshControl = ({ inFlight, setRefreshInterval, refreshInterval, doRefresh }) => {
const setRefresh = val => setRefreshInterval(getRefreshInterval(val));
const popoverButton = handleClick => (

View file

@ -9,6 +9,11 @@ import { loadingCount } from 'ui/chrome';
let isActive = false;
export interface LoadingIndicatorInterface {
show: () => void;
hide: () => void;
}
export const loadingIndicator = {
show: () => {
if (!isActive) {

View file

@ -1,16 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createAction } from 'redux-actions';
export const setLoading = createAction('setResolvedLoading');
export const setValue = createAction('setResolvedValue');
export const setValues = createAction('setResolvedValues');
export const clearValue = createAction('clearResolvedValue');
export const clearValues = createAction('clearResolvedValues');
export const inFlightActive = createAction('inFlightActive');
export const inFlightComplete = createAction('inFlightComplete');

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Action } from 'redux';
import { createAction } from 'redux-actions';
export const setLoadingActionType = 'setResolvedLoading';
export const setValueActionType = 'setResolvedValue';
export const inFlightActiveActionType = 'inFlightActive';
export const inFlightCompleteActionType = 'inFlightComplete';
type InFlightActive = Action<typeof inFlightActiveActionType>;
type InFlightComplete = Action<typeof inFlightCompleteActionType>;
interface SetResolvedLoadingPayload {
path: any[];
}
type SetResolvedLoading = Action<typeof setLoadingActionType> & {
payload: SetResolvedLoadingPayload;
};
interface SetResolvedValuePayload {
path: any[];
value: any;
}
type SetResolvedValue = Action<typeof setValueActionType> & {
payload: SetResolvedValuePayload;
};
export type Action = SetResolvedLoading | SetResolvedValue | InFlightActive | InFlightComplete;
export const setLoading = createAction<SetResolvedLoadingPayload>(setLoadingActionType);
export const setValue = createAction<SetResolvedValuePayload>(setValueActionType);
export const setValues = createAction('setResolvedValues');
export const clearValue = createAction('clearResolvedValue');
export const clearValues = createAction('clearResolvedValues');
export const inFlightActive = createAction<undefined>(inFlightActiveActionType, () => undefined);
export const inFlightComplete = createAction<undefined>(
inFlightCompleteActionType,
() => undefined
);

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;
* you may not use this file except in compliance with the Elastic License.
*/
import {
inFlightActive,
inFlightComplete,
setLoading,
setValue,
} from '../../actions/resolved_args';
import { inFlightMiddlewareFactory } from '../in_flight';
const next = jest.fn();
const dispatch = jest.fn();
const loadingIndicator = {
show: jest.fn(),
hide: jest.fn(),
};
const pendingCache: string[] = [];
const testMiddleware = inFlightMiddlewareFactory({
loadingIndicator,
pendingCache,
})({ dispatch, getState: jest.fn() })(next);
describe('inflight middleware', () => {
beforeEach(() => {
dispatch.mockClear();
});
describe('loading indicator', () => {
beforeEach(() => {
loadingIndicator.show = jest.fn();
loadingIndicator.hide = jest.fn();
});
it('shows the loading indicator on inFlightActive action', () => {
const inFlightActiveAction = inFlightActive();
testMiddleware(inFlightActiveAction);
expect(loadingIndicator.show).toBeCalled();
});
it('hides the loading indicator on inFlightComplete action', () => {
const inFlightCompleteAction = inFlightComplete();
testMiddleware(inFlightCompleteAction);
expect(loadingIndicator.hide).toBeCalled();
});
describe('value', () => {
beforeEach(() => {
while (pendingCache.length) {
pendingCache.pop();
}
});
it('dispatches the inFlightAction for loadingValue actions', () => {
const path = ['some', 'path'];
const loadingAction = setLoading({ path });
testMiddleware(loadingAction);
expect(dispatch).toBeCalledWith(inFlightActive());
});
it('adds path to pendingCache for loadingValue actions', () => {
const expectedPath = 'path';
const path = [expectedPath];
const loadingAction = setLoading({ path });
testMiddleware(loadingAction);
expect(pendingCache[0]).toBe(expectedPath);
});
it('dispatches inFlight complete if all pending is resolved', () => {
const resolvedPath1 = 'path1';
const resolvedPath2 = 'path2';
const setAction1 = setValue({ path: [resolvedPath1], value: {} });
const setAction2 = setValue({ path: [resolvedPath2], value: {} });
pendingCache.push(resolvedPath1);
pendingCache.push(resolvedPath2);
testMiddleware(setAction1);
expect(dispatch).not.toBeCalled();
testMiddleware(setAction2);
expect(dispatch).toBeCalledWith(inFlightComplete());
});
});
});
});

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { convert } from '../../lib/modify_path';
import { setLoading, setValue, inFlightActive, inFlightComplete } from '../actions/resolved_args';
export const inFlight = ({ dispatch }) => next => {
const pendingCache = [];
return action => {
const isLoading = action.type === setLoading.toString();
const isSetting = action.type === setValue.toString();
if (isLoading || isSetting) {
const cacheKey = convert(action.payload.path).join('/');
if (isLoading) {
pendingCache.push(cacheKey);
dispatch(inFlightActive());
} else if (isSetting) {
const idx = pendingCache.indexOf(cacheKey);
pendingCache.splice(idx, 1);
if (pendingCache.length === 0) {
dispatch(inFlightComplete());
}
}
}
// execute the action
next(action);
};
};

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Dispatch, Middleware } from 'redux';
import {
loadingIndicator as defaultLoadingIndicator,
LoadingIndicatorInterface,
} from '../../lib/loading_indicator';
// @ts-ignore
import { convert } from '../../lib/modify_path';
interface InFlightMiddlewareOptions {
pendingCache: string[];
loadingIndicator: LoadingIndicatorInterface;
}
import {
Action as AnyAction,
inFlightActive,
inFlightActiveActionType,
inFlightComplete,
inFlightCompleteActionType,
setLoadingActionType,
setValueActionType,
} from '../actions/resolved_args';
const pathToKey = (path: any[]) => convert(path).join('/');
export const inFlightMiddlewareFactory = ({
loadingIndicator,
pendingCache,
}: InFlightMiddlewareOptions): Middleware => {
return ({ dispatch }) => (next: Dispatch) => {
return (action: AnyAction) => {
if (action.type === setLoadingActionType) {
const cacheKey = pathToKey(action.payload.path);
pendingCache.push(cacheKey);
dispatch(inFlightActive());
} else if (action.type === setValueActionType) {
const cacheKey = pathToKey(action.payload.path);
const idx = pendingCache.indexOf(cacheKey);
if (idx >= 0) {
pendingCache.splice(idx, 1);
}
if (pendingCache.length === 0) {
dispatch(inFlightComplete());
}
} else if (action.type === inFlightActiveActionType) {
loadingIndicator.show();
} else if (action.type === inFlightCompleteActionType) {
loadingIndicator.hide();
}
// execute the action
next(action);
};
};
};
export const inFlight = inFlightMiddlewareFactory({
loadingIndicator: defaultLoadingIndicator,
pendingCache: [],
});

Some files were not shown because too many files have changed in this diff Show more