Add new "references" attribute to saved objects for relationships (#28199)

* Add new references attribute to saved objects

* Add dual support for dashboard export API

* Use new relationships API supporting legacy relationships extraction

* Code cleanup

* Fix style and CI error

* Add missing spaces test for findRelationships

* Convert collect_references_deep to typescript

* Add missing trailing commas

* Fix broken test by making saved object API consistently return references

* Fix broken api integration tests

* Add comment about the two TS types for saved object

* Only return title from the attributes returned in findRelationships

* Fix broken test

* Add missing security tests

* Drop filterTypes support

* Implement references to search, dashboard, visualization, graph

* Add index pattern migration to dashboards

* Add references mapping to dashboard mppings.json

* Remove findRelationships from repository and into it's own function / file

* Apply PR feedback pt1

* Fix some failing tests

* Remove error throwing in migrations

* Add references to edit saved object screen

* Pass types to findRelationships

* [ftr] restore snapshots from master, rely on migrations to add references

* [security] remove `find_relationships` action

* remove data set modifications

* [security/savedObjectsClient] remove _getAuthorizedTypes method

* fix security & spaces tests to consider references and migrationVersion

* Add space id prefixes to es_archiver/saved_objects/spaces/data.json

* Rename referenced attributes to have a suffix of RefName

* Fix length check in scenario references doesn't exist

* Add test for inject references to not be called when references array is empty or missing

* some code cleanup

* Make migrations run on machine learning data files, fix rollup filterPath for savedSearchRefName

* fix broken test

* Fix collector.js to include references in elasticsearch response

* code cleanup pt2

* add some more tests

* fix broken tests

* updated documentation on referencedBy option for saved object client find function

* Move visualization migrations into kibana plugin

* Update docs with better description on references

* Apply PR feedback

* Fix merge

* fix tests I broke adressing PR feedback

* PR feedback pt2
This commit is contained in:
Mike Côté 2019-01-30 15:53:03 -05:00 committed by GitHub
parent 410c094547
commit 1b0f595f01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 3123 additions and 1171 deletions

View file

@ -33,6 +33,9 @@ contains the following properties:
`attributes` (required)::
(object) The data to persist
`references` (optional)::
(array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export.
`version` (optional)::
(number) Enables specifying a version

View file

@ -33,6 +33,8 @@ Note: You cannot access this endpoint via the Console in Kibana.
`attributes` (required)::
(object) The data to persist
`references` (optional)::
(array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export.
==== Examples

View file

@ -29,6 +29,8 @@ Note: You cannot access this endpoint via the Console in Kibana.
(array|string) The fields to return in the response
`sort_field` (optional)::
(string) The field on which the response will be sorted
`has_reference` (optional)::
(object) Filters to objects having a relationship with the type and id combination
[NOTE]
==============================================

View file

@ -26,6 +26,8 @@ Note: You cannot access this endpoint via the Console in Kibana.
`attributes` (required)::
(object) The data to persist
`references` (optional)::
(array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export.
==== Examples

View file

@ -42,7 +42,7 @@
}
}
},
"savedSearchId": {
"savedSearchRefName": {
"type": "keyword"
},
"title": {

View file

@ -19,9 +19,53 @@
import { cloneDeep, get, omit } from 'lodash';
function migrateIndexPattern(doc) {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
if (typeof searchSourceJSON !== 'string') {
return;
}
let searchSource;
try {
searchSource = JSON.parse(searchSourceJSON);
} catch (e) {
// Let it go, the data is invalid and we'll leave it as is
return;
}
if (!searchSource.index) {
return;
}
doc.references.push({
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: searchSource.index,
});
searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index';
delete searchSource.index;
doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource);
}
export const migrations = {
visualization: {
'7.0.0': (doc) => {
// Set new "references" attribute
doc.references = doc.references || [];
// Migrate index pattern
migrateIndexPattern(doc);
// Migrate saved search
const savedSearchId = get(doc, 'attributes.savedSearchId');
if (savedSearchId) {
doc.references.push({
type: 'search',
name: 'search_0',
id: savedSearchId,
});
doc.attributes.savedSearchRefName = 'search_0';
delete doc.attributes.savedSearchId;
}
// Migrate table splits
try {
const visState = JSON.parse(doc.attributes.visState);
if (get(visState, 'type') !== 'table') {
@ -55,5 +99,52 @@ export const migrations = {
throw new Error(`Failure attempting to migrate saved object '${doc.attributes.title}' - ${e}`);
}
}
}
},
dashboard: {
'7.0.0': (doc) => {
// Set new "references" attribute
doc.references = doc.references || [];
// Migrate index pattern
migrateIndexPattern(doc);
// Migrate panels
const panelsJSON = get(doc, 'attributes.panelsJSON');
if (typeof panelsJSON !== 'string') {
return doc;
}
let panels;
try {
panels = JSON.parse(panelsJSON);
} catch (e) {
// Let it go, the data is invalid and we'll leave it as is
return doc;
}
if (!Array.isArray(panels)) {
return doc;
}
panels.forEach((panel, i) => {
if (!panel.type || !panel.id) {
return;
}
panel.panelRefName = `panel_${i}`;
doc.references.push({
name: `panel_${i}`,
type: panel.type,
id: panel.id,
});
delete panel.type;
delete panel.id;
});
doc.attributes.panelsJSON = JSON.stringify(panels);
return doc;
},
},
search: {
'7.0.0': (doc) => {
// Set new "references" attribute
doc.references = doc.references || [];
// Migrate index pattern
migrateIndexPattern(doc);
return doc;
},
},
};

View file

@ -19,8 +19,7 @@
import { migrations } from './migrations';
describe('table vis migrations', () => {
describe('visualization', () => {
describe('7.0.0', () => {
const migrate = doc => migrations.visualization['7.0.0'](doc);
const generateDoc = ({ type, aggs }) => ({
@ -31,9 +30,296 @@ describe('table vis migrations', () => {
uiStateJSON: '{}',
version: 1,
kibanaSavedObjectMeta: {
searchSourceJSON: '{}'
}
}
searchSourceJSON: '{}',
},
},
});
it('does not throw error on empty object', () => {
const migratedDoc = migrate({
attributes: {
visState: '{}',
},
});
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"visState": "{}",
},
"references": Array [],
}
`);
});
it('skips errors when searchSourceJSON is null', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: null,
},
savedSearchId: '123',
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": null,
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('skips errors when searchSourceJSON is undefined', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: undefined,
},
savedSearchId: '123',
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": undefined,
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('skips error when searchSourceJSON is not a string', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: 123,
},
savedSearchId: '123',
},
};
expect(migrate(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": 123,
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('skips error when searchSourceJSON is invalid json', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: '{abc123}',
},
savedSearchId: '123',
},
};
expect(migrate(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{abc123}",
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('skips error when "index" is missing from searchSourceJSON', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true }),
},
savedSearchId: '123',
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true}",
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('extracts "index" attribute from doc', () => {
const doc = {
id: '1',
type: 'visualization',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }),
},
savedSearchId: '123',
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "pattern*",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
}
`);
});
it('skips extracting savedSearchId when missing', () => {
const doc = {
id: '1',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: '{}',
},
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{}",
},
"visState": "{}",
},
"id": "1",
"references": Array [],
}
`);
});
it('extract savedSearchId from doc', () => {
const doc = {
id: '1',
attributes: {
visState: '{}',
kibanaSavedObjectMeta: {
searchSourceJSON: '{}',
},
savedSearchId: '123',
},
};
const migratedDoc = migrate(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{}",
},
"savedSearchRefName": "search_0",
"visState": "{}",
},
"id": "1",
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
}
`);
});
it('should return a new object if vis is table and has multiple split aggs', () => {
@ -41,17 +327,17 @@ describe('table vis migrations', () => {
{
id: '1',
schema: 'metric',
params: {}
params: {},
},
{
id: '2',
schema: 'split',
params: { foo: 'bar', row: true }
params: { foo: 'bar', row: true },
},
{
id: '3',
schema: 'split',
params: { hey: 'ya', row: false }
params: { hey: 'ya', row: false },
},
];
const tableDoc = generateDoc({ type: 'table', aggs });
@ -73,18 +359,18 @@ describe('table vis migrations', () => {
{
id: '1',
schema: 'metric',
params: {}
params: {},
},
{
id: '2',
schema: 'split',
params: { foo: 'bar', row: true }
params: { foo: 'bar', row: true },
},
{
id: '3',
schema: 'segment',
params: { hey: 'ya' }
}
params: { hey: 'ya' },
},
];
const pieDoc = generateDoc({ type: 'pie', aggs });
const expected = pieDoc;
@ -97,13 +383,13 @@ describe('table vis migrations', () => {
{
id: '1',
schema: 'metric',
params: {}
params: {},
},
{
id: '2',
schema: 'split',
params: { foo: 'bar', row: true }
}
params: { foo: 'bar', row: true },
},
];
const tableDoc = generateDoc({ type: 'table', aggs });
const expected = tableDoc;
@ -116,23 +402,23 @@ describe('table vis migrations', () => {
{
id: '1',
schema: 'metric',
params: {}
params: {},
},
{
id: '2',
schema: 'split',
params: { foo: 'bar', row: true }
params: { foo: 'bar', row: true },
},
{
id: '3',
schema: 'split',
params: { hey: 'ya', row: false }
params: { hey: 'ya', row: false },
},
{
id: '4',
schema: 'bucket',
params: { heyyy: 'yaaa' }
}
params: { heyyy: 'yaaa' },
},
];
const expected = ['metric', 'split', 'bucket', 'bucket'];
const migrated = migrate(generateDoc({ type: 'table', aggs }));
@ -145,18 +431,18 @@ describe('table vis migrations', () => {
{
id: '1',
schema: 'metric',
params: {}
params: {},
},
{
id: '2',
schema: 'split',
params: { foo: 'bar', row: true }
params: { foo: 'bar', row: true },
},
{
id: '3',
schema: 'split',
params: { hey: 'ya', row: false }
}
params: { hey: 'ya', row: false },
},
];
const expected = [{}, { foo: 'bar', row: true }, { hey: 'ya' }];
const migrated = migrate(generateDoc({ type: 'table', aggs }));
@ -173,12 +459,555 @@ describe('table vis migrations', () => {
uiStateJSON: '{}',
version: 1,
kibanaSavedObjectMeta: {
searchSourceJSON: '{}'
}
}
searchSourceJSON: '{}',
},
},
};
expect(() => migrate(doc)).toThrowError(/My Vis/);
});
});
});
describe('dashboard', () => {
describe('7.0.0', () => {
const migration = migrations.dashboard['7.0.0'];
test('skips error on empty object', () => {
expect(migration({})).toMatchInlineSnapshot(`
Object {
"references": Array [],
}
`);
});
test('skips errors when searchSourceJSON is null', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: null,
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": null,
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('skips errors when searchSourceJSON is undefined', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: undefined,
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": undefined,
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('skips error when searchSourceJSON is not a string', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: 123,
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": 123,
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('skips error when searchSourceJSON is invalid json', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: '{abc123}',
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{abc123}",
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('skips error when "index" is missing from searchSourceJSON', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true }),
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true}",
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('extracts "index" attribute from doc', () => {
const doc = {
id: '1',
type: 'dashboard',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }),
},
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "pattern*",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
}
`);
});
test('skips error when panelsJSON is not a string', () => {
const doc = {
id: '1',
attributes: {
panelsJSON: 123,
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": 123,
},
"id": "1",
"references": Array [],
}
`);
});
test('skips error when panelsJSON is not valid JSON', () => {
const doc = {
id: '1',
attributes: {
panelsJSON: '{123abc}',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "{123abc}",
},
"id": "1",
"references": Array [],
}
`);
});
test('skips panelsJSON when its not an array', () => {
const doc = {
id: '1',
attributes: {
panelsJSON: '{}',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "{}",
},
"id": "1",
"references": Array [],
}
`);
});
test('skips error when a panel is missing "type" attribute', () => {
const doc = {
id: '1',
attributes: {
panelsJSON: '[{"id":"123"}]',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "[{\\"id\\":\\"123\\"}]",
},
"id": "1",
"references": Array [],
}
`);
});
test('skips error when a panel is missing "id" attribute', () => {
const doc = {
id: '1',
attributes: {
panelsJSON: '[{"type":"visualization"}]',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "[{\\"type\\":\\"visualization\\"}]",
},
"id": "1",
"references": Array [],
}
`);
});
test('extract panel references from doc', () => {
const doc = {
id: '1',
attributes: {
panelsJSON:
'[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]',
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
}
`);
});
});
});
describe('search', () => {
describe('7.0.0', () => {
const migration = migrations.search['7.0.0'];
test('skips errors when searchSourceJSON is null', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: null,
},
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": null,
},
},
"id": "123",
"references": Array [],
"type": "search",
}
`);
});
test('skips errors when searchSourceJSON is undefined', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: undefined,
},
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": undefined,
},
},
"id": "123",
"references": Array [],
"type": "search",
}
`);
});
test('skips error when searchSourceJSON is not a string', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: 123,
},
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": 123,
},
},
"id": "123",
"references": Array [],
"type": "search",
}
`);
});
test('skips error when searchSourceJSON is invalid json', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: '{abc123}',
},
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{abc123}",
},
},
"id": "123",
"references": Array [],
"type": "search",
}
`);
});
test('skips error when "index" is missing from searchSourceJSON', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true }),
},
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true}",
},
},
"id": "123",
"references": Array [],
"type": "search",
}
`);
});
test('extracts "index" attribute from doc', () => {
const doc = {
id: '123',
type: 'search',
attributes: {
foo: true,
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }),
},
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "123",
"references": Array [
Object {
"id": "pattern*",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "search",
}
`);
});
});
});

View file

@ -22,6 +22,10 @@ import { uiModules } from 'ui/modules';
import { createDashboardEditUrl } from '../dashboard_constants';
import { createLegacyClass } from 'ui/utils/legacy_class';
import { SavedObjectProvider } from 'ui/courier';
import {
extractReferences,
injectReferences,
} from './saved_dashboard_references';
const module = uiModules.get('app/dashboard');
@ -37,6 +41,8 @@ module.factory('SavedDashboard', function (Private, config, i18n) {
type: SavedDashboard.type,
mapping: SavedDashboard.mapping,
searchSource: SavedDashboard.searchsource,
extractReferences: extractReferences,
injectReferences: injectReferences,
// if this is null/undefined then the SavedObject will be assigned the defaults
id: id,

View file

@ -0,0 +1,78 @@
/*
* 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 extractReferences({ attributes, references = [] }) {
const panelReferences = [];
const panels = JSON.parse(attributes.panelsJSON);
panels.forEach((panel, i) => {
if (!panel.type) {
throw new Error(`"type" attribute is missing from panel "${i}"`);
}
if (!panel.id) {
throw new Error(`"id" attribute is missing from panel "${i}"`);
}
panel.panelRefName = `panel_${i}`;
panelReferences.push({
name: `panel_${i}`,
type: panel.type,
id: panel.id,
});
delete panel.type;
delete panel.id;
});
return {
references: [
...references,
...panelReferences,
],
attributes: {
...attributes,
panelsJSON: JSON.stringify(panels),
},
};
}
export function injectReferences(savedObject, references) {
// Skip if panelsJSON is missing otherwise this will cause saved object import to fail when
// importing objects without panelsJSON. At development time of this, there is no guarantee each saved
// object has panelsJSON in all previous versions of kibana.
if (typeof savedObject.panelsJSON !== 'string') {
return;
}
const panels = JSON.parse(savedObject.panelsJSON);
// Same here, prevent failing saved object import if ever panels aren't an array.
if (!Array.isArray(panels)) {
return;
}
panels.forEach((panel) => {
if (!panel.panelRefName) {
return;
}
const reference = references.find(reference => reference.name === panel.panelRefName);
if (!reference) {
// Throw an error since "panelRefName" means the reference exists within
// "references" and in this scenario we have bad data.
throw new Error(`Could not find reference "${panel.panelRefName}"`);
}
panel.id = reference.id;
panel.type = reference.type;
delete panel.panelRefName;
});
savedObject.panelsJSON = JSON.stringify(panels);
}

View file

@ -0,0 +1,220 @@
/*
* 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 { extractReferences, injectReferences } from './saved_dashboard_references';
describe('extractReferences', () => {
test('extracts references from panelsJSON', () => {
const doc = {
id: '1',
attributes: {
foo: true,
panelsJSON: JSON.stringify([
{
type: 'visualization',
id: '1',
title: 'Title 1',
},
{
type: 'visualization',
id: '2',
title: 'Title 2',
},
]),
},
};
const updatedDoc = extractReferences(doc);
/* eslint-disable max-len */
expect(updatedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_0\\"},{\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_1\\"}]",
},
"references": Array [
Object {
"id": "1",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "2",
"name": "panel_1",
"type": "visualization",
},
],
}
`);
/* eslint-enable max-len */
});
test('fails when "type" attribute is missing from a panel', () => {
const doc = {
id: '1',
attributes: {
foo: true,
panelsJSON: JSON.stringify([
{
id: '1',
title: 'Title 1',
},
]),
},
};
expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot(
`"\\"type\\" attribute is missing from panel \\"0\\""`
);
});
test('fails when "id" attribute is missing from a panel', () => {
const doc = {
id: '1',
attributes: {
foo: true,
panelsJSON: JSON.stringify([
{
type: 'visualization',
title: 'Title 1',
},
]),
},
};
expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot(
`"\\"id\\" attribute is missing from panel \\"0\\""`
);
});
});
describe('injectReferences', () => {
test('injects references into context', () => {
const context = {
id: '1',
foo: true,
panelsJSON: JSON.stringify([
{
panelRefName: 'panel_0',
title: 'Title 1',
},
{
panelRefName: 'panel_1',
title: 'Title 2',
},
]),
};
const references = [
{
name: 'panel_0',
type: 'visualization',
id: '1',
},
{
name: 'panel_1',
type: 'visualization',
id: '2',
},
];
injectReferences(context, references);
/* eslint-disable max-len */
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\",\\"type\\":\\"visualization\\"}]",
}
`);
/* eslint-enable max-len */
});
test('skips when panelsJSON is missing', () => {
const context = {
id: '1',
foo: true,
};
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
}
`);
});
test('skips when panelsJSON is not an array', () => {
const context = {
id: '1',
foo: true,
panelsJSON: '{}',
};
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
"panelsJSON": "{}",
}
`);
});
test('skips a panel when panelRefName is missing', () => {
const context = {
id: '1',
foo: true,
panelsJSON: JSON.stringify([
{
panelRefName: 'panel_0',
title: 'Title 1',
},
{
title: 'Title 2',
},
]),
};
const references = [
{
name: 'panel_0',
type: 'visualization',
id: '1',
},
];
injectReferences(context, references);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
"panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\"}]",
}
`);
});
test(`fails when it can't find the reference in the array`, () => {
const context = {
id: '1',
foo: true,
panelsJSON: JSON.stringify([
{
panelRefName: 'panel_0',
title: 'Title 1',
},
]),
};
expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot(
`"Could not find reference \\"panel_0\\""`
);
});
});

View file

@ -125,6 +125,14 @@ uiModules.get('apps/management')
value: '{}'
});
}
if (!fieldMap.references) {
fields.push({
name: 'references',
type: 'array',
value: '[]',
});
}
};
$scope.notFound = $routeParams.notFound;
@ -136,7 +144,10 @@ uiModules.get('apps/management')
$scope.obj = obj;
$scope.link = service.urlFor(obj.id);
const fields = _.reduce(obj.attributes, createField, []);
const fields = _.reduce(obj.attributes, createField, []);
// Special handling for references which isn't within "attributes"
createField(fields, obj.references, 'references');
if (service.Class) readObjectClass(fields, service.Class);
// sorts twice since we want numerical sort to prioritize over name,
@ -234,7 +245,9 @@ uiModules.get('apps/management')
_.set(source, field.name, value);
});
savedObjectsClient.update(service.type, $routeParams.id, source)
const { references, ...attributes } = source;
savedObjectsClient.update(service.type, $routeParams.id, attributes, { references })
.then(function () {
return redirectHandler('updated');
})

View file

@ -83,12 +83,12 @@ describe('Relationships', () => {
it('should render searches normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
indexPatterns: [
'index-pattern': [
{
id: '1',
}
],
visualizations: [
visualization: [
{
id: '2',
}
@ -123,7 +123,7 @@ describe('Relationships', () => {
it('should render visualizations normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
dashboards: [
dashboard: [
{
id: '1',
},
@ -161,7 +161,7 @@ describe('Relationships', () => {
it('should render dashboards normally', async () => {
const props = {
getRelationships: jest.fn().mockImplementation(() => ({
visualizations: [
visualization: [
{
id: '1',
},

View file

@ -148,7 +148,7 @@ class RelationshipsUI extends Component {
/>);
break;
case 'search':
if (type === 'visualizations') {
if (type === 'visualization') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.search.visualizations.calloutText"
defaultMessage="Here are some visualizations that use this saved search. If
@ -168,22 +168,44 @@ class RelationshipsUI extends Component {
}
break;
case 'visualization':
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.calloutText"
defaultMessage="Here are some dashboards which contain this visualization. If
you delete this visualization, these dashboards will no longer
show them."
/>);
if (type === 'index-pattern') {
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.indexPattern.calloutTitle"
defaultMessage="Index Pattern"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.indexPattern.calloutText"
defaultMessage="Here is the index pattern tied to this visualization."
/>);
} else if (type === 'search') {
calloutColor = 'success';
calloutTitle = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.search.calloutTitle"
defaultMessage="Saved Search"
/>);
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.search.calloutText"
defaultMessage="Here is the saved search tied to this visualization."
/>);
} else {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.visualization.calloutText"
defaultMessage="Here are some dashboards which contain this visualization. If
you delete this visualization, these dashboards will no longer
show them."
/>);
}
break;
case 'index-pattern':
if (type === 'visualizations') {
if (type === 'visualization') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.indexPattern.visualizations.calloutText"
defaultMessage="Here are some visualizations that use this index pattern. If
you delete this index pattern, these visualizations will not
longer work properly."
/>);
} else if (type === 'searches') {
} else if (type === 'search') {
calloutText = (<FormattedMessage
id="kbn.management.objects.objectsTable.relationships.indexPattern.searches.calloutText"
defaultMessage="Here are some saved searches that use this index pattern. If

View file

@ -82,7 +82,10 @@ async function importIndexPattern(doc, indexPatterns, overwriteAll) {
}
async function importDocument(obj, doc, overwriteAll) {
await obj.applyESResp(doc);
await obj.applyESResp({
references: doc._references || [],
...doc,
});
return await obj.save({ confirmOverwrite: !overwriteAll });
}

View file

@ -27,6 +27,7 @@ export async function retrieveAndExportDocs(objs, savedObjectsClient) {
_type: obj.type,
_source: obj.attributes,
_migrationVersion: obj.migrationVersion,
_references: obj.references,
};
});

View file

@ -31,6 +31,10 @@ import { updateOldState } from 'ui/vis/vis_update_state';
import { VisualizeConstants } from '../visualize_constants';
import { createLegacyClass } from 'ui/utils/legacy_class';
import { SavedObjectProvider } from 'ui/courier';
import {
extractReferences,
injectReferences,
} from './saved_visualization_references';
uiModules
.get('app/visualize')
@ -47,6 +51,8 @@ uiModules
type: SavedVis.type,
mapping: SavedVis.mapping,
searchSource: SavedVis.searchSource,
extractReferences: extractReferences,
injectReferences: injectReferences,
id: opts.id,
indexPattern: opts.indexPattern,

View file

@ -0,0 +1,51 @@
/*
* 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 extractReferences({ attributes, references = [] }) {
if (!attributes.savedSearchId) {
return { attributes, references };
}
return {
references: [
...references,
{
type: 'search',
name: 'search_0',
id: attributes.savedSearchId,
},
],
attributes: {
...attributes,
savedSearchId: undefined,
savedSearchRefName: 'search_0',
},
};
}
export function injectReferences(savedObject, references) {
if (!savedObject.savedSearchRefName) {
return;
}
const reference = references.find(reference => reference.name === savedObject.savedSearchRefName);
if (!reference) {
throw new Error(`Could not find reference "${savedObject.savedSearchRefName}"`);
}
savedObject.savedSearchId = reference.id;
delete savedObject.savedSearchRefName;
}

View file

@ -0,0 +1,117 @@
/*
* 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 { extractReferences, injectReferences } from './saved_visualization_references';
describe('extractReferences', () => {
test('extracts nothing if savedSearchId is empty', () => {
const doc = {
id: '1',
attributes: {
foo: true,
},
};
const updatedDoc = extractReferences(doc);
expect(updatedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
},
"references": Array [],
}
`);
});
test('extracts references from savedSearchId', () => {
const doc = {
id: '1',
attributes: {
foo: true,
savedSearchId: '123',
},
};
const updatedDoc = extractReferences(doc);
expect(updatedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"savedSearchId": undefined,
"savedSearchRefName": "search_0",
},
"references": Array [
Object {
"id": "123",
"name": "search_0",
"type": "search",
},
],
}
`);
});
});
describe('injectReferences', () => {
test('injects nothing when savedSearchRefName is null', () => {
const context = {
id: '1',
foo: true,
};
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
}
`);
});
test('injects references into context', () => {
const context = {
id: '1',
foo: true,
savedSearchRefName: 'search_0',
};
const references = [
{
name: 'search_0',
type: 'search',
id: '123',
},
];
injectReferences(context, references);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
"savedSearchId": "123",
}
`);
});
test(`fails when it can't find the reference in the array`, () => {
const context = {
id: '1',
foo: true,
savedSearchRefName: 'search_0',
};
expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot(
`"Could not find reference \\"search_0\\""`
);
});
});

View file

@ -27,28 +27,44 @@ describe('findRelationships', () => {
const size = 10;
const savedObjectsClient = {
_index: '.kibana',
get: () => ({
attributes: {
panelsJSON: JSON.stringify([{ id: '1' }, { id: '2' }, { id: '3' }]),
panelsJSON: JSON.stringify([{ panelRefName: 'panel_0' }, { panelRefName: 'panel_1' }, { panelRefName: 'panel_2' }]),
},
references: [{
name: 'panel_0',
type: 'visualization',
id: '1',
}, {
name: 'panel_1',
type: 'visualization',
id: '2',
}, {
name: 'panel_2',
type: 'visualization',
id: '3',
}],
}),
bulkGet: () => ({
bulkGet: () => ({ saved_objects: [] }),
find: () => ({
saved_objects: [
{
id: '1',
type: 'visualization',
attributes: {
title: 'Foo',
},
},
{
id: '2',
type: 'visualization',
attributes: {
title: 'Bar',
},
},
{
id: '3',
type: 'visualization',
attributes: {
title: 'FooBar',
},
@ -59,11 +75,14 @@ describe('findRelationships', () => {
const result = await findRelationships(
type,
id,
size,
savedObjectsClient
{
size,
savedObjectsClient,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualizations: [
visualization: [
{ id: '1', title: 'Foo' },
{ id: '2', title: 'Bar' },
{ id: '3', title: 'FooBar' },
@ -77,11 +96,36 @@ describe('findRelationships', () => {
const size = 10;
const savedObjectsClient = {
get: () => {},
get: () => ({
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
}),
},
},
references: [{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: '1',
}],
}),
bulkGet: () => ({
saved_objects: [
{
id: '1',
type: 'index-pattern',
attributes: {
title: 'My Index Pattern',
},
},
],
}),
find: () => ({
saved_objects: [
{
id: '1',
type: 'dashboard',
attributes: {
title: 'My Dashboard',
panelsJSON: JSON.stringify([
@ -98,6 +142,7 @@ describe('findRelationships', () => {
},
{
id: '2',
type: 'dashboard',
attributes: {
title: 'Your Dashboard',
panelsJSON: JSON.stringify([
@ -119,11 +164,17 @@ describe('findRelationships', () => {
const result = await findRelationships(
type,
id,
size,
savedObjectsClient
{
size,
savedObjectsClient,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
dashboards: [
'index-pattern': [
{ id: '1', title: 'My Index Pattern' },
],
dashboard: [
{ id: '1', title: 'My Dashboard' },
{ id: '2', title: 'Your Dashboard' },
],
@ -136,43 +187,52 @@ describe('findRelationships', () => {
const size = 10;
const savedObjectsClient = {
get: type => {
if (type === 'search') {
return {
id: '1',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'index-pattern:1',
}),
},
},
};
}
return {
id: 'index-pattern:1',
attributes: {
title: 'My Index Pattern',
get: () => ({
id: '1',
type: 'search',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
}),
},
};
},
},
references: [{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: '1',
}],
}),
bulkGet: () => ({
saved_objects: [
{
id: '1',
type: 'index-pattern',
attributes: {
title: 'My Index Pattern',
},
},
],
}),
find: () => ({
saved_objects: [
{
id: '1',
type: 'visualization',
attributes: {
title: 'Foo',
},
},
{
id: '2',
type: 'visualization',
attributes: {
title: 'Bar',
},
},
{
id: '3',
type: 'visualization',
attributes: {
title: 'FooBar',
},
@ -184,16 +244,19 @@ describe('findRelationships', () => {
const result = await findRelationships(
type,
id,
size,
savedObjectsClient
{
size,
savedObjectsClient,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualizations: [
visualization: [
{ id: '1', title: 'Foo' },
{ id: '2', title: 'Bar' },
{ id: '3', title: 'FooBar' },
],
indexPatterns: [{ id: 'index-pattern:1', title: 'My Index Pattern' }],
'index-pattern': [{ id: '1', title: 'My Index Pattern' }],
});
});
@ -203,106 +266,103 @@ describe('findRelationships', () => {
const size = 10;
const savedObjectsClient = {
get: () => {},
find: options => {
if (options.type === 'visualization') {
return {
saved_objects: [
{
id: '1',
found: true,
attributes: {
title: 'Foo',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
},
},
{
id: '2',
found: true,
attributes: {
title: 'Bar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
},
},
{
id: '3',
found: true,
attributes: {
title: 'FooBar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo2',
}),
},
},
},
]
};
}
return {
saved_objects: [
{
id: '1',
attributes: {
title: 'Foo',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
get: () => ({
id: '1',
type: 'index-pattern',
attributes: {
title: 'My Index Pattern'
},
}),
find: () => ({
saved_objects: [
{
id: '1',
type: 'visualization',
attributes: {
title: 'Foo',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
},
{
id: '2',
attributes: {
title: 'Bar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
},
{
id: '2',
type: 'visualization',
attributes: {
title: 'Bar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo',
}),
},
},
{
id: '3',
attributes: {
title: 'FooBar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo2',
}),
},
},
{
id: '3',
type: 'visualization',
attributes: {
title: 'FooBar',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
index: 'foo2',
}),
},
},
]
};
}
},
{
id: '1',
type: 'search',
attributes: {
title: 'My Saved Search',
},
},
],
}),
};
const result = await findRelationships(
type,
id,
size,
savedObjectsClient
{
size,
savedObjectsClient,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({
visualizations: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }],
searches: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }],
visualization: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }, { id: '3', title: 'FooBar' }],
search: [{ id: '1', title: 'My Saved Search' }],
});
});
it('should return an empty object for invalid types', async () => {
it('should return an empty object for non related objects', async () => {
const type = 'invalid';
const result = await findRelationships(type);
const id = 'foo';
const size = 10;
const savedObjectsClient = {
get: () => ({
id: '1',
type: 'index-pattern',
attributes: {
title: 'My Index Pattern',
},
references: [],
}),
find: () => ({ saved_objects: [] }),
};
const result = await findRelationships(
type,
id,
{
size,
savedObjectsClient,
savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'],
},
);
expect(result).to.eql({});
});
});

View file

@ -1,89 +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 sinon from 'sinon';
import * as deps from '../collect_panels';
import { collectDashboards } from '../collect_dashboards';
import { expect } from 'chai';
describe('collectDashboards(req, ids)', () => {
let collectPanelsStub;
const savedObjectsClient = { bulkGet: sinon.stub() };
const ids = ['dashboard-01', 'dashboard-02'];
beforeEach(() => {
collectPanelsStub = sinon.stub(deps, 'collectPanels');
collectPanelsStub.onFirstCall().returns(Promise.resolve([
{ id: 'dashboard-01' },
{ id: 'panel-01' },
{ id: 'index-*' }
]));
collectPanelsStub.onSecondCall().returns(Promise.resolve([
{ id: 'dashboard-02' },
{ id: 'panel-01' },
{ id: 'index-*' }
]));
savedObjectsClient.bulkGet.returns(Promise.resolve({
saved_objects: [
{ id: 'dashboard-01' }, { id: 'dashboard-02' }
]
}));
});
afterEach(() => {
collectPanelsStub.restore();
savedObjectsClient.bulkGet.resetHistory();
});
it('should request all dashboards', async () => {
await collectDashboards(savedObjectsClient, ids);
expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true);
const args = savedObjectsClient.bulkGet.getCall(0).args;
expect(args[0]).to.eql([{
id: 'dashboard-01',
type: 'dashboard'
}, {
id: 'dashboard-02',
type: 'dashboard'
}]);
});
it('should call collectPanels with dashboard docs', async () => {
await collectDashboards(savedObjectsClient, ids);
expect(collectPanelsStub.calledTwice).to.equal(true);
expect(collectPanelsStub.args[0][1]).to.eql({ id: 'dashboard-01' });
expect(collectPanelsStub.args[1][1]).to.eql({ id: 'dashboard-02' });
});
it('should return an unique list of objects', async () => {
const results = await collectDashboards(savedObjectsClient, ids);
expect(results).to.eql([
{ id: 'dashboard-01' },
{ id: 'panel-01' },
{ id: 'index-*' },
{ id: 'dashboard-02' },
]);
});
});

View file

@ -1,106 +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 sinon from 'sinon';
import { collectIndexPatterns } from '../collect_index_patterns';
import { expect } from 'chai';
describe('collectIndexPatterns(req, panels)', () => {
const panels = [
{
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'index-*' })
}
}
}, {
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'logstash-*' })
}
}
}, {
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'logstash-*' })
}
}
}, {
attributes: {
savedSearchId: 1,
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'bad-*' })
}
}
}
];
const savedObjectsClient = { bulkGet: sinon.stub() };
beforeEach(() => {
savedObjectsClient.bulkGet.returns(Promise.resolve({
saved_objects: [
{ id: 'index-*' }, { id: 'logstash-*' }
]
}));
});
afterEach(() => {
savedObjectsClient.bulkGet.resetHistory();
});
it('should request all index patterns', async () => {
await collectIndexPatterns(savedObjectsClient, panels);
expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true);
expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{
id: 'index-*',
type: 'index-pattern'
}, {
id: 'logstash-*',
type: 'index-pattern'
}]);
});
it('should return the index pattern docs', async () => {
const results = await collectIndexPatterns(savedObjectsClient, panels);
expect(results).to.eql([
{ id: 'index-*' },
{ id: 'logstash-*' }
]);
});
it('should return an empty array if nothing is requested', async () => {
const input = [
{
attributes: {
savedSearchId: 1,
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'bad-*' })
}
}
}
];
const results = await collectIndexPatterns(savedObjectsClient, input);
expect(results).to.eql([]);
expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false);
});
});

View file

@ -1,105 +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 sinon from 'sinon';
import * as collectIndexPatternsDep from '../collect_index_patterns';
import * as collectSearchSourcesDep from '../collect_search_sources';
import { collectPanels } from '../collect_panels';
import { expect } from 'chai';
describe('collectPanels(req, dashboard)', () => {
let collectSearchSourcesStub;
let collectIndexPatternsStub;
let dashboard;
const savedObjectsClient = { bulkGet: sinon.stub() };
beforeEach(() => {
dashboard = {
attributes: {
panelsJSON: JSON.stringify([
{ id: 'panel-01', type: 'search' },
{ id: 'panel-02', type: 'visualization' }
])
}
};
savedObjectsClient.bulkGet.returns(Promise.resolve({
saved_objects: [
{ id: 'panel-01' }, { id: 'panel-02' }
]
}));
collectIndexPatternsStub = sinon.stub(collectIndexPatternsDep, 'collectIndexPatterns');
collectIndexPatternsStub.returns([{ id: 'logstash-*' }]);
collectSearchSourcesStub = sinon.stub(collectSearchSourcesDep, 'collectSearchSources');
collectSearchSourcesStub.returns([ { id: 'search-01' }]);
});
afterEach(() => {
collectSearchSourcesStub.restore();
collectIndexPatternsStub.restore();
savedObjectsClient.bulkGet.resetHistory();
});
it('should request each panel in the panelJSON', async () => {
await collectPanels(savedObjectsClient, dashboard);
expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true);
expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{
id: 'panel-01',
type: 'search'
}, {
id: 'panel-02',
type: 'visualization'
}]);
});
it('should call collectSearchSources()', async () => {
await collectPanels(savedObjectsClient, dashboard);
expect(collectSearchSourcesStub.calledOnce).to.equal(true);
expect(collectSearchSourcesStub.args[0][1]).to.eql([
{ id: 'panel-01' },
{ id: 'panel-02' }
]);
});
it('should call collectIndexPatterns()', async () => {
await collectPanels(savedObjectsClient, dashboard);
expect(collectIndexPatternsStub.calledOnce).to.equal(true);
expect(collectIndexPatternsStub.args[0][1]).to.eql([
{ id: 'panel-01' },
{ id: 'panel-02' }
]);
});
it('should return panels, index patterns, search sources, and dashboard', async () => {
const results = await collectPanels(savedObjectsClient, dashboard);
expect(results).to.eql([
{ id: 'panel-01' },
{ id: 'panel-02' },
{ id: 'logstash-*' },
{ id: 'search-01' },
dashboard
]);
});
});

View file

@ -1,85 +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 sinon from 'sinon';
import * as deps from '../collect_index_patterns';
import { collectSearchSources } from '../collect_search_sources';
import { expect } from 'chai';
describe('collectSearchSources(req, panels)', () => {
const savedObjectsClient = { bulkGet: sinon.stub() };
let panels;
let collectIndexPatternsStub;
beforeEach(() => {
panels = [
{ attributes: { savedSearchId: 1 } },
{ attributes: { savedSearchId: 2 } }
];
collectIndexPatternsStub = sinon.stub(deps, 'collectIndexPatterns');
collectIndexPatternsStub.returns(Promise.resolve([{ id: 'logstash-*' }]));
savedObjectsClient.bulkGet.returns(Promise.resolve({
saved_objects: [
{ id: 1 }, { id: 2 }
]
}));
});
afterEach(() => {
collectIndexPatternsStub.restore();
savedObjectsClient.bulkGet.resetHistory();
});
it('should request all search sources', async () => {
await collectSearchSources(savedObjectsClient, panels);
expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true);
expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([
{ type: 'search', id: 1 }, { type: 'search', id: 2 }
]);
});
it('should return the search source and index patterns', async () => {
const results = await collectSearchSources(savedObjectsClient, panels);
expect(results).to.eql([
{ id: 1 },
{ id: 2 },
{ id: 'logstash-*' }
]);
});
it('should return an empty array if nothing is requested', async () => {
const input = [
{
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ index: 'bad-*' })
}
}
}
];
const results = await collectSearchSources(savedObjectsClient, input);
expect(results).to.eql([]);
expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false);
});
});

View file

@ -1,74 +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 * as deps from '../collect_dashboards';
import { exportDashboards } from '../export_dashboards';
import sinon from 'sinon';
import { expect } from 'chai';
describe('exportDashboards(req)', () => {
let req;
let collectDashboardsStub;
beforeEach(() => {
req = {
query: { dashboard: 'dashboard-01' },
server: {
config: () => ({ get: () => '6.0.0' }),
plugins: {
elasticsearch: {
getCluster: () => ({ callWithRequest: sinon.stub() })
}
},
},
getSavedObjectsClient() {
return null;
}
};
collectDashboardsStub = sinon.stub(deps, 'collectDashboards');
collectDashboardsStub.returns(Promise.resolve([
{ id: 'dashboard-01' },
{ id: 'logstash-*' },
{ id: 'panel-01' }
]));
});
afterEach(() => {
collectDashboardsStub.restore();
});
it('should return a response object with version', () => {
return exportDashboards(req).then((resp) => {
expect(resp).to.have.property('version', '6.0.0');
});
});
it('should return a response object with objects', () => {
return exportDashboards(req).then((resp) => {
expect(resp).to.have.property('objects');
expect(resp.objects).to.eql([
{ id: 'dashboard-01' },
{ id: 'logstash-*' },
{ id: 'panel-01' }
]);
});
});
});

View file

@ -1,43 +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 { collectPanels } from './collect_panels';
export async function collectDashboards(savedObjectsClient, ids) {
if (ids.length === 0) return [];
const objects = ids.map(id => {
return {
type: 'dashboard',
id: id
};
});
const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(objects);
const results = await Promise.all(savedObjects.map(d => collectPanels(savedObjectsClient, d)));
return results
.reduce((acc, result) => acc.concat(result), [])
.reduce((acc, obj) => {
if (!acc.find(o => o.id === obj.id)) acc.push(obj);
return acc;
}, []);
}

View file

@ -1,43 +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.
*/
export async function collectIndexPatterns(savedObjectsClient, panels) {
const docs = panels.reduce((acc, panel) => {
const { kibanaSavedObjectMeta, savedSearchId } = panel.attributes;
if (kibanaSavedObjectMeta && kibanaSavedObjectMeta.searchSourceJSON && !savedSearchId) {
let searchSourceData;
try {
searchSourceData = JSON.parse(kibanaSavedObjectMeta.searchSourceJSON);
} catch (err) {
return acc;
}
if (searchSourceData.index && !acc.find(s => s.id === searchSourceData.index)) {
acc.push({ type: 'index-pattern', id: searchSourceData.index });
}
}
return acc;
}, []);
if (docs.length === 0) return [];
const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(docs);
return savedObjects;
}

View file

@ -1,43 +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 { get } from 'lodash';
import { collectIndexPatterns } from './collect_index_patterns';
import { collectSearchSources } from './collect_search_sources';
export async function collectPanels(savedObjectsClient, dashboard) {
let panels;
try {
panels = JSON.parse(get(dashboard, 'attributes.panelsJSON', '[]'));
} catch(err) {
panels = [];
}
if (panels.length === 0) return [].concat([dashboard]);
const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(panels);
const [ indexPatterns, searchSources ] = await Promise.all([
collectIndexPatterns(savedObjectsClient, savedObjects),
collectSearchSources(savedObjectsClient, savedObjects)
]);
return savedObjects.concat(indexPatterns).concat(searchSources).concat([dashboard]);
}

View file

@ -0,0 +1,196 @@
/*
* 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 { SavedObject } from '../../../../../../server/saved_objects/service/saved_objects_client';
import { collectReferencesDeep } from './collect_references_deep';
const data = [
{
id: '1',
type: 'dashboard',
attributes: {
panelsJSON: JSON.stringify([{ panelRefName: 'panel_0' }, { panelRefName: 'panel_1' }]),
},
references: [
{
name: 'panel_0',
type: 'visualization',
id: '2',
},
{
name: 'panel_1',
type: 'visualization',
id: '3',
},
],
},
{
id: '2',
type: 'visualization',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
}),
},
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: '4',
},
],
},
{
id: '3',
type: 'visualization',
attributes: {
savedSearchRefName: 'search_0',
},
references: [
{
name: 'search_0',
type: 'search',
id: '5',
},
],
},
{
id: '4',
type: 'index-pattern',
attributes: {
title: 'pattern*',
},
},
{
id: '5',
type: 'search',
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
}),
},
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: '4',
},
],
},
];
test('collects dashboard and all dependencies', async () => {
const savedObjectClient = {
errors: {} as any,
create: jest.fn(),
bulkCreate: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
get: jest.fn(),
update: jest.fn(),
bulkGet: jest.fn(getObjects => {
return {
saved_objects: getObjects.map((obj: SavedObject) =>
data.find(row => row.id === obj.id && row.type === obj.type)
),
};
}),
};
const objects = await collectReferencesDeep(savedObjectClient, [{ type: 'dashboard', id: '1' }]);
expect(objects).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {
"panelsJSON": "[{\\"panelRefName\\":\\"panel_0\\"},{\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "2",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "3",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
},
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "2",
"references": Array [
Object {
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "visualization",
},
Object {
"attributes": Object {
"savedSearchRefName": "search_0",
},
"id": "3",
"references": Array [
Object {
"id": "5",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
},
Object {
"attributes": Object {
"title": "pattern*",
},
"id": "4",
"type": "index-pattern",
},
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "5",
"references": Array [
Object {
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
});

View file

@ -0,0 +1,55 @@
/*
* 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 {
SavedObject,
SavedObjectsClient,
} from '../../../../../../server/saved_objects/service/saved_objects_client';
const MAX_BULK_GET_SIZE = 10000;
interface ObjectsToCollect {
id: string;
type: string;
}
export async function collectReferencesDeep(
savedObjectClient: SavedObjectsClient,
objects: ObjectsToCollect[]
) {
let result: SavedObject[] = [];
const queue = [...objects];
while (queue.length !== 0) {
const itemsToGet = queue.splice(0, MAX_BULK_GET_SIZE);
const { saved_objects: savedObjects } = await savedObjectClient.bulkGet(itemsToGet);
result = result.concat(savedObjects);
for (const { references = [] } of savedObjects) {
for (const reference of references) {
const isDuplicate = queue
.concat(result)
.some(obj => obj.type === reference.type && obj.id === reference.id);
if (isDuplicate) {
continue;
}
queue.push({ type: reference.type, id: reference.id });
}
}
}
return result;
}

View file

@ -1,39 +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 { collectIndexPatterns } from './collect_index_patterns';
export async function collectSearchSources(savedObjectsClient, panels) {
const docs = panels.reduce((acc, panel) => {
const { savedSearchId } = panel.attributes;
if (savedSearchId) {
if (!acc.find(s => s.id === savedSearchId) && !panels.find(p => p.id === savedSearchId)) {
acc.push({ type: 'search', id: savedSearchId });
}
}
return acc;
}, []);
if (docs.length === 0) return [];
const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(docs);
const indexPatterns = await collectIndexPatterns(savedObjectsClient, savedObjects);
return savedObjects.concat(indexPatterns);
}

View file

@ -18,7 +18,7 @@
*/
import _ from 'lodash';
import { collectDashboards } from './collect_dashboards';
import { collectReferencesDeep } from './collect_references_deep';
export async function exportDashboards(req) {
@ -26,8 +26,9 @@ export async function exportDashboards(req) {
const config = req.server.config();
const savedObjectsClient = req.getSavedObjectsClient();
const objectsToExport = ids.map(id => ({ id, type: 'dashboard' }));
const objects = await collectDashboards(savedObjectsClient, ids);
const objects = await collectReferencesDeep(savedObjectsClient, objectsToExport);
return {
version: config.get('pkg.version'),
objects

View file

@ -17,166 +17,45 @@
* under the License.
*/
async function findDashboardRelationships(id, size, savedObjectsClient) {
const dashboard = await savedObjectsClient.get('dashboard', id);
const visualizations = [];
export async function findRelationships(type, id, options = {}) {
const {
size,
savedObjectsClient,
savedObjectTypes,
} = options;
// TODO: should we handle exceptions here or at the parent level?
const panelsJSON = JSON.parse(dashboard.attributes.panelsJSON);
if (panelsJSON) {
const visualizationIds = panelsJSON.map(panel => panel.id);
const visualizationResponse = await savedObjectsClient.bulkGet(
visualizationIds.slice(0, size).map(id => ({
id,
type: 'visualization',
}))
);
const { references = [] } = await savedObjectsClient.get(type, id);
const bulkGetOpts = references.map(ref => ({ id: ref.id, type: ref.type }));
visualizations.push(
...visualizationResponse.saved_objects.reduce((accum, object) => {
if (!object.error) {
accum.push({
id: object.id,
title: object.attributes.title,
});
}
return accum;
}, [])
);
}
return { visualizations };
}
async function findVisualizationRelationships(id, size, savedObjectsClient) {
await savedObjectsClient.get('visualization', id);
const allDashboardsResponse = await savedObjectsClient.find({
type: 'dashboard',
fields: ['title', 'panelsJSON'],
});
const dashboards = [];
for (const dashboard of allDashboardsResponse.saved_objects) {
if (dashboard.error) {
continue;
}
const panelsJSON = JSON.parse(dashboard.attributes.panelsJSON);
if (panelsJSON) {
for (const panel of panelsJSON) {
if (panel.type === 'visualization' && panel.id === id) {
dashboards.push({
id: dashboard.id,
title: dashboard.attributes.title,
});
}
}
}
if (dashboards.length >= size) {
break;
}
}
return { dashboards };
}
async function findSavedSearchRelationships(id, size, savedObjectsClient) {
const search = await savedObjectsClient.get('search', id);
const searchSourceJSON = JSON.parse(search.attributes.kibanaSavedObjectMeta.searchSourceJSON);
const indexPatterns = [];
try {
const indexPattern = await savedObjectsClient.get('index-pattern', searchSourceJSON.index);
indexPatterns.push({ id: indexPattern.id, title: indexPattern.attributes.title });
} catch (err) {
// Do nothing
}
const allVisualizationsResponse = await savedObjectsClient.find({
type: 'visualization',
searchFields: ['savedSearchId'],
search: id,
fields: ['title'],
});
const visualizations = allVisualizationsResponse.saved_objects.reduce((accum, object) => {
if (!object.error) {
accum.push({
id: object.id,
title: object.attributes.title,
});
}
return accum;
}, []);
return { visualizations, indexPatterns };
}
async function findIndexPatternRelationships(id, size, savedObjectsClient) {
await savedObjectsClient.get('index-pattern', id);
const [allVisualizationsResponse, savedSearchResponse] = await Promise.all([
const [referencedObjects, referencedResponse] = await Promise.all([
bulkGetOpts.length > 0
? savedObjectsClient.bulkGet(bulkGetOpts)
: Promise.resolve({ saved_objects: [] }),
savedObjectsClient.find({
type: 'visualization',
searchFields: ['kibanaSavedObjectMeta.searchSourceJSON'],
search: '*',
fields: [`title`, `kibanaSavedObjectMeta.searchSourceJSON`],
}),
savedObjectsClient.find({
type: 'search',
searchFields: ['kibanaSavedObjectMeta.searchSourceJSON'],
search: '*',
fields: [`title`, `kibanaSavedObjectMeta.searchSourceJSON`],
hasReference: { type, id },
perPage: size,
fields: ['title'],
type: savedObjectTypes,
}),
]);
const visualizations = [];
for (const visualization of allVisualizationsResponse.saved_objects) {
if (visualization.error) {
continue;
}
const searchSourceJSON = JSON.parse(visualization.attributes.kibanaSavedObjectMeta.searchSourceJSON);
if (searchSourceJSON && searchSourceJSON.index === id) {
visualizations.push({
id: visualization.id,
title: visualization.attributes.title,
});
}
const relationshipObjects = [].concat(
referencedObjects.saved_objects.map(extractCommonProperties),
referencedResponse.saved_objects.map(extractCommonProperties),
);
if (visualizations.length >= size) {
break;
}
}
const searches = [];
for (const search of savedSearchResponse.saved_objects) {
if (search.error) {
continue;
}
const searchSourceJSON = JSON.parse(search.attributes.kibanaSavedObjectMeta.searchSourceJSON);
if (searchSourceJSON && searchSourceJSON.index === id) {
searches.push({
id: search.id,
title: search.attributes.title,
});
}
if (searches.length >= size) {
break;
}
}
return { visualizations, searches };
return relationshipObjects.reduce((result, relationshipObject) => {
const objectsForType = (result[relationshipObject.type] || []);
const { type, ...relationshipObjectWithoutType } = relationshipObject;
result[type] = objectsForType.concat(relationshipObjectWithoutType);
return result;
}, {});
}
export async function findRelationships(type, id, size, savedObjectsClient) {
switch (type) {
case 'dashboard':
return await findDashboardRelationships(id, size, savedObjectsClient);
case 'visualization':
return await findVisualizationRelationships(id, size, savedObjectsClient);
case 'search':
return await findSavedSearchRelationships(id, size, savedObjectsClient);
case 'index-pattern':
return await findIndexPatternRelationships(id, size, savedObjectsClient);
}
return {};
function extractCommonProperties(savedObject) {
return {
id: savedObject.id,
type: savedObject.type,
title: savedObject.attributes.title,
};
}

View file

@ -17,10 +17,8 @@
* under the License.
*/
import Boom from 'boom';
import Joi from 'joi';
import { findRelationships } from '../../../../lib/management/saved_objects/relationships';
import { isNotFoundError } from '../../../../../../../../server/saved_objects/service/lib/errors';
export function registerRelationships(server) {
server.route({
@ -33,7 +31,7 @@ export function registerRelationships(server) {
id: Joi.string(),
}),
query: Joi.object().keys({
size: Joi.number(),
size: Joi.number().default(10000),
}),
},
},
@ -41,17 +39,15 @@ export function registerRelationships(server) {
handler: async (req) => {
const type = req.params.type;
const id = req.params.id;
const size = req.query.size || 10;
const size = req.query.size;
const savedObjectsClient = req.getSavedObjectsClient();
try {
return await findRelationships(type, id, size, req.getSavedObjectsClient());
} catch (err) {
if (isNotFoundError(err)) {
throw Boom.boomify(new Error('Resource not found'), { statusCode: 404 });
}
throw Boom.boomify(err, { statusCode: 500 });
}
return await findRelationships(type, id, {
size,
savedObjectsClient,
// Pass in all types except space, spaces wrapper will throw error
savedObjectTypes: server.savedObjects.types.filter(type => type !== 'space'),
});
},
});
}

View file

@ -62,6 +62,7 @@ export function registerScrollForExportRoute(server) {
savedObjectVersion: 2
},
_migrationVersion: hit.migrationVersion,
_references: hit.references || [],
};
});
}

View file

@ -25,6 +25,20 @@ Object {
"namespace": Object {
"type": "keyword",
},
"references": Object {
"properties": Object {
"id": Object {
"type": "keyword",
},
"name": Object {
"type": "keyword",
},
"type": Object {
"type": "keyword",
},
},
"type": "nested",
},
"type": Object {
"type": "keyword",
},

View file

@ -74,6 +74,20 @@ function defaultMapping(): IndexMapping {
updated_at: {
type: 'date',
},
references: {
type: 'nested',
properties: {
name: {
type: 'keyword',
},
type: {
type: 'keyword',
},
id: {
type: 'keyword',
},
},
},
},
};
}

View file

@ -19,7 +19,7 @@
import _ from 'lodash';
import sinon from 'sinon';
import { SavedObjectDoc } from '../../serialization';
import { RawSavedObjectDoc } from '../../serialization';
import { DocumentMigrator } from './document_migrator';
describe('DocumentMigrator', () => {
@ -486,13 +486,13 @@ describe('DocumentMigrator', () => {
...testOpts(),
migrations: {
aaa: {
'1.2.3': (doc: SavedObjectDoc) => doc,
'10.4.0': (doc: SavedObjectDoc) => doc,
'2.2.1': (doc: SavedObjectDoc) => doc,
'1.2.3': (doc: RawSavedObjectDoc) => doc,
'10.4.0': (doc: RawSavedObjectDoc) => doc,
'2.2.1': (doc: RawSavedObjectDoc) => doc,
},
bbb: {
'3.2.3': (doc: SavedObjectDoc) => doc,
'2.0.0': (doc: SavedObjectDoc) => doc,
'3.2.3': (doc: RawSavedObjectDoc) => doc,
'2.0.0': (doc: RawSavedObjectDoc) => doc,
},
},
});
@ -525,11 +525,11 @@ describe('DocumentMigrator', () => {
});
function renameAttr(path: string, newPath: string) {
return (doc: SavedObjectDoc) =>
_.omit(_.set(doc, newPath, _.get(doc, path)), path) as SavedObjectDoc;
return (doc: RawSavedObjectDoc) =>
_.omit(_.set(doc, newPath, _.get(doc, path)), path) as RawSavedObjectDoc;
}
function setAttr(path: string, value: any) {
return (doc: SavedObjectDoc) =>
_.set(doc, path, _.isFunction(value) ? value(_.get(doc, path)) : value) as SavedObjectDoc;
return (doc: RawSavedObjectDoc) =>
_.set(doc, path, _.isFunction(value) ? value(_.get(doc, path)) : value) as RawSavedObjectDoc;
}

View file

@ -63,12 +63,12 @@
import Boom from 'boom';
import _ from 'lodash';
import Semver from 'semver';
import { MigrationVersion, SavedObjectDoc } from '../../serialization';
import { MigrationVersion, RawSavedObjectDoc } from '../../serialization';
import { LogFn, Logger, MigrationLogger } from './migration_logger';
export type TransformFn = (doc: SavedObjectDoc) => SavedObjectDoc;
export type TransformFn = (doc: RawSavedObjectDoc) => RawSavedObjectDoc;
type ValidateDoc = (doc: SavedObjectDoc) => void;
type ValidateDoc = (doc: RawSavedObjectDoc) => void;
interface MigrationDefinition {
[type: string]: { [version: string]: TransformFn };
@ -142,11 +142,11 @@ export class DocumentMigrator implements VersionedTransformer {
/**
* Migrates a document to the latest version.
*
* @param {SavedObjectDoc} doc
* @returns {SavedObjectDoc}
* @param {RawSavedObjectDoc} doc
* @returns {RawSavedObjectDoc}
* @memberof DocumentMigrator
*/
public migrate = (doc: SavedObjectDoc): SavedObjectDoc => {
public migrate = (doc: RawSavedObjectDoc): RawSavedObjectDoc => {
return this.transformDoc(doc);
};
}
@ -225,7 +225,7 @@ function buildDocumentTransform({
migrations: ActiveMigrations;
validateDoc: ValidateDoc;
}): TransformFn {
return function transformAndValidate(doc: SavedObjectDoc) {
return function transformAndValidate(doc: RawSavedObjectDoc) {
const result = doc.migrationVersion
? applyMigrations(doc, migrations)
: markAsUpToDate(doc, migrations);
@ -243,7 +243,7 @@ function buildDocumentTransform({
};
}
function applyMigrations(doc: SavedObjectDoc, migrations: ActiveMigrations) {
function applyMigrations(doc: RawSavedObjectDoc, migrations: ActiveMigrations) {
while (true) {
const prop = nextUnmigratedProp(doc, migrations);
if (!prop) {
@ -256,14 +256,14 @@ function applyMigrations(doc: SavedObjectDoc, migrations: ActiveMigrations) {
/**
* Gets the doc's props, handling the special case of "type".
*/
function props(doc: SavedObjectDoc) {
function props(doc: RawSavedObjectDoc) {
return Object.keys(doc).concat(doc.type);
}
/**
* Looks up the prop version in a saved object document or in our latest migrations.
*/
function propVersion(doc: SavedObjectDoc | ActiveMigrations, prop: string) {
function propVersion(doc: RawSavedObjectDoc | ActiveMigrations, prop: string) {
return (
(doc[prop] && doc[prop].latestVersion) ||
(doc.migrationVersion && (doc as any).migrationVersion[prop])
@ -273,7 +273,7 @@ function propVersion(doc: SavedObjectDoc | ActiveMigrations, prop: string) {
/**
* Sets the doc's migrationVersion to be the most recent version
*/
function markAsUpToDate(doc: SavedObjectDoc, migrations: ActiveMigrations) {
function markAsUpToDate(doc: RawSavedObjectDoc, migrations: ActiveMigrations) {
return {
...doc,
migrationVersion: props(doc).reduce((acc, prop) => {
@ -288,7 +288,7 @@ function markAsUpToDate(doc: SavedObjectDoc, migrations: ActiveMigrations) {
* about the document and transform that caused the failure.
*/
function wrapWithTry(version: string, prop: string, transform: TransformFn, log: Logger) {
return function tryTransformDoc(doc: SavedObjectDoc) {
return function tryTransformDoc(doc: RawSavedObjectDoc) {
try {
const result = transform(doc);
@ -313,7 +313,7 @@ function wrapWithTry(version: string, prop: string, transform: TransformFn, log:
/**
* Finds the first unmigrated property in the specified document.
*/
function nextUnmigratedProp(doc: SavedObjectDoc, migrations: ActiveMigrations) {
function nextUnmigratedProp(doc: RawSavedObjectDoc, migrations: ActiveMigrations) {
return props(doc).find(p => {
const latestVersion = propVersion(migrations, p);
const docVersion = propVersion(doc, p);
@ -343,10 +343,10 @@ function nextUnmigratedProp(doc: SavedObjectDoc, migrations: ActiveMigrations) {
* Applies any relevent migrations to the document for the specified property.
*/
function migrateProp(
doc: SavedObjectDoc,
doc: RawSavedObjectDoc,
prop: string,
migrations: ActiveMigrations
): SavedObjectDoc {
): RawSavedObjectDoc {
const originalType = doc.type;
let migrationVersion = _.clone(doc.migrationVersion) || {};
const typeChanged = () => !doc.hasOwnProperty(prop) || doc.type !== originalType;
@ -367,7 +367,7 @@ function migrateProp(
/**
* Retrieves any prop transforms that have not been applied to doc.
*/
function applicableTransforms(migrations: ActiveMigrations, doc: SavedObjectDoc, prop: string) {
function applicableTransforms(migrations: ActiveMigrations, doc: RawSavedObjectDoc, prop: string) {
const minVersion = propVersion(doc, prop);
const { transforms } = migrations[prop];
return minVersion
@ -380,7 +380,7 @@ function applicableTransforms(migrations: ActiveMigrations, doc: SavedObjectDoc,
* has not mutated migrationVersion in an unsupported way.
*/
function updateMigrationVersion(
doc: SavedObjectDoc,
doc: RawSavedObjectDoc,
migrationVersion: MigrationVersion,
prop: string,
version: string
@ -396,7 +396,7 @@ function updateMigrationVersion(
* as this could get us into an infinite loop. So, we explicitly check for that here.
*/
function assertNoDowngrades(
doc: SavedObjectDoc,
doc: RawSavedObjectDoc,
migrationVersion: MigrationVersion,
prop: string,
version: string

View file

@ -20,7 +20,7 @@
import _ from 'lodash';
import sinon from 'sinon';
import { SavedObjectsSchema } from '../../schema';
import { SavedObjectDoc, SavedObjectsSerializer } from '../../serialization';
import { RawSavedObjectDoc, SavedObjectsSerializer } from '../../serialization';
import { CallCluster } from './call_cluster';
import { IndexMigrator } from './index_migrator';
@ -50,6 +50,14 @@ describe('IndexMigrator', () => {
namespace: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
include_type_name: true,
@ -83,6 +91,14 @@ describe('IndexMigrator', () => {
namespace: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
@ -191,6 +207,14 @@ describe('IndexMigrator', () => {
namespace: { type: 'keyword' },
type: { type: 'keyword' },
updated_at: { type: 'date' },
references: {
type: 'nested',
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
id: { type: 'keyword' },
},
},
},
},
settings: { number_of_shards: 1, auto_expand_replicas: '0-1' },
@ -240,7 +264,7 @@ describe('IndexMigrator', () => {
let count = 0;
const opts = defaultOpts();
const callCluster = clusterStub(opts);
const migrateDoc = sinon.spy((doc: SavedObjectDoc) => ({
const migrateDoc = sinon.spy((doc: RawSavedObjectDoc) => ({
...doc,
attributes: { name: ++count },
}));
@ -266,24 +290,26 @@ describe('IndexMigrator', () => {
type: 'foo',
attributes: { name: 'Bar' },
migrationVersion: {},
references: [],
});
sinon.assert.calledWith(migrateDoc, {
id: '2',
type: 'foo',
attributes: { name: 'Baz' },
migrationVersion: {},
references: [],
});
expect(callCluster.args.filter(([action]) => action === 'bulk').length).toEqual(2);
sinon.assert.calledWith(callCluster, 'bulk', {
body: [
{ index: { _id: 'foo:1', _index: '.kibana_2' } },
{ foo: { name: 1 }, type: 'foo', migrationVersion: {} },
{ foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] },
],
});
sinon.assert.calledWith(callCluster, 'bulk', {
body: [
{ index: { _id: 'foo:2', _index: '.kibana_2' } },
{ foo: { name: 2 }, type: 'foo', migrationVersion: {} },
{ foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] },
],
});
});

View file

@ -34,11 +34,11 @@ describe('migrateRawDocs', () => {
expect(result).toEqual([
{
_id: 'a:b',
_source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {} },
_source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] },
},
{
_id: 'c:d',
_source: { type: 'c', c: { name: 'HOI!' }, migrationVersion: {} },
_source: { type: 'c', c: { name: 'HOI!' }, migrationVersion: {}, references: [] },
},
]);
@ -56,7 +56,7 @@ describe('migrateRawDocs', () => {
{ _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } },
{
_id: 'c:d',
_source: { type: 'c', c: { name: 'TADA' }, migrationVersion: {} },
_source: { type: 'c', c: { name: 'TADA' }, migrationVersion: {}, references: [] },
},
]);
@ -69,6 +69,7 @@ describe('migrateRawDocs', () => {
name: 'DDD',
},
migrationVersion: {},
references: [],
},
],
]);

View file

@ -41,7 +41,10 @@ export function migrateRawDocs(
if (serializer.isRawSavedObject(raw)) {
const savedObject = serializer.rawToSavedObject(raw);
savedObject.migrationVersion = savedObject.migrationVersion || {};
return serializer.savedObjectToRaw(migrateDoc(savedObject));
return serializer.savedObjectToRaw({
references: [],
...migrateDoc(savedObject),
});
}
return raw;

View file

@ -25,6 +25,20 @@ Object {
"namespace": Object {
"type": "keyword",
},
"references": Object {
"properties": Object {
"id": Object {
"type": "keyword",
},
"name": Object {
"type": "keyword",
},
"type": Object {
"type": "keyword",
},
},
"type": "nested",
},
"type": Object {
"type": "keyword",
},

View file

@ -24,7 +24,7 @@
import { once } from 'lodash';
import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from '../../schema';
import { SavedObjectDoc, SavedObjectsSerializer } from '../../serialization';
import { RawSavedObjectDoc, SavedObjectsSerializer } from '../../serialization';
import { docValidator } from '../../validation';
import { buildActiveMappings, CallCluster, IndexMigrator, LogFn, MappingProperties } from '../core';
import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator';
@ -146,11 +146,11 @@ export class KibanaMigrator {
/**
* Migrates an individual doc to the latest version, as defined by the plugin migrations.
*
* @param {SavedObjectDoc} doc
* @returns {SavedObjectDoc}
* @param {RawSavedObjectDoc} doc
* @returns {RawSavedObjectDoc}
* @memberof KibanaMigrator
*/
public migrateDocument(doc: SavedObjectDoc): SavedObjectDoc {
public migrateDocument(doc: RawSavedObjectDoc): RawSavedObjectDoc {
return this.documentMigrator.migrate(doc);
}
}

View file

@ -37,6 +37,14 @@ export const createBulkCreateRoute = prereqs => ({
attributes: Joi.object().required(),
version: Joi.number(),
migrationVersion: Joi.object().optional(),
references: Joi.array().items(
Joi.object()
.keys({
name: Joi.string().required(),
type: Joi.string().required(),
id: Joi.string().required(),
}),
).default([]),
}).required()
),
},

View file

@ -59,7 +59,8 @@ describe('POST /api/saved_objects/_bulk_get', () => {
id: 'abc123',
type: 'index-pattern',
title: 'logstash-*',
version: 2
version: 2,
references: [],
}]
};

View file

@ -40,14 +40,22 @@ export const createCreateRoute = prereqs => {
payload: Joi.object({
attributes: Joi.object().required(),
migrationVersion: Joi.object().optional(),
references: Joi.array().items(
Joi.object()
.keys({
name: Joi.string().required(),
type: Joi.string().required(),
id: Joi.string().required(),
}),
).default([]),
}).required(),
},
handler(request) {
const { savedObjectsClient } = request.pre;
const { type, id } = request.params;
const { overwrite } = request.query;
const { migrationVersion } = request.payload;
const options = { id, overwrite, migrationVersion };
const { migrationVersion, references } = request.payload;
const options = { id, overwrite, migrationVersion, references };
return savedObjectsClient.create(type, request.payload.attributes, options);
},

View file

@ -57,7 +57,8 @@ describe('POST /api/saved_objects/{type}', () => {
const clientResponse = {
type: 'index-pattern',
id: 'logstash-*',
title: 'Testing'
title: 'Testing',
references: [],
};
savedObjectsClient.create.returns(Promise.resolve(clientResponse));
@ -100,7 +101,7 @@ describe('POST /api/saved_objects/{type}', () => {
expect(savedObjectsClient.create.calledOnce).toBe(true);
const args = savedObjectsClient.create.getCall(0).args;
const options = { overwrite: false, id: undefined, migrationVersion: undefined };
const options = { overwrite: false, id: undefined, migrationVersion: undefined, references: [] };
const attributes = { title: 'Testing' };
expect(args).toEqual(['index-pattern', attributes, options]);
@ -121,7 +122,7 @@ describe('POST /api/saved_objects/{type}', () => {
expect(savedObjectsClient.create.calledOnce).toBe(true);
const args = savedObjectsClient.create.getCall(0).args;
const options = { overwrite: false, id: 'logstash-*' };
const options = { overwrite: false, id: 'logstash-*', references: [] };
const attributes = { title: 'Testing' };
expect(args).toEqual(['index-pattern', attributes, options]);

View file

@ -34,6 +34,11 @@ export const createFindRoute = (prereqs) => ({
default_search_operator: Joi.string().valid('OR', 'AND').default('OR'),
search_fields: Joi.array().items(Joi.string()).single(),
sort_field: Joi.array().items(Joi.string()).single(),
has_reference: Joi.object()
.keys({
type: Joi.string().required(),
id: Joi.string().required(),
}).optional(),
fields: Joi.array().items(Joi.string()).single()
}).default()
},

View file

@ -74,13 +74,15 @@ describe('GET /api/saved_objects/_find', () => {
id: 'logstash-*',
title: 'logstash-*',
timeFieldName: '@timestamp',
notExpandable: true
notExpandable: true,
references: [],
}, {
type: 'index-pattern',
id: 'stocks-*',
title: 'stocks-*',
timeFieldName: '@timestamp',
notExpandable: true
notExpandable: true,
references: [],
}
]
};

View file

@ -53,7 +53,8 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
id: 'logstash-*',
title: 'logstash-*',
timeFieldName: '@timestamp',
notExpandable: true
notExpandable: true,
references: [],
};
savedObjectsClient.get.returns(Promise.resolve(clientResponse));

View file

@ -32,14 +32,22 @@ export const createUpdateRoute = (prereqs) => {
}).required(),
payload: Joi.object({
attributes: Joi.object().required(),
version: Joi.number().min(1)
version: Joi.number().min(1),
references: Joi.array().items(
Joi.object()
.keys({
name: Joi.string().required(),
type: Joi.string().required(),
id: Joi.string().required(),
}),
).default([]),
}).required()
},
handler(request) {
const { savedObjectsClient } = request.pre;
const { type, id } = request.params;
const { attributes, version } = request.payload;
const options = { version };
const { attributes, version, references } = request.payload;
const options = { version, references };
return savedObjectsClient.update(type, id, attributes, options);
}

View file

@ -51,7 +51,8 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
payload: {
attributes: {
title: 'Testing'
}
},
references: [],
}
};
@ -66,7 +67,7 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
it('calls upon savedObjectClient.update', async () => {
const attributes = { title: 'Testing' };
const options = { version: 2 };
const options = { version: 2, references: [] };
const request = {
method: 'PUT',
url: '/api/saved_objects/index-pattern/logstash-*',

View file

@ -43,13 +43,22 @@ export interface MigrationVersion {
[type: string]: string;
}
/**
* A reference object to anohter saved object.
*/
export interface SavedObjectReference {
name: string;
type: string;
id: string;
}
/**
* A saved object type definition that allows for miscellaneous, unknown
* properties, as current discussions around security, ACLs, etc indicate
* that future props are likely to be added. Migrations support this
* scenario out of the box.
*/
export interface SavedObjectDoc {
interface SavedObjectDoc {
attributes: object;
id: string;
type: string;
@ -61,6 +70,19 @@ export interface SavedObjectDoc {
[rootProp: string]: any;
}
interface Referencable {
references: SavedObjectReference[];
}
/**
* We want to have two types, one that guarantees a "references" attribute
* will exist and one that allows it to be null. Since we're not migrating
* all the saved objects to have a "references" array, we need to support
* the scenarios where it may be missing (ex migrations).
*/
export type RawSavedObjectDoc = SavedObjectDoc & Partial<Referencable>;
export type SanitizedSavedObjectDoc = SavedObjectDoc & Referencable;
function assertNonEmptyString(value: string, name: string) {
if (!value || typeof value !== 'string') {
throw new TypeError(`Expected "${value}" to be a ${name}`);
@ -94,13 +116,14 @@ export class SavedObjectsSerializer {
*
* @param {RawDoc} rawDoc - The raw ES document to be converted to saved object format.
*/
public rawToSavedObject({ _id, _source, _version }: RawDoc): SavedObjectDoc {
public rawToSavedObject({ _id, _source, _version }: RawDoc): SanitizedSavedObjectDoc {
const { type, namespace } = _source;
return {
type,
id: this.trimIdPrefix(namespace, type, _id),
...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }),
attributes: _source[type],
references: _source.references || [],
...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }),
...(_source.updated_at && { updated_at: _source.updated_at }),
...(_version != null && { version: _version }),
@ -110,13 +133,23 @@ export class SavedObjectsSerializer {
/**
* Converts a document from the saved object client format to the format that is stored in elasticsearch.
*
* @param {SavedObjectDoc} savedObj - The saved object to be converted to raw ES format.
* @param {SanitizedSavedObjectDoc} savedObj - The saved object to be converted to raw ES format.
*/
public savedObjectToRaw(savedObj: SavedObjectDoc): RawDoc {
const { id, type, namespace, attributes, migrationVersion, updated_at, version } = savedObj;
public savedObjectToRaw(savedObj: SanitizedSavedObjectDoc): RawDoc {
const {
id,
type,
namespace,
attributes,
migrationVersion,
updated_at,
version,
references,
} = savedObj;
const source = {
[type]: attributes,
type,
references,
...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }),
...(migrationVersion && { migrationVersion }),
...(updated_at && { updated_at }),

View file

@ -34,6 +34,24 @@ describe('saved object conversion', () => {
expect(actual).toHaveProperty('type', 'foo');
});
test('it copies the _source.references property to references', () => {
const serializer = new SavedObjectsSerializer(new SavedObjectsSchema());
const actual = serializer.rawToSavedObject({
_id: 'foo:bar',
_source: {
type: 'foo',
references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }],
},
});
expect(actual).toHaveProperty('references', [
{
name: 'ref_0',
type: 'index-pattern',
id: 'pattern*',
},
]);
});
test('if specified it copies the _source.migrationVersion property to migrationVersion', () => {
const serializer = new SavedObjectsSerializer(new SavedObjectsSchema());
const actual = serializer.rawToSavedObject({
@ -95,6 +113,7 @@ describe('saved object conversion', () => {
acl: '33.3.5',
},
updated_at: now,
references: [],
};
expect(expected).toEqual(actual);
});
@ -166,6 +185,7 @@ describe('saved object conversion', () => {
attributes: {
world: 'earth',
},
references: [],
});
});
@ -180,6 +200,7 @@ describe('saved object conversion', () => {
expect(actual).toEqual({
id: 'universe',
type: 'hello',
references: [],
});
});
@ -214,6 +235,7 @@ describe('saved object conversion', () => {
},
namespace: 'foo-namespace',
updated_at: new Date(),
references: [],
},
};
@ -385,6 +407,23 @@ describe('saved object conversion', () => {
expect(actual._source).toHaveProperty('type', 'foo');
});
test('it copies the references property to _source.references', () => {
const serializer = new SavedObjectsSerializer(new SavedObjectsSchema());
const actual = serializer.savedObjectToRaw({
id: '1',
type: 'foo',
attributes: {},
references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }],
});
expect(actual._source).toHaveProperty('references', [
{
name: 'ref_0',
type: 'index-pattern',
id: 'pattern*',
},
]);
});
test('if specified it copies the updated_at property to _source.updated_at', () => {
const serializer = new SavedObjectsSerializer(new SavedObjectsSchema());
const now = new Date();

View file

@ -72,6 +72,7 @@ export class SavedObjectsRepository {
* @property {boolean} [options.overwrite=false]
* @property {object} [options.migrationVersion=undefined]
* @property {string} [options.namespace]
* @property {array} [options.references] - [{ name, type, id }]
* @returns {promise} - { id, type, version, attributes }
*/
async create(type, attributes = {}, options = {}) {
@ -80,6 +81,7 @@ export class SavedObjectsRepository {
migrationVersion,
overwrite = false,
namespace,
references = [],
} = options;
const method = id && !overwrite ? 'create' : 'index';
@ -93,6 +95,7 @@ export class SavedObjectsRepository {
attributes,
migrationVersion,
updated_at: time,
references,
});
const raw = this._serializer.savedObjectToRaw(migrated);
@ -122,11 +125,11 @@ export class SavedObjectsRepository {
/**
* Creates multiple documents at once
*
* @param {array} objects - [{ type, id, attributes, migrationVersion }]
* @param {array} objects - [{ type, id, attributes, references, migrationVersion }]
* @param {object} [options={}]
* @property {boolean} [options.overwrite=false] - overwrites existing documents
* @property {string} [options.namespace]
* @returns {promise} - {saved_objects: [[{ id, type, version, attributes, error: { message } }]}
* @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]}
*/
async bulkCreate(objects, options = {}) {
const {
@ -143,6 +146,7 @@ export class SavedObjectsRepository {
migrationVersion: object.migrationVersion,
namespace,
updated_at: time,
references: object.references || [],
});
const raw = this._serializer.savedObjectToRaw(migrated);
@ -178,6 +182,7 @@ export class SavedObjectsRepository {
id = responseId,
type,
attributes,
references = [],
} = objects[i];
if (error) {
@ -202,7 +207,8 @@ export class SavedObjectsRepository {
type,
updated_at: time,
version,
attributes
attributes,
references,
};
})
};
@ -292,6 +298,7 @@ export class SavedObjectsRepository {
* @property {string} [options.sortOrder]
* @property {Array<string>} [options.fields]
* @property {string} [options.namespace]
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find(options = {}) {
@ -300,6 +307,7 @@ export class SavedObjectsRepository {
search,
defaultSearchOperator = 'OR',
searchFields,
hasReference,
page = 1,
perPage = 20,
sortField,
@ -337,6 +345,7 @@ export class SavedObjectsRepository {
sortField,
sortOrder,
namespace,
hasReference,
})
}
};
@ -414,6 +423,7 @@ export class SavedObjectsRepository {
...time && { updated_at: time },
version: doc._version,
attributes: doc._source[type],
references: doc._source.references || [],
migrationVersion: doc._source.migrationVersion,
};
})
@ -456,6 +466,7 @@ export class SavedObjectsRepository {
...updatedAt && { updated_at: updatedAt },
version: response._version,
attributes: response._source[type],
references: response._source.references || [],
migrationVersion: response._source.migrationVersion,
};
}
@ -468,12 +479,14 @@ export class SavedObjectsRepository {
* @param {object} [options={}]
* @property {integer} options.version - ensures version matches that of persisted object
* @property {string} [options.namespace]
* @property {array} [options.references] - [{ name, type, id }]
* @returns {promise}
*/
async update(type, id, attributes, options = {}) {
const {
version,
namespace
namespace,
references = [],
} = options;
const time = this._getCurrentTime();
@ -488,6 +501,7 @@ export class SavedObjectsRepository {
doc: {
[type]: attributes,
updated_at: time,
references,
}
},
});
@ -502,6 +516,7 @@ export class SavedObjectsRepository {
type,
updated_at: time,
version: response._version,
references,
attributes
};
}
@ -575,6 +590,7 @@ export class SavedObjectsRepository {
id,
type,
updated_at: time,
references: response.get._source.references,
version: response._version,
attributes: response.get._source[type],
};

View file

@ -259,6 +259,11 @@ describe('SavedObjectsRepository', () => {
}, {
id: 'logstash-*',
namespace: 'foo-namespace',
references: [{
name: 'ref_0',
type: 'test',
id: '123',
}],
});
expect(response).toEqual({
@ -268,7 +273,12 @@ describe('SavedObjectsRepository', () => {
version: 2,
attributes: {
title: 'Logstash',
}
},
references: [{
name: 'ref_0',
type: 'test',
id: '123',
}],
});
});
@ -428,8 +438,8 @@ describe('SavedObjectsRepository', () => {
callAdminCluster.returns({ items: [] });
await savedObjectsRepository.bulkCreate([
{ type: 'config', id: 'one', attributes: { title: 'Test One' } },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }
{ type: 'config', id: 'one', attributes: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }] },
{ type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' }, references: [{ name: 'ref_0', type: 'test', id: '2' }] },
]);
sinon.assert.calledOnce(callAdminCluster);
@ -439,9 +449,14 @@ describe('SavedObjectsRepository', () => {
expect(bulkCalls[0][1].body).toEqual([
{ create: { _type: '_doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }] },
{ create: { _type: '_doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
{
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': { title: 'Test Two' },
references: [{ name: 'ref_0', type: 'test', id: '2' }],
},
]);
sinon.assert.calledOnce(onBeforeWrite);
@ -463,9 +478,21 @@ describe('SavedObjectsRepository', () => {
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: '_doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One!!' }, migrationVersion: { foo: '2.3.4' } },
{
type: 'config',
...mockTimestampFields,
config: { title: 'Test One!!' },
migrationVersion: { foo: '2.3.4' },
references: [],
},
{ create: { _type: '_doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two!!' }, migrationVersion: { foo: '2.3.4' } }
{
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': { title: 'Test Two!!' },
migrationVersion: { foo: '2.3.4' },
references: [],
},
]
}));
});
@ -479,7 +506,7 @@ describe('SavedObjectsRepository', () => {
body: [
// uses create because overwriting is not allowed
{ create: { _type: '_doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
{ type: 'foo', ...mockTimestampFields, 'foo': {}, references: [] },
]
}));
@ -494,7 +521,7 @@ describe('SavedObjectsRepository', () => {
body: [
// uses index because overwriting is allowed
{ index: { _type: '_doc', _id: 'foo:bar' } },
{ type: 'foo', ...mockTimestampFields, 'foo': {} },
{ type: 'foo', ...mockTimestampFields, 'foo': {}, references: [] },
]
}));
@ -538,6 +565,7 @@ describe('SavedObjectsRepository', () => {
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
references: [],
}
]
});
@ -576,12 +604,14 @@ describe('SavedObjectsRepository', () => {
version: 2,
...mockTimestampFields,
attributes: { title: 'Test One' },
references: [],
}, {
id: 'two',
type: 'index-pattern',
version: 2,
...mockTimestampFields,
attributes: { title: 'Test Two' },
references: [],
}
]
});
@ -602,9 +632,21 @@ describe('SavedObjectsRepository', () => {
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: '_doc', _id: 'foo-namespace:config:one' } },
{ namespace: 'foo-namespace', type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{
namespace: 'foo-namespace',
type: 'config',
...mockTimestampFields,
config: { title: 'Test One' },
references: [],
},
{ create: { _type: '_doc', _id: 'foo-namespace:index-pattern:two' } },
{ namespace: 'foo-namespace', type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
{
namespace: 'foo-namespace',
type: 'index-pattern',
...mockTimestampFields,
'index-pattern': { title: 'Test Two' },
references: [],
},
]
}));
sinon.assert.calledOnce(onBeforeWrite);
@ -620,9 +662,9 @@ describe('SavedObjectsRepository', () => {
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: '_doc', _id: 'config:one' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' } },
{ type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, references: [] },
{ create: { _type: '_doc', _id: 'index-pattern:two' } },
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } }
{ type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' }, references: [] }
]
}));
sinon.assert.calledOnce(onBeforeWrite);
@ -642,7 +684,7 @@ describe('SavedObjectsRepository', () => {
sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({
body: [
{ create: { _type: '_doc', _id: 'globaltype:one' } },
{ type: 'globaltype', ...mockTimestampFields, 'globaltype': { title: 'Test One' } },
{ type: 'globaltype', ...mockTimestampFields, 'globaltype': { title: 'Test One' }, references: [] },
]
}));
sinon.assert.calledOnce(onBeforeWrite);
@ -812,22 +854,29 @@ describe('SavedObjectsRepository', () => {
}
});
it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => {
callAdminCluster.returns(namespacedSearchResults);
const relevantOpts = {
namespace: 'foo-namespace',
search: 'foo*',
searchFields: ['foo'],
type: 'bar',
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
};
it(
'passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl',
async () => {
callAdminCluster.returns(namespacedSearchResults);
const relevantOpts = {
namespace: 'foo-namespace',
search: 'foo*',
searchFields: ['foo'],
type: 'bar',
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
hasReference: {
type: 'foo',
id: '1',
},
};
await savedObjectsRepository.find(relevantOpts);
sinon.assert.calledOnce(getSearchDsl);
sinon.assert.calledWithExactly(getSearchDsl, mappings, schema, relevantOpts);
});
await savedObjectsRepository.find(relevantOpts);
sinon.assert.calledOnce(getSearchDsl);
sinon.assert.calledWithExactly(getSearchDsl, mappings, schema, relevantOpts);
}
);
it('merges output of getSearchDsl into es request body', async () => {
callAdminCluster.returns(noNamespaceSearchResults);
@ -858,7 +907,8 @@ describe('SavedObjectsRepository', () => {
type: doc._source.type,
...mockTimestampFields,
version: doc._version,
attributes: doc._source[doc._source.type]
attributes: doc._source[doc._source.type],
references: [],
});
});
});
@ -881,7 +931,8 @@ describe('SavedObjectsRepository', () => {
type: doc._source.type,
...mockTimestampFields,
version: doc._version,
attributes: doc._source[doc._source.type]
attributes: doc._source[doc._source.type],
references: [],
});
});
});
@ -970,7 +1021,8 @@ describe('SavedObjectsRepository', () => {
version: 2,
attributes: {
title: 'Testing'
}
},
references: [],
});
});
@ -985,7 +1037,8 @@ describe('SavedObjectsRepository', () => {
version: 2,
attributes: {
title: 'Testing'
}
},
references: [],
});
});
@ -1132,7 +1185,8 @@ describe('SavedObjectsRepository', () => {
type: 'config',
...mockTimestampFields,
version: 2,
attributes: { title: 'Test' }
attributes: { title: 'Test' },
references: [],
});
expect(savedObjects[1]).toEqual({
id: 'bad',
@ -1168,13 +1222,25 @@ describe('SavedObjectsRepository', () => {
});
it('returns current ES document version', async () => {
const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { namespace: 'foo-namespace' });
const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, {
namespace: 'foo-namespace',
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
expect(response).toEqual({
id,
type,
...mockTimestampFields,
version: newVersion,
attributes
attributes,
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
});
@ -1197,6 +1263,11 @@ describe('SavedObjectsRepository', () => {
title: 'Testing',
}, {
namespace: 'foo-namespace',
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
sinon.assert.calledOnce(callAdminCluster);
@ -1205,7 +1276,15 @@ describe('SavedObjectsRepository', () => {
id: 'foo-namespace:index-pattern:logstash-*',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } }
doc: {
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing' },
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
},
},
ignore: [404],
refresh: 'wait_for',
@ -1216,7 +1295,15 @@ describe('SavedObjectsRepository', () => {
});
it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => {
await savedObjectsRepository.update('index-pattern', 'logstash-*', { title: 'Testing' });
await savedObjectsRepository.update('index-pattern', 'logstash-*', {
title: 'Testing',
}, {
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
sinon.assert.calledOnce(callAdminCluster);
sinon.assert.calledWithExactly(callAdminCluster, 'update', {
@ -1224,7 +1311,15 @@ describe('SavedObjectsRepository', () => {
id: 'index-pattern:logstash-*',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } }
doc: {
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing' },
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
},
},
ignore: [404],
refresh: 'wait_for',
@ -1239,6 +1334,11 @@ describe('SavedObjectsRepository', () => {
name: 'bar',
}, {
namespace: 'foo-namespace',
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
sinon.assert.calledOnce(callAdminCluster);
@ -1247,7 +1347,15 @@ describe('SavedObjectsRepository', () => {
id: 'globaltype:foo',
version: undefined,
body: {
doc: { updated_at: mockTimestamp, 'globaltype': { name: 'bar' } }
doc: {
updated_at: mockTimestamp,
'globaltype': { name: 'bar' },
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
},
},
ignore: [404],
refresh: 'wait_for',

View file

@ -99,13 +99,37 @@ function getClauseForType(schema, namespace, type) {
* @param {String} search
* @param {Array<string>} searchFields
* @param {String} defaultSearchOperator
* @param {Object} hasReference
* @return {Object}
*/
export function getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator) {
export function getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator, hasReference) {
const types = getTypes(mappings, type);
const bool = {
filter: [{
bool: {
must: hasReference
? [{
nested: {
path: 'references',
query: {
bool: {
must: [
{
term: {
'references.id': hasReference.id,
},
},
{
term: {
'references.type': hasReference.type,
},
},
],
},
},
},
}]
: undefined,
should: types.map(type => getClauseForType(schema, namespace, type)),
minimum_should_match: 1
}

View file

@ -778,4 +778,46 @@ describe('searchDsl/queryParams', () => {
});
});
});
describe('type (plural, namespaced and global), hasReference', () => {
it('supports hasReference', () => {
expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], null, null, 'OR', { type: 'bar', id: '1' }))
.toEqual({
query: {
bool: {
filter: [{
bool: {
must: [{
nested: {
path: 'references',
query: {
bool: {
must: [
{
term: {
'references.id': '1',
},
},
{
term: {
'references.type': 'bar',
},
},
],
},
},
},
}],
should: [
createTypeClause('saved', 'foo-namespace'),
createTypeClause('global'),
],
minimum_should_match: 1,
}
}]
}
}
});
});
});
});

View file

@ -31,6 +31,7 @@ export function getSearchDsl(mappings, schema, options = {}) {
sortField,
sortOrder,
namespace,
hasReference,
} = options;
if (!type) {
@ -42,7 +43,7 @@ export function getSearchDsl(mappings, schema, options = {}) {
}
return {
...getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator),
...getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator, hasReference),
...getSortingParams(mappings, type, sortField, sortOrder),
};
}

View file

@ -46,7 +46,7 @@ describe('getSearchDsl', () => {
});
describe('passes control', () => {
it('passes (mappings, schema, namespace, type, search, searchFields) to getQueryParams', () => {
it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => {
const spy = sandbox.spy(queryParamsNS, 'getQueryParams');
const mappings = { type: { properties: {} } };
const schema = { isNamespaceAgnostic: () => {} };
@ -56,6 +56,10 @@ describe('getSearchDsl', () => {
search: 'bar',
searchFields: ['baz'],
defaultSearchOperator: 'AND',
hasReference: {
type: 'bar',
id: '1',
},
};
getSearchDsl(mappings, schema, opts);
@ -69,6 +73,7 @@ describe('getSearchDsl', () => {
opts.search,
opts.searchFields,
opts.defaultSearchOperator,
opts.hasReference,
);
});

View file

@ -36,7 +36,7 @@ export interface BulkCreateObject<T extends SavedObjectAttributes = any> {
}
export interface BulkCreateResponse<T extends SavedObjectAttributes = any> {
savedObjects: Array<SavedObject<T>>;
saved_objects: Array<SavedObject<T>>;
}
export interface FindOptions extends BaseOptions {
@ -68,7 +68,7 @@ export interface BulkGetObject {
export type BulkGetObjects = BulkGetObject[];
export interface BulkGetResponse<T extends SavedObjectAttributes = any> {
savedObjects: Array<SavedObject<T>>;
saved_objects: Array<SavedObject<T>>;
}
export interface SavedObjectAttributes {
@ -84,6 +84,13 @@ export interface SavedObject<T extends SavedObjectAttributes = any> {
message: string;
};
attributes: T;
references: SavedObjectReference[];
}
export interface SavedObjectReference {
name: string;
type: string;
id: string;
}
export declare class SavedObjectsClient {

View file

@ -148,6 +148,7 @@ export class SavedObjectsClient {
* @property {string} [options.sortOrder]
* @property {Array<string>} [options.fields]
* @property {string} [options.namespace]
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
async find(options = {}) {

View file

@ -285,6 +285,32 @@ describe('Saved Object', function () {
});
});
});
describe('with extractReferences', () => {
it('calls the function', async () => {
const id = '123';
stubESResponse(getMockedDocResponse('id'));
let extractReferencesCallCount = 0;
const extractReferences = ({ attributes, references }) => {
extractReferencesCallCount++;
return { attributes, references };
};
return createInitializedSavedObject({ type: 'dashboard', extractReferences })
.then((savedObject) => {
sinon.stub(savedObjectsClientStub, 'create').callsFake(() => {
return BluebirdPromise.resolve({
id,
version: 2,
type: 'dashboard',
});
});
return savedObject.save();
})
.then(() => {
expect(extractReferencesCallCount).to.be(1);
});
});
});
});
describe('applyESResp', function () {
@ -405,6 +431,73 @@ describe('Saved Object', function () {
});
});
it('does not inject references when references array is missing', async () => {
const injectReferences = sinon.stub();
const config = {
type: 'dashboard',
injectReferences,
};
const savedObject = new SavedObject(config);
return savedObject.init()
.then(() => {
const response = {
found: true,
_source: {
dinosaurs: { tRex: 'has big teeth' },
},
};
return savedObject.applyESResp(response);
})
.then(() => {
expect(injectReferences).to.have.property('notCalled', true);
});
});
it('does not inject references when references array is empty', async () => {
const injectReferences = sinon.stub();
const config = {
type: 'dashboard',
injectReferences,
};
const savedObject = new SavedObject(config);
return savedObject.init()
.then(() => {
const response = {
found: true,
_source: {
dinosaurs: { tRex: 'has big teeth' },
},
references: [],
};
return savedObject.applyESResp(response);
})
.then(() => {
expect(injectReferences).to.have.property('notCalled', true);
});
});
it('injects references when function is provided and references exist', async () => {
const injectReferences = sinon.stub();
const config = {
type: 'dashboard',
injectReferences,
};
const savedObject = new SavedObject(config);
return savedObject.init()
.then(() => {
const response = {
found: true,
_source: {
dinosaurs: { tRex: 'has big teeth' },
},
references: [{}],
};
return savedObject.applyESResp(response);
})
.then(() => {
expect(injectReferences).to.have.property('calledOnce', true);
});
});
});
describe ('config', function () {

View file

@ -103,6 +103,8 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
const afterESResp = config.afterESResp || _.noop;
const customInit = config.init || _.noop;
const extractReferences = config.extractReferences;
const injectReferences = config.injectReferences;
// optional search source which this object configures
this.searchSource = config.searchSource ? new SearchSource() : undefined;
@ -117,7 +119,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
// in favor of a better rename/save flow.
this.copyOnSave = false;
const parseSearchSource = (searchSourceJson) => {
const parseSearchSource = (searchSourceJson, references) => {
if (!this.searchSource) return;
// if we have a searchSource, set its values based on the searchSourceJson field
@ -136,6 +138,16 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
throw new InvalidJSONProperty(`Invalid searchSourceJSON in ${esType} "${this.id}".`);
}
// Inject index id if a reference is saved
if (searchSourceValues.indexRefName) {
const reference = references.find(reference => reference.name === searchSourceValues.indexRefName);
if (!reference) {
throw new Error(`Could not find reference for ${searchSourceValues.indexRefName} on ${this.getEsType()} ${this.id}`);
}
searchSourceValues.index = reference.id;
delete searchSourceValues.indexRefName;
}
const searchSourceFields = this.searchSource.getFields();
const fnProps = _.transform(searchSourceFields, function (dynamic, val, name) {
if (_.isFunction(val)) dynamic[name] = val;
@ -213,6 +225,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
_id: resp.id,
_type: resp.type,
_source: _.cloneDeep(resp.attributes),
references: resp.references,
found: resp._version ? true : false
};
})
@ -254,8 +267,13 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
this.lastSavedTitle = this.title;
return Promise.try(() => {
parseSearchSource(meta.searchSourceJSON);
parseSearchSource(meta.searchSourceJSON, resp.references);
return this.hydrateIndexPattern();
}).then(() => {
if (injectReferences && resp.references && resp.references.length > 0) {
injectReferences(this, resp.references);
}
return this;
}).then(() => {
return Promise.cast(afterESResp.call(this, resp));
});
@ -266,12 +284,13 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
*
* @return {Object}
*/
this.serialize = () => {
const body = {};
this._serialize = () => {
const attributes = {};
const references = [];
_.forOwn(mapping, (fieldMapping, fieldName) => {
if (this[fieldName] != null) {
body[fieldName] = (fieldMapping._serialize)
attributes[fieldName] = (fieldMapping._serialize)
? fieldMapping._serialize(this[fieldName])
: this[fieldName];
}
@ -279,12 +298,22 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
if (this.searchSource) {
const searchSourceFields = _.omit(this.searchSource.getFields(), ['sort', 'size']);
body.kibanaSavedObjectMeta = {
if (searchSourceFields.index) {
const indexId = searchSourceFields.index;
searchSourceFields.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index';
references.push({
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: indexId,
});
delete searchSourceFields.index;
}
attributes.kibanaSavedObjectMeta = {
searchSourceJSON: angular.toJson(searchSourceFields)
};
}
return body;
return { attributes, references };
};
/**
@ -304,16 +333,17 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
/**
* Attempts to create the current object using the serialized source. If an object already
* exists, a warning message requests an overwrite confirmation.
* @param source - serialized version of this object (return value from this.serialize())
* @param source - serialized version of this object (return value from this._serialize())
* What will be indexed into elasticsearch.
* @param options - options to pass to the saved object create method
* @returns {Promise} - A promise that is resolved with the objects id if the object is
* successfully indexed. If the overwrite confirmation was rejected, an error is thrown with
* a confirmRejected = true parameter so that case can be handled differently than
* a create or index error.
* @resolved {SavedObject}
*/
const createSource = (source) => {
return savedObjectsClient.create(esType, source, this.creationOpts())
const createSource = (source, options = {}) => {
return savedObjectsClient.create(esType, source, options)
.catch(err => {
// record exists, confirm overwriting
if (_.get(err, 'res.status') === 409) {
@ -331,7 +361,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
values: { name: this.getDisplayName() }
}),
})
.then(() => savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true })))
.then(() => savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true, ...options })))
.catch(() => Promise.reject(new Error(OVERWRITE_REJECTED)));
}
return Promise.reject(err);
@ -406,16 +436,21 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm
this.id = null;
}
const source = this.serialize();
// Here we want to extract references and set them within "references" attribute
let { attributes, references } = this._serialize();
if (extractReferences) {
({ attributes, references } = extractReferences({ attributes, references }));
}
if (!references) throw new Error('References not returned from extractReferences');
this.isSaving = true;
return checkForDuplicateTitle(isTitleDuplicateConfirmed, onTitleDuplicate)
.then(() => {
if (confirmOverwrite) {
return createSource(source);
return createSource(attributes, this.creationOpts({ references }));
} else {
return savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true }));
return savedObjectsClient.create(esType, attributes, this.creationOpts({ references, overwrite: true }));
}
})
.then((resp) => {

View file

@ -20,11 +20,12 @@
import _ from 'lodash';
export class SavedObject {
constructor(client, { id, type, version, attributes, error, migrationVersion } = {}) {
constructor(client, { id, type, version, attributes, error, migrationVersion, references } = {}) {
this._client = client;
this.id = id;
this.type = type;
this.attributes = attributes || {};
this.references = references || [];
this._version = version;
this.migrationVersion = migrationVersion;
if (error) {
@ -46,9 +47,17 @@ export class SavedObject {
save() {
if (this.id) {
return this._client.update(this.type, this.id, this.attributes, { migrationVersion: this.migrationVersion });
return this._client.update(
this.type,
this.id,
this.attributes,
{
migrationVersion: this.migrationVersion,
references: this.references,
},
);
} else {
return this._client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion });
return this._client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion, references: this.references });
}
}

View file

@ -51,6 +51,7 @@ export class SavedObjectsClient {
* @property {string} [options.id] - force id on creation, not recommended
* @property {boolean} [options.overwrite=false]
* @property {object} [options.migrationVersion]
* @property {array} [options.references] [{ name, type, id }]
* @returns {promise} - SavedObject({ id, type, version, attributes })
*/
create = (type, attributes = {}, options = {}) => {
@ -61,7 +62,17 @@ export class SavedObjectsClient {
const path = this._getPath([type, options.id]);
const query = _.pick(options, ['overwrite']);
return this._request({ method: 'POST', path, query, body: { attributes, migrationVersion: options.migrationVersion } })
return this
._request({
method: 'POST',
path,
query,
body: {
attributes,
migrationVersion: options.migrationVersion,
references: options.references,
},
})
.catch(error => {
if (isAutoCreateIndexError(error)) {
return showAutoCreateIndexErrorPage();
@ -75,7 +86,7 @@ export class SavedObjectsClient {
/**
* Creates multiple documents at once
*
* @param {array} objects - [{ type, id, attributes, migrationVersion }]
* @param {array} objects - [{ type, id, attributes, references, migrationVersion }]
* @param {object} [options={}]
* @property {boolean} [options.overwrite=false]
* @returns {promise} - { savedObjects: [{ id, type, version, attributes, error: { message } }]}
@ -117,6 +128,7 @@ export class SavedObjectsClient {
* @property {integer} [options.page=1]
* @property {integer} [options.perPage=20]
* @property {array} options.fields
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ]}
*/
find = (options = {}) => {
@ -178,9 +190,10 @@ export class SavedObjectsClient {
* @param {object} options
* @prop {integer} options.version - ensures version matches that of persisted object
* @prop {object} options.migrationVersion - The optional migrationVersion of this document
* @prop {array} option.references - the references of the saved object
* @returns {promise}
*/
update(type, id, attributes, { version, migrationVersion } = {}) {
update(type, id, attributes, { version, migrationVersion, references } = {}) {
if (!type || !id || !attributes) {
return Promise.reject(new Error('requires type, id and attributes'));
}
@ -189,6 +202,7 @@ export class SavedObjectsClient {
const body = {
attributes,
migrationVersion,
references,
version
};

View file

@ -40,8 +40,8 @@ export default function ({ getService }) {
after(() => esArchiver.unload('management/saved_objects'));
const SEARCH_RESPONSE_SCHEMA = Joi.object().keys({
visualizations: GENERIC_RESPONSE_SCHEMA,
indexPatterns: GENERIC_RESPONSE_SCHEMA,
visualization: GENERIC_RESPONSE_SCHEMA,
'index-pattern': GENERIC_RESPONSE_SCHEMA,
});
describe('searches', async () => {
@ -61,13 +61,13 @@ export default function ({ getService }) {
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
visualizations: [
visualization: [
{
id: 'a42c0580-3224-11e8-a572-ffca06da1357',
title: 'VisualizationFromSavedSearch',
},
],
indexPatterns: [
'index-pattern': [
{
id: '8963ca30-3224-11e8-a572-ffca06da1357',
title: 'saved_objects*',
@ -85,7 +85,7 @@ export default function ({ getService }) {
describe('dashboards', async () => {
const DASHBOARD_RESPONSE_SCHEMA = Joi.object().keys({
visualizations: GENERIC_RESPONSE_SCHEMA,
visualization: GENERIC_RESPONSE_SCHEMA,
});
it('should validate dashboard response schema', async () => {
@ -104,7 +104,7 @@ export default function ({ getService }) {
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
visualizations: [
visualization: [
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
title: 'Visualization',
@ -128,7 +128,8 @@ export default function ({ getService }) {
describe('visualizations', async () => {
const VISUALIZATIONS_RESPONSE_SCHEMA = Joi.object().keys({
dashboards: GENERIC_RESPONSE_SCHEMA,
dashboard: GENERIC_RESPONSE_SCHEMA,
search: GENERIC_RESPONSE_SCHEMA,
});
it('should validate visualization response schema', async () => {
@ -147,7 +148,13 @@ export default function ({ getService }) {
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
dashboards: [
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
title: 'OneRecord'
},
],
dashboard: [
{
id: 'b70c7ae0-3224-11e8-a572-ffca06da1357',
title: 'Dashboard',
@ -166,8 +173,8 @@ export default function ({ getService }) {
describe('index patterns', async () => {
const INDEX_PATTERN_RESPONSE_SCHEMA = Joi.object().keys({
searches: GENERIC_RESPONSE_SCHEMA,
visualizations: GENERIC_RESPONSE_SCHEMA,
search: GENERIC_RESPONSE_SCHEMA,
visualization: GENERIC_RESPONSE_SCHEMA,
});
it('should validate visualization response schema', async () => {
@ -186,13 +193,13 @@ export default function ({ getService }) {
.expect(200)
.then(resp => {
expect(resp.body).to.eql({
searches: [
search: [
{
id: '960372e0-3224-11e8-a572-ffca06da1357',
title: 'OneRecord',
},
],
visualizations: [
visualization: [
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
title: 'Visualization',

View file

@ -69,7 +69,8 @@ export default function ({ getService }) {
version: 1,
attributes: {
title: 'A great new dashboard'
}
},
references: [],
},
]
});
@ -101,7 +102,8 @@ export default function ({ getService }) {
version: 1,
attributes: {
title: 'An existing visualization'
}
},
references: [],
},
{
type: 'dashboard',
@ -110,7 +112,8 @@ export default function ({ getService }) {
version: 1,
attributes: {
title: 'A great new dashboard'
}
},
references: [],
},
]
});

View file

@ -68,7 +68,15 @@ export default function ({ getService }) {
visState: resp.body.saved_objects[0].attributes.visState,
uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON,
kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta
}
},
migrationVersion: {
visualization: '7.0.0',
},
references: [{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
}],
},
{
id: 'does not exist',
@ -86,7 +94,8 @@ export default function ({ getService }) {
attributes: {
buildNum: 8467,
defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab'
}
},
references: [],
}
]
});

View file

@ -54,7 +54,11 @@ export default function ({ getService }) {
version: 1,
attributes: {
title: 'My favorite vis'
}
},
migrationVersion: {
visualization: '7.0.0',
},
references: [],
});
});
});
@ -95,7 +99,11 @@ export default function ({ getService }) {
version: 1,
attributes: {
title: 'My favorite vis'
}
},
migrationVersion: {
visualization: '7.0.0',
},
references: [],
});
});

View file

@ -45,7 +45,8 @@ export default function ({ getService }) {
version: 1,
attributes: {
'title': 'Count of requests'
}
},
references: [],
}
]
});

View file

@ -42,6 +42,9 @@ export default function ({ getService }) {
type: 'visualization',
updated_at: '2017-09-21T18:51:23.794Z',
version: resp.body.version,
migrationVersion: {
visualization: '7.0.0',
},
attributes: {
title: 'Count of requests',
description: '',
@ -50,7 +53,12 @@ export default function ({ getService }) {
visState: resp.body.attributes.visState,
uiStateJSON: resp.body.attributes.uiStateJSON,
kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta
}
},
references: [{
type: 'index-pattern',
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
}],
});
})
));

View file

@ -85,11 +85,11 @@ export default ({ getService }) => {
// The docs in the alias have been migrated
assert.deepEqual(await fetchDocs({ callCluster, index }), [
{ id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 } },
{ id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 } },
{ id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } },
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' } },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' } },
{ id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [] },
{ id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [] },
{ id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [] },
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [] },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [] },
]);
});
@ -131,10 +131,10 @@ export default ({ getService }) => {
// The index for the initial migration has not been destroyed...
assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_2` }), [
{ id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 } },
{ id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 } },
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' } },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' } },
{ id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [] },
{ id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [] },
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [] },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [] },
]);
// The docs were migrated again...
@ -144,15 +144,17 @@ export default ({ getService }) => {
type: 'bar',
migrationVersion: { bar: '2.3.4' },
bar: { mynum: 68, name: 'NAME i' },
references: [],
},
{
id: 'bar:o',
type: 'bar',
migrationVersion: { bar: '2.3.4' },
bar: { mynum: 6, name: 'NAME o' },
references: [],
},
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' } },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' } },
{ id: 'foo:a', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [] },
{ id: 'foo:e', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [] },
]);
});
@ -203,7 +205,7 @@ export default ({ getService }) => {
// The docs in the alias have been migrated
assert.deepEqual(await fetchDocs({ callCluster, index }), [
{ id: 'foo:lotr', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' } },
{ id: 'foo:lotr', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [] },
]);
});
});

View file

@ -51,7 +51,8 @@ export default function ({ getService }) {
version: 2,
attributes: {
title: 'My second favorite vis'
}
},
references: [],
});
});
});

View file

@ -7,6 +7,7 @@
import { resolve } from 'path';
import Boom from 'boom';
import migrations from './migrations';
import { initServer } from './server';
import mappings from './mappings.json';
@ -28,7 +29,8 @@ export function graph(kibana) {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
hacks: ['plugins/graph/hacks/toggle_app_link_in_nav'],
home: ['plugins/graph/register_feature'],
mappings
mappings,
migrations,
},
config(Joi) {

View file

@ -0,0 +1,41 @@
/*
* 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 { get } from 'lodash';
export default {
'graph-workspace': {
'7.0.0': (doc) => {
// Set new "references" attribute
doc.references = doc.references || [];
// Migrate index pattern
const wsState = get(doc, 'attributes.wsState');
if (typeof wsState !== 'string') {
return doc;
}
let state;
try {
state = JSON.parse(JSON.parse(wsState));
} catch (e) {
// Let it go, the data is invalid and we'll leave it as is
return doc;
}
const { indexPattern } = state;
if (!indexPattern) {
return doc;
}
state.indexPatternRefName = 'indexPattern_0';
delete state.indexPattern;
doc.attributes.wsState = JSON.stringify(JSON.stringify(state));
doc.references.push({
name: 'indexPattern_0',
type: 'index-pattern',
id: indexPattern,
});
return doc;
}
}
};

View file

@ -0,0 +1,102 @@
/*
* 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 migrations from './migrations';
describe('graph-workspace', () => {
describe('7.0.0', () => {
const migration = migrations['graph-workspace']['7.0.0'];
test('returns doc on empty object', () => {
expect(migration({})).toMatchInlineSnapshot(`
Object {
"references": Array [],
}
`);
});
test('returns doc when wsState is not a string', () => {
const doc = {
id: '1',
attributes: {
wsState: true,
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"wsState": true,
},
"id": "1",
"references": Array [],
}
`);
});
test('returns doc when wsState is not valid JSON', () => {
const doc = {
id: '1',
attributes: {
wsState: '123abc',
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"wsState": "123abc",
},
"id": "1",
"references": Array [],
}
`);
});
test('returns doc when "indexPattern" is missing from wsState', () => {
const doc = {
id: '1',
attributes: {
wsState: JSON.stringify(JSON.stringify({ foo: true })),
},
};
expect(migration(doc)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"wsState": "\\"{\\\\\\"foo\\\\\\":true}\\"",
},
"id": "1",
"references": Array [],
}
`);
});
test('extract "indexPattern" attribute from doc', () => {
const doc = {
id: '1',
attributes: {
wsState: JSON.stringify(JSON.stringify({ foo: true, indexPattern: 'pattern*' })),
bar: true,
},
};
const migratedDoc = migration(doc);
expect(migratedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"bar": true,
"wsState": "\\"{\\\\\\"foo\\\\\\":true,\\\\\\"indexPatternRefName\\\\\\":\\\\\\"indexPattern_0\\\\\\"}\\"",
},
"id": "1",
"references": Array [
Object {
"id": "pattern*",
"name": "indexPattern_0",
"type": "index-pattern",
},
],
}
`);
});
});
});

View file

@ -7,6 +7,10 @@
import { uiModules } from 'ui/modules';
import { SavedObjectProvider } from 'ui/courier';
import { i18n } from '@kbn/i18n';
import {
extractReferences,
injectReferences,
} from './saved_workspace_references';
const module = uiModules.get('app/dashboard');
@ -21,6 +25,8 @@ export function SavedWorkspaceProvider(Private) {
type: SavedWorkspace.type,
mapping: SavedWorkspace.mapping,
searchSource: SavedWorkspace.searchsource,
extractReferences: extractReferences,
injectReferences: injectReferences,
// if this is null/undefined then the SavedObject will be assigned the defaults
id: id,

View file

@ -0,0 +1,53 @@
/*
* 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.
*/
export function extractReferences({ attributes, references = [] }) {
// For some reason, wsState comes in stringified 2x
const state = JSON.parse(JSON.parse(attributes.wsState));
const { indexPattern } = state;
if (!indexPattern) {
throw new Error('indexPattern attribute is missing in "wsState"');
}
state.indexPatternRefName = 'indexPattern_0';
delete state.indexPattern;
return {
references: [
...references,
{
name: 'indexPattern_0',
type: 'index-pattern',
id: indexPattern,
}
],
attributes: {
...attributes,
wsState: JSON.stringify(JSON.stringify(state)),
},
};
}
export function injectReferences(savedObject, references) {
// Skip if wsState is missing, at the time of development of this, there is no guarantee each
// saved object has wsState.
if (typeof savedObject.wsState !== 'string') {
return;
}
// Only need to parse / stringify once here compared to extractReferences
const state = JSON.parse(savedObject.wsState);
// Like the migration, skip injectReferences if "indexPatternRefName" is missing
if (!state.indexPatternRefName) {
return;
}
const indexPatternReference = references.find(reference => reference.name === state.indexPatternRefName);
if (!indexPatternReference) {
// Throw an error as "indexPatternRefName" means the reference exists within
// "references" and in this scenario we have bad data.
throw new Error(`Could not find reference "${state.indexPatternRefName}"`);
}
state.indexPattern = indexPatternReference.id;
delete state.indexPatternRefName;
savedObject.wsState = JSON.stringify(state);
}

View file

@ -0,0 +1,124 @@
/*
* 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 { extractReferences, injectReferences } from './saved_workspace_references';
describe('extractReferences', () => {
test('extracts references from wsState', () => {
const doc = {
id: '1',
attributes: {
foo: true,
wsState: JSON.stringify(
JSON.stringify({
indexPattern: 'pattern*',
bar: true,
})
),
},
};
const updatedDoc = extractReferences(doc);
expect(updatedDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"foo": true,
"wsState": "\\"{\\\\\\"bar\\\\\\":true,\\\\\\"indexPatternRefName\\\\\\":\\\\\\"indexPattern_0\\\\\\"}\\"",
},
"references": Array [
Object {
"id": "pattern*",
"name": "indexPattern_0",
"type": "index-pattern",
},
],
}
`);
});
test('fails when indexPattern is missing from workspace', () => {
const doc = {
id: '1',
attributes: {
wsState: JSON.stringify(
JSON.stringify({
bar: true,
})
),
},
};
expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot(
`"indexPattern attribute is missing in \\"wsState\\""`
);
});
});
describe('injectReferences', () => {
test('injects references into context', () => {
const context = {
id: '1',
foo: true,
wsState: JSON.stringify({
indexPatternRefName: 'indexPattern_0',
bar: true,
}),
};
const references = [
{
name: 'indexPattern_0',
type: 'index-pattern',
id: 'pattern*',
},
];
injectReferences(context, references);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
"wsState": "{\\"bar\\":true,\\"indexPattern\\":\\"pattern*\\"}",
}
`);
});
test('skips when wsState is not a string', () => {
const context = {
id: '1',
foo: true,
};
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"foo": true,
"id": "1",
}
`);
});
test('skips when indexPatternRefName is missing wsState', () => {
const context = {
id: '1',
wsState: JSON.stringify({ bar: true }),
};
injectReferences(context, []);
expect(context).toMatchInlineSnapshot(`
Object {
"id": "1",
"wsState": "{\\"bar\\":true}",
}
`);
});
test(`fails when it can't find the reference in the array`, () => {
const context = {
id: '1',
wsState: JSON.stringify({
indexPatternRefName: 'indexPattern_0',
}),
};
expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot(
`"Could not find reference \\"indexPattern_0\\""`
);
});
});

View file

@ -7,6 +7,7 @@
"panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Remote-IP-Timechart\",\"col\":1,\"row\":1},{\"size_x\":6,\"size_y\":3,\"panelIndex\":2,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Response-Code-Timechart\",\"col\":7,\"row\":1},{\"size_x\":6,\"size_y\":3,\"panelIndex\":3,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-Remote-IPs-Table\",\"col\":1,\"row\":4},{\"size_x\":6,\"size_y\":3,\"panelIndex\":4,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Map\",\"col\":7,\"row\":4},{\"size_x\":12,\"size_y\":9,\"panelIndex\":5,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-URLs-Table\",\"col\":1,\"row\":7}]",
"optionsJSON": "{}",
"version": 1,
"migrationVersion": {},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[{\"query\":{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"*\"}}}],\"highlightAll\":true,\"version\":true}"
}

View file

@ -7,6 +7,7 @@
"panelsJSON": "[{\"col\":1,\"id\":\"ML-Apache2-Access-Unique-Count-URL-Timechart\",\"panelIndex\":1,\"row\":1,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":7,\"id\":\"ML-Apache2-Access-Response-Code-Timechart\",\"panelIndex\":2,\"row\":1,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":1,\"id\":\"ML-Apache2-Access-Top-Remote-IPs-Table\",\"panelIndex\":3,\"row\":4,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":7,\"id\":\"ML-Apache2-Access-Map\",\"panelIndex\":4,\"row\":4,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"size_x\":12,\"size_y\":8,\"panelIndex\":5,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-URLs-Table\",\"col\":1,\"row\":7}]",
"optionsJSON": "{}",
"version": 1,
"migrationVersion": {},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[{\"query\":{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"*\"}}}],\"highlightAll\":true,\"version\":true}"
}

View file

@ -7,6 +7,7 @@
"description": "Filebeat Apache2 Access Data",
"title": "ML Apache2 Access Data",
"version": 1,
"migrationVersion": {},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"index\":\"INDEX_PATTERN_ID\",\"query\":{\"query_string\":{\"query\":\"_exists_:apache2.access\",\"analyze_wildcard\":true}},\"filter\":[],\"highlight\":{\"pre_tags\":[\"@kibana-highlighted-field@\"],\"post_tags\":[\"@/kibana-highlighted-field@\"],\"fields\":{\"*\":{}},\"require_field_match\":false,\"fragment_size\":2147483647}}"
},

View file

@ -1,11 +1,12 @@
{
"visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"autoPrecision\":true,\"field\":\"apache2.access.geoip.location\"},\"schema\":\"segment\",\"type\":\"geohash_grid\"}],\"listeners\":{},\"params\":{\"addTooltip\":true,\"heatBlur\":15,\"heatMaxZoom\":16,\"heatMinOpacity\":0.1,\"heatNormalizeData\":true,\"heatRadius\":25,\"isDesaturated\":true,\"legendPosition\":\"bottomright\",\"mapCenter\":[15,5],\"mapType\":\"Scaled Circle Markers\",\"mapZoom\":2,\"wms\":{\"enabled\":false,\"options\":{\"attribution\":\"Maps provided by USGS\",\"format\":\"image/png\",\"layers\":\"0\",\"styles\":\"\",\"transparent\":true,\"version\":\"1.3.0\"},\"url\":\"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"}},\"title\":\"ML Apache2 Access Map\",\"type\":\"tile_map\"}",
"description": "",
"title": "ML Apache2 Access Map",
"uiStateJSON": "{\n \"mapCenter\": [\n 12.039320557540572,\n -0.17578125\n ]\n}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"autoPrecision\":true,\"field\":\"apache2.access.geoip.location\"},\"schema\":\"segment\",\"type\":\"geohash_grid\"}],\"listeners\":{},\"params\":{\"addTooltip\":true,\"heatBlur\":15,\"heatMaxZoom\":16,\"heatMinOpacity\":0.1,\"heatNormalizeData\":true,\"heatRadius\":25,\"isDesaturated\":true,\"legendPosition\":\"bottomright\",\"mapCenter\":[15,5],\"mapType\":\"Scaled Circle Markers\",\"mapZoom\":2,\"wms\":{\"enabled\":false,\"options\":{\"attribution\":\"Maps provided by USGS\",\"format\":\"image/png\",\"layers\":\"0\",\"styles\":\"\",\"transparent\":true,\"version\":\"1.3.0\"},\"url\":\"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"}},\"title\":\"ML Apache2 Access Map\",\"type\":\"tile_map\"}",
"description": "",
"title": "ML Apache2 Access Map",
"uiStateJSON": "{\n \"mapCenter\": [\n 12.039320557540572,\n -0.17578125\n ]\n}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[]}"
}
}
}

View file

@ -1,11 +1,12 @@
{
"visState": "{\"title\":\"ML Apache2 Access Remote IP Timechart\",\"type\":\"area\",\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per 5 minutes\"},\"type\":\"category\"}],\"defaultYExtents\":false,\"drawLinesBetweenPoints\":true,\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"interpolate\":\"linear\",\"legendPosition\":\"right\",\"radiusRatio\":9,\"scale\":\"linear\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"interpolate\":\"linear\",\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"area\",\"valueAxis\":\"ValueAxis-1\"}],\"setYExtents\":false,\"showCircles\":true,\"times\":[],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"value\"}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Remote IP Timechart",
"uiStateJSON": "{\"vis\":{\"legendOpen\":false}}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"title\":\"ML Apache2 Access Remote IP Timechart\",\"type\":\"area\",\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per 5 minutes\"},\"type\":\"category\"}],\"defaultYExtents\":false,\"drawLinesBetweenPoints\":true,\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"interpolate\":\"linear\",\"legendPosition\":\"right\",\"radiusRatio\":9,\"scale\":\"linear\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"interpolate\":\"linear\",\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"area\",\"valueAxis\":\"ValueAxis-1\"}],\"setYExtents\":false,\"showCircles\":true,\"times\":[],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"value\"}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Remote IP Timechart",
"uiStateJSON": "{\"vis\":{\"legendOpen\":false}}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{}"
}
}
}

View file

@ -1,11 +1,12 @@
{
"visState": "{\"title\":\"ML Apache2 Access Response Code Timechart\",\"type\":\"histogram\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"scale\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.response_code\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Response Code Timechart",
"uiStateJSON": "{\n \"vis\": {\n \"colors\": {\n \"200\": \"#7EB26D\",\n \"404\": \"#614D93\"\n }\n }\n}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"title\":\"ML Apache2 Access Response Code Timechart\",\"type\":\"histogram\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"scale\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.response_code\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Response Code Timechart",
"uiStateJSON": "{\n \"vis\": {\n \"colors\": {\n \"200\": \"#7EB26D\",\n \"404\": \"#614D93\"\n }\n }\n}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[]}"
}
}
}

View file

@ -1,11 +1,12 @@
{
"visState": "{\"title\":\"ML Apache2 Access Top Remote IPs Table\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Top Remote IPs Table",
"uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"title\":\"ML Apache2 Access Top Remote IPs Table\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Top Remote IPs Table",
"uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{}"
}
}
}

View file

@ -1,11 +1,12 @@
{
"visState": "{\"title\":\"ML Apache2 Access Top URLs Table\",\"type\":\"table\",\"params\":{\"perPage\":100,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.url\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Top URLs Table",
"uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"title\":\"ML Apache2 Access Top URLs Table\",\"type\":\"table\",\"params\":{\"perPage\":100,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.url\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Top URLs Table",
"uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{}"
}
}
}

View file

@ -1,11 +1,12 @@
{
"visState": "{\"title\":\"ML Apache2 Access Unique Count URL Timechart\",\"type\":\"line\",\"params\":{\"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\":\"@timestamp per day\"}}],\"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\":\"Unique count of apache2.access.url\"}}],\"seriesParams\":[{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"lineWidth\":2,\"data\":{\"id\":\"1\",\"label\":\"Unique count of apache2.access.url\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"showCircles\":true,\"interpolate\":\"linear\",\"scale\":\"linear\",\"drawLinesBetweenPoints\":true,\"radiusRatio\":9,\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"apache2.access.url\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Unique Count URL Timechart",
"uiStateJSON": "{}",
"version": 1,
"savedSearchId": "ML-Filebeat-Apache2-Access",
"visState": "{\"title\":\"ML Apache2 Access Unique Count URL Timechart\",\"type\":\"line\",\"params\":{\"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\":\"@timestamp per day\"}}],\"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\":\"Unique count of apache2.access.url\"}}],\"seriesParams\":[{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"lineWidth\":2,\"data\":{\"id\":\"1\",\"label\":\"Unique count of apache2.access.url\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"showCircles\":true,\"interpolate\":\"linear\",\"scale\":\"linear\",\"drawLinesBetweenPoints\":true,\"radiusRatio\":9,\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"apache2.access.url\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}",
"description": "",
"title": "ML Apache2 Access Unique Count URL Timechart",
"uiStateJSON": "{}",
"version": 1,
"migrationVersion": {},
"savedSearchId": "ML-Filebeat-Apache2-Access",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{}"
}
}
}

View file

@ -102,8 +102,9 @@ async function fetchRollupVisualizations(kibanaIndex, callCluster, rollupIndexPa
index: kibanaIndex,
ignoreUnavailable: true,
filterPath: [
'hits.hits._source.visualization.savedSearchId',
'hits.hits._source.visualization.savedSearchRefName',
'hits.hits._source.visualization.kibanaSavedObjectMeta',
'hits.hits._source.references',
],
body: {
query: {
@ -128,19 +129,21 @@ async function fetchRollupVisualizations(kibanaIndex, callCluster, rollupIndexPa
const {
_source: {
visualization: {
savedSearchId,
savedSearchRefName,
kibanaSavedObjectMeta: {
searchSourceJSON,
},
},
references = [],
},
} = visualization;
const searchSource = JSON.parse(searchSourceJSON);
if (savedSearchId) {
if (savedSearchRefName) {
// This visualization depends upon a saved search.
if (rollupSavedSearchesToFlagMap[savedSearchId]) {
const savedSearch = references.find(ref => ref.name === savedSearchRefName);
if (rollupSavedSearchesToFlagMap[savedSearch.id]) {
rollupVisualizations++;
rollupVisualizationsFromSavedSearches++;
}

View file

@ -149,6 +149,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClient {
* @property {string} [options.sortOrder]
* @property {Array<string>} [options.fields]
* @property {string} [options.namespace]
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
public async find(options: FindOptions = {}) {

View file

@ -229,6 +229,7 @@ describe('interceptors', () => {
attributes: {
name: 'a space',
},
references: [],
},
];
@ -265,6 +266,7 @@ describe('interceptors', () => {
attributes: {
name: 'a space',
},
references: [],
},
];
@ -302,6 +304,7 @@ describe('interceptors', () => {
attributes: {
name: 'a space',
},
references: [],
},
];
@ -348,6 +351,7 @@ describe('interceptors', () => {
attributes: {
name: 'Default Space',
},
references: [],
},
];
@ -379,6 +383,7 @@ describe('interceptors', () => {
attributes: {
name: 'a space',
},
references: [],
},
{
id: 'b-space',
@ -386,6 +391,7 @@ describe('interceptors', () => {
attributes: {
name: 'b space',
},
references: [],
},
];

View file

@ -195,7 +195,7 @@
"description": "",
"version": 1,
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"
"searchSourceJSON": "{\"index\":\"space_1-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"
}
}
}
@ -216,7 +216,7 @@
"title": "Requests",
"hits": 0,
"description": "",
"panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]",
"panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]",
"optionsJSON": "{}",
"uiStateJSON": "{}",
"version": 1,
@ -291,7 +291,7 @@
"description": "",
"version": 1,
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"
"searchSourceJSON": "{\"index\":\"space_2-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}"
}
}
}
@ -312,7 +312,7 @@
"title": "Requests",
"hits": 0,
"description": "",
"panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]",
"panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]",
"optionsJSON": "{}",
"uiStateJSON": "{}",
"version": 1,

View file

@ -88,6 +88,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
attributes: {
title: 'A great new dashboard',
},
references: [],
},
{
type: 'globaltype',
@ -97,6 +98,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest:
attributes: {
name: 'A new globaltype object',
},
references: [],
},
{
type: 'globaltype',

View file

@ -69,6 +69,7 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest<an
attributes: {
name: 'My favorite global object',
},
references: [],
},
],
});
@ -102,6 +103,13 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest<an
uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON,
kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta,
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`,
},
],
},
{
id: `${getIdPrefix(spaceId)}does not exist`,
@ -119,6 +127,7 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest<an
attributes: {
name: 'My favorite global object',
},
references: [],
},
],
});

View file

@ -67,6 +67,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
attributes: {
title: 'My favorite vis',
},
references: [],
});
const expectedSpacePrefix = spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}:`;
@ -107,6 +108,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
attributes: {
name: `Can't be contained to a space`,
},
references: [],
});
// query ES directory to ensure namespace wasn't specified

View file

@ -67,6 +67,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
attributes: {
name: 'My favorite global object',
},
references: [],
},
],
});
@ -99,6 +100,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
attributes: {
title: 'Count of requests',
},
references: [],
},
],
});

View file

@ -69,6 +69,7 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
attributes: {
name: 'My favorite global object',
},
references: [],
});
};
@ -108,6 +109,13 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
uiStateJSON: resp.body.attributes.uiStateJSON,
kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta,
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`,
},
],
});
};

View file

@ -78,6 +78,7 @@ export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
attributes: {
name: 'My second favorite',
},
references: [],
});
};
@ -102,6 +103,7 @@ export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
attributes: {
title: 'My second favorite vis',
},
references: [],
});
};