Merge branch 'master' into fixes-searchbar-test

This commit is contained in:
Elastic Machine 2020-06-25 11:04:19 -06:00 committed by GitHub
commit a72f42ff19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
244 changed files with 11228 additions and 6124 deletions

View file

@ -28,6 +28,7 @@ export interface IFieldType
| [searchable](./kibana-plugin-plugins-data-public.ifieldtype.searchable.md) | <code>boolean</code> | |
| [sortable](./kibana-plugin-plugins-data-public.ifieldtype.sortable.md) | <code>boolean</code> | |
| [subType](./kibana-plugin-plugins-data-public.ifieldtype.subtype.md) | <code>IFieldSubType</code> | |
| [toSpec](./kibana-plugin-plugins-data-public.ifieldtype.tospec.md) | <code>() =&gt; FieldSpec</code> | |
| [type](./kibana-plugin-plugins-data-public.ifieldtype.type.md) | <code>string</code> | |
| [visualizable](./kibana-plugin-plugins-data-public.ifieldtype.visualizable.md) | <code>boolean</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IFieldType](./kibana-plugin-plugins-data-public.ifieldtype.md) &gt; [toSpec](./kibana-plugin-plugins-data-public.ifieldtype.tospec.md)
## IFieldType.toSpec property
<b>Signature:</b>
```typescript
toSpec?: () => FieldSpec;
```

View file

@ -7,5 +7,7 @@
<b>Signature:</b>
```typescript
fields: IIndexPatternFieldList;
fields: IIndexPatternFieldList & {
toSpec: () => FieldSpec[];
};
```

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) &gt; [initFromSpec](./kibana-plugin-plugins-data-public.indexpattern.initfromspec.md)
## IndexPattern.initFromSpec() method
<b>Signature:</b>
```typescript
initFromSpec(spec: IndexPatternSpec): this;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| spec | <code>IndexPatternSpec</code> | |
<b>Returns:</b>
`this`

View file

@ -21,7 +21,7 @@ export declare class IndexPattern implements IIndexPattern
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [fieldFormatMap](./kibana-plugin-plugins-data-public.indexpattern.fieldformatmap.md) | | <code>any</code> | |
| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | <code>IIndexPatternFieldList</code> | |
| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | <code>IIndexPatternFieldList &amp; {</code><br/><code> toSpec: () =&gt; FieldSpec[];</code><br/><code> }</code> | |
| [fieldsFetcher](./kibana-plugin-plugins-data-public.indexpattern.fieldsfetcher.md) | | <code>any</code> | |
| [flattenHit](./kibana-plugin-plugins-data-public.indexpattern.flattenhit.md) | | <code>any</code> | |
| [formatField](./kibana-plugin-plugins-data-public.indexpattern.formatfield.md) | | <code>any</code> | |
@ -30,7 +30,6 @@ export declare class IndexPattern implements IIndexPattern
| [metaFields](./kibana-plugin-plugins-data-public.indexpattern.metafields.md) | | <code>string[]</code> | |
| [timeFieldName](./kibana-plugin-plugins-data-public.indexpattern.timefieldname.md) | | <code>string &#124; undefined</code> | |
| [title](./kibana-plugin-plugins-data-public.indexpattern.title.md) | | <code>string</code> | |
| [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) | | <code>string</code> | |
| [typeMeta](./kibana-plugin-plugins-data-public.indexpattern.typemeta.md) | | <code>TypeMeta</code> | |
## Methods
@ -49,6 +48,7 @@ export declare class IndexPattern implements IIndexPattern
| [getSourceFiltering()](./kibana-plugin-plugins-data-public.indexpattern.getsourcefiltering.md) | | |
| [getTimeField()](./kibana-plugin-plugins-data-public.indexpattern.gettimefield.md) | | |
| [init(forceFieldRefresh)](./kibana-plugin-plugins-data-public.indexpattern.init.md) | | |
| [initFromSpec(spec)](./kibana-plugin-plugins-data-public.indexpattern.initfromspec.md) | | |
| [isTimeBased()](./kibana-plugin-plugins-data-public.indexpattern.istimebased.md) | | |
| [isTimeBasedWildcard()](./kibana-plugin-plugins-data-public.indexpattern.istimebasedwildcard.md) | | |
| [isTimeNanosBased()](./kibana-plugin-plugins-data-public.indexpattern.istimenanosbased.md) | | |
@ -59,5 +59,6 @@ export declare class IndexPattern implements IIndexPattern
| [removeScriptedField(field)](./kibana-plugin-plugins-data-public.indexpattern.removescriptedfield.md) | | |
| [save(saveAttempts)](./kibana-plugin-plugins-data-public.indexpattern.save.md) | | |
| [toJSON()](./kibana-plugin-plugins-data-public.indexpattern.tojson.md) | | |
| [toSpec()](./kibana-plugin-plugins-data-public.indexpattern.tospec.md) | | |
| [toString()](./kibana-plugin-plugins-data-public.indexpattern.tostring.md) | | |

View file

@ -1,11 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) &gt; [type](./kibana-plugin-plugins-data-public.indexpattern.type.md)
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) &gt; [toSpec](./kibana-plugin-plugins-data-public.indexpattern.tospec.md)
## IndexPattern.type property
## IndexPattern.toSpec() method
<b>Signature:</b>
```typescript
type?: string;
toSpec(): IndexPatternSpec;
```
<b>Returns:</b>
`IndexPatternSpec`

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `Field` class
<b>Signature:</b>
```typescript
constructor(indexPattern: IIndexPattern, spec: FieldSpec | Field, shortDotsEnable: boolean, { fieldFormats, onNotification }: FieldDependencies);
constructor(indexPattern: IIndexPattern, spec: FieldSpecExportFmt | FieldSpec | Field, shortDotsEnable: boolean, { fieldFormats, onNotification }: FieldDependencies);
```
## Parameters
@ -17,7 +17,7 @@ constructor(indexPattern: IIndexPattern, spec: FieldSpec | Field, shortDotsEnabl
| Parameter | Type | Description |
| --- | --- | --- |
| indexPattern | <code>IIndexPattern</code> | |
| spec | <code>FieldSpec &#124; Field</code> | |
| spec | <code>FieldSpecExportFmt &#124; FieldSpec &#124; Field</code> | |
| shortDotsEnable | <code>boolean</code> | |
| { fieldFormats, onNotification } | <code>FieldDependencies</code> | |

View file

@ -7,5 +7,5 @@
<b>Signature:</b>
```typescript
conflictDescriptions?: Record<string, string[]>;
conflictDescriptions?: FieldSpecConflictDescriptions;
```

View file

@ -22,7 +22,7 @@ export declare class Field implements IFieldType
| --- | --- | --- | --- |
| [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) | | <code>FieldSpec</code> | |
| [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) | | <code>boolean</code> | |
| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | <code>Record&lt;string, string[]&gt;</code> | |
| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | <code>FieldSpecConflictDescriptions</code> | |
| [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) | | <code>number</code> | |
| [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) | | <code>string</code> | |
| [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | <code>string[]</code> | |
@ -37,6 +37,7 @@ export declare class Field implements IFieldType
| [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | <code>boolean</code> | |
| [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) | | <code>boolean</code> | |
| [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) | | <code>IFieldSubType</code> | |
| [toSpec](./kibana-plugin-plugins-data-public.indexpatternfield.tospec.md) | | <code>() =&gt; FieldSpecExportFmt</code> | |
| [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) | | <code>string</code> | |
| [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) | | <code>boolean</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) &gt; [toSpec](./kibana-plugin-plugins-data-public.indexpatternfield.tospec.md)
## IndexPatternField.toSpec property
<b>Signature:</b>
```typescript
toSpec: () => FieldSpecExportFmt;
```

View file

@ -28,6 +28,7 @@ export interface IFieldType
| [searchable](./kibana-plugin-plugins-data-server.ifieldtype.searchable.md) | <code>boolean</code> | |
| [sortable](./kibana-plugin-plugins-data-server.ifieldtype.sortable.md) | <code>boolean</code> | |
| [subType](./kibana-plugin-plugins-data-server.ifieldtype.subtype.md) | <code>IFieldSubType</code> | |
| [toSpec](./kibana-plugin-plugins-data-server.ifieldtype.tospec.md) | <code>() =&gt; FieldSpec</code> | |
| [type](./kibana-plugin-plugins-data-server.ifieldtype.type.md) | <code>string</code> | |
| [visualizable](./kibana-plugin-plugins-data-server.ifieldtype.visualizable.md) | <code>boolean</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) &gt; [IFieldType](./kibana-plugin-plugins-data-server.ifieldtype.md) &gt; [toSpec](./kibana-plugin-plugins-data-server.ifieldtype.tospec.md)
## IFieldType.toSpec property
<b>Signature:</b>
```typescript
toSpec?: () => FieldSpec;
```

View file

@ -38,7 +38,7 @@ import { AppCategory } from '../../../../types';
import { InternalApplicationStart } from '../../../application/types';
import { HttpStart } from '../../../http';
import { OnIsLockedUpdate } from './';
import { createEuiListItem, createRecentNavLink } from './nav_link';
import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link';
function getAllCategories(allCategorizedLinks: Record<string, ChromeNavLink[]>) {
const allCategories = {} as Record<string, AppCategory | undefined>;
@ -184,17 +184,13 @@ export function CollapsibleNav({
label: 'Home',
iconType: 'home',
href: homeHref,
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
closeNav();
if (
event.isDefaultPrevented() ||
event.altKey ||
event.metaKey ||
event.ctrlKey
) {
onClick: (event) => {
if (isModifiedOrPrevented(event)) {
return;
}
event.preventDefault();
closeNav();
navigateToApp('home');
},
},
@ -230,7 +226,13 @@ export function CollapsibleNav({
return {
...hydratedLink,
'data-test-subj': 'collapsibleNavAppLink--recent',
onClick: closeNav,
onClick: (event) => {
if (isModifiedOrPrevented(event)) {
return;
}
closeNav();
},
};
})}
maxWidth="none"

View file

@ -17,20 +17,15 @@
* under the License.
*/
import { EuiImage } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..';
import { HttpStart } from '../../../http';
import { relativeToAbsolute } from '../../nav_links/to_nav_link';
function isModifiedEvent(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
function LinkIcon({ url }: { url: string }) {
return <EuiImage size="s" alt="" aria-hidden={true} url={url} />;
}
export const isModifiedOrPrevented = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) =>
event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented;
interface Props {
link: ChromeNavLink;
@ -69,14 +64,16 @@ export function createEuiListItem({
href,
/* Use href and onClick to support "open in new tab" and SPA navigation in the same link */
onClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
onClick();
if (!isModifiedOrPrevented(event)) {
onClick();
}
if (
!externalLink && // ignore external links
!legacyMode && // ignore when in legacy mode
!legacy && // ignore links to legacy apps
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
!isModifiedEvent(event) // ignore clicks with modifier keys
!isModifiedOrPrevented(event)
) {
event.preventDefault();
navigateToApp(id);
@ -88,7 +85,8 @@ export function createEuiListItem({
'data-test-subj': dataTestSubj,
...(basePath && {
iconType: euiIconType,
icon: !euiIconType && icon ? <LinkIcon url={basePath.prepend(`/${icon}`)} /> : undefined,
icon:
!euiIconType && icon ? <EuiIcon type={basePath.prepend(`/${icon}`)} size="m" /> : undefined,
}),
};
}

View file

@ -46,6 +46,7 @@ export { httpServiceMock } from './http/http_service.mock';
export { loggingSystemMock } from './logging/logging_system.mock';
export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock';
export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock';
export { migrationMocks } from './saved_objects/migrations/mocks';
export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
export { metricsServiceMock } from './metrics/metrics_service.mock';

View file

@ -195,7 +195,7 @@ async function migrateSourceToDest(context: Context) {
await Index.write(
callCluster,
dest.indexName,
migrateRawDocs(serializer, documentMigrator.migrate, docs, log)
await migrateRawDocs(serializer, documentMigrator.migrate, docs, log)
);
}
}

View file

@ -26,7 +26,7 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks';
describe('migrateRawDocs', () => {
test('converts raw docs to saved objects', async () => {
const transform = jest.fn<any, any>((doc: any) => _.set(doc, 'attributes.name', 'HOI!'));
const result = migrateRawDocs(
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
[
@ -55,7 +55,7 @@ describe('migrateRawDocs', () => {
const transform = jest.fn<any, any>((doc: any) =>
_.set(_.cloneDeep(doc), 'attributes.name', 'TADA')
);
const result = migrateRawDocs(
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
[

View file

@ -21,7 +21,11 @@
* This file provides logic for migrating raw documents.
*/
import { SavedObjectsRawDoc, SavedObjectsSerializer } from '../../serialization';
import {
SavedObjectsRawDoc,
SavedObjectsSerializer,
SavedObjectUnsanitizedDoc,
} from '../../serialization';
import { TransformFn } from './document_migrator';
import { SavedObjectsMigrationLogger } from '.';
@ -33,26 +37,51 @@ import { SavedObjectsMigrationLogger } from '.';
* @param {SavedObjectsRawDoc[]} rawDocs
* @returns {SavedObjectsRawDoc[]}
*/
export function migrateRawDocs(
export async function migrateRawDocs(
serializer: SavedObjectsSerializer,
migrateDoc: TransformFn,
rawDocs: SavedObjectsRawDoc[],
log: SavedObjectsMigrationLogger
): SavedObjectsRawDoc[] {
return rawDocs.map((raw) => {
): Promise<SavedObjectsRawDoc[]> {
const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc);
const processedDocs = [];
for (const raw of rawDocs) {
if (serializer.isRawSavedObject(raw)) {
const savedObject = serializer.rawToSavedObject(raw);
savedObject.migrationVersion = savedObject.migrationVersion || {};
return serializer.savedObjectToRaw({
references: [],
...migrateDoc(savedObject),
});
processedDocs.push(
serializer.savedObjectToRaw({
references: [],
...(await migrateDocWithoutBlocking(savedObject)),
})
);
} else {
log.error(
`Error: Unable to migrate the corrupt Saved Object document ${raw._id}. To prevent Kibana from performing a migration on every restart, please delete or fix this document by ensuring that the namespace and type in the document's id matches the values in the namespace and type fields.`,
{ rawDocument: raw }
);
processedDocs.push(raw);
}
log.error(
`Error: Unable to migrate the corrupt Saved Object document ${raw._id}. To prevent Kibana from performing a migration on every restart, please delete or fix this document by ensuring that the namespace and type in the document's id matches the values in the namespace and type fields.`,
{ rawDocument: raw }
);
return raw;
});
}
return processedDocs;
}
/**
* Migration transform functions are potentially CPU heavy e.g. doing decryption/encryption
* or (de)/serializing large JSON payloads.
* Executing all transforms for a batch in a synchronous loop can block the event-loop for a long time.
* To prevent this we use setImmediate to ensure that the event-loop can process other parallel
* work in between each transform.
*/
function transformNonBlocking(
transform: TransformFn
): (doc: SavedObjectUnsanitizedDoc) => Promise<SavedObjectUnsanitizedDoc> {
// promises aren't enough to unblock the event loop
return (doc: SavedObjectUnsanitizedDoc) =>
new Promise((resolve) => {
// set immediate is though
setImmediate(() => {
resolve(transform(doc));
});
});
}

View file

@ -27,6 +27,7 @@ export function stubbedSavedObjectIndexPattern(id) {
id,
type: 'index-pattern',
attributes: {
timeFieldName: 'timestamp',
customFormats: '{}',
fields: mockLogstashFields,
},

View file

@ -33,3 +33,43 @@ Object {
"type": "type",
}
`;
exports[`Field spec snapshot 1`] = `
Object {
"aggregatable": true,
"conflictDescriptions": Object {
"a": Array [
"b",
"c",
],
"d": Array [
"e",
],
},
"count": 1,
"esTypes": Array [
"type",
],
"format": Object {
"id": "number",
"params": Object {
"pattern": "$0,0.[00]",
},
},
"lang": "lang",
"name": "name",
"readFromDocValues": false,
"script": "script",
"scripted": true,
"searchable": true,
"subType": Object {
"multi": Object {
"parent": "parent",
},
"nested": Object {
"path": "path",
},
},
"type": "type",
}
`;

View file

@ -20,7 +20,7 @@
import { Field } from './field';
import { IndexPattern } from '../index_patterns';
import { FieldFormatsStartCommon } from '../..';
import { KBN_FIELD_TYPES } from '../../../common';
import { KBN_FIELD_TYPES, FieldSpec, FieldSpecExportFmt } from '../../../common';
describe('Field', function () {
function flatten(obj: Record<string, any>) {
@ -59,8 +59,9 @@ describe('Field', function () {
fieldFormatMap: { name: {}, _source: {}, _score: {}, _id: {} },
} as unknown) as IndexPattern,
format: { name: 'formatName' },
$$spec: {},
$$spec: ({} as unknown) as FieldSpec,
conflictDescriptions: { a: ['b', 'c'], d: ['e'] },
toSpec: () => (({} as unknown) as FieldSpecExportFmt),
} as Field;
it('the correct properties are writable', () => {
@ -145,7 +146,7 @@ describe('Field', function () {
}).toThrow();
expect(() => {
field.$$spec = { a: 'b' };
field.$$spec = ({ a: 'b' } as unknown) as FieldSpec;
}).toThrow();
});
@ -219,4 +220,21 @@ describe('Field', function () {
});
expect(flatten(field)).toMatchSnapshot();
});
it('spec snapshot', () => {
const field = new Field(
{
fieldFormatMap: {
name: { toJSON: () => ({ id: 'number', params: { pattern: '$0,0.[00]' } }) },
},
} as IndexPattern,
fieldValues,
false,
{
fieldFormats: {} as FieldFormatsStartCommon,
onNotification: () => {},
}
);
expect(field.toSpec()).toMatchSnapshot();
});
});

View file

@ -28,11 +28,14 @@ import {
FieldFormat,
shortenDottedString,
} from '../../../common';
import { OnNotification } from '../types';
import {
OnNotification,
FieldSpec,
FieldSpecConflictDescriptions,
FieldSpecExportFmt,
} from '../types';
import { FieldFormatsStartCommon } from '../../field_formats';
export type FieldSpec = Record<string, any>;
interface FieldDependencies {
fieldFormats: FieldFormatsStartCommon;
onNotification: OnNotification;
@ -59,11 +62,11 @@ export class Field implements IFieldType {
readFromDocValues?: boolean;
format: any;
$$spec: FieldSpec;
conflictDescriptions?: Record<string, string[]>;
conflictDescriptions?: FieldSpecConflictDescriptions;
constructor(
indexPattern: IIndexPattern,
spec: FieldSpec | Field,
spec: FieldSpecExportFmt | FieldSpec | Field,
shortDotsEnable: boolean,
{ fieldFormats, onNotification }: FieldDependencies
) {
@ -95,7 +98,7 @@ export class Field implements IFieldType {
if (!type) type = getKbnFieldType('unknown');
let format = spec.format;
let format: any = spec.format;
if (!FieldFormat.isInstanceOfFieldFormat(format)) {
format =
@ -148,6 +151,26 @@ export class Field implements IFieldType {
// multi info
obj.fact('subType');
return obj.create();
const newObj = obj.create();
newObj.toSpec = function () {
return {
count: this.count,
script: this.script,
lang: this.lang,
conflictDescriptions: this.conflictDescriptions,
name: this.name,
type: this.type,
esTypes: this.esTypes,
scripted: this.scripted,
searchable: this.searchable,
aggregatable: this.aggregatable,
readFromDocValues: this.readFromDocValues,
subType: this.subType,
format: this.indexPattern?.fieldFormatMap[this.name]?.toJSON() || undefined,
};
};
return newObj;
}
// only providing type info as constructor returns new object instead of `this`
toSpec = () => (({} as unknown) as FieldSpecExportFmt);
}

View file

@ -20,8 +20,8 @@
import { findIndex } from 'lodash';
import { IIndexPattern } from '../../types';
import { IFieldType } from '../../../common';
import { Field, FieldSpec } from './field';
import { OnNotification } from '../types';
import { Field } from './field';
import { OnNotification, FieldSpec } from '../types';
import { FieldFormatsStartCommon } from '../../field_formats';
type FieldMap = Map<Field['name'], Field>;
@ -102,6 +102,10 @@ export const getIndexPatternFieldListCreator = ({
this.removeByGroup(newField);
this.setByGroup(newField);
};
toSpec = () => {
return [...this.map((field) => field.toSpec())];
};
}
return new FieldList(...fieldListParams);

View file

@ -17,10 +17,7 @@
* under the License.
*/
export interface IFieldSubType {
multi?: { parent: string };
nested?: { path: string };
}
import { FieldSpec, IFieldSubType } from '../types';
export interface IFieldType {
name: string;
@ -41,4 +38,5 @@ export interface IFieldType {
subType?: IFieldSubType;
displayName?: string;
format?: any;
toSpec?: () => FieldSpec;
}

View file

@ -0,0 +1,503 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IndexPattern toSpec should match snapshot 1`] = `
Object {
"fields": Array [
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 10,
"esTypes": Array [
"long",
],
"format": Object {
"id": "number",
"params": Object {
"pattern": "$0,0.[00]",
},
},
"lang": undefined,
"name": "bytes",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 20,
"esTypes": Array [
"boolean",
],
"format": undefined,
"lang": undefined,
"name": "ssl",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "boolean",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 30,
"esTypes": Array [
"date",
],
"format": undefined,
"lang": undefined,
"name": "@timestamp",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 30,
"esTypes": Array [
"date",
],
"format": undefined,
"lang": undefined,
"name": "time",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"keyword",
],
"format": undefined,
"lang": undefined,
"name": "@tags",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"date",
],
"format": undefined,
"lang": undefined,
"name": "utc_time",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"integer",
],
"format": undefined,
"lang": undefined,
"name": "phpmemory",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"ip",
],
"format": undefined,
"lang": undefined,
"name": "ip",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "ip",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"attachment",
],
"format": undefined,
"lang": undefined,
"name": "request_body",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "attachment",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"geo_point",
],
"format": undefined,
"lang": undefined,
"name": "point",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_point",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"geo_shape",
],
"format": undefined,
"lang": undefined,
"name": "area",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_shape",
},
Object {
"aggregatable": false,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"murmur3",
],
"format": undefined,
"lang": undefined,
"name": "hashed",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "murmur3",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"geo_point",
],
"format": undefined,
"lang": undefined,
"name": "geo.coordinates",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_point",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"text",
],
"format": undefined,
"lang": undefined,
"name": "extension",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"keyword",
],
"format": undefined,
"lang": undefined,
"name": "extension.keyword",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": Object {
"multi": Object {
"parent": "extension",
},
},
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"text",
],
"format": undefined,
"lang": undefined,
"name": "machine.os",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"keyword",
],
"format": undefined,
"lang": undefined,
"name": "machine.os.raw",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": Object {
"multi": Object {
"parent": "machine.os",
},
},
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"keyword",
],
"format": undefined,
"lang": undefined,
"name": "geo.src",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"_id",
],
"format": undefined,
"lang": undefined,
"name": "_id",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"_type",
],
"format": undefined,
"lang": undefined,
"name": "_type",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"_source",
],
"format": undefined,
"lang": undefined,
"name": "_source",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "_source",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"text",
],
"format": undefined,
"lang": undefined,
"name": "non-filterable",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": false,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": false,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"text",
],
"format": undefined,
"lang": undefined,
"name": "non-sortable",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": false,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"conflict",
],
"format": undefined,
"lang": undefined,
"name": "custom_user_field",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "conflict",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"text",
],
"format": undefined,
"lang": "expression",
"name": "script string",
"readFromDocValues": false,
"script": "'i am a string'",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"long",
],
"format": undefined,
"lang": "expression",
"name": "script number",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"date",
],
"format": undefined,
"lang": "painless",
"name": "script date",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"esTypes": Array [
"murmur3",
],
"format": undefined,
"lang": "expression",
"name": "script murmur3",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "murmur3",
},
],
"id": "test-pattern",
"sourceFilters": undefined,
"timeFieldName": "timestamp",
"title": "test-pattern",
"typeMeta": undefined,
"version": 2,
}
`;

View file

@ -18,7 +18,6 @@
*/
export * from './index_patterns_api_client';
export * from './types';
export * from './_pattern_cache';
export * from './flatten_hit';
export * from './format_hit';

View file

@ -30,6 +30,10 @@ import { Field } from '../fields';
import { fieldFormatsMock } from '../../field_formats/mocks';
class MockFieldFormatter {}
fieldFormatsMock.getType = jest.fn().mockImplementation(() => MockFieldFormatter);
jest.mock('../../field_mapping', () => {
const originalModule = jest.requireActual('../../field_mapping');
@ -303,6 +307,29 @@ describe('IndexPattern', () => {
});
});
describe('toSpec', () => {
test('should match snapshot', () => {
indexPattern.fieldFormatMap.bytes = {
toJSON: () => ({ id: 'number', params: { pattern: '$0,0.[00]' } }),
};
expect(indexPattern.toSpec()).toMatchSnapshot();
});
test('can restore from spec', async () => {
indexPattern.fieldFormatMap.bytes = {
toJSON: () => ({ id: 'number', params: { pattern: '$0,0.[00]' } }),
};
const spec = indexPattern.toSpec();
const restoredPattern = await create(spec.id as string);
restoredPattern.initFromSpec(spec);
expect(restoredPattern.id).toEqual(indexPattern.id);
expect(restoredPattern.title).toEqual(indexPattern.title);
expect(restoredPattern.timeFieldName).toEqual(indexPattern.timeFieldName);
expect(restoredPattern.fields.length).toEqual(indexPattern.fields.length);
expect(restoredPattern.fieldFormatMap.bytes instanceof MockFieldFormatter).toEqual(true);
});
});
describe('popularizeField', () => {
test('should increment the popularity count by default', () => {
// const saveSpy = sinon.stub(indexPattern, 'save');

View file

@ -20,6 +20,7 @@
import _, { each, reject } from 'lodash';
import { i18n } from '@kbn/i18n';
import { SavedObjectsClientContract } from 'src/core/public';
import { SavedObjectAttributes } from 'src/core/public';
import { DuplicateField, SavedObjectNotFound } from '../../../../kibana_utils/common';
import {
@ -36,11 +37,12 @@ import { createFieldsFetcher } from './_fields_fetcher';
import { formatHitProvider } from './format_hit';
import { flattenHitWrapper } from './flatten_hit';
import { IIndexPatternsApiClient } from '.';
import { TypeMeta } from '.';
import { OnNotification, OnError } from '../types';
import { FieldFormatsStartCommon } from '../../field_formats';
import { PatternCache } from './_pattern_cache';
import { expandShorthand, FieldMappingSpec, MappingObject } from '../../field_mapping';
import { IndexPatternSpec, TypeMeta, FieldSpec, SourceFilter } from '../types';
import { SerializedFieldFormat } from '../../../../expressions/common';
const MAX_ATTEMPTS_TO_RESOLVE_CONFLICTS = 3;
const type = 'index-pattern';
@ -60,10 +62,9 @@ export class IndexPattern implements IIndexPattern {
public id?: string;
public title: string = '';
public type?: string;
public fieldFormatMap: any;
public typeMeta?: TypeMeta;
public fields: IIndexPatternFieldList;
public fields: IIndexPatternFieldList & { toSpec: () => FieldSpec[] };
public timeFieldName: string | undefined;
public formatHit: any;
public formatField: any;
@ -74,7 +75,7 @@ export class IndexPattern implements IIndexPattern {
private savedObjectsClient: SavedObjectsClientContract;
private patternCache: PatternCache;
private getConfig: any;
private sourceFilters?: [];
private sourceFilters?: SourceFilter[];
private originalBody: { [key: string]: any } = {};
public fieldsFetcher: any; // probably want to factor out any direct usage and change to private
private shortDotsEnable: boolean = false;
@ -196,6 +197,35 @@ export class IndexPattern implements IIndexPattern {
this.initFields();
}
public initFromSpec(spec: IndexPatternSpec) {
// create fieldFormatMap from field list
const fieldFormatMap: Record<string, SerializedFieldFormat> = {};
if (_.isArray(spec.fields)) {
spec.fields.forEach((field: FieldSpec) => {
if (field.format) {
fieldFormatMap[field.name as string] = { ...field.format };
}
});
}
this.version = spec.version;
this.title = spec.title || '';
this.timeFieldName = spec.timeFieldName;
this.sourceFilters = spec.sourceFilters;
// ignoring this because the same thing happens elsewhere but via _.assign
// @ts-ignore
this.fields = spec.fields || [];
this.typeMeta = spec.typeMeta;
this.fieldFormatMap = _.mapValues(fieldFormatMap, (mapping) => {
return this.deserializeFieldFormatMap(mapping);
});
this.initFields();
return this;
}
private updateFromElasticSearch(response: any, forceFieldRefresh: boolean = false) {
if (!response.found) {
throw new SavedObjectNotFound(type, this.id, 'management/kibana/indexPatterns');
@ -206,15 +236,16 @@ export class IndexPattern implements IIndexPattern {
return;
}
response._source[name] = fieldMapping._deserialize(response._source[name]);
response[name] = fieldMapping._deserialize(response[name]);
});
// give index pattern all of the values in _source
_.assign(this, response._source);
// give index pattern all of the values
_.assign(this, response);
if (!this.title && this.id) {
this.title = this.id;
}
this.version = response.version;
return this.indexFields(forceFieldRefresh);
}
@ -266,13 +297,11 @@ export class IndexPattern implements IIndexPattern {
}
const savedObject = await this.savedObjectsClient.get(type, this.id);
this.version = savedObject._version;
const response = {
_id: savedObject.id,
_type: savedObject.type,
_source: _.cloneDeep(savedObject.attributes),
version: savedObject._version,
found: savedObject._version ? true : false,
...(_.cloneDeep(savedObject.attributes) as SavedObjectAttributes),
};
// Do this before we attempt to update from ES since that call can potentially perform a save
this.originalBody = this.prepBody();
@ -283,6 +312,19 @@ export class IndexPattern implements IIndexPattern {
return this;
}
public toSpec(): IndexPatternSpec {
return {
id: this.id,
version: this.version,
title: this.title,
timeFieldName: this.timeFieldName,
sourceFilters: this.sourceFilters,
fields: this.fields.toSpec(),
typeMeta: this.typeMeta,
};
}
// Get the source filtering configuration for that index.
getSourceFiltering() {
return {

View file

@ -32,12 +32,8 @@ import {
createEnsureDefaultIndexPattern,
EnsureDefaultIndexPattern,
} from './ensure_default_index_pattern';
import {
getIndexPatternFieldListCreator,
CreateIndexPatternFieldList,
Field,
FieldSpec,
} from '../fields';
import { getIndexPatternFieldListCreator, CreateIndexPatternFieldList, Field } from '../fields';
import { IndexPatternSpec, FieldSpec } from '../types';
import { OnNotification, OnError } from '../types';
import { FieldFormatsStartCommon } from '../../field_formats';
@ -195,6 +191,21 @@ export class IndexPatternsService {
return indexPatternCache.set(id, indexPattern);
};
specToIndexPattern(spec: IndexPatternSpec) {
const indexPattern = new IndexPattern(spec.id, {
getConfig: (cfg: any) => this.config.get(cfg),
savedObjectsClient: this.savedObjectsClient,
apiClient: this.apiClient,
patternCache: indexPatternCache,
fieldFormats: this.fieldFormats,
onNotification: this.onNotification,
onError: this.onError,
});
indexPattern.initFromSpec(spec);
return indexPattern;
}
make = (id?: string): Promise<IndexPattern> => {
const indexPattern = new IndexPattern(id, {
getConfig: (cfg: any) => this.config.get(cfg),

View file

@ -1,35 +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 type AggregationRestrictions = Record<
string,
{
agg?: string;
interval?: number;
fixed_interval?: string;
calendar_interval?: string;
delay?: string;
time_zone?: string;
}
>;
export interface TypeMeta {
aggs?: Record<string, AggregationRestrictions>;
[key: string]: any;
}

View file

@ -19,6 +19,8 @@
import { ToastInputFields, ErrorToastOptions } from 'src/core/public/notifications';
import { IFieldType } from './fields';
import { SerializedFieldFormat } from '../../../expressions/common';
import { KBN_FIELD_TYPES } from '..';
export interface IIndexPattern {
[key: string]: any;
@ -51,3 +53,65 @@ export interface IndexPatternAttributes {
export type OnNotification = (toastInputFields: ToastInputFields) => void;
export type OnError = (error: Error, toastInputFields: ErrorToastOptions) => void;
export type AggregationRestrictions = Record<
string,
{
agg?: string;
interval?: number;
fixed_interval?: string;
calendar_interval?: string;
delay?: string;
time_zone?: string;
}
>;
export interface IFieldSubType {
multi?: { parent: string };
nested?: { path: string };
}
export interface TypeMeta {
aggs?: Record<string, AggregationRestrictions>;
[key: string]: any;
}
export type FieldSpecConflictDescriptions = Record<string, string[]>;
// This should become FieldSpec once types are cleaned up
export interface FieldSpecExportFmt {
count?: number;
script?: string;
lang?: string;
conflictDescriptions?: FieldSpecConflictDescriptions;
name: string;
type: KBN_FIELD_TYPES;
esTypes?: string[];
scripted: boolean;
searchable: boolean;
aggregatable: boolean;
readFromDocValues?: boolean;
subType?: IFieldSubType;
format?: SerializedFieldFormat;
indexed?: boolean;
}
export interface FieldSpec {
[key: string]: any;
format?: SerializedFieldFormat;
}
export interface IndexPatternSpec {
id?: string;
version?: string;
title: string;
timeFieldName?: string;
sourceFilters?: SourceFilter[];
fields?: FieldSpec[];
typeMeta?: TypeMeta;
}
export interface SourceFilter {
value: string;
}

View file

@ -249,8 +249,6 @@ export {
IndexPattern,
IIndexPatternFieldList,
Field as IndexPatternField,
TypeMeta as IndexPatternTypeMeta,
AggregationRestrictions as IndexPatternAggRestrictions,
// TODO: exported only in stub_index_pattern test. Move into data plugin and remove export.
getIndexPatternFieldListCreator,
} from './index_patterns';
@ -263,6 +261,8 @@ export {
KBN_FIELD_TYPES,
IndexPatternAttributes,
UI_SETTINGS,
TypeMeta as IndexPatternTypeMeta,
AggregationRestrictions as IndexPatternAggRestrictions,
} from '../common';
/*

View file

@ -34,11 +34,4 @@ export {
IIndexPatternFieldList,
} from '../../common/index_patterns';
// TODO: figure out how to replace IndexPatterns in get_inner_angular.
export {
IndexPatternsService,
IndexPatternsContract,
IndexPattern,
TypeMeta,
AggregationRestrictions,
} from './index_patterns';
export { IndexPatternsService, IndexPatternsContract, IndexPattern } from './index_patterns';

View file

@ -902,6 +902,10 @@ export interface IFieldType {
sortable?: boolean;
// (undocumented)
subType?: IFieldSubType;
// Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts
//
// (undocumented)
toSpec?: () => FieldSpec;
// (undocumented)
type: string;
// (undocumented)
@ -937,8 +941,6 @@ export interface IIndexPattern {
//
// @public (undocumented)
export interface IIndexPatternFieldList extends Array<IndexPatternField> {
// Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts
//
// (undocumented)
add(field: FieldSpec): void;
// (undocumented)
@ -993,7 +995,9 @@ export class IndexPattern implements IIndexPattern {
// (undocumented)
fieldFormatMap: any;
// (undocumented)
fields: IIndexPatternFieldList;
fields: IIndexPatternFieldList & {
toSpec: () => FieldSpec[];
};
// (undocumented)
fieldsFetcher: any;
// (undocumented)
@ -1036,6 +1040,10 @@ export class IndexPattern implements IIndexPattern {
id?: string;
// (undocumented)
init(forceFieldRefresh?: boolean): Promise<this>;
// Warning: (ae-forgotten-export) The symbol "IndexPatternSpec" needs to be exported by the entry point index.d.ts
//
// (undocumented)
initFromSpec(spec: IndexPatternSpec): this;
// (undocumented)
isTimeBased(): boolean;
// (undocumented)
@ -1065,9 +1073,9 @@ export class IndexPattern implements IIndexPattern {
// (undocumented)
toJSON(): string | undefined;
// (undocumented)
toString(): string;
toSpec(): IndexPatternSpec;
// (undocumented)
type?: string;
toString(): string;
// (undocumented)
typeMeta?: IndexPatternTypeMeta;
}
@ -1106,12 +1114,15 @@ export interface IndexPatternAttributes {
export class IndexPatternField implements IFieldType {
// (undocumented)
$$spec: FieldSpec;
// Warning: (ae-forgotten-export) The symbol "FieldSpecExportFmt" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "FieldDependencies" needs to be exported by the entry point index.d.ts
constructor(indexPattern: IIndexPattern, spec: FieldSpec | IndexPatternField, shortDotsEnable: boolean, { fieldFormats, onNotification }: FieldDependencies);
constructor(indexPattern: IIndexPattern, spec: FieldSpecExportFmt | FieldSpec | IndexPatternField, shortDotsEnable: boolean, { fieldFormats, onNotification }: FieldDependencies);
// (undocumented)
aggregatable?: boolean;
// Warning: (ae-forgotten-export) The symbol "FieldSpecConflictDescriptions" needs to be exported by the entry point index.d.ts
//
// (undocumented)
conflictDescriptions?: Record<string, string[]>;
conflictDescriptions?: FieldSpecConflictDescriptions;
// (undocumented)
count?: number;
// (undocumented)
@ -1141,6 +1152,8 @@ export class IndexPatternField implements IFieldType {
// (undocumented)
subType?: IFieldSubType;
// (undocumented)
toSpec: () => FieldSpecExportFmt;
// (undocumented)
type: string;
// (undocumented)
visualizable?: boolean;

View file

@ -392,6 +392,10 @@ export interface IFieldType {
sortable?: boolean;
// (undocumented)
subType?: IFieldSubType;
// Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts
//
// (undocumented)
toSpec?: () => FieldSpec;
// (undocumented)
type: string;
// (undocumented)

View file

@ -28,6 +28,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { DiscoverField } from './discover_field';
import { coreMock } from '../../../../../../core/public/mocks';
import { IndexPatternField } from '../../../../../data/public';
import { FieldSpecExportFmt } from '../../../../../data/common';
jest.mock('../../../kibana_services', () => ({
getServices: () => ({
@ -74,6 +75,7 @@ function getComponent(selected = false, showDetails = false, useShortDots = fals
format: null,
routes: {},
$$spec: {},
toSpec: () => (({} as unknown) as FieldSpecExportFmt),
} as IndexPatternField;
const props = {

View file

@ -46,6 +46,7 @@ export const url: SavedObjectsType = {
fields: {
keyword: {
type: 'keyword',
ignore_above: 2048,
},
},
},

View file

@ -251,8 +251,7 @@ export default function ({ getService, getPageObjects }) {
});
});
// Unskip once https://github.com/elastic/kibana/issues/15736 is fixed.
it.skip('and updates the pie slice legend color', async function () {
it('and updates the pie slice legend color', async function () {
await retry.try(async () => {
const colorExists = await PageObjects.visChart.doesSelectedLegendColorExist('#FFFFFF');
expect(colorExists).to.be(true);
@ -272,8 +271,7 @@ export default function ({ getService, getPageObjects }) {
});
});
// Unskip once https://github.com/elastic/kibana/issues/15736 is fixed.
it.skip('resets the legend color as well', async function () {
it('resets the legend color as well', async function () {
await retry.try(async () => {
const colorExists = await PageObjects.visChart.doesSelectedLegendColorExist('#57c17b');
expect(colorExists).to.be(true);

View file

@ -49,10 +49,11 @@ export default function ({ getService, getPageObjects }) {
expect(emptyWidgetExists).to.be(true);
});
it.skip('should open add panel when add button is clicked', async () => {
it('should open add panel when add button is clicked', async () => {
await testSubjects.click('dashboardAddPanelButton');
const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen();
expect(isAddPanelOpen).to.be(true);
await testSubjects.click('euiFlyoutCloseButton');
});
it('should add new visualization from dashboard', async () => {

View file

@ -35,7 +35,7 @@ export default function ({ getService, getPageObjects }) {
await esArchiver.unload('invalid_scripted_field');
});
// https://github.com/elastic/kibana/issues/61366
// ES issue https://github.com/elastic/elasticsearch/issues/54235
describe.skip('invalid scripted field error', () => {
it('is rendered', async () => {
const isFetchErrorVisible = await testSubjects.exists('discoverFetchError');

View file

@ -27,7 +27,7 @@ export default function ({ getService, getPageObjects }) {
const renderable = getService('renderable');
const PageObjects = getPageObjects(['visualize', 'visEditor', 'header', 'visChart']);
describe.skip('data table with index without time filter', function indexPatternCreation() {
describe('data table with index without time filter', function indexPatternCreation() {
const vizName1 = 'Visualization DataTable without time filter';
before(async function () {
@ -112,65 +112,49 @@ export default function ({ getService, getPageObjects }) {
expect(data.trim().split('\n')).to.be.eql(['14,004 1,412.6']);
});
it('should show correct data for a data table with date histogram', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickDataTable();
await PageObjects.visualize.clickNewSearch(
PageObjects.visualize.index.LOGSTASH_NON_TIME_BASED
);
await PageObjects.visEditor.clickBucket('Split rows');
await PageObjects.visEditor.selectAggregation('Date Histogram');
await PageObjects.visEditor.selectField('@timestamp');
await PageObjects.visEditor.setInterval('Daily');
await PageObjects.visEditor.clickGo();
const data = await PageObjects.visChart.getTableVisData();
log.debug(data.split('\n'));
expect(data.trim().split('\n')).to.be.eql([
'2015-09-20',
'4,757',
'2015-09-21',
'4,614',
'2015-09-22',
'4,633',
]);
});
// bug https://github.com/elastic/kibana/issues/68977
describe.skip('data table with date histogram', async () => {
before(async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickDataTable();
await PageObjects.visualize.clickNewSearch(
PageObjects.visualize.index.LOGSTASH_NON_TIME_BASED
);
await PageObjects.visEditor.clickBucket('Split rows');
await PageObjects.visEditor.selectAggregation('Date Histogram');
await PageObjects.visEditor.selectField('@timestamp');
await PageObjects.visEditor.setInterval('Daily');
await PageObjects.visEditor.clickGo();
});
it('should show correct data for a data table with date histogram', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickDataTable();
await PageObjects.visualize.clickNewSearch(
PageObjects.visualize.index.LOGSTASH_NON_TIME_BASED
);
await PageObjects.visEditor.clickBucket('Split rows');
await PageObjects.visEditor.selectAggregation('Date Histogram');
await PageObjects.visEditor.selectField('@timestamp');
await PageObjects.visEditor.setInterval('Daily');
await PageObjects.visEditor.clickGo();
const data = await PageObjects.visChart.getTableVisData();
expect(data.trim().split('\n')).to.be.eql([
'2015-09-20',
'4,757',
'2015-09-21',
'4,614',
'2015-09-22',
'4,633',
]);
});
it('should show correct data', async () => {
const data = await PageObjects.visChart.getTableVisData();
log.debug(data.split('\n'));
expect(data.trim().split('\n')).to.be.eql([
'2015-09-20',
'4,757',
'2015-09-21',
'4,614',
'2015-09-22',
'4,633',
]);
});
it('should correctly filter for applied time filter on the main timefield', async () => {
await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21');
await PageObjects.header.waitUntilLoadingHasFinished();
await renderable.waitForRender();
const data = await PageObjects.visChart.getTableVisData();
expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']);
});
it('should correctly filter for applied time filter on the main timefield', async () => {
await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21');
await PageObjects.header.waitUntilLoadingHasFinished();
await renderable.waitForRender();
const data = await PageObjects.visChart.getTableVisData();
expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']);
});
it('should correctly filter for pinned filters', async () => {
await filterBar.toggleFilterPinned('@timestamp');
await PageObjects.header.waitUntilLoadingHasFinished();
await renderable.waitForRender();
const data = await PageObjects.visChart.getTableVisData();
expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']);
it('should correctly filter for pinned filters', async () => {
await filterBar.toggleFilterPinned('@timestamp');
await PageObjects.header.waitUntilLoadingHasFinished();
await renderable.waitForRender();
const data = await PageObjects.visChart.getTableVisData();
expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']);
});
});
});
}

View file

@ -28,8 +28,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const security = getService('security');
const PageObjects = getPageObjects(['visualize', 'visualBuilder', 'timePicker', 'visChart']);
// FLAKY: https://github.com/elastic/kibana/issues/43150
describe.skip('visual builder', function describeIndexTests() {
describe('visual builder', function describeIndexTests() {
this.tags('includeFirefox');
beforeEach(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']);
@ -74,7 +73,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
// FLAKY: https://github.com/elastic/kibana/issues/46677
describe('gauge', () => {
beforeEach(async () => {
await PageObjects.visualBuilder.resetPage();
@ -107,7 +105,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
describe('switch index patterns', () => {
// FLAKY: https://github.com/elastic/kibana/issues/43150
describe.skip('switch index patterns', () => {
beforeEach(async () => {
log.debug('Load kibana_sample_data_flights data');
await esArchiver.loadIfNeeded('kibana_sample_data_flights');

View file

@ -198,7 +198,7 @@
"@elastic/eui": "24.1.0",
"@elastic/filesaver": "1.1.2",
"@elastic/maki": "6.3.0",
"@elastic/node-crypto": "1.1.1",
"@elastic/node-crypto": "1.2.1",
"@elastic/numeral": "^2.5.0",
"@kbn/babel-preset": "1.0.0",
"@kbn/config-schema": "1.0.0",

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { APICaller } from 'kibana/server';
import { APICaller, Logger } from 'kibana/server';
import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames';
import { ESSearchResponse } from '../../../../typings/elasticsearch';
import { ScopedAnnotationsClient } from '../../../../../observability/server';
@ -19,12 +19,14 @@ export async function getStoredAnnotations({
environment,
apiCaller,
annotationsClient,
logger,
}: {
setup: Setup & SetupTimeRange;
serviceName: string;
environment?: string;
apiCaller: APICaller;
annotationsClient: ScopedAnnotationsClient;
logger: Logger;
}): Promise<Annotation[]> {
try {
const environmentFilter = getEnvironmentUiFilterES(environment);
@ -71,6 +73,14 @@ export async function getStoredAnnotations({
if (error.body?.error?.type === 'index_not_found_exception') {
return [];
}
if (error.body?.error?.type === 'security_exception') {
logger.warn(
`Unable to get stored annotations due to a security exception. Please make sure that the user has 'indices:data/read/search' permissions for ${annotationsClient.index}`
);
return [];
}
throw error;
}
}

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { APICaller } from 'kibana/server';
import { APICaller, Logger } from 'kibana/server';
import { ScopedAnnotationsClient } from '../../../../../observability/server';
import { getDerivedServiceAnnotations } from './get_derived_service_annotations';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
@ -15,12 +15,14 @@ export async function getServiceAnnotations({
environment,
annotationsClient,
apiCaller,
logger,
}: {
serviceName: string;
environment?: string;
setup: Setup & SetupTimeRange;
annotationsClient?: ScopedAnnotationsClient;
apiCaller: APICaller;
logger: Logger;
}) {
// start fetching derived annotations (based on transactions), but don't wait on it
// it will likely be significantly slower than the stored annotations
@ -37,6 +39,7 @@ export async function getServiceAnnotations({
environment,
annotationsClient,
apiCaller,
logger,
})
: [];

View file

@ -105,6 +105,7 @@ export const serviceAnnotationsRoute = createRoute(() => ({
environment,
annotationsClient,
apiCaller: context.core.elasticsearch.legacy.client.callAsCurrentUser,
logger: context.logger,
});
},
}));

View file

@ -99,6 +99,138 @@ const savedObjectWithDecryptedContent = await esoClient.getDecryptedAsInternalU
one would pass to `SavedObjectsClient.get`. These argument allows to specify `namespace` property that, for example, is
required if Saved Object was created within a non-default space.
### Defining migrations
EncryptedSavedObjects rely on standard SavedObject migrations, but due to the additional complexity introduced by the need to decrypt and reencrypt the migrated document, there are some caveats to how we support this.
The good news is, most of this complexity is abstracted away by the plugin and all you need to do is leverage our api.
The `EncryptedSavedObjects` Plugin _SetupContract_ exposes an `createMigration` api which facilitates defining a migration for your EncryptedSavedObject type.
The `createMigration` function takes four arguments:
|Argument|Description|Type|
|---|---|---|
|isMigrationNeededPredicate|A predicate which is called for each document, prior to being decrypted, which confirms whether a document requires migration or not. This predicate is important as the decryption step is costly and we would rather not decrypt and re-encrypt a document if we can avoid it.|function|
|migration|A migration function which will migrate each decrypted document from the old shape to the new one.|function|
|inputType|Optional. An `EncryptedSavedObjectTypeRegistration` which describes the ESOType of the input (the document prior to migration). If this type isn't provided, we'll assume the input doc follows the registered type. |object|
|migratedType| Optional. An `EncryptedSavedObjectTypeRegistration` which describes the ESOType of the output (the document after migration). If this type isn't provided, we'll assume the migrated doc follows the registered type.|object|
### Example: Migrating a Value
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration790 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
return doc.consumer === 'alerting' || doc.consumer === undefined;
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
const {
attributes: { consumer },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
consumer: consumer === 'alerting' || !consumer ? 'alerts' : consumer,
},
};
}
);
```
In the above example you can see thwe following:
1. In `shouldBeMigrated` we limit the migrated alerts to those whose `consumer` field equals `alerting` or is undefined.
2. In the migration function we then migrate the value of `consumer` to the value we want (`alerts` or `unknown`, depending on the current value). In this function we can assume that only documents with a `consumer` of `alerting` or `undefined` will be passed in, but it's still safest not to, and so we use the current `consumer` as the default when needed.
3. Note that we haven't passed in any type definitions. This is because we can rely on the registered type, as the migration is changing a value and not the shape of the object.
As we said above, an EncryptedSavedObject migration is a normal SavedObjects migration, and so we can plug it into the underlying SavedObject just like any other kind of migration:
```typescript
savedObjects.registerType({
name: 'alert',
hidden: true,
namespaceType: 'single',
migrations: {
// apply this migration in 7.9.0
'7.9.0': migration790,
},
mappings: {
//...
},
});
```
### Example: Migating a Type
If your migration needs to change the type by, for example, removing an encrypted field, you will have to specify the legacy type for the input.
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration790 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
return doc.consumer === 'alerting' || doc.consumer === undefined;
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
const {
attributes: { legacyEncryptedField, ...attributes },
} = doc;
return {
...doc,
attributes: {
...attributes
},
};
},
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
}
);
```
As you can see in this example we provide a legacy type which describes the _input_ which needs to be decrypted.
The migration function will default to using the registered type to encrypt the migrated document after the migration is applied.
If you need to migrate between two legacy types, you can specify both types at once:
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration780 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
// ...
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
// ...
},
// legacy input type
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
},
// legacy migration type
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy', 'legacyEncryptedField']),
}
);
```
## Testing
### Unit tests

View file

@ -0,0 +1,296 @@
/*
* 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 { SavedObjectUnsanitizedDoc } from 'kibana/server';
import { migrationMocks } from 'src/core/server/mocks';
import { encryptedSavedObjectsServiceMock } from './crypto/index.mock';
import { getCreateMigration } from './create_migration';
afterEach(() => {
jest.clearAllMocks();
});
describe('createMigration()', () => {
const { log } = migrationMocks.createContext();
const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) };
const migrationType = {
type: 'known-type-1',
attributesToEncrypt: new Set(['firstAttr', 'secondAttr']),
};
interface InputType {
firstAttr: string;
nonEncryptedAttr?: string;
}
interface MigrationType {
firstAttr: string;
encryptedAttr?: string;
}
const encryptionSavedObjectService = encryptedSavedObjectsServiceMock.create();
it('throws if the types arent compatible', async () => {
const migrationCreator = getCreateMigration(encryptionSavedObjectService, () =>
encryptedSavedObjectsServiceMock.create()
);
expect(() =>
migrationCreator(
function (doc): doc is SavedObjectUnsanitizedDoc {
return true;
},
(doc) => doc,
{
type: 'known-type-1',
attributesToEncrypt: new Set(),
},
{
type: 'known-type-2',
attributesToEncrypt: new Set(),
}
)
).toThrowErrorMatchingInlineSnapshot(
`"An Invalid Encrypted Saved Objects migration is trying to migrate across types (\\"known-type-1\\" => \\"known-type-2\\"), which isn't permitted"`
);
});
describe('migration of an existing type', () => {
it('uses the type in the current service for both input and migration types when none are specified', async () => {
const instantiateServiceWithLegacyType = jest.fn(() =>
encryptedSavedObjectsServiceMock.create()
);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const noopMigration = migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
return true;
},
(doc) => doc
);
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes);
noopMigration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
{ log }
);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
});
describe('migration of a single legacy type', () => {
it('uses the input type as the mirgation type when omitted', async () => {
const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create();
const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const noopMigration = migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
return true;
},
(doc) => doc,
inputType
);
const attributes = {
firstAttr: 'first_attr',
};
serviceWithLegacyType.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes);
noopMigration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
{ log }
);
expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
});
describe('migration across two legacy types', () => {
const serviceWithInputLegacyType = encryptedSavedObjectsServiceMock.create();
const serviceWithMigrationLegacyType = encryptedSavedObjectsServiceMock.create();
const instantiateServiceWithLegacyType = jest.fn();
function createMigration() {
instantiateServiceWithLegacyType
.mockImplementationOnce(() => serviceWithInputLegacyType)
.mockImplementationOnce(() => serviceWithMigrationLegacyType);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
return migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
// migrate doc that have the second field
return (
typeof (doc as SavedObjectUnsanitizedDoc<InputType>).attributes.nonEncryptedAttr ===
'string'
);
},
({ attributes: { firstAttr, nonEncryptedAttr }, ...doc }) => ({
attributes: {
// modify an encrypted field
firstAttr: `~~${firstAttr}~~`,
// encrypt a non encrypted field if it's there
...(nonEncryptedAttr ? { encryptedAttr: `${nonEncryptedAttr}` } : {}),
},
...doc,
}),
inputType,
migrationType
);
}
it('doesnt decrypt saved objects that dont need to be migrated', async () => {
const migration = createMigration();
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(inputType);
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(migrationType);
expect(
migration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
},
},
{ log }
)
).toMatchObject({
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
},
});
expect(serviceWithInputLegacyType.decryptAttributesSync).not.toHaveBeenCalled();
expect(serviceWithMigrationLegacyType.encryptAttributesSync).not.toHaveBeenCalled();
});
it('decrypt, migrates and reencrypts saved objects that need to be migrated', async () => {
const migration = createMigration();
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(inputType);
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(migrationType);
serviceWithInputLegacyType.decryptAttributesSync.mockReturnValueOnce({
firstAttr: 'first_attr',
nonEncryptedAttr: 'non encrypted',
});
serviceWithMigrationLegacyType.encryptAttributesSync.mockReturnValueOnce({
firstAttr: `#####`,
encryptedAttr: `#####`,
});
expect(
migration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
nonEncryptedAttr: 'non encrypted',
},
},
{ log }
)
).toMatchObject({
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
encryptedAttr: `#####`,
},
});
expect(serviceWithInputLegacyType.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
{
firstAttr: '#####',
nonEncryptedAttr: 'non encrypted',
}
);
expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
{
firstAttr: `~~first_attr~~`,
encryptedAttr: 'non encrypted',
}
);
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 {
SavedObjectUnsanitizedDoc,
SavedObjectMigrationFn,
SavedObjectMigrationContext,
} from 'src/core/server';
import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto';
type SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes> = (
doc: SavedObjectUnsanitizedDoc<InputAttributes> | SavedObjectUnsanitizedDoc<MigratedAttributes>,
context: SavedObjectMigrationContext
) => SavedObjectUnsanitizedDoc<MigratedAttributes>;
type IsMigrationNeededPredicate<InputAttributes, MigratedAttributes> = (
encryptedDoc:
| SavedObjectUnsanitizedDoc<InputAttributes>
| SavedObjectUnsanitizedDoc<MigratedAttributes>
) => encryptedDoc is SavedObjectUnsanitizedDoc<InputAttributes>;
export type CreateEncryptedSavedObjectsMigrationFn = <
InputAttributes = unknown,
MigratedAttributes = InputAttributes
>(
isMigrationNeededPredicate: IsMigrationNeededPredicate<InputAttributes, MigratedAttributes>,
migration: SavedObjectMigrationFn<InputAttributes, MigratedAttributes>,
inputType?: EncryptedSavedObjectTypeRegistration,
migratedType?: EncryptedSavedObjectTypeRegistration
) => SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes>;
export const getCreateMigration = (
encryptedSavedObjectsService: Readonly<EncryptedSavedObjectsService>,
instantiateServiceWithLegacyType: (
typeRegistration: EncryptedSavedObjectTypeRegistration
) => EncryptedSavedObjectsService
): CreateEncryptedSavedObjectsMigrationFn => (
isMigrationNeededPredicate,
migration,
inputType,
migratedType
) => {
if (inputType && migratedType && inputType.type !== migratedType.type) {
throw new Error(
`An Invalid Encrypted Saved Objects migration is trying to migrate across types ("${inputType.type}" => "${migratedType.type}"), which isn't permitted`
);
}
const inputService = inputType
? instantiateServiceWithLegacyType(inputType)
: encryptedSavedObjectsService;
const migratedService = migratedType
? instantiateServiceWithLegacyType(migratedType)
: encryptedSavedObjectsService;
return (encryptedDoc, context) => {
if (!isMigrationNeededPredicate(encryptedDoc)) {
return encryptedDoc;
}
const descriptor = {
id: encryptedDoc.id!,
type: encryptedDoc.type,
namespace: encryptedDoc.namespace,
};
// decrypt the attributes using the input type definition
// then migrate the document
// then encrypt the attributes using the migration type definition
return mapAttributes(
migration(
mapAttributes(encryptedDoc, (inputAttributes) =>
inputService.decryptAttributesSync<any>(descriptor, inputAttributes)
),
context
),
(migratedAttributes) =>
migratedService.encryptAttributesSync<any>(descriptor, migratedAttributes)
);
};
};
function mapAttributes<T>(obj: SavedObjectUnsanitizedDoc<T>, mapper: (attributes: T) => T) {
return Object.assign(obj, {
attributes: mapper(obj.attributes),
});
}

View file

@ -0,0 +1,84 @@
/*
* 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 {
EncryptedSavedObjectsService,
EncryptedSavedObjectTypeRegistration,
SavedObjectDescriptor,
} from './encrypted_saved_objects_service';
function createEncryptedSavedObjectsServiceMock() {
return ({
isRegistered: jest.fn(),
stripOrDecryptAttributes: jest.fn(),
encryptAttributes: jest.fn(),
decryptAttributes: jest.fn(),
encryptAttributesSync: jest.fn(),
decryptAttributesSync: jest.fn(),
} as unknown) as jest.Mocked<EncryptedSavedObjectsService>;
}
export const encryptedSavedObjectsServiceMock = {
create: createEncryptedSavedObjectsServiceMock,
createWithTypes(registrations: EncryptedSavedObjectTypeRegistration[] = []) {
const mock = createEncryptedSavedObjectsServiceMock();
function processAttributes<T extends Record<string, any>>(
descriptor: Pick<SavedObjectDescriptor, 'type'>,
attrs: T,
action: (attrs: T, attrName: string, shouldExpose: boolean) => void
) {
const registration = registrations.find((r) => r.type === descriptor.type);
if (!registration) {
return attrs;
}
const clonedAttrs = { ...attrs };
for (const attr of registration.attributesToEncrypt) {
const [attrName, shouldExpose] =
typeof attr === 'string'
? [attr, false]
: [attr.key, attr.dangerouslyExposeValue === true];
if (attrName in clonedAttrs) {
action(clonedAttrs, attrName, shouldExpose);
}
}
return clonedAttrs;
}
mock.isRegistered.mockImplementation(
(type) => registrations.findIndex((r) => r.type === type) >= 0
);
mock.encryptAttributes.mockImplementation(async (descriptor, attrs) =>
processAttributes(
descriptor,
attrs,
(clonedAttrs, attrName) => (clonedAttrs[attrName] = `*${clonedAttrs[attrName]}*`)
)
);
mock.decryptAttributes.mockImplementation(async (descriptor, attrs) =>
processAttributes(
descriptor,
attrs,
(clonedAttrs, attrName) =>
(clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1))
)
);
mock.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) =>
Promise.resolve({
attributes: processAttributes(descriptor, attrs, (clonedAttrs, attrName, shouldExpose) => {
if (shouldExpose) {
clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1);
} else {
delete clonedAttrs[attrName];
}
}),
})
);
return mock;
},
};

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock';
jest.mock('@elastic/node-crypto', () => jest.fn());
import { EncryptedSavedObjectsAuditLogger } from '../audit';
import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service';
import { EncryptionError } from './encryption_error';
@ -15,19 +14,37 @@ import { EncryptionError } from './encryption_error';
import { loggingSystemMock } from 'src/core/server/mocks';
import { encryptedSavedObjectsAuditLoggerMock } from '../audit/index.mock';
const crypto = nodeCrypto({ encryptionKey: 'encryption-key-abc' });
const mockNodeCrypto: jest.Mocked<Crypto> = {
encrypt: jest.fn(),
decrypt: jest.fn(),
encryptSync: jest.fn(),
decryptSync: jest.fn(),
};
let service: EncryptedSavedObjectsService;
let mockAuditLogger: jest.Mocked<EncryptedSavedObjectsAuditLogger>;
beforeEach(() => {
// Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests.
mockNodeCrypto.encrypt.mockImplementation(async (input: any, aad?: string) =>
crypto.encrypt(input, aad)
);
mockNodeCrypto.decrypt.mockImplementation(
async (encryptedOutput: string | Buffer, aad?: string) => crypto.decrypt(encryptedOutput, aad)
);
mockNodeCrypto.encryptSync.mockImplementation((input: any, aad?: string) =>
crypto.encryptSync(input, aad)
);
mockNodeCrypto.decryptSync.mockImplementation((encryptedOutput: string | Buffer, aad?: string) =>
crypto.decryptSync(encryptedOutput, aad)
);
mockAuditLogger = encryptedSavedObjectsAuditLoggerMock.create();
// Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests.
jest.requireMock('@elastic/node-crypto').mockImplementation((...args: any[]) => {
const { default: nodeCrypto } = jest.requireActual('@elastic/node-crypto');
return nodeCrypto(...args);
});
service = new EncryptedSavedObjectsService(
'encryption-key-abc',
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -35,12 +52,6 @@ beforeEach(() => {
afterEach(() => jest.resetAllMocks());
it('correctly initializes crypto', () => {
const mockNodeCrypto = jest.requireMock('@elastic/node-crypto');
expect(mockNodeCrypto).toHaveBeenCalledTimes(1);
expect(mockNodeCrypto).toHaveBeenCalledWith({ encryptionKey: 'encryption-key-abc' });
});
describe('#registerType', () => {
it('throws if `attributesToEncrypt` is empty', () => {
expect(() =>
@ -213,15 +224,13 @@ describe('#stripOrDecryptAttributes', () => {
});
describe('#encryptAttributes', () => {
let mockEncrypt: jest.Mock;
beforeEach(() => {
mockEncrypt = jest
.fn()
.mockImplementation(async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`);
jest.requireMock('@elastic/node-crypto').mockReturnValue({ encrypt: mockEncrypt });
mockNodeCrypto.encrypt.mockImplementation(
async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`
);
service = new EncryptedSavedObjectsService(
'encryption-key-abc',
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -399,7 +408,7 @@ describe('#encryptAttributes', () => {
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
mockEncrypt
mockNodeCrypto.encrypt
.mockResolvedValueOnce('Successfully encrypted attrOne')
.mockRejectedValueOnce(new Error('Something went wrong with attrThree...'));
@ -915,7 +924,7 @@ describe('#decryptAttributes', () => {
it('fails if encrypted with another encryption key', async () => {
service = new EncryptedSavedObjectsService(
'encryption-key-abc*',
nodeCrypto({ encryptionKey: 'encryption-key-abc*' }),
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -941,3 +950,532 @@ describe('#decryptAttributes', () => {
});
});
});
describe('#encryptAttributesSync', () => {
beforeEach(() => {
mockNodeCrypto.encryptSync.mockImplementation(
(valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`
);
service = new EncryptedSavedObjectsService(
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
});
it('does not encrypt attributes that are not supposed to be encrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrFour']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('encrypts only attributes that are supposed to be encrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|',
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
attrFour: null,
});
});
it('encrypts only attributes that are supposed to be encrypted even if not all provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
});
});
it('includes `namespace` into AAD if provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
attributes
)
).toEqual({
attrTwo: 'two',
attrThree: '|three|["object-ns","known-type-1","object-id",{"attrTwo":"two"}]|',
});
});
it('does not include specified attributes to AAD', () => {
const knownType1attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
const knownType2attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-2',
attributesToEncrypt: new Set(['attrThree']),
attributesToExcludeFromAAD: new Set(['attrTwo']),
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id-1' },
knownType1attributes
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id-1",{"attrOne":"one","attrTwo":"two"}]|',
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-2', id: 'object-id-2' },
knownType2attributes
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: '|three|["known-type-2","object-id-2",{"attrOne":"one"}]|',
});
});
it('encrypts even if no attributes are included into AAD', () => {
const attributes = { attrOne: 'one', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id-1' }, attributes)
).toEqual({
attrOne: '|one|["known-type-1","object-id-1",{}]|',
attrThree: '|three|["known-type-1","object-id-1",{}]|',
});
});
it('fails if encryption of any attribute fails', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
mockNodeCrypto.encryptSync
.mockImplementationOnce(() => 'Successfully encrypted attrOne')
.mockImplementationOnce(() => {
throw new Error('Something went wrong with attrThree...');
});
expect(() =>
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toThrowError(EncryptionError);
expect(attributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
});
describe('#decryptAttributesSync', () => {
it('does not decrypt attributes that are not supposed to be decrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrFour']),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts only attributes that are supposed to be decrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: expect.not.stringMatching(/^one$/),
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
attrFour: null,
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
attrFour: null,
});
});
it('decrypts only attributes that are supposed to be encrypted even if not all provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts if all attributes that contribute to AAD are present', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
attributesToExcludeFromAAD: new Set(['attrOne']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesWithoutAttr
)
).toEqual({
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts even if attributes in AAD are defined in a different order', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
const attributesInDifferentOrder = {
attrThree: encryptedAttributes.attrThree,
attrTwo: 'two',
attrOne: 'one',
};
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesInDifferentOrder
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts if correct namespace is provided', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
encryptedAttributes
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts even if no attributes are included into AAD', () => {
const attributes = { attrOne: 'one', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: expect.not.stringMatching(/^one$/),
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrThree: 'three',
});
});
it('decrypts non-string attributes and restores their original type', () => {
const attributes = {
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
attrFour: null,
attrFive: { nested: 'five' },
attrSix: 6,
};
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour', 'attrFive', 'attrSix']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: expect.not.stringMatching(/^one$/),
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
attrFour: null,
attrFive: expect.any(String),
attrSix: expect.any(String),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
attrFour: null,
attrFive: { nested: 'five' },
attrSix: 6,
});
});
describe('decryption failures', () => {
let encryptedAttributes: Record<string, string>;
const type1 = {
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
};
const type2 = {
type: 'known-type-2',
attributesToEncrypt: new Set(['attrThree']),
};
beforeEach(() => {
service.registerType(type1);
service.registerType(type2);
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
});
it('fails to decrypt if not all attributes that contribute to AAD are present', () => {
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesWithoutAttr
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if ID does not match', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id*' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if type does not match', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-2', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if namespace does not match', () => {
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
);
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-NS' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if namespace is expected, but is not provided', () => {
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
);
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if encrypted attribute is defined, but not a string', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrThree: 2,
}
)
).toThrowError('Encrypted "attrThree" attribute should be a string, but found number');
});
it('fails to decrypt if encrypted attribute is not correct', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrThree: 'some-unknown-string',
}
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if the AAD attribute has changed', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrOne: 'oNe',
}
)
).toThrowError(EncryptionError);
});
it('fails if encrypted with another encryption key', () => {
service = new EncryptedSavedObjectsService(
nodeCrypto({ encryptionKey: 'encryption-key-abc*' }),
loggingSystemMock.create().get(),
mockAuditLogger
);
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
});
});

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import stringify from 'json-stable-stringify';
import { Crypto, EncryptOutput } from '@elastic/node-crypto';
import typeDetect from 'type-detect';
import stringify from 'json-stable-stringify';
import { Logger } from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsAuditLogger } from '../audit';
@ -70,8 +70,6 @@ export function descriptorToArray(descriptor: SavedObjectDescriptor) {
* attributes.
*/
export class EncryptedSavedObjectsService {
private readonly crypto: Readonly<Crypto>;
/**
* Map of all registered saved object types where the `key` is saved object type and the `value`
* is the definition (names of attributes that need to be encrypted etc.).
@ -82,17 +80,15 @@ export class EncryptedSavedObjectsService {
> = new Map();
/**
* @param encryptionKey The key used to encrypt and decrypt saved objects attributes.
* @param crypto nodeCrypto instance.
* @param logger Ordinary logger instance.
* @param audit Audit logger instance.
*/
constructor(
encryptionKey: string,
private readonly crypto: Readonly<Crypto>,
private readonly logger: Logger,
private readonly audit: EncryptedSavedObjectsAuditLogger
) {
this.crypto = nodeCrypto({ encryptionKey });
}
) {}
/**
* Registers saved object type as the one that contains attributes that should be encrypted.
@ -193,20 +189,11 @@ export class EncryptedSavedObjectsService {
return { attributes: clonedAttributes as T, error: decryptionError };
}
/**
* Takes saved object attributes for the specified type and encrypts all of them that are supposed
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
* attributes were encrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to encrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public async encryptAttributes<T extends Record<string, unknown>>(
private *attributesToEncryptIterator<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Promise<T> {
): Iterator<[unknown, string], T, string> {
const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeDefinition === undefined) {
return attributes;
@ -218,10 +205,7 @@ export class EncryptedSavedObjectsService {
const attributeValue = attributes[attributeName];
if (attributeValue != null) {
try {
encryptedAttributes[attributeName] = await this.crypto.encrypt(
attributeValue,
encryptionAAD
);
encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!;
} catch (err) {
this.logger.error(
`Failed to encrypt "${attributeName}" attribute: ${err.message || err}`
@ -263,6 +247,64 @@ export class EncryptedSavedObjectsService {
};
}
/**
* Takes saved object attributes for the specified type and encrypts all of them that are supposed
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
* attributes were encrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to encrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public async encryptAttributes<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Promise<T> {
const iterator = this.attributesToEncryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(await this.crypto.encrypt(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* Takes saved object attributes for the specified type and encrypts all of them that are supposed
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
* attributes were encrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to encrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public encryptAttributesSync<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): T {
const iterator = this.attributesToEncryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(this.crypto.encryptSync(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* Takes saved object attributes for the specified type and decrypts all of them that are supposed
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
@ -278,13 +320,65 @@ export class EncryptedSavedObjectsService {
attributes: T,
params?: CommonParameters
): Promise<T> {
const iterator = this.attributesToDecryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(
(await this.crypto.decrypt(attributeValue, encryptionAAD)) as string
);
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* Takes saved object attributes for the specified type and decrypts all of them that are supposed
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
* attributes were decrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to decrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if decryption fails for whatever reason.
* @throws Will throw if any of the attributes to decrypt is not a string.
*/
public decryptAttributesSync<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): T {
const iterator = this.attributesToDecryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(this.crypto.decryptSync(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
private *attributesToDecryptIterator<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Iterator<[string, string], T, EncryptOutput> {
const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeDefinition === undefined) {
return attributes;
}
const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes);
const decryptedAttributes: Record<string, string> = {};
const decryptedAttributes: Record<string, EncryptOutput> = {};
for (const attributeName of typeDefinition.attributesToEncrypt) {
const attributeValue = attributes[attributeName];
if (attributeValue == null) {
@ -301,10 +395,7 @@ export class EncryptedSavedObjectsService {
}
try {
decryptedAttributes[attributeName] = (await this.crypto.decrypt(
attributeValue,
encryptionAAD
)) as string;
decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!;
} catch (err) {
this.logger.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`);
this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user);

View file

@ -4,71 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EncryptedSavedObjectsService,
EncryptedSavedObjectTypeRegistration,
SavedObjectDescriptor,
} from '.';
export const encryptedSavedObjectsServiceMock = {
create(registrations: EncryptedSavedObjectTypeRegistration[] = []) {
const mock: jest.Mocked<EncryptedSavedObjectsService> = new (jest.requireMock(
'./encrypted_saved_objects_service'
).EncryptedSavedObjectsService)();
function processAttributes<T extends Record<string, any>>(
descriptor: Pick<SavedObjectDescriptor, 'type'>,
attrs: T,
action: (attrs: T, attrName: string, shouldExpose: boolean) => void
) {
const registration = registrations.find((r) => r.type === descriptor.type);
if (!registration) {
return attrs;
}
const clonedAttrs = { ...attrs };
for (const attr of registration.attributesToEncrypt) {
const [attrName, shouldExpose] =
typeof attr === 'string'
? [attr, false]
: [attr.key, attr.dangerouslyExposeValue === true];
if (attrName in clonedAttrs) {
action(clonedAttrs, attrName, shouldExpose);
}
}
return clonedAttrs;
}
mock.isRegistered.mockImplementation(
(type) => registrations.findIndex((r) => r.type === type) >= 0
);
mock.encryptAttributes.mockImplementation(async (descriptor, attrs) =>
processAttributes(
descriptor,
attrs,
(clonedAttrs, attrName) => (clonedAttrs[attrName] = `*${clonedAttrs[attrName]}*`)
)
);
mock.decryptAttributes.mockImplementation(async (descriptor, attrs) =>
processAttributes(
descriptor,
attrs,
(clonedAttrs, attrName) =>
(clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1))
)
);
mock.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) =>
Promise.resolve({
attributes: processAttributes(descriptor, attrs, (clonedAttrs, attrName, shouldExpose) => {
if (shouldExpose) {
clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1);
} else {
delete clonedAttrs[attrName];
}
}),
})
);
return mock;
},
};
export { encryptedSavedObjectsServiceMock } from './encrypted_saved_objects_service.mocks';

View file

@ -11,3 +11,4 @@ export {
SavedObjectDescriptor,
} from './encrypted_saved_objects_service';
export { EncryptionError } from './encryption_error';
export { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition';

View file

@ -12,6 +12,7 @@ function createEncryptedSavedObjectsSetupMock() {
registerType: jest.fn(),
__legacyCompat: { registerLegacyAPI: jest.fn() },
usingEphemeralEncryptionKey: true,
createMigration: jest.fn(),
} as jest.Mocked<EncryptedSavedObjectsPluginSetup>;
}

View file

@ -16,6 +16,7 @@ describe('EncryptedSavedObjects Plugin', () => {
await expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() }))
.resolves.toMatchInlineSnapshot(`
Object {
"createMigration": [Function],
"registerType": [Function],
"usingEphemeralEncryptionKey": true,
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto from '@elastic/node-crypto';
import { Logger, PluginInitializerContext, CoreSetup } from 'src/core/server';
import { first } from 'rxjs/operators';
import { SecurityPluginSetup } from '../../security/server';
@ -15,6 +16,7 @@ import {
} from './crypto';
import { EncryptedSavedObjectsAuditLogger } from './audit';
import { setupSavedObjects, ClientInstanciator } from './saved_objects';
import { getCreateMigration, CreateEncryptedSavedObjectsMigrationFn } from './create_migration';
export interface PluginsSetup {
security?: SecurityPluginSetup;
@ -23,6 +25,7 @@ export interface PluginsSetup {
export interface EncryptedSavedObjectsPluginSetup {
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void;
usingEphemeralEncryptionKey: boolean;
createMigration: CreateEncryptedSavedObjectsMigrationFn;
}
export interface EncryptedSavedObjectsPluginStart {
@ -45,18 +48,18 @@ export class Plugin {
core: CoreSetup,
deps: PluginsSetup
): Promise<EncryptedSavedObjectsPluginSetup> {
const { config, usingEphemeralEncryptionKey } = await createConfig$(this.initializerContext)
.pipe(first())
.toPromise();
const {
config: { encryptionKey },
usingEphemeralEncryptionKey,
} = await createConfig$(this.initializerContext).pipe(first()).toPromise();
const crypto = nodeCrypto({ encryptionKey });
const auditLogger = new EncryptedSavedObjectsAuditLogger(
deps.security?.audit.getLogger('encryptedSavedObjects')
);
const service = Object.freeze(
new EncryptedSavedObjectsService(
config.encryptionKey,
this.logger,
new EncryptedSavedObjectsAuditLogger(
deps.security?.audit.getLogger('encryptedSavedObjects')
)
)
new EncryptedSavedObjectsService(crypto, this.logger, auditLogger)
);
this.savedObjectsSetup = setupSavedObjects({
@ -70,6 +73,18 @@ export class Plugin {
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) =>
service.registerType(typeRegistration),
usingEphemeralEncryptionKey,
createMigration: getCreateMigration(
service,
(typeRegistration: EncryptedSavedObjectTypeRegistration) => {
const serviceForMigration = new EncryptedSavedObjectsService(
crypto,
this.logger,
auditLogger
);
serviceForMigration.registerType(typeRegistration);
return serviceForMigration;
}
),
};
}

View file

@ -22,7 +22,7 @@ let encryptedSavedObjectsServiceMockInstance: jest.Mocked<EncryptedSavedObjectsS
beforeEach(() => {
mockBaseClient = savedObjectsClientMock.create();
mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([
encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.createWithTypes([
{
type: 'known-type',
attributesToEncrypt: new Set([

View file

@ -42,7 +42,7 @@ describe('#setupSavedObjects', () => {
coreSetupMock = coreMock.createSetup();
coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]);
mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.create([
mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.createWithTypes([
{ type: 'known-type', attributesToEncrypt: new Set(['attrSecret']) },
]);
setupContract = setupSavedObjects({

View file

@ -7,6 +7,7 @@
import React, { useState, useCallback, useMemo } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { useAlertPrefillContext } from '../../../alerting/use_alert_prefill';
import { AlertFlyout } from './alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
@ -15,6 +16,9 @@ export const InventoryAlertDropdown = () => {
const [flyoutVisible, setFlyoutVisible] = useState(false);
const kibana = useKibana();
const { inventoryPrefill } = useAlertPrefillContext();
const { nodeType, metric, filterQuery } = inventoryPrefill;
const closePopover = useCallback(() => {
setPopoverOpen(false);
}, [setPopoverOpen]);
@ -57,7 +61,13 @@ export const InventoryAlertDropdown = () => {
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
<AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} />
<AlertFlyout
setVisible={setFlyoutVisible}
visible={flyoutVisible}
nodeType={nodeType}
options={{ metric }}
filter={filterQuery}
/>
</>
);
};

View file

@ -0,0 +1,24 @@
/*
* 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 { useState } from 'react';
import { SnapshotMetricInput } from '../../../../common/http_api/snapshot_api';
import { InventoryItemType } from '../../../../common/inventory_models/types';
export const useInventoryAlertPrefill = () => {
const [nodeType, setNodeType] = useState<InventoryItemType>('host');
const [filterQuery, setFilterQuery] = useState<string | undefined>();
const [metric, setMetric] = useState<SnapshotMetricInput>({ type: 'cpu' });
return {
nodeType,
filterQuery,
metric,
setNodeType,
setFilterQuery,
setMetric,
};
};

View file

@ -7,14 +7,18 @@
import React, { useState, useCallback, useMemo } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { AlertFlyout } from './alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { useAlertPrefillContext } from '../../use_alert_prefill';
import { AlertFlyout } from './alert_flyout';
export const MetricsAlertDropdown = () => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [flyoutVisible, setFlyoutVisible] = useState(false);
const kibana = useKibana();
const { metricThresholdPrefill } = useAlertPrefillContext();
const { groupBy, filterQuery, metrics } = metricThresholdPrefill;
const closePopover = useCallback(() => {
setPopoverOpen(false);
}, [setPopoverOpen]);
@ -57,7 +61,11 @@ export const MetricsAlertDropdown = () => {
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
<AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} />
<AlertFlyout
setVisible={setFlyoutVisible}
visible={flyoutVisible}
options={{ groupBy, filterQuery, metrics }}
/>
</>
);
};

View file

@ -0,0 +1,118 @@
/*
* 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 { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { actionTypeRegistryMock } from '../../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { alertTypeRegistryMock } from '../../../../../triggers_actions_ui/public/application/alert_type_registry.mock';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context';
import { AlertContextMeta } from '../types';
import { MetricsExplorerMetric } from '../../../../common/http_api/metrics_explorer';
import React from 'react';
import { Expressions } from './expression';
import { act } from 'react-dom/test-utils';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types';
jest.mock('../../../containers/source/use_source_via_http', () => ({
useSourceViaHttp: () => ({
source: { id: 'default' },
createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }),
}),
}));
describe('Expression', () => {
async function setup(currentOptions: {
metrics?: MetricsExplorerMetric[];
filterQuery?: string;
groupBy?: string;
}) {
const alertParams = {
criteria: [],
groupBy: undefined,
filterQueryText: '',
};
const mocks = coreMock.createSetup();
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
const context: AlertsContextValue<AlertContextMeta> = {
http: mocks.http,
toastNotifications: mocks.notifications.toasts,
actionTypeRegistry: actionTypeRegistryMock.create() as any,
alertTypeRegistry: alertTypeRegistryMock.create() as any,
docLinks: mocks.docLinks,
capabilities: {
...capabilities,
actions: {
delete: true,
save: true,
show: true,
},
},
metadata: {
currentOptions,
},
};
const wrapper = mountWithIntl(
<Expressions
alertsContext={context}
alertInterval="1m"
alertParams={alertParams}
errors={[]}
setAlertParams={(key, value) => Reflect.set(alertParams, key, value)}
setAlertProperty={() => {}}
/>
);
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
return { wrapper, update, alertParams };
}
it('should prefill the alert using the context metadata', async () => {
const currentOptions = {
groupBy: 'host.hostname',
filterQuery: 'foo',
metrics: [
{ aggregation: 'avg', field: 'system.load.1' },
{ aggregation: 'cardinality', field: 'system.cpu.user.pct' },
] as MetricsExplorerMetric[],
};
const { alertParams } = await setup(currentOptions);
expect(alertParams.groupBy).toBe('host.hostname');
expect(alertParams.filterQueryText).toBe('foo');
expect(alertParams.criteria).toEqual([
{
metric: 'system.load.1',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
},
{
metric: 'system.cpu.user.pct',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'cardinality',
},
]);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { debounce, pick } from 'lodash';
import { debounce, pick, omit } from 'lodash';
import { Unit } from '@elastic/datemath';
import * as rt from 'io-ts';
import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react';
@ -52,7 +52,7 @@ import { useSourceViaHttp } from '../../../containers/source/use_source_via_http
import { convertKueryToElasticSearchQuery } from '../../../utils/kuery';
import { ExpressionRow } from './expression_row';
import { AlertContextMeta, TimeUnit, MetricExpression } from '../types';
import { AlertContextMeta, TimeUnit, MetricExpression, AlertParams } from '../types';
import { ExpressionChart } from './expression_chart';
import { validateMetricThreshold } from './validation';
@ -60,14 +60,7 @@ const FILTER_TYPING_DEBOUNCE_MS = 500;
interface Props {
errors: IErrorObject[];
alertParams: {
criteria: MetricExpression[];
groupBy?: string;
filterQuery?: string;
sourceId?: string;
filterQueryText?: string;
alertOnNoData?: boolean;
};
alertParams: AlertParams;
alertsContext: AlertsContextValue<AlertContextMeta>;
alertInterval: string;
setAlertParams(key: string, value: any): void;
@ -81,6 +74,7 @@ const defaultExpression = {
timeSize: 1,
timeUnit: 'm',
} as MetricExpression;
export { defaultExpression };
export const Expressions: React.FC<Props> = (props) => {
const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props;
@ -247,6 +241,13 @@ export const Expressions: React.FC<Props> = (props) => {
}
}, [alertsContext.metadata, derivedIndexPattern, setAlertParams]);
const preFillAlertGroupBy = useCallback(() => {
const md = alertsContext.metadata;
if (md && md.currentOptions?.groupBy && !md.series) {
setAlertParams('groupBy', md.currentOptions.groupBy);
}
}, [alertsContext.metadata, setAlertParams]);
const onSelectPreviewLookbackInterval = useCallback((e) => {
setPreviewLookbackInterval(e.target.value);
setPreviewResult(null);
@ -286,6 +287,10 @@ export const Expressions: React.FC<Props> = (props) => {
preFillAlertFilter();
}
if (!alertParams.groupBy) {
preFillAlertGroupBy();
}
if (!alertParams.sourceId) {
setAlertParams('sourceId', source?.id || 'default');
}
@ -465,7 +470,7 @@ export const Expressions: React.FC<Props> = (props) => {
id="selectPreviewLookbackInterval"
value={previewLookbackInterval}
onChange={onSelectPreviewLookbackInterval}
options={previewOptions}
options={previewDOMOptions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -588,6 +593,10 @@ export const Expressions: React.FC<Props> = (props) => {
);
};
const previewDOMOptions: Array<{ text: string; value: string }> = previewOptions.map((o) =>
omit(o, 'shortText')
);
// required for dynamic import
// eslint-disable-next-line import/no-default-export
export default Expressions;

View file

@ -50,7 +50,7 @@ export function validateMetricThreshold({
if (!c.aggType) {
errors[id].aggField.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.aggregationRequired', {
defaultMessage: 'Aggreation is required.',
defaultMessage: 'Aggregation is required.',
})
);
}

View file

@ -0,0 +1,34 @@
/*
* 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 { isEqual } from 'lodash';
import { useState } from 'react';
import { MetricsExplorerMetric } from '../../../../common/http_api/metrics_explorer';
interface MetricThresholdPrefillOptions {
groupBy: string | string[] | undefined;
filterQuery: string | undefined;
metrics: MetricsExplorerMetric[];
}
export const useMetricThresholdAlertPrefill = () => {
const [prefillOptionsState, setPrefillOptionsState] = useState<MetricThresholdPrefillOptions>({
groupBy: undefined,
filterQuery: undefined,
metrics: [],
});
const { groupBy, filterQuery, metrics } = prefillOptionsState;
return {
groupBy,
filterQuery,
metrics,
setPrefillOptions(newState: MetricThresholdPrefillOptions) {
if (!isEqual(newState, prefillOptionsState)) setPrefillOptionsState(newState);
},
};
};

View file

@ -51,3 +51,12 @@ export interface ExpressionChartData {
id: string;
series: ExpressionChartSeries;
}
export interface AlertParams {
criteria: MetricExpression[];
groupBy?: string;
filterQuery?: string;
sourceId?: string;
filterQueryText?: string;
alertOnNoData?: boolean;
}

View file

@ -0,0 +1,18 @@
/*
* 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 createContainer from 'constate';
import { useMetricThresholdAlertPrefill } from './metric_threshold/hooks/use_metric_threshold_alert_prefill';
import { useInventoryAlertPrefill } from './inventory/hooks/use_inventory_alert_prefill';
const useAlertPrefill = () => {
const metricThresholdPrefill = useMetricThresholdAlertPrefill();
const inventoryPrefill = useInventoryAlertPrefill();
return { metricThresholdPrefill, inventoryPrefill };
};
export const [AlertPrefillProvider, useAlertPrefillContext] = createContainer(useAlertPrefill);

View file

@ -1,120 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LogEntryColumnContent } from './log_entry_column';
import { euiStyled } from '../../../../../observability/public';
interface LogEntryActionsColumnProps {
isHovered: boolean;
isMenuOpen: boolean;
onOpenMenu: () => void;
onCloseMenu: () => void;
onViewDetails?: () => void;
onViewLogInContext?: () => void;
}
const MENU_LABEL = i18n.translate('xpack.infra.logEntryItemView.logEntryActionsMenuToolTip', {
defaultMessage: 'View actions for line',
});
const LOG_DETAILS_LABEL = i18n.translate('xpack.infra.logs.logEntryActionsDetailsButton', {
defaultMessage: 'View details',
});
const LOG_VIEW_IN_CONTEXT_LABEL = i18n.translate(
'xpack.infra.lobs.logEntryActionsViewInContextButton',
{
defaultMessage: 'View in context',
}
);
export const LogEntryActionsColumn: React.FC<LogEntryActionsColumnProps> = ({
isHovered,
isMenuOpen,
onOpenMenu,
onCloseMenu,
onViewDetails,
onViewLogInContext,
}) => {
const handleClickViewDetails = useCallback(() => {
onCloseMenu();
// Function might be `undefined` and the linter doesn't like that.
// eslint-disable-next-line no-unused-expressions
onViewDetails?.();
}, [onCloseMenu, onViewDetails]);
const handleClickViewInContext = useCallback(() => {
onCloseMenu();
// Function might be `undefined` and the linter doesn't like that.
// eslint-disable-next-line no-unused-expressions
onViewLogInContext?.();
}, [onCloseMenu, onViewLogInContext]);
const button = (
<ButtonWrapper>
<EuiButtonIcon
aria-label={MENU_LABEL}
color="ghost"
iconType="boxesHorizontal"
onClick={onOpenMenu}
/>
</ButtonWrapper>
);
const items = [
<EuiContextMenuItem key="log_details" onClick={handleClickViewDetails}>
{LOG_DETAILS_LABEL}
</EuiContextMenuItem>,
];
if (onViewLogInContext !== undefined) {
items.push(
<EuiContextMenuItem key="view_in_context" onClick={handleClickViewInContext}>
{LOG_VIEW_IN_CONTEXT_LABEL}
</EuiContextMenuItem>
);
}
return (
<ActionsColumnContent>
{isHovered || isMenuOpen ? (
<AbsoluteWrapper>
<EuiPopover
closePopover={onCloseMenu}
isOpen={isMenuOpen}
button={button}
ownFocus={true}
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</AbsoluteWrapper>
) : null}
</ActionsColumnContent>
);
};
const ActionsColumnContent = euiStyled(LogEntryColumnContent)`
overflow: hidden;
user-select: none;
`;
const ButtonWrapper = euiStyled.div`
background: ${(props) => props.theme.eui.euiColorPrimary};
border-radius: 50%;
padding: 4px;
transform: translateY(-6px);
`;
// this prevents the button from influencing the line height
const AbsoluteWrapper = euiStyled.div`
position: absolute;
`;

View file

@ -0,0 +1,94 @@
/*
* 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 React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { euiStyled } from '../../../../../observability/public';
import { LogEntryColumnContent } from './log_entry_column';
interface LogEntryContextMenuItem {
label: string;
onClick: () => void;
}
interface LogEntryContextMenuProps {
'aria-label'?: string;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
items: LogEntryContextMenuItem[];
}
const DEFAULT_MENU_LABEL = i18n.translate(
'xpack.infra.logEntryItemView.logEntryActionsMenuToolTip',
{
defaultMessage: 'View actions for line',
}
);
export const LogEntryContextMenu: React.FC<LogEntryContextMenuProps> = ({
'aria-label': ariaLabel,
isOpen,
onOpen,
onClose,
items,
}) => {
const closeMenuAndCall = useMemo(() => {
return (callback: LogEntryContextMenuItem['onClick']) => {
return () => {
onClose();
callback();
};
};
}, [onClose]);
const button = (
<ButtonWrapper>
<EuiButtonIcon
aria-label={ariaLabel || DEFAULT_MENU_LABEL}
color="ghost"
iconType="boxesHorizontal"
onClick={onOpen}
/>
</ButtonWrapper>
);
const wrappedItems = useMemo(() => {
return items.map((item, i) => (
<EuiContextMenuItem key={i} onClick={closeMenuAndCall(item.onClick)}>
{item.label}
</EuiContextMenuItem>
));
}, [items, closeMenuAndCall]);
return (
<LogEntryContextMenuContent>
<AbsoluteWrapper>
<EuiPopover closePopover={onClose} isOpen={isOpen} button={button} ownFocus={true}>
<EuiContextMenuPanel items={wrappedItems} />
</EuiPopover>
</AbsoluteWrapper>
</LogEntryContextMenuContent>
);
};
const LogEntryContextMenuContent = euiStyled(LogEntryColumnContent)`
overflow: hidden;
user-select: none;
`;
const AbsoluteWrapper = euiStyled.div`
position: absolute;
`;
const ButtonWrapper = euiStyled.div`
background: ${(props) => props.theme.eui.euiColorPrimary};
border-radius: 50%;
padding: 4px;
transform: translateY(-6px);
`;

View file

@ -5,6 +5,7 @@
*/
import React, { memo, useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { euiStyled } from '../../../../../observability/public';
@ -18,11 +19,26 @@ import {
import { TextScale } from '../../../../common/log_text_scale';
import { LogEntryColumn, LogEntryColumnWidths, iconColumnId } from './log_entry_column';
import { LogEntryFieldColumn } from './log_entry_field_column';
import { LogEntryActionsColumn } from './log_entry_actions_column';
import { LogEntryMessageColumn } from './log_entry_message_column';
import { LogEntryTimestampColumn } from './log_entry_timestamp_column';
import { monospaceTextStyle, hoveredContentStyle, highlightedContentStyle } from './text_styles';
import { LogEntry, LogColumn } from '../../../../common/http_api';
import { LogEntryContextMenu } from './log_entry_context_menu';
const MENU_LABEL = i18n.translate('xpack.infra.logEntryItemView.logEntryActionsMenuToolTip', {
defaultMessage: 'View actions for line',
});
const LOG_DETAILS_LABEL = i18n.translate('xpack.infra.logs.logEntryActionsDetailsButton', {
defaultMessage: 'View details',
});
const LOG_VIEW_IN_CONTEXT_LABEL = i18n.translate(
'xpack.infra.lobs.logEntryActionsViewInContextButton',
{
defaultMessage: 'View in context',
}
);
interface LogEntryRowProps {
boundingBoxRef?: React.Ref<Element>;
@ -76,6 +92,29 @@ export const LogEntryRow = memo(
const hasActionViewLogInContext = hasContext && openViewLogInContext !== undefined;
const hasActionsMenu = hasActionFlyoutWithItem || hasActionViewLogInContext;
const menuItems = useMemo(() => {
const items = [];
if (hasActionFlyoutWithItem) {
items.push({
label: LOG_DETAILS_LABEL,
onClick: openFlyout,
});
}
if (hasActionViewLogInContext) {
items.push({
label: LOG_VIEW_IN_CONTEXT_LABEL,
onClick: handleOpenViewLogInContext,
});
}
return items;
}, [
hasActionFlyoutWithItem,
hasActionViewLogInContext,
openFlyout,
handleOpenViewLogInContext,
]);
const logEntryColumnsById = useMemo(
() =>
logEntry.columns.reduce<{
@ -183,16 +222,15 @@ export const LogEntryRow = memo(
key="logColumn iconLogColumn iconLogColumn:details"
{...columnWidths[iconColumnId]}
>
<LogEntryActionsColumn
isHovered={isHovered}
isMenuOpen={isMenuOpen}
onOpenMenu={openMenu}
onCloseMenu={closeMenu}
onViewDetails={hasActionFlyoutWithItem ? openFlyout : undefined}
onViewLogInContext={
hasActionViewLogInContext ? handleOpenViewLogInContext : undefined
}
/>
{isHovered || isMenuOpen ? (
<LogEntryContextMenu
aria-label={MENU_LABEL}
isOpen={isMenuOpen}
onOpen={openMenu}
onClose={closeMenu}
items={menuItems}
/>
) : null}
</LogEntryColumn>
) : null}
</LogEntryRowWrapper>

View file

@ -59,7 +59,7 @@ class WithKueryAutocompletionComponent extends React.Component<
) => {
const { indexPattern } = this.props;
const language = 'kuery';
const hasQuerySuggestions = this.props.kibana.services.data.autocomplete.hasQuerySuggestions(
const hasQuerySuggestions = this.props.kibana.services.data?.autocomplete.hasQuerySuggestions(
language
);

View file

@ -31,6 +31,7 @@ import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters
import { InventoryAlertDropdown } from '../../alerting/inventory/components/alert_dropdown';
import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown';
import { AlertPrefillProvider } from '../../alerting/use_alert_prefill';
const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', {
defaultMessage: 'Add data',
@ -44,114 +45,119 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
return (
<EuiErrorBoundary>
<Source.Provider sourceId="default">
<WaffleOptionsProvider>
<WaffleTimeProvider>
<WaffleFiltersProvider>
<ColumnarPage>
<DocumentTitle
title={i18n.translate('xpack.infra.homePage.documentTitle', {
defaultMessage: 'Metrics',
})}
/>
<HelpCenterContent
feedbackLink="https://discuss.elastic.co/c/metrics"
appName={i18n.translate('xpack.infra.header.infrastructureHelpAppName', {
defaultMessage: 'Metrics',
})}
/>
<Header
breadcrumbs={[
{
text: i18n.translate('xpack.infra.header.infrastructureTitle', {
defaultMessage: 'Metrics',
}),
},
]}
readOnlyBadge={!uiCapabilities?.infrastructure?.save}
/>
<AppNavigation
aria-label={i18n.translate('xpack.infra.header.infrastructureNavigationTitle', {
defaultMessage: 'Metrics',
})}
>
<EuiFlexGroup gutterSize={'none'} alignItems={'center'}>
<EuiFlexItem>
<RoutedTabs
tabs={[
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', {
defaultMessage: 'Inventory',
}),
pathname: '/inventory',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', {
defaultMessage: 'Metrics Explorer',
}),
pathname: '/explorer',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.settingsTabTitle', {
defaultMessage: 'Settings',
}),
pathname: '/settings',
},
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Route path={'/explorer'} component={MetricsAlertDropdown} />
<Route path={'/inventory'} component={InventoryAlertDropdown} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={kibana.services?.application?.getUrlForApp(
'/home#/tutorial_directory/metrics'
)}
size="s"
color="primary"
iconType="plusInCircle"
>
{ADD_DATA_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</AppNavigation>
<Switch>
<Route path={'/inventory'} component={SnapshotPage} />
<Route
path={'/explorer'}
render={(props) => (
<WithSource>
{({ configuration, createDerivedIndexPattern }) => (
<MetricsExplorerOptionsContainer.Provider>
<WithMetricsExplorerOptionsUrlState />
{configuration ? (
<MetricsExplorerPage
derivedIndexPattern={createDerivedIndexPattern('metrics')}
source={configuration}
{...props}
/>
) : (
<SourceLoadingPage />
)}
</MetricsExplorerOptionsContainer.Provider>
)}
</WithSource>
)}
<AlertPrefillProvider>
<WaffleOptionsProvider>
<WaffleTimeProvider>
<WaffleFiltersProvider>
<ColumnarPage>
<DocumentTitle
title={i18n.translate('xpack.infra.homePage.documentTitle', {
defaultMessage: 'Metrics',
})}
/>
<Route path={'/settings'} component={MetricsSettingsPage} />
</Switch>
</ColumnarPage>
</WaffleFiltersProvider>
</WaffleTimeProvider>
</WaffleOptionsProvider>
<HelpCenterContent
feedbackLink="https://discuss.elastic.co/c/metrics"
appName={i18n.translate('xpack.infra.header.infrastructureHelpAppName', {
defaultMessage: 'Metrics',
})}
/>
<Header
breadcrumbs={[
{
text: i18n.translate('xpack.infra.header.infrastructureTitle', {
defaultMessage: 'Metrics',
}),
},
]}
readOnlyBadge={!uiCapabilities?.infrastructure?.save}
/>
<AppNavigation
aria-label={i18n.translate('xpack.infra.header.infrastructureNavigationTitle', {
defaultMessage: 'Metrics',
})}
>
<EuiFlexGroup gutterSize={'none'} alignItems={'center'}>
<EuiFlexItem>
<RoutedTabs
tabs={[
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', {
defaultMessage: 'Inventory',
}),
pathname: '/inventory',
},
{
app: 'metrics',
title: i18n.translate(
'xpack.infra.homePage.metricsExplorerTabTitle',
{
defaultMessage: 'Metrics Explorer',
}
),
pathname: '/explorer',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.settingsTabTitle', {
defaultMessage: 'Settings',
}),
pathname: '/settings',
},
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Route path={'/explorer'} component={MetricsAlertDropdown} />
<Route path={'/inventory'} component={InventoryAlertDropdown} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={kibana.services?.application?.getUrlForApp(
'/home#/tutorial_directory/metrics'
)}
size="s"
color="primary"
iconType="plusInCircle"
>
{ADD_DATA_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</AppNavigation>
<Switch>
<Route path={'/inventory'} component={SnapshotPage} />
<Route
path={'/explorer'}
render={(props) => (
<WithSource>
{({ configuration, createDerivedIndexPattern }) => (
<MetricsExplorerOptionsContainer.Provider>
<WithMetricsExplorerOptionsUrlState />
{configuration ? (
<MetricsExplorerPage
derivedIndexPattern={createDerivedIndexPattern('metrics')}
source={configuration}
{...props}
/>
) : (
<SourceLoadingPage />
)}
</MetricsExplorerOptionsContainer.Provider>
)}
</WithSource>
)}
/>
<Route path={'/settings'} component={MetricsSettingsPage} />
</Switch>
</ColumnarPage>
</WaffleFiltersProvider>
</WaffleTimeProvider>
</WaffleOptionsProvider>
</AlertPrefillProvider>
</Source.Provider>
</EuiErrorBoundary>
);

View file

@ -0,0 +1,56 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useWaffleFilters, WaffleFiltersState } from './use_waffle_filters';
// Mock useUrlState hook
jest.mock('react-router-dom', () => ({
useHistory: () => ({
location: '',
replace: () => {},
}),
}));
jest.mock('../../../../containers/source', () => ({
useSourceContext: () => ({
createDerivedIndexPattern: () => 'jestbeat-*',
}),
}));
let PREFILL: Record<string, any> = {};
jest.mock('../../../../alerting/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
inventoryPrefill: {
setFilterQuery(filterQuery: string) {
PREFILL = { filterQuery };
},
},
}),
}));
const renderUseWaffleFiltersHook = () => renderHook(() => useWaffleFilters());
describe('useWaffleFilters', () => {
beforeEach(() => {
PREFILL = {};
});
it('should sync the options to the inventory alert preview context', () => {
const { result, rerender } = renderUseWaffleFiltersHook();
const newQuery = {
expression: 'foo',
kind: 'kuery',
} as WaffleFiltersState;
act(() => {
result.current.applyFilterQuery(newQuery);
});
rerender();
expect(PREFILL.filterQuery).toEqual(newQuery.expression);
});
});

View file

@ -10,6 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import createContainter from 'constate';
import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill';
import { useUrlState } from '../../../../utils/use_url_state';
import { useSourceContext } from '../../../../containers/source';
import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery';
@ -68,6 +69,10 @@ export const useWaffleFilters = () => {
filterQueryDraft,
]);
const { inventoryPrefill } = useAlertPrefillContext();
const prefillContext = useMemo(() => inventoryPrefill, [inventoryPrefill]); // For Jest compatibility
useEffect(() => prefillContext.setFilterQuery(state.expression), [prefillContext, state]);
return {
filterQuery: urlState,
filterQueryDraft,

View file

@ -0,0 +1,62 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useWaffleOptions, WaffleOptionsState } from './use_waffle_options';
// Mock useUrlState hook
jest.mock('react-router-dom', () => ({
useHistory: () => ({
location: '',
replace: () => {},
}),
}));
// Jest can't access variables outside the scope of the mock factory function except to
// reassign them, so we can't make these both part of the same object
let PREFILL_NODETYPE: WaffleOptionsState['nodeType'] | undefined;
let PREFILL_METRIC: WaffleOptionsState['metric'] | undefined;
jest.mock('../../../../alerting/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
inventoryPrefill: {
setNodeType(nodeType: WaffleOptionsState['nodeType']) {
PREFILL_NODETYPE = nodeType;
},
setMetric(metric: WaffleOptionsState['metric']) {
PREFILL_METRIC = metric;
},
},
}),
}));
const renderUseWaffleOptionsHook = () => renderHook(() => useWaffleOptions());
describe('useWaffleOptions', () => {
beforeEach(() => {
PREFILL_NODETYPE = undefined;
PREFILL_METRIC = undefined;
});
it('should sync the options to the inventory alert preview context', () => {
const { result, rerender } = renderUseWaffleOptionsHook();
const newOptions = {
nodeType: 'pod',
metric: { type: 'memory' },
} as WaffleOptionsState;
act(() => {
result.current.changeNodeType(newOptions.nodeType);
});
rerender();
expect(PREFILL_NODETYPE).toEqual(newOptions.nodeType);
act(() => {
result.current.changeMetric(newOptions.metric);
});
rerender();
expect(PREFILL_METRIC).toEqual(newOptions.metric);
});
});

View file

@ -10,6 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import createContainer from 'constate';
import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill';
import { InventoryColorPaletteRT } from '../../../../lib/lib';
import {
SnapshotMetricInput,
@ -121,6 +122,13 @@ export const useWaffleOptions = () => {
[setState]
);
const { inventoryPrefill } = useAlertPrefillContext();
useEffect(() => {
const { setNodeType, setMetric } = inventoryPrefill;
setNodeType(state.nodeType);
setMetric(state.metric);
}, [state, inventoryPrefill]);
return {
...DEFAULT_WAFFLE_OPTIONS_STATE,
...state,

View file

@ -4,26 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import {
useMetricsExplorerOptions,
MetricsExplorerOptionsContainer,
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
DEFAULT_OPTIONS,
DEFAULT_TIMERANGE,
} from './use_metrics_explorer_options';
const renderUseMetricsExplorerOptionsHook = () =>
renderHook(() => useMetricsExplorerOptions(), {
initialProps: {},
wrapper: ({ children }) => (
<MetricsExplorerOptionsContainer.Provider>
{children}
</MetricsExplorerOptionsContainer.Provider>
),
});
let PREFILL: Record<string, any> = {};
jest.mock('../../../../alerting/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
metricThresholdPrefill: {
setPrefillOptions(opts: Record<string, any>) {
PREFILL = opts;
},
},
}),
}));
const renderUseMetricsExplorerOptionsHook = () => renderHook(() => useMetricsExplorerOptions());
interface LocalStore {
[key: string]: string;
@ -52,6 +53,7 @@ describe('useMetricExplorerOptions', () => {
beforeEach(() => {
delete STORE.MetricsExplorerOptions;
delete STORE.MetricsExplorerTimeRange;
PREFILL = {};
});
it('should just work', () => {
@ -100,4 +102,22 @@ describe('useMetricExplorerOptions', () => {
const { result } = renderUseMetricsExplorerOptionsHook();
expect(result.current.options).toEqual(newOptions);
});
it('should sync the options to the threshold alert preview context', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'count' }],
filterQuery: 'foo',
groupBy: 'host.hostname',
};
act(() => {
result.current.setOptions(newOptions);
});
rerender();
expect(PREFILL.metrics).toEqual(newOptions.metrics);
expect(PREFILL.groupBy).toEqual(newOptions.groupBy);
expect(PREFILL.filterQuery).toEqual(newOptions.filterQuery);
});
});

View file

@ -5,7 +5,8 @@
*/
import createContainer from 'constate';
import { useState, useEffect, Dispatch, SetStateAction } from 'react';
import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react';
import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill';
import { MetricsExplorerColor } from '../../../../../common/color_palette';
import {
MetricsExplorerAggregation,
@ -122,6 +123,21 @@ export const useMetricsExplorerOptions = () => {
DEFAULT_CHART_OPTIONS
);
const [isAutoReloading, setAutoReloading] = useState<boolean>(false);
const { metricThresholdPrefill } = useAlertPrefillContext();
// For Jest compatibility; including metricThresholdPrefill as a dep in useEffect causes an
// infinite loop in test environment
const prefillContext = useMemo(() => metricThresholdPrefill, [metricThresholdPrefill]);
useEffect(() => {
if (prefillContext) {
const { setPrefillOptions } = prefillContext;
const { metrics, groupBy, filterQuery } = options;
setPrefillOptions({ metrics, groupBy, filterQuery });
}
}, [options, prefillContext]);
return {
defaultViewState: {
options: DEFAULT_OPTIONS,

View file

@ -23,14 +23,14 @@ import { CreateDatasourceFrom } from '../types';
export const CreateDatasourcePageLayout: React.FunctionComponent<{
from: CreateDatasourceFrom;
cancelUrl: string;
cancelOnClick?: React.ReactEventHandler;
onCancel?: React.ReactEventHandler;
agentConfig?: AgentConfig;
packageInfo?: PackageInfo;
'data-test-subj'?: string;
}> = ({
from,
cancelUrl,
cancelOnClick,
onCancel,
agentConfig,
packageInfo,
children,
@ -45,7 +45,7 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{
iconType="arrowLeft"
flush="left"
href={cancelUrl}
onClick={cancelOnClick}
onClick={onCancel}
data-test-subj={`${dataTestSubj}_cancelBackLink`}
>
<FormattedMessage

View file

@ -256,7 +256,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => {
const layoutProps = {
from,
cancelUrl,
cancelOnClick: cancelClickHandler,
onCancel: cancelClickHandler,
agentConfig,
packageInfo,
};

View file

@ -242,7 +242,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => {
};
return (
<CreateDatasourcePageLayout {...layoutProps}>
<CreateDatasourcePageLayout {...layoutProps} data-test-subj="editDataSource">
{isLoadingData ? (
<Loading />
) : loadingError || !agentConfig || !packageInfo ? (

View file

@ -489,6 +489,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
<EuiSpacer size="m" />
<EuiBasicTable<Agent>
className="fleet__agentList__table"
data-test-subj="fleetAgentListTable"
loading={isLoading && agentsRequest.isInitialRequest}
hasActions={true}
noItemsMessage={

View file

@ -157,12 +157,14 @@ And you can attach exception list items like so:
{
"field": "actingProcess.file.signer",
"operator": "included",
"match": "Elastic, N.V."
"type": "match",
"value": "Elastic, N.V."
},
{
"field": "event.category",
"operator": "included",
"match_any": [
"type": "match_any",
"value": [
"process",
"malware"
]

View file

@ -46,10 +46,8 @@ export const EXISTS = 'exists';
export const NESTED = 'nested';
export const ENTRIES: EntriesArray = [
{
entries: [
{ field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' },
],
field: 'some.field',
entries: [{ field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }],
field: 'some.parentField',
type: 'nested',
},
{ field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' },

View file

@ -17,7 +17,8 @@ import { getEntriesArrayMock, getEntryMatchMock, getEntryNestedMock } from './en
// it checks against every item in that union. Since entries consist of 5
// different entry types, it returns 5 of these. To make more readable,
// extracted here.
const returnedSchemaError = `"Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "list", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "exists" |} | {| entries: Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "list", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "exists" |})>, field: string, type: "nested" |})>"`;
const returnedSchemaError =
'"Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, list: {| id: string, type: "ip" | "keyword" |}, operator: "excluded" | "included", type: "list" |} | {| field: string, operator: "excluded" | "included", type: "exists" |} | {| entries: Array<{| field: string, operator: "excluded" | "included", type: "match", value: string |}>, field: string, type: "nested" |})>"';
describe('default_entries_array', () => {
test('it should validate an empty array', () => {

View file

@ -0,0 +1,61 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { DefaultNamespace } from './default_namespace';
describe('default_namespace', () => {
test('it should validate "single"', () => {
const payload = 'single';
const decoded = DefaultNamespace.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate "agnostic"', () => {
const payload = 'agnostic';
const decoded = DefaultNamespace.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it defaults to "single" if "undefined"', () => {
const payload = undefined;
const decoded = DefaultNamespace.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('single');
});
test('it defaults to "single" if "null"', () => {
const payload = null;
const decoded = DefaultNamespace.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual('single');
});
test('it should NOT validate if not "single" or "agnostic"', () => {
const payload = 'something else';
const decoded = DefaultNamespace.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
`Invalid value "something else" supplied to "DefaultNamespace"`,
]);
expect(message.schema).toEqual({});
});
});

View file

@ -7,7 +7,7 @@
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
const namespaceType = t.keyof({ agnostic: null, single: null });
export const namespaceType = t.keyof({ agnostic: null, single: null });
type NamespaceType = t.TypeOf<typeof namespaceType>;

View file

@ -9,10 +9,12 @@ import {
EXISTS,
FIELD,
LIST,
LIST_ID,
MATCH,
MATCH_ANY,
NESTED,
OPERATOR,
TYPE,
} from '../../constants.mock';
import {
@ -40,9 +42,9 @@ export const getEntryMatchAnyMock = (): EntryMatchAny => ({
export const getEntryListMock = (): EntryList => ({
field: FIELD,
list: { id: LIST_ID, type: TYPE },
operator: OPERATOR,
type: LIST,
value: [ENTRY_VALUE],
});
export const getEntryExistsMock = (): EntryExists => ({
@ -52,7 +54,7 @@ export const getEntryExistsMock = (): EntryExists => ({
});
export const getEntryNestedMock = (): EntryNested => ({
entries: [getEntryMatchMock(), getEntryExistsMock()],
entries: [getEntryMatchMock(), getEntryMatchMock()],
field: FIELD,
type: NESTED,
});

View file

@ -251,16 +251,16 @@ describe('Entries', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate when "value" is not string array', () => {
const payload: Omit<EntryList, 'value'> & { value: string } = {
test('it should not validate when "list" is not expected value', () => {
const payload: Omit<EntryList, 'list'> & { list: string } = {
...getEntryListMock(),
value: 'someListId',
list: 'someListId',
};
const decoded = entriesList.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "someListId" supplied to "value"',
'Invalid value "someListId" supplied to "list"',
]);
expect(message.schema).toEqual({});
});
@ -338,6 +338,20 @@ describe('Entries', () => {
expect(message.schema).toEqual({});
});
test('it should NOT validate when "entries" contains an entry item that is not type "match"', () => {
const payload: Omit<EntryNested, 'entries'> & {
entries: EntryMatchAny[];
} = { ...getEntryNestedMock(), entries: [getEntryMatchAnyMock()] };
const decoded = entriesNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "match_any" supplied to "entries,type"',
'Invalid value "["some host name"]" supplied to "entries,value"',
]);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: EntryNested & {
extraKey?: string;

View file

@ -8,7 +8,7 @@
import * as t from 'io-ts';
import { operator } from '../common/schemas';
import { operator, type } from '../common/schemas';
import { DefaultStringArray } from '../../siem_common_deps';
export const entriesMatch = t.exact(
@ -34,9 +34,9 @@ export type EntryMatchAny = t.TypeOf<typeof entriesMatchAny>;
export const entriesList = t.exact(
t.type({
field: t.string,
list: t.exact(t.type({ id: t.string, type })),
operator,
type: t.keyof({ list: null }),
value: DefaultStringArray,
})
);
export type EntryList = t.TypeOf<typeof entriesList>;
@ -52,7 +52,7 @@ export type EntryExists = t.TypeOf<typeof entriesExists>;
export const entriesNested = t.exact(
t.type({
entries: t.array(t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists])),
entries: t.array(entriesMatch),
field: t.string,
type: t.keyof({ nested: null }),
})

View file

@ -5,5 +5,6 @@
*/
export * from './default_comments_array';
export * from './default_entries_array';
export * from './default_namespace';
export * from './comments';
export * from './entries';

View file

@ -11,6 +11,7 @@ import { ListPlugin } from './plugin';
// exporting these since its required at top level in siem plugin
export { ListClient } from './services/lists/list_client';
export { ExceptionListClient } from './services/exception_lists/exception_list_client';
export { ListPluginSetup } from './types';
export const config = { schema: ConfigSchema };

View file

@ -105,6 +105,16 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = {
field: {
type: 'keyword',
},
list: {
properties: {
id: {
type: 'keyword',
},
type: {
type: 'keyword',
},
},
},
operator: {
type: 'keyword',
},

View file

@ -0,0 +1,24 @@
{
"list_id": "endpoint_list",
"item_id": "endpoint_list_item_lg_val_list",
"_tags": ["endpoint", "process", "malware", "os:windows"],
"tags": ["user added string for a tag", "malware"],
"type": "simple",
"description": "This is a sample exception list item with a large value list included",
"name": "Sample Endpoint Exception List Item with large value list",
"comments": [],
"entries": [
{
"field": "event.module",
"operator": "excluded",
"type": "match_any",
"value": ["zeek"]
},
{
"field": "source.ip",
"operator": "excluded",
"type": "list",
"list": { "id": "list-ip", "type": "ip" }
}
]
}

View file

@ -1,5 +1,5 @@
{
"id": "hand_inserted_item_id",
"list_id": "list-ip",
"value": "127.0.0.1"
"value": "10.4.2.140"
}

View file

@ -25,7 +25,6 @@ export const EMS_TILES_VECTOR_TILE_PATH = 'vector/tile';
export const MAP_SAVED_OBJECT_TYPE = 'map';
export const APP_ID = 'maps';
export const APP_ICON = 'gisApp';
export const TELEMETRY_TYPE = APP_ID;
export const MAP_APP_PATH = `app/${APP_ID}`;
export const GIS_API_PATH = `api/${APP_ID}`;

View file

@ -32,17 +32,9 @@ exports[`LayerPanel is rendered 1`] = `
<EuiFlexItem
grow={false}
>
<EuiButtonIcon
aria-label="Fit to bounds"
iconType="vector"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Fit"
id="xpack.maps.layerPanel.fitToBoundsButtonLabel"
values={Object {}}
/>
</EuiButtonIcon>
<EuiIcon
type="vector"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle

View file

@ -7,7 +7,7 @@
import { connect } from 'react-redux';
import { LayerPanel } from './view';
import { getSelectedLayer } from '../../selectors/map_selectors';
import { fitToLayerExtent, updateSourceProp } from '../../actions';
import { updateSourceProp } from '../../actions';
function mapStateToProps(state = {}) {
const selectedLayer = getSelectedLayer(state);
@ -19,9 +19,6 @@ function mapStateToProps(state = {}) {
function mapDispatchToProps(dispatch) {
return {
fitToBounds: (layerId) => {
dispatch(fitToLayerExtent(layerId));
},
updateSourceProp: (id, propName, value, newLayerType) =>
dispatch(updateSourceProp(id, propName, value, newLayerType)),
};

View file

@ -13,7 +13,7 @@ import { LayerErrors } from './layer_errors';
import { LayerSettings } from './layer_settings';
import { StyleSettings } from './style_settings';
import {
EuiButtonIcon,
EuiIcon,
EuiFlexItem,
EuiTitle,
EuiPanel,
@ -27,7 +27,6 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
@ -175,18 +174,7 @@ export class LayerPanel extends React.Component {
<EuiFlyoutHeader hasBorder className="mapLayerPanel__header">
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.maps.layerPanel.fitToBoundsAriaLabel', {
defaultMessage: 'Fit to bounds',
})}
iconType={selectedLayer.getLayerTypeIconName()}
onClick={this.props.fitToBounds}
>
<FormattedMessage
id="xpack.maps.layerPanel.fitToBoundsButtonLabel"
defaultMessage="Fit"
/>
</EuiButtonIcon>
<EuiIcon type={selectedLayer.getLayerTypeIconName()} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">

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