diff --git a/.backportrc.json b/.backportrc.json index 87bc3a1be583ba..8f458343c51afd 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -25,6 +25,7 @@ ], "targetPRLabels": ["backport"], "branchLabelMapping": { + "^v8.0.0$": "master", "^v7.9.0$": "7.x", "^v(\\d+).(\\d+).\\d+$": "$1.$2" } diff --git a/.ci/pipeline-library/build.gradle b/.ci/pipeline-library/build.gradle index 2753750871ad59..ac5e7a4ed034a2 100644 --- a/.ci/pipeline-library/build.gradle +++ b/.ci/pipeline-library/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation 'org.jenkins-ci.plugins.workflow:workflow-step-api:2.19@jar' testImplementation 'com.lesfurets:jenkins-pipeline-unit:1.4' testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-core:2.+' testImplementation 'org.assertj:assertj-core:3.15+' // Temporary https://github.com/jenkinsci/JenkinsPipelineUnit/issues/209 } diff --git a/.ci/pipeline-library/src/test/KibanaBasePipelineTest.groovy b/.ci/pipeline-library/src/test/KibanaBasePipelineTest.groovy index 23282089ab76c2..086484f2385b0e 100644 --- a/.ci/pipeline-library/src/test/KibanaBasePipelineTest.groovy +++ b/.ci/pipeline-library/src/test/KibanaBasePipelineTest.groovy @@ -19,9 +19,9 @@ class KibanaBasePipelineTest extends BasePipelineTest { env.BUILD_DISPLAY_NAME = "#${env.BUILD_ID}" env.JENKINS_URL = 'http://jenkins.localhost:8080' - env.BUILD_URL = "${env.JENKINS_URL}/job/elastic+kibana+${env.BRANCH_NAME}/${env.BUILD_ID}/" + env.BUILD_URL = "${env.JENKINS_URL}/job/elastic+kibana+${env.BRANCH_NAME}/${env.BUILD_ID}/".toString() - env.JOB_BASE_NAME = "elastic / kibana # ${env.BRANCH_NAME}" + env.JOB_BASE_NAME = "elastic / kibana # ${env.BRANCH_NAME}".toString() env.JOB_NAME = env.JOB_BASE_NAME env.WORKSPACE = 'WS' @@ -31,6 +31,9 @@ class KibanaBasePipelineTest extends BasePipelineTest { getBuildStatus: { 'SUCCESS' }, printStacktrace: { ex -> print ex }, ], + githubPr: [ + isPr: { false }, + ], jenkinsApi: [ getFailedSteps: { [] } ], testUtils: [ getFailures: { [] } ], ]) diff --git a/.ci/pipeline-library/src/test/buildState.groovy b/.ci/pipeline-library/src/test/buildState.groovy new file mode 100644 index 00000000000000..b748cce29e7f4b --- /dev/null +++ b/.ci/pipeline-library/src/test/buildState.groovy @@ -0,0 +1,48 @@ +import org.junit.* +import static groovy.test.GroovyAssert.* + +class BuildStateTest extends KibanaBasePipelineTest { + def buildState + + @Before + void setUp() { + super.setUp() + + buildState = loadScript("vars/buildState.groovy") + } + + @Test + void 'get() returns existing data'() { + buildState.add('test', 1) + def actual = buildState.get('test') + assertEquals(1, actual) + } + + @Test + void 'get() returns null for missing data'() { + def actual = buildState.get('missing_key') + assertEquals(null, actual) + } + + @Test + void 'add() does not overwrite existing keys'() { + assertTrue(buildState.add('test', 1)) + assertFalse(buildState.add('test', 2)) + + def actual = buildState.get('test') + + assertEquals(1, actual) + } + + @Test + void 'set() overwrites existing keys'() { + assertFalse(buildState.has('test')) + buildState.set('test', 1) + assertTrue(buildState.has('test')) + buildState.set('test', 2) + + def actual = buildState.get('test') + + assertEquals(2, actual) + } +} diff --git a/.ci/pipeline-library/src/test/githubCommitStatus.groovy b/.ci/pipeline-library/src/test/githubCommitStatus.groovy new file mode 100644 index 00000000000000..17878624b73cfe --- /dev/null +++ b/.ci/pipeline-library/src/test/githubCommitStatus.groovy @@ -0,0 +1,85 @@ +import org.junit.* +import static org.mockito.Mockito.*; + +class GithubCommitStatusTest extends KibanaBasePipelineTest { + def githubCommitStatus + def githubApiMock + def buildStateMock + + def EXPECTED_STATUS_URL = 'repos/elastic/kibana/statuses/COMMIT_HASH' + def EXPECTED_CONTEXT = 'kibana-ci' + def EXPECTED_BUILD_URL = 'http://jenkins.localhost:8080/job/elastic+kibana+master/1/' + + interface BuildState { + Object get(String key) + } + + interface GithubApi { + Object post(String url, Map data) + } + + @Before + void setUp() { + super.setUp() + + buildStateMock = mock(BuildState) + githubApiMock = mock(GithubApi) + + when(buildStateMock.get('checkoutInfo')).thenReturn([ commit: 'COMMIT_HASH', ]) + when(githubApiMock.post(any(), any())).thenReturn(null) + + props([ + buildState: buildStateMock, + githubApi: githubApiMock, + ]) + + githubCommitStatus = loadScript("vars/githubCommitStatus.groovy") + } + + void verifyStatusCreate(String state, String description) { + verify(githubApiMock).post( + EXPECTED_STATUS_URL, + [ + 'state': state, + 'description': description, + 'context': EXPECTED_CONTEXT, + 'target_url': EXPECTED_BUILD_URL, + ] + ) + } + + @Test + void 'onStart() should create a pending status'() { + githubCommitStatus.onStart() + verifyStatusCreate('pending', 'Build started.') + } + + @Test + void 'onFinish() should create a success status'() { + githubCommitStatus.onFinish() + verifyStatusCreate('success', 'Build completed successfully.') + } + + @Test + void 'onFinish() should create an error status for failed builds'() { + mockFailureBuild() + githubCommitStatus.onFinish() + verifyStatusCreate('error', 'Build failed.') + } + + @Test + void 'onStart() should exit early for PRs'() { + prop('githubPr', [ isPr: { true } ]) + + githubCommitStatus.onStart() + verifyZeroInteractions(githubApiMock) + } + + @Test + void 'onFinish() should exit early for PRs'() { + prop('githubPr', [ isPr: { true } ]) + + githubCommitStatus.onFinish() + verifyZeroInteractions(githubApiMock) + } +} diff --git a/.eslintignore b/.eslintignore index fbdd70703f3c40..9de2cc28729602 100644 --- a/.eslintignore +++ b/.eslintignore @@ -33,6 +33,7 @@ target /x-pack/plugins/canvas/shareable_runtime/build /x-pack/plugins/canvas/storybook /x-pack/plugins/monitoring/public/lib/jquery_flot +/x-pack/plugins/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/** /x-pack/legacy/plugins/infra/common/graphql/types.ts /x-pack/legacy/plugins/infra/public/graphql/types.ts /x-pack/legacy/plugins/infra/server/graphql/types.ts diff --git a/Jenkinsfile b/Jenkinsfile index db9d218db15ba5..7869fa68788bda 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,7 @@ library 'kibana-pipeline-library' kibanaLibrary.load() -kibanaPipeline(timeoutMinutes: 155, checkPrChanges: true) { +kibanaPipeline(timeoutMinutes: 155, checkPrChanges: true, setCommitStatus: true) { githubPr.withDefaultPrComments { ciStats.trackBuild { catchError { diff --git a/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md b/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md new file mode 100644 index 00000000000000..d1e0be17a92b24 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.app.exactroute.md @@ -0,0 +1,30 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [App](./kibana-plugin-core-public.app.md) > [exactRoute](./kibana-plugin-core-public.app.exactroute.md) + +## App.exactRoute property + +If set to true, the application's route will only be checked against an exact match. Defaults to `false`. + +Signature: + +```typescript +exactRoute?: boolean; +``` + +## Example + + +```ts +core.application.register({ + id: 'my_app', + title: 'My App' + exactRoute: true, + mount: () => { ... }, +}) + +// '[basePath]/app/my_app' will be matched +// '[basePath]/app/my_app/some/path' will not be matched + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.app.md b/docs/development/core/public/kibana-plugin-core-public.app.md index 90737d241f5484..8dd60972549f9a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.md @@ -18,5 +18,6 @@ export interface App extends AppBase | --- | --- | --- | | [appRoute](./kibana-plugin-core-public.app.approute.md) | string | Override the application's routing path from /app/${id}. Must be unique across registered applications. Should not include the base path from HTTP. | | [chromeless](./kibana-plugin-core-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | +| [exactRoute](./kibana-plugin-core-public.app.exactroute.md) | boolean | If set to true, the application's route will only be checked against an exact match. Defaults to false. | | [mount](./kibana-plugin-core-public.app.mount.md) | AppMount<HistoryLocationState> | AppMountDeprecated<HistoryLocationState> | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-core-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-core-public.appmountdeprecated.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.getcustomnavlink_.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.getcustomnavlink_.md new file mode 100644 index 00000000000000..64805eefbfea1f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.getcustomnavlink_.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [getCustomNavLink$](./kibana-plugin-core-public.chromestart.getcustomnavlink_.md) + +## ChromeStart.getCustomNavLink$() method + +Get an observable of the current custom nav link + +Signature: + +```typescript +getCustomNavLink$(): Observable | undefined>; +``` +Returns: + +`Observable | undefined>` + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index b4eadc93fe78d3..e983ad50d2afe5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -55,6 +55,7 @@ core.chrome.setHelpExtension(elem => { | [getBadge$()](./kibana-plugin-core-public.chromestart.getbadge_.md) | Get an observable of the current badge | | [getBrand$()](./kibana-plugin-core-public.chromestart.getbrand_.md) | Get an observable of the current brand information. | | [getBreadcrumbs$()](./kibana-plugin-core-public.chromestart.getbreadcrumbs_.md) | Get an observable of the current list of breadcrumbs | +| [getCustomNavLink$()](./kibana-plugin-core-public.chromestart.getcustomnavlink_.md) | Get an observable of the current custom nav link | | [getHelpExtension$()](./kibana-plugin-core-public.chromestart.gethelpextension_.md) | Get an observable of the current custom help conttent | | [getIsNavDrawerLocked$()](./kibana-plugin-core-public.chromestart.getisnavdrawerlocked_.md) | Get an observable of the current locked state of the nav drawer. | | [getIsVisible$()](./kibana-plugin-core-public.chromestart.getisvisible_.md) | Get an observable of the current visibility state of the chrome. | @@ -64,6 +65,7 @@ core.chrome.setHelpExtension(elem => { | [setBadge(badge)](./kibana-plugin-core-public.chromestart.setbadge.md) | Override the current badge | | [setBrand(brand)](./kibana-plugin-core-public.chromestart.setbrand.md) | Set the brand configuration. | | [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs | +| [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link | | [setHelpExtension(helpExtension)](./kibana-plugin-core-public.chromestart.sethelpextension.md) | Override the current set of custom help content | | [setHelpSupportUrl(url)](./kibana-plugin-core-public.chromestart.sethelpsupporturl.md) | Override the default support URL shown in the help menu | | [setIsVisible(isVisible)](./kibana-plugin-core-public.chromestart.setisvisible.md) | Set the temporary visibility for the chrome. This does nothing if the chrome is hidden by default and should be used to hide the chrome for things like full-screen modes with an exit button. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setcustomnavlink.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setcustomnavlink.md new file mode 100644 index 00000000000000..adfb57f9c5ff25 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.setcustomnavlink.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setCustomNavLink](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) + +## ChromeStart.setCustomNavLink() method + +Override the current set of custom nav link + +Signature: + +```typescript +setCustomNavLink(newCustomNavLink?: Partial): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| newCustomNavLink | Partial<ChromeNavLink> | | + +Returns: + +`void` + diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.doclinks.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.doclinks.md deleted file mode 100644 index b239319c427fe4..00000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.doclinks.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [docLinks](./kibana-plugin-core-public.coresetup.doclinks.md) - -## CoreSetup.docLinks property - -[DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) - -Signature: - -```typescript -docLinks: DocLinksSetup; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index 4f981b5a40139f..870fa33dce9000 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -18,7 +18,6 @@ export interface CoreSetupApplicationSetup | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [context](./kibana-plugin-core-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | -| [docLinks](./kibana-plugin-core-public.coresetup.doclinks.md) | DocLinksSetup | [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) | | [fatalErrors](./kibana-plugin-core-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | | [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart, TStart> | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | | [http](./kibana-plugin-core-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.doc_link_version.md b/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.doc_link_version.md deleted file mode 100644 index c8d13bab92b058..00000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.doc_link_version.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) > [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinkssetup.doc_link_version.md) - -## DocLinksSetup.DOC\_LINK\_VERSION property - -Signature: - -```typescript -readonly DOC_LINK_VERSION: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.elastic_website_url.md b/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.elastic_website_url.md deleted file mode 100644 index d8493148bae107..00000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.elastic_website_url.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) > [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinkssetup.elastic_website_url.md) - -## DocLinksSetup.ELASTIC\_WEBSITE\_URL property - -Signature: - -```typescript -readonly ELASTIC_WEBSITE_URL: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.md b/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.md deleted file mode 100644 index 9e7938bd9c8507..00000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) - -## DocLinksSetup interface - - -Signature: - -```typescript -export interface DocLinksSetup -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinkssetup.doc_link_version.md) | string | | -| [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinkssetup.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinkssetup.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
} | | - diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.doc_link_version.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.doc_link_version.md new file mode 100644 index 00000000000000..8140b3fcf380f9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.doc_link_version.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) > [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) + +## DocLinksStart.DOC\_LINK\_VERSION property + +Signature: + +```typescript +readonly DOC_LINK_VERSION: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.elastic_website_url.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.elastic_website_url.md new file mode 100644 index 00000000000000..af770ed3055aad --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.elastic_website_url.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) > [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) + +## DocLinksStart.ELASTIC\_WEBSITE\_URL property + +Signature: + +```typescript +readonly ELASTIC_WEBSITE_URL: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md similarity index 94% rename from docs/development/core/public/kibana-plugin-core-public.doclinkssetup.links.md rename to docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 80e2702451d861..a03b1b74fc1ac2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinkssetup.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) > [links](./kibana-plugin-core-public.doclinkssetup.links.md) +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) > [links](./kibana-plugin-core-public.doclinksstart.links.md) -## DocLinksSetup.links property +## DocLinksStart.links property Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index af2a41b691727d..8f739950d249b9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -2,11 +2,20 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) -## DocLinksStart type +## DocLinksStart interface Signature: ```typescript -export declare type DocLinksStart = DocLinksSetup; +export interface DocLinksStart ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | +| [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
} | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index dda6b6ac0c60a3..b0612ff4d5b65f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -65,7 +65,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | | [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | -| [DocLinksSetup](./kibana-plugin-core-public.doclinkssetup.md) | | +| [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [EnvironmentMode](./kibana-plugin-core-public.environmentmode.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | @@ -157,7 +157,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) | | | [ChromeHelpExtensionMenuLink](./kibana-plugin-core-public.chromehelpextensionmenulink.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-core-public.chromenavlinkupdateablefields.md) | | -| [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [Freezable](./kibana-plugin-core-public.freezable.md) | | | [HandlerContextType](./kibana-plugin-core-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-core-public.handlerfunction.md) to represent the type of the context. | diff --git a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md new file mode 100644 index 00000000000000..9c70e658014b3a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppenderConfigType](./kibana-plugin-core-server.appenderconfigtype.md) + +## AppenderConfigType type + + +Signature: + +```typescript +export declare type AppenderConfigType = TypeOf; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.logging.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.logging.md new file mode 100644 index 00000000000000..12fe49e65d9cad --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.logging.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [logging](./kibana-plugin-core-server.coresetup.logging.md) + +## CoreSetup.logging property + +[LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) + +Signature: + +```typescript +logging: LoggingServiceSetup; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 30c054345928bf..e9ed5b830b6918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -21,6 +21,7 @@ export interface CoreSetupElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | | [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart, TStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup & {
resources: HttpResources;
} | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | +| [logging](./kibana-plugin-core-server.coresetup.logging.md) | LoggingServiceSetup | [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [status](./kibana-plugin-core-server.coresetup.status.md) | StatusServiceSetup | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.loggerconfigtype.md b/docs/development/core/server/kibana-plugin-core-server.loggerconfigtype.md new file mode 100644 index 00000000000000..c389b7e6279954 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggerconfigtype.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggerConfigType](./kibana-plugin-core-server.loggerconfigtype.md) + +## LoggerConfigType type + + +Signature: + +```typescript +export declare type LoggerConfigType = TypeOf; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.appenders.md b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.appenders.md new file mode 100644 index 00000000000000..486a5543473ea6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.appenders.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggerContextConfigInput](./kibana-plugin-core-server.loggercontextconfiginput.md) > [appenders](./kibana-plugin-core-server.loggercontextconfiginput.appenders.md) + +## LoggerContextConfigInput.appenders property + +Signature: + +```typescript +appenders?: Record | Map; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.loggers.md b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.loggers.md new file mode 100644 index 00000000000000..64d31f7d55045b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.loggers.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggerContextConfigInput](./kibana-plugin-core-server.loggercontextconfiginput.md) > [loggers](./kibana-plugin-core-server.loggercontextconfiginput.loggers.md) + +## LoggerContextConfigInput.loggers property + +Signature: + +```typescript +loggers?: LoggerConfigType[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.md b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.md new file mode 100644 index 00000000000000..fb6922d839cb85 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggercontextconfiginput.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggerContextConfigInput](./kibana-plugin-core-server.loggercontextconfiginput.md) + +## LoggerContextConfigInput interface + + +Signature: + +```typescript +export interface LoggerContextConfigInput +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [appenders](./kibana-plugin-core-server.loggercontextconfiginput.appenders.md) | Record<string, AppenderConfigType> | Map<string, AppenderConfigType> | | +| [loggers](./kibana-plugin-core-server.loggercontextconfiginput.loggers.md) | LoggerConfigType[] | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md new file mode 100644 index 00000000000000..04a3cf9aff6448 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md @@ -0,0 +1,42 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) > [configure](./kibana-plugin-core-server.loggingservicesetup.configure.md) + +## LoggingServiceSetup.configure() method + +Customizes the logging config for the plugin's context. + +Signature: + +```typescript +configure(config$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| config$ | Observable<LoggerContextConfigInput> | | + +Returns: + +`void` + +## Remarks + +Assumes that that the `context` property of the individual `logger` items emitted by `config$` are relative to the plugin's logging context (defaults to `plugins.`). + +## Example + +Customize the configuration for the plugins.data.search context. + +```ts +core.logging.configure( + of({ + appenders: new Map(), + loggers: [{ context: 'search', appenders: ['default'] }] + }) +) + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.md new file mode 100644 index 00000000000000..010438ce28803a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) + +## LoggingServiceSetup interface + +Provides APIs to plugins for customizing the plugin's logger. + +Signature: + +```typescript +export interface LoggingServiceSetup +``` + +## Methods + +| Method | Description | +| --- | --- | +| [configure(config$)](./kibana-plugin-core-server.loggingservicesetup.configure.md) | Customizes the logging config for the plugin's context. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 0f1bbbe7176e50..29c340bc390f28 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -108,7 +108,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LegacyServiceSetupDeps](./kibana-plugin-core-server.legacyservicesetupdeps.md) | | | [LegacyServiceStartDeps](./kibana-plugin-core-server.legacyservicestartdeps.md) | | | [Logger](./kibana-plugin-core-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | +| [LoggerContextConfigInput](./kibana-plugin-core-server.loggercontextconfiginput.md) | | | [LoggerFactory](./kibana-plugin-core-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | +| [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) | Provides APIs to plugins for customizing the plugin's logger. | | [LogMeta](./kibana-plugin-core-server.logmeta.md) | Contextual metadata | | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [NodesVersionCompatibility](./kibana-plugin-core-server.nodesversioncompatibility.md) | | @@ -148,7 +150,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkUpdateResponse](./kibana-plugin-core-server.savedobjectsbulkupdateresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | -| [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | +| [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation.Note: this type intentially doesn't include a type definition for defining the dynamic mapping parameter. Saved Object fields should always inherit the dynamic: 'strict' paramater. If you are unsure of the shape of your data use type: 'object', enabled: false instead. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | | [SavedObjectsDeleteByNamespaceOptions](./kibana-plugin-core-server.savedobjectsdeletebynamespaceoptions.md) | | @@ -209,6 +211,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Type Alias | Description | | --- | --- | +| [AppenderConfigType](./kibana-plugin-core-server.appenderconfigtype.md) | | | [AuthenticationHandler](./kibana-plugin-core-server.authenticationhandler.md) | See [AuthToolkit](./kibana-plugin-core-server.authtoolkit.md). | | [AuthHeaders](./kibana-plugin-core-server.authheaders.md) | Auth Headers map | | [AuthResult](./kibana-plugin-core-server.authresult.md) | | @@ -242,6 +245,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) | Creates an object containing request response payload, HTTP headers, error details, and other data transmitted to the client. | | [KnownHeaders](./kibana-plugin-core-server.knownheaders.md) | Set of well-known HTTP headers. | | [LifecycleResponseFactory](./kibana-plugin-core-server.lifecycleresponsefactory.md) | Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client. | +| [LoggerConfigType](./kibana-plugin-core-server.loggerconfigtype.md) | | | [MIGRATION\_ASSISTANCE\_INDEX\_ACTION](./kibana-plugin-core-server.migration_assistance_index_action.md) | | | [MIGRATION\_DEPRECATION\_LEVEL](./kibana-plugin-core-server.migration_deprecation_level.md) | | | [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.dynamic.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.dynamic.md deleted file mode 100644 index e63e543e68d517..00000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.dynamic.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) > [dynamic](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.dynamic.md) - -## SavedObjectsComplexFieldMapping.dynamic property - -Signature: - -```typescript -dynamic?: string; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md index 60e62212609d9f..a7d13b0015e3fd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md @@ -6,6 +6,8 @@ See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. +Note: this type intentially doesn't include a type definition for defining the `dynamic` mapping parameter. Saved Object fields should always inherit the `dynamic: 'strict'` paramater. If you are unsure of the shape of your data use `type: 'object', enabled: false` instead. + Signature: ```typescript @@ -16,7 +18,6 @@ export interface SavedObjectsComplexFieldMapping | Property | Type | Description | | --- | --- | --- | -| [dynamic](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.dynamic.md) | string | | | [properties](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.properties.md) | SavedObjectsMappingProperties | | | [type](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.type.md) | string | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md index d39871b99f7447..b51421741933a8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md @@ -10,7 +10,6 @@ fieldFormats: { FieldFormat: typeof FieldFormat; FieldFormatsRegistry: typeof FieldFormatsRegistry; - serialize: (agg: import("./search").AggConfig) => import("../../expressions").SerializedFieldFormat; DEFAULT_CONVERTER_COLOR: { range: string; regex: string; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.md index be6af335f20cd5..6f42fb32fdb7be 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.md @@ -28,6 +28,7 @@ export interface IFieldType | [searchable](./kibana-plugin-plugins-data-public.ifieldtype.searchable.md) | boolean | | | [sortable](./kibana-plugin-plugins-data-public.ifieldtype.sortable.md) | boolean | | | [subType](./kibana-plugin-plugins-data-public.ifieldtype.subtype.md) | IFieldSubType | | +| [toSpec](./kibana-plugin-plugins-data-public.ifieldtype.tospec.md) | () => FieldSpec | | | [type](./kibana-plugin-plugins-data-public.ifieldtype.type.md) | string | | | [visualizable](./kibana-plugin-plugins-data-public.ifieldtype.visualizable.md) | boolean | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.tospec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.tospec.md new file mode 100644 index 00000000000000..1fb4084c25d343 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldtype.tospec.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IFieldType](./kibana-plugin-plugins-data-public.ifieldtype.md) > [toSpec](./kibana-plugin-plugins-data-public.ifieldtype.tospec.md) + +## IFieldType.toSpec property + +Signature: + +```typescript +toSpec?: () => FieldSpec; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md index 9a93148e4a466f..d4dca48c7cd7b4 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md @@ -7,5 +7,7 @@ Signature: ```typescript -fields: IIndexPatternFieldList; +fields: IIndexPatternFieldList & { + toSpec: () => FieldSpec[]; + }; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.initfromspec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.initfromspec.md new file mode 100644 index 00000000000000..764dd116382217 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.initfromspec.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [initFromSpec](./kibana-plugin-plugins-data-public.indexpattern.initfromspec.md) + +## IndexPattern.initFromSpec() method + +Signature: + +```typescript +initFromSpec(spec: IndexPatternSpec): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| spec | IndexPatternSpec | | + +Returns: + +`this` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index 8ffa7b6b36f56b..d39b384c538f1b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -21,7 +21,7 @@ export declare class IndexPattern implements IIndexPattern | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [fieldFormatMap](./kibana-plugin-plugins-data-public.indexpattern.fieldformatmap.md) | | any | | -| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | IIndexPatternFieldList | | +| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | IIndexPatternFieldList & {
toSpec: () => FieldSpec[];
} | | | [fieldsFetcher](./kibana-plugin-plugins-data-public.indexpattern.fieldsfetcher.md) | | any | | | [flattenHit](./kibana-plugin-plugins-data-public.indexpattern.flattenhit.md) | | any | | | [formatField](./kibana-plugin-plugins-data-public.indexpattern.formatfield.md) | | any | | @@ -30,7 +30,6 @@ export declare class IndexPattern implements IIndexPattern | [metaFields](./kibana-plugin-plugins-data-public.indexpattern.metafields.md) | | string[] | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpattern.timefieldname.md) | | string | undefined | | | [title](./kibana-plugin-plugins-data-public.indexpattern.title.md) | | string | | -| [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) | | string | | | [typeMeta](./kibana-plugin-plugins-data-public.indexpattern.typemeta.md) | | TypeMeta | | ## 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) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.tospec.md similarity index 53% rename from docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md rename to docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.tospec.md index 58047d9e27ac63..d1a78eea660cea 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.type.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.tospec.md @@ -1,11 +1,15 @@ -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [toSpec](./kibana-plugin-plugins-data-public.indexpattern.tospec.md) -## IndexPattern.type property +## IndexPattern.toSpec() method Signature: ```typescript -type?: string; +toSpec(): IndexPatternSpec; ``` +Returns: + +`IndexPatternSpec` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md index e1e0d58ce38c10..7a195702b6f13f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `Field` class Signature: ```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 | IIndexPattern | | -| spec | FieldSpec | Field | | +| spec | FieldSpecExportFmt | FieldSpec | Field | | | shortDotsEnable | boolean | | | { fieldFormats, onNotification } | FieldDependencies | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md index ca2552aeb1b425..ec19a4854bf0e6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md @@ -7,5 +7,5 @@ Signature: ```typescript -conflictDescriptions?: Record; +conflictDescriptions?: FieldSpecConflictDescriptions; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md index 8fa1ee0d72e54d..d82999e7a96af4 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md @@ -22,7 +22,7 @@ export declare class Field implements IFieldType | --- | --- | --- | --- | | [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) | | FieldSpec | | | [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) | | boolean | | -| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | Record<string, string[]> | | +| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | FieldSpecConflictDescriptions | | | [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) | | number | | | [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) | | string | | | [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | string[] | | @@ -37,6 +37,7 @@ export declare class Field implements IFieldType | [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | boolean | | | [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) | | boolean | | | [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) | | IFieldSubType | | +| [toSpec](./kibana-plugin-plugins-data-public.indexpatternfield.tospec.md) | | () => FieldSpecExportFmt | | | [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) | | string | | | [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) | | boolean | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.tospec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.tospec.md new file mode 100644 index 00000000000000..35714faa03bc9a --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.tospec.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [toSpec](./kibana-plugin-plugins-data-public.indexpatternfield.tospec.md) + +## IndexPatternField.toSpec property + +Signature: + +```typescript +toSpec: () => FieldSpecExportFmt; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md index 11f18a195d2716..45fc1a608e8ca3 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md @@ -10,7 +10,6 @@ fieldFormats: { FieldFormatsRegistry: typeof FieldFormatsRegistry; FieldFormat: typeof FieldFormat; - serializeFieldFormat: (agg: import("../public/search").AggConfig) => import("../../expressions").SerializedFieldFormat; BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.md index 5375cf2a2ef431..77a2954428f8d4 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.md @@ -28,6 +28,7 @@ export interface IFieldType | [searchable](./kibana-plugin-plugins-data-server.ifieldtype.searchable.md) | boolean | | | [sortable](./kibana-plugin-plugins-data-server.ifieldtype.sortable.md) | boolean | | | [subType](./kibana-plugin-plugins-data-server.ifieldtype.subtype.md) | IFieldSubType | | +| [toSpec](./kibana-plugin-plugins-data-server.ifieldtype.tospec.md) | () => FieldSpec | | | [type](./kibana-plugin-plugins-data-server.ifieldtype.type.md) | string | | | [visualizable](./kibana-plugin-plugins-data-server.ifieldtype.visualizable.md) | boolean | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.tospec.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.tospec.md new file mode 100644 index 00000000000000..d1863bebce4f00 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldtype.tospec.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IFieldType](./kibana-plugin-plugins-data-server.ifieldtype.md) > [toSpec](./kibana-plugin-plugins-data-server.ifieldtype.tospec.md) + +## IFieldType.toSpec property + +Signature: + +```typescript +toSpec?: () => FieldSpec; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md index 13c69d6bf7548e..a6fdfdf6891c86 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md @@ -10,7 +10,7 @@ setup(core: CoreSetup, { usageCollection }: DataPluginSetupDependencies): { search: ISearchSetup; fieldFormats: { - register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; + register: (customFieldFormat: import("../common").FieldFormatInstanceType) => number; }; }; ``` @@ -27,7 +27,7 @@ setup(core: CoreSetup, { usageCollection }: DataPluginS `{ search: ISearchSetup; fieldFormats: { - register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; + register: (customFieldFormat: import("../common").FieldFormatInstanceType) => number; }; }` diff --git a/docs/images/data-viz-homepage.jpg b/docs/images/data-viz-homepage.jpg new file mode 100644 index 00000000000000..f7a952b65d41f4 Binary files /dev/null and b/docs/images/data-viz-homepage.jpg differ diff --git a/docs/setup/connect-to-elasticsearch.asciidoc b/docs/setup/connect-to-elasticsearch.asciidoc index 8c04167de12361..bffb3f97cd1b9c 100644 --- a/docs/setup/connect-to-elasticsearch.asciidoc +++ b/docs/setup/connect-to-elasticsearch.asciidoc @@ -18,10 +18,16 @@ to see all that you can do in {kib}. [[upload-data-kibana]] === Upload a CSV, JSON, or log file -To visualize data in a CSV, JSON, or log file, you can -upload it using the File Data Visualizer. On the home page, -click *Import a CSV, NDSON, or log file*, and then drag your file into the -File Data Visualizer. +experimental[] + +To visualize data in a CSV, JSON, or log file, you can upload it using the File +Data Visualizer. On the home page, click *Import a CSV, NDSON, or log file*, and +then drag your file into the File Data Visualizer. Alternatively, you can open +it by navigating to *Machine Learning* from the side navigation and selecting +*Data Visualizer*. + +[role="screenshot"] +image::images/data-viz-homepage.jpg[File Data Visualizer on the home page] You can upload a file up to 100 MB. This value is configurable up to 1 GB in <>. diff --git a/package.json b/package.json index 3eaa1fb05e906e..10eaef8ed5dc74 100644 --- a/package.json +++ b/package.json @@ -406,7 +406,7 @@ "babel-eslint": "^10.0.3", "babel-jest": "^25.5.1", "babel-plugin-istanbul": "^6.0.0", - "backport": "5.4.1", + "backport": "5.4.6", "chai": "3.5.0", "chance": "1.0.18", "cheerio": "0.22.0", diff --git a/packages/kbn-monaco/src/xjson/grammar.ts b/packages/kbn-monaco/src/xjson/grammar.ts index e95059f9ece2dd..fbd7b3d319c1de 100644 --- a/packages/kbn-monaco/src/xjson/grammar.ts +++ b/packages/kbn-monaco/src/xjson/grammar.ts @@ -200,12 +200,13 @@ export const createParser = () => { try { value(); + white(); } catch (e) { errored = true; annos.push({ type: AnnoTypes.error, at: e.at - 1, text: e.message }); } if (!errored && ch) { - error('Syntax error'); + annos.push({ type: AnnoTypes.error, at: at, text: 'Syntax Error' }); } return { annotations: annos }; } diff --git a/packages/kbn-storybook/lib/webpack.dll.config.js b/packages/kbn-storybook/lib/webpack.dll.config.js index bc871fab471b28..534f503e2956a7 100644 --- a/packages/kbn-storybook/lib/webpack.dll.config.js +++ b/packages/kbn-storybook/lib/webpack.dll.config.js @@ -73,7 +73,6 @@ module.exports = { 'rxjs', 'sinon', 'tinycolor2', - './src/legacy/ui/public/styles/font_awesome.less', './src/legacy/ui/public/styles/bootstrap/bootstrap_light.less', ], plugins: [ diff --git a/scripts/backport.js b/scripts/backport.js index 64cd5721834ead..2094534e2c4b39 100644 --- a/scripts/backport.js +++ b/scripts/backport.js @@ -18,4 +18,5 @@ */ require('../src/setup_node_env/node_version_validator'); -require('backport'); +var backport = require('backport'); +backport.run(); diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index ce2d652257e1f0..90b1bb4fd5320d 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -15,15 +15,15 @@ - [Switch to new platform services](#switch-to-new-platform-services) - [Migrate to the new plugin system](#migrate-to-the-new-plugin-system) - [Browser-side plan of action](#browser-side-plan-of-action) - - [1. Create a plugin definition file](#1-create-a-plugin-definition-file) - - [2. Export all static code and types from `public/index.ts`](#2-export-all-static-code-and-types-from-publicindexts) - - [3. Export your runtime contract](#3-export-your-runtime-contract) - - [4. Move "owned" UI modules into your plugin and expose them from your public contract](#4-move-owned-ui-modules-into-your-plugin-and-expose-them-from-your-public-contract) - - [5. Provide plugin extension points decoupled from angular.js](#5-provide-plugin-extension-points-decoupled-from-angularjs) - - [6. Move all webpack alias imports into uiExport entry files](#6-move-all-webpack-alias-imports-into-uiexport-entry-files) - - [7. Switch to new platform services](#7-switch-to-new-platform-services) - - [8. Migrate to the new plugin system](#8-migrate-to-the-new-plugin-system) - - [Bonus: Tips for complex migration scenarios](#bonus-tips-for-complex-migration-scenarios) + - [1. Create a plugin definition file](#1-create-a-plugin-definition-file) + - [2. Export all static code and types from `public/index.ts`](#2-export-all-static-code-and-types-from-publicindexts) + - [3. Export your runtime contract](#3-export-your-runtime-contract) + - [4. Move "owned" UI modules into your plugin and expose them from your public contract](#4-move-owned-ui-modules-into-your-plugin-and-expose-them-from-your-public-contract) + - [5. Provide plugin extension points decoupled from angular.js](#5-provide-plugin-extension-points-decoupled-from-angularjs) + - [6. Move all webpack alias imports into uiExport entry files](#6-move-all-webpack-alias-imports-into-uiexport-entry-files) + - [7. Switch to new platform services](#7-switch-to-new-platform-services) + - [8. Migrate to the new plugin system](#8-migrate-to-the-new-plugin-system) + - [Bonus: Tips for complex migration scenarios](#bonus-tips-for-complex-migration-scenarios) - [Keep Kibana fast](#keep-kibana-fast) - [Frequently asked questions](#frequently-asked-questions) - [Is migrating a plugin an all-or-nothing thing?](#is-migrating-a-plugin-an-all-or-nothing-thing) @@ -59,6 +59,7 @@ - [On the client side](#on-the-client-side) - [Updates an application navlink at runtime](#updates-an-application-navlink-at-runtime) - [Logging config migration](#logging-config-migration) + - [Use HashRouter in migrated apps](#use-react-hashrouter-in-migrated-apps) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -100,6 +101,7 @@ src/plugins "ui": true } ``` + More details about[manifest file format](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) Note that `package.json` files are irrelevant to and ignored by the new platform. @@ -121,8 +123,7 @@ export function plugin(initializerContext: PluginInitializerContext) { import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; export class Plugin { - constructor(initializerContext: PluginInitializerContext) { - } + constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup) { // called when plugin is setting up @@ -155,8 +156,7 @@ export function plugin(initializerContext: PluginInitializerContext) { import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; export class Plugin { - constructor(initializerContext: PluginInitializerContext) { - } + constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup) { // called when plugin is setting up during Kibana's startup sequence @@ -234,7 +234,7 @@ export class Plugin { return { getFoo() { return 'foo'; - } + }, }; } @@ -242,8 +242,8 @@ export class Plugin { return { getBar() { return 'bar'; - } - } + }, + }; } } ``` @@ -255,9 +255,7 @@ Unlike core, capabilities exposed by plugins are _not_ automatically injected in ```json { "id": "demo", - "requiredPlugins": [ - "foobar" - ], + "requiredPlugins": ["foobar"], "server": true, "ui": true } @@ -376,15 +374,15 @@ export default (kibana) => { method: 'POST', async handler(request) { search(server, request); // target acquired - } + }, }); server.expose('getDemoBar', () => { return `Demo ${server.plugins.foo.getBar()}`; }); - } + }, }); -} +}; ``` This example legacy plugin uses hapi's `server` object directly inside of its `init` function, which is something we can address in a later step. What we need to address in this step is when we pass the raw `server` and `request` objects into our custom `search` function. @@ -397,11 +395,10 @@ Instead, we identify which functionality we actually need from those objects and import { ElasticsearchPlugin, Request } from '../elasticsearch'; export interface ServerFacade { plugins: { - elasticsearch: ElasticsearchPlugin - } -} -export interface RequestFacade extends Request { + elasticsearch: ElasticsearchPlugin; + }; } +export interface RequestFacade extends Request {} // likely imported from another file function search(server: ServerFacade, request: RequestFacade) { @@ -416,27 +413,27 @@ export default (kibana) => { init(server) { const serverFacade: ServerFacade = { plugins: { - elasticsearch: server.plugins.elasticsearch - } - } + elasticsearch: server.plugins.elasticsearch, + }, + }; server.route({ path: '/api/demo_plugin/search', method: 'POST', async handler(request) { const requestFacade: RequestFacade = { - headers: request.headers + headers: request.headers, }; search(serverFacade, requestFacade); - } + }, }); server.expose('getDemoBar', () => { return `Demo ${server.plugins.foo.getBar()}`; }); - } + }, }); -} +}; ``` This change might seem trivial, but it's important for two reasons. @@ -459,9 +456,9 @@ export default (kibana) => { init(server) { const serverFacade: ServerFacade = { plugins: { - elasticsearch: server.plugins.elasticsearch - } - } + elasticsearch: server.plugins.elasticsearch, + }, + }; // HTTP functionality from legacy server.route({ @@ -469,19 +466,19 @@ export default (kibana) => { method: 'POST', async handler(request) { const requestFacade: RequestFacade = { - headers: request.headers + headers: request.headers, }; search(serverFacade, requestFacade); - } + }, }); // Exposing functionality for other plugins server.expose('getDemoBar', () => { return `Demo ${server.plugins.foo.getBar()}`; // Accessing functionality from another plugin }); - } + }, }); -} +}; ``` We now move this logic into a new plugin definition, which is based off of the conventions used in real new platform plugins. While the legacy plugin definition is in the root of the plugin, this new plugin definition will be under the plugin's `server/` directory since it is only the server-side plugin definition. @@ -492,18 +489,18 @@ import { CoreSetup, Plugin } from 'src/core/server'; import { ElasticsearchPlugin } from '../elasticsearch'; interface FooSetup { - getBar(): string + getBar(): string; } // We inject the miminal legacy dependencies into our plugin including dependencies on other legacy // plugins. Take care to only expose the legacy functionality you need e.g. don't inject the whole // `Legacy.Server` if you only depend on `Legacy.Server['route']`. interface LegacySetup { - route: Legacy.Server['route'] + route: Legacy.Server['route']; plugins: { - elasticsearch: ElasticsearchPlugin, // note: Elasticsearch is in CoreSetup in NP, rather than a plugin - foo: FooSetup - } + elasticsearch: ElasticsearchPlugin; // note: Elasticsearch is in CoreSetup in NP, rather than a plugin + foo: FooSetup; + }; } // Define the public API's for our plugins setup and start lifecycle @@ -524,26 +521,26 @@ export class DemoPlugin implements Plugin { route: server.route, plugins: { elasticsearch: server.plugins.elasticsearch, - foo: server.plugins.foo - } + foo: server.plugins.foo, + }, }; const demoSetup = new Plugin().setup(coreSetup, pluginsSetup, __LEGACY); // continue to expose functionality to legacy plugins server.expose('getDemoBar', demoSetup.getDemoBar); - } + }, }); -} +}; ``` > Note: An equally valid approach is to extend `CoreSetup` with a `__legacy` @@ -728,8 +725,8 @@ export interface DemoStartDeps { * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. * @public */ -export type DemoSetup = {} -export type DemoStart = {} +export type DemoSetup = {}; +export type DemoStart = {}; /** @internal */ export class DemoPlugin implements Plugin { @@ -762,15 +759,10 @@ import { DemoSetup, DemoStart } from './plugin'; const myPureFn = (x: number): number => x + 1; const MyReactComponent = (props) => { return

Hello, {props.name}

; -} +}; // These are your public types & static code -export { - myPureFn, - MyReactComponent, - DemoSetup, - DemoStart, -} +export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; ``` While you're at it, you can also add your plugin initializer to this file: @@ -790,15 +782,10 @@ export const plugin: PluginInitializer x + 1; const MyReactComponent = (props) => { return

Hello, {props.name}

; -} +}; /** @public */ -export { - myPureFn, - MyReactComponent, - DemoSetup, - DemoStart, -} +export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; ``` Great! So you have your plugin definition, and you've moved all of your static exports to the top level of your plugin... now let's move on to the runtime contract your plugin will be exposing. @@ -819,12 +806,12 @@ import { setup as fooSetup, start as fooStart } from '../../foo/public/legacy'; const pluginInstance = plugin({} as PluginInitializerContext); const __LEGACYSetup = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooSetup, // dependency on a legacy plugin + bar: {}, // shim for a core service that hasn't migrated yet + foo: fooSetup, // dependency on a legacy plugin }; const __LEGACYStart = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooStart, // dependency on a legacy plugin + bar: {}, // shim for a core service that hasn't migrated yet + foo: fooStart, // dependency on a legacy plugin }; export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins, __LEGACYSetup); @@ -935,6 +922,7 @@ For a few plugins, some of these steps (such as angular removal) could be a mont One convention that is useful for this is creating a dedicated `public/np_ready` directory to house the code that is ready to migrate, and gradually move more and more code into it until the rest of your plugin is essentially empty. At that point, you'll be able to copy your `index.ts`, `plugin.ts`, and the contents of `./np_ready` over into your plugin in the new platform, leaving your legacy shim behind. This carries the added benefit of providing a way for us to introduce helpful tooling in the future, such as [custom eslint rules](https://github.com/elastic/kibana/pull/40537), which could be run against that specific directory to ensure your code is ready to migrate. ## Keep Kibana fast + **tl;dr**: Load as much code lazily as possible. Everyone loves snappy applications with responsive UI and hates spinners. Users deserve the best user experiences regardless of whether they run Kibana locally or in the cloud, regardless of their hardware & environment. There are 2 main aspects of the perceived speed of an application: loading time and responsiveness to user actions. @@ -945,16 +933,16 @@ Always prefer to require UI root components lazily when possible (such as in mou ```typescript import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; export class MyPlugin implements Plugin { - setup(core: CoreSetup, plugins: SetupDeps){ - core.application.register({ - id: 'app', - title: 'My app', - async mount(params: AppMountParameters) { + setup(core: CoreSetup, plugins: SetupDeps) { + core.application.register({ + id: 'app', + title: 'My app', + async mount(params: AppMountParameters) { const { mountApp } = await import('./app/mount_app'); return mountApp(await core.getStartServices(), params); - }, - }); - plugins.management.sections.getSection('another').registerApp({ + }, + }); + plugins.management.sections.getSection('another').registerApp({ id: 'app', title: 'My app', order: 1, @@ -962,23 +950,27 @@ export class MyPlugin implements Plugin { const { mountManagementSection } = await import('./app/mount_management_section'); return mountManagementSection(coreSetup, params); }, - }) - return { - doSomething(){} - } - } + }); + return { + doSomething() {}, + }; + } } ``` #### How to understand how big the bundle size of my plugin is? + New platform plugins are distributed as a pre-built with `@kbn/optimizer` package artifacts. It allows us to get rid of the shipping of `optimizer` in the distributable version of Kibana. Every NP plugin artifact contains all plugin dependencies required to run the plugin, except some stateful dependencies shared across plugin bundles via `@kbn/ui-shared-deps`. It means that NP plugin artifacts tend to have a bigger size than the legacy platform version. To understand the current size of your plugin artifact, run `@kbn/optimizer` as + ```bash node scripts/build_kibana_platform_plugins.js --dist --no-examples ``` + and check the output in the `target` sub-folder of your plugin folder + ```bash ls -lh plugins/my_plugin/target/public/ # output @@ -987,13 +979,17 @@ ls -lh plugins/my_plugin/target/public/ # eagerly loaded chunk ... 50K my_plugin.plugin.js ``` + you might see at least one js bundle - `my_plugin.plugin.js`. This is the only artifact loaded by the platform during bootstrap in the browser. The rule of thumb is to keep its size as small as possible. Other lazily loaded parts of your plugin present in the same folder as separate chunks under `{number}.plugin.js` names. If you want to investigate what your plugin bundle consists of you need to run `@kbn/optimizer` with `--profile` flag to get generated [webpack stats file](https://webpack.js.org/api/stats/). + ```bash node scripts/build_kibana_platform_plugins.js --dist --no-examples --profile ``` + Many OSS tools are allowing you to analyze generated stats file + - [an official tool](http://webpack.github.io/analyse/#modules) from webpack authors - [webpack-visualizer](https://chrisbateman.github.io/webpack-visualizer/) @@ -1074,11 +1070,12 @@ Examples of code that could be shared statically: Examples of code that could **not** be shared statically and how to fix it: - A function that calls a Core service, but does not take that service as a parameter. + - If the function does not take a client as an argument, it must have an instance of the client in its internal state, populated by your plugin. This would not work across plugin boundaries because your plugin would not be able to call `setClient` in the copy of this module in other plugins: ```js let esClient; - export const setClient = (client) => esClient = client; + export const setClient = (client) => (esClient = client); export const query = (params) => esClient.search(params); ``` @@ -1089,6 +1086,7 @@ Examples of code that could **not** be shared statically and how to fix it: ``` - A function that allows other plugins to register values that get pushed into an array defined internally to the module. + - The values registered would only be visible to the plugin that imported it. Each plugin would essentially have their own registry of visTypes that is not visible to any other plugins. ```js @@ -1101,17 +1099,19 @@ Examples of code that could **not** be shared statically and how to fix it: ```js class MyPlugin { - constructor() { this.visTypes = [] } + constructor() { + this.visTypes = []; + } setup() { return { - registerVisType: (visType) => this.visTypes.push(visType) - } + registerVisType: (visType) => this.visTypes.push(visType), + }; } start() { return { - getVisTypes: () => this.visTypes - } + getVisTypes: () => this.visTypes, + }; } } ``` @@ -1144,10 +1144,10 @@ If you have code that should be available to other plugins on both the client an There are some Core services that are purely presentational, for example `core.overlays.openModal()` or `core.application.createLink()` where UI code does need access to these deeply within your application. However, passing these services down as props throughout your application leads to lots of boilerplate. To avoid this, you have three options: 1. Use an abstraction layer, like Redux, to decouple your UI code from core (**this is the highly preferred option**); or - - [redux-thunk](https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument) and [redux-saga](https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions) already have ways to do this. + - [redux-thunk](https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument) and [redux-saga](https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions) already have ways to do this. 2. Use React Context to provide these services to large parts of your React tree; or 3. Create a high-order-component that injects core into a React component; or - - This would be a stateful module that holds a reference to Core, but provides it as props to components with a `withCore(MyComponent)` interface. This can make testing components simpler. (Note: this module cannot be shared across plugin boundaries, see above). + - This would be a stateful module that holds a reference to Core, but provides it as props to components with a `withCore(MyComponent)` interface. This can make testing components simpler. (Note: this module cannot be shared across plugin boundaries, see above). 4. Create a global singleton module that gets imported into each module that needs it. (Note: this module cannot be shared across plugin boundaries, see above). [Example](https://gist.github.com/epixa/06c8eeabd99da3c7545ab295e49acdc3). If you find that you need many different Core services throughout your application, this may be a code smell and could lead to pain down the road. For instance, if you need access to an HTTP Client or SavedObjectsClient in many places in your React tree, it's likely that a data layer abstraction (like Redux) could make developing your plugin much simpler (see option 1). @@ -1281,6 +1281,7 @@ In server code, `core` can be accessed from either `server.newPlatform` or `kbnS _See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-core-server.coresetup.md)_ ##### Plugin services + | Legacy Platform | New Platform | Notes | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | | `server.plugins.xpack_main.registerFeature` | [`plugins.features.registerFeature`](x-pack/plugins/features/server/plugin.ts) | | @@ -1332,6 +1333,7 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `visualize` | | | #### Plugin Spec + | Legacy Platform | New Platform | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | `id` | [`manifest.id`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | @@ -1427,6 +1429,7 @@ export class Plugin implements Plugin { ``` All plugins are considered enabled by default. If you want to disable your plugin by default, you could declare the `enabled` flag in plugin config. This is a special Kibana platform key. The platform reads its value and won't create a plugin instance if `enabled: false`. + ```js export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), @@ -1435,7 +1438,7 @@ export const config = { #### Handle plugin configuration deprecations -If your plugin have deprecated properties, you can describe them using the `deprecations` config descriptor field. +If your plugin have deprecated properties, you can describe them using the `deprecations` config descriptor field. The system is quite similar to the legacy plugin's deprecation management. The most important difference is that deprecations are managed on a per-plugin basis, meaning that you don't need to specify the whole @@ -1457,7 +1460,7 @@ export const config: PluginConfigDescriptor = { deprecations: ({ rename, unused }) => [ rename('oldProperty', 'newProperty'), unused('someUnusedProperty'), - ] + ], }; ``` @@ -1471,7 +1474,7 @@ export const config: PluginConfigDescriptor = { deprecations: ({ renameFromRoot, unusedFromRoot }) => [ renameFromRoot('oldplugin.property', 'myplugin.property'), unusedFromRoot('oldplugin.deprecated'), - ] + ], }; ``` @@ -1480,22 +1483,25 @@ During migration, if you still need the deprecations to be effective in the lega both plugin definitions. ### Use scoped services + Whenever Kibana needs to get access to data saved in elasticsearch, it should perform a check whether an end-user has access to the data. In the legacy platform, Kibana requires to bind elasticsearch related API with an incoming request to access elasticsearch service on behalf of a user. + ```js - async function handler(req, res) { - const dataCluster = server.plugins.elasticsearch.getCluster('data'); - const data = await dataCluster.callWithRequest(req, 'ping'); - } +async function handler(req, res) { + const dataCluster = server.plugins.elasticsearch.getCluster('data'); + const data = await dataCluster.callWithRequest(req, 'ping'); +} ``` The new platform introduced [a handler interface](/rfcs/text/0003_handler_interface.md) on the server-side to perform that association internally. Core services, that require impersonation with an incoming request, are exposed via `context` argument of [the request handler interface.](/docs/development/core/server/kibana-plugin-core-server.requesthandler.md) The above example looks in the new platform as + ```js - async function handler(context, req, res) { - const data = await context.core.elasticsearch.adminClient.callAsInternalUser('ping') - } +async function handler(context, req, res) { + const data = await context.core.elasticsearch.adminClient.callAsInternalUser('ping'); +} ``` The [request handler context](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md) exposed the next scoped **core** services: @@ -1508,6 +1514,7 @@ The [request handler context](/docs/development/core/server/kibana-plugin-core-s | `request.getUiSettingsService` | [`context.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | #### Declare a custom scoped service + Plugins can extend the handler context with custom API that will be available to the plugin itself and all dependent plugins. For example, the plugin creates a custom elasticsearch client and want to use it via the request handler context: @@ -1709,4 +1716,68 @@ export class MyPlugin implements Plugin { ``` ### Logging config migration -[Read](./server/logging/README.md#logging-config-migration) \ No newline at end of file + +[Read](./server/logging/README.md#logging-config-migration) + +### Use HashRouter in migrated apps + +Kibana applications are meant to be leveraging the `ScopedHistory` provided in an app's `mount` function to wire their router. For react, +this is done by using the `react-router-dom` `Router` component: + +```typescript +export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { + render( + + + + + + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + unlisten(); + }; +}; +``` + +Some legacy apps were using `react-router-dom`'s `HashRouter` instead. Using `HashRouter` in a migrated application will cause some route change +events to not be catched by the router, as the `BrowserHistory` used behind the provided scoped history does not emit +the `hashevent` that is required for the `HashRouter` to behave correctly. + +It is strictly recommended to migrate your application's routing to browser history, which is the only routing officially supported by the platform. + +However, during the transition period, it is possible to make the two histories cohabitate by manually emitting the required events from +the scoped to the hash history. You may use this workaround at your own risk. While we are not aware of any problems it currently creates, there may be edge cases that do not work properly. + +```typescript +export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { + render( + + + + + + + + , + element + ); + + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by the scoped history will not emit them. + const unlisten = history.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + return () => { + unmountComponentAtNode(element); + // unsubscribe to `history.listen` when unmounting. + unlisten(); + }; +}; +``` diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 95361d8287c716..d7f15decb255dd 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -201,6 +201,7 @@ export class ApplicationService { this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), + exactRoute: app.exactRoute ?? false, mount: wrapMount(plugin, app), unmountBeforeMounting: false, legacy: false, @@ -236,6 +237,7 @@ export class ApplicationService { this.mounters.set(app.id, { appRoute, appBasePath, + exactRoute: false, mount, unmountBeforeMounting: true, legacy: true, diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 2827b93f6d17e5..f992e121437a96 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -30,7 +30,6 @@ import { ScopedHistory } from '../scoped_history'; describe('AppRouter', () => { let mounters: MockedMounterMap; let globalHistory: History; - let appStatuses$: BehaviorSubject>; let update: ReturnType; let scopedAppHistory: History; @@ -53,6 +52,17 @@ describe('AppRouter', () => { ); }; + const createMountersRenderer = () => + createRenderer( + + ); + beforeEach(() => { mounters = new Map([ createAppMounter({ appId: 'app1', html: 'App 1' }), @@ -90,16 +100,7 @@ describe('AppRouter', () => { }), ] as Array>); globalHistory = createMemoryHistory(); - appStatuses$ = mountersToAppStatus$(); - update = createRenderer( - - ); + update = createMountersRenderer(); }); it('calls mount handler and returned unmount function when navigating between apps', async () => { @@ -220,15 +221,7 @@ describe('AppRouter', () => { }) ); globalHistory = createMemoryHistory(); - update = createRenderer( - - ); + update = createMountersRenderer(); await navigate('/fake-login'); @@ -252,15 +245,7 @@ describe('AppRouter', () => { }) ); globalHistory = createMemoryHistory(); - update = createRenderer( - - ); + update = createMountersRenderer(); await navigate('/spaces/fake-login'); @@ -268,6 +253,53 @@ describe('AppRouter', () => { expect(mounters.get('login')!.mounter.mount).not.toHaveBeenCalled(); }); + it('should mount an exact route app only when the path is an exact match', async () => { + mounters.set( + ...createAppMounter({ + appId: 'exactApp', + html: '
exact app
', + exactRoute: true, + appRoute: '/app/exact-app', + }) + ); + + globalHistory = createMemoryHistory(); + update = createMountersRenderer(); + + await navigate('/app/exact-app/some-path'); + + expect(mounters.get('exactApp')!.mounter.mount).not.toHaveBeenCalled(); + + await navigate('/app/exact-app'); + + expect(mounters.get('exactApp')!.mounter.mount).toHaveBeenCalledTimes(1); + }); + + it('should mount an an app with a route nested in an exact route app', async () => { + mounters.set( + ...createAppMounter({ + appId: 'exactApp', + html: '
exact app
', + exactRoute: true, + appRoute: '/app/exact-app', + }) + ); + mounters.set( + ...createAppMounter({ + appId: 'nestedApp', + html: '
nested app
', + appRoute: '/app/exact-app/another-app', + }) + ); + globalHistory = createMemoryHistory(); + update = createMountersRenderer(); + + await navigate('/app/exact-app/another-app'); + + expect(mounters.get('exactApp')!.mounter.mount).not.toHaveBeenCalled(); + expect(mounters.get('nestedApp')!.mounter.mount).toHaveBeenCalledTimes(1); + }); + it('should not remount when changing pages within app', async () => { const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); @@ -304,15 +336,7 @@ describe('AppRouter', () => { it('should not remount when when changing pages within app using hash history', async () => { globalHistory = createHashHistory(); - update = createRenderer( - - ); + update = createMountersRenderer(); const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index 8590fb3c820ef7..80a7fc2c2cad60 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -47,11 +47,13 @@ export const createAppMounter = ({ appId, html = `
App ${appId}
`, appRoute = `/app/${appId}`, + exactRoute = false, extraMountHook, }: { appId: string; html?: string; appRoute?: string; + exactRoute?: boolean; extraMountHook?: (params: AppMountParameters) => void; }): MockedMounterTuple => { const unmount = jest.fn(); @@ -62,6 +64,7 @@ export const createAppMounter = ({ appRoute, appBasePath: appRoute, legacy: false, + exactRoute, mount: jest.fn(async (params: AppMountParameters) => { const { appBasePath: basename, element } = params; Object.assign(element, { @@ -90,6 +93,7 @@ export const createLegacyAppMounter = ( appBasePath: `/app/${appId.split(':')[0]}`, unmountBeforeMounting: true, legacy: true, + exactRoute: false, mount: legacyMount, }, unmount: jest.fn(), diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 44b095bd9e6d83..6926b6acf24115 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -234,6 +234,24 @@ export interface App extends AppBase { * base path from HTTP. */ appRoute?: string; + + /** + * If set to true, the application's route will only be checked against an exact match. Defaults to `false`. + * + * @example + * ```ts + * core.application.register({ + * id: 'my_app', + * title: 'My App' + * exactRoute: true, + * mount: () => { ... }, + * }) + * + * // '[basePath]/app/my_app' will be matched + * // '[basePath]/app/my_app/some/path' will not be matched + * ``` + */ + exactRoute?: boolean; } /** @public */ @@ -569,6 +587,7 @@ export type Mounter = SelectivePartial< appBasePath: string; mount: T extends LegacyApp ? LegacyAppMounter : AppMounter; legacy: boolean; + exactRoute: boolean; unmountBeforeMounting: T extends LegacyApp ? true : boolean; }, T extends LegacyApp ? never : 'unmountBeforeMounting' diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index 229354a014103b..a94313dd53abbc 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -55,6 +55,7 @@ describe('AppContainer', () => { appRoute: '/some-route', unmountBeforeMounting: false, legacy: false, + exactRoute: false, mount: async ({ element }: AppMountParameters) => { await promise; const container = document.createElement('div'); @@ -143,6 +144,7 @@ describe('AppContainer', () => { appRoute: '/some-route', unmountBeforeMounting: false, legacy: false, + exactRoute: false, mount: async ({ element }: AppMountParameters) => { await waitPromise; throw new Error(`Mounting failed!`); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 5d02f96134b273..f2d2d1e6587ac7 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -63,6 +63,7 @@ export const AppRouter: FunctionComponent = ({ ( { setHelpSupportUrl: jest.fn(), getIsNavDrawerLocked$: jest.fn(), getNavType$: jest.fn(), + getCustomNavLink$: jest.fn(), + setCustomNavLink: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); @@ -81,6 +83,7 @@ const createStartContractMock = () => { startContract.getApplicationClasses$.mockReturnValue(new BehaviorSubject(['class-name'])); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); + startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); startContract.getNavType$.mockReturnValue(new BehaviorSubject('modern' as NavType)); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index e39733cc10de70..8dc81dceaccd61 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -363,6 +363,27 @@ describe('start', () => { }); }); + describe('custom nav link', () => { + it('updates/emits the current custom nav link', async () => { + const { chrome, service } = await start(); + const promise = chrome.getCustomNavLink$().pipe(toArray()).toPromise(); + + chrome.setCustomNavLink({ title: 'Manage cloud deployment' }); + chrome.setCustomNavLink(undefined); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + undefined, + Object { + "title": "Manage cloud deployment", + }, + undefined, + ] + `); + }); + }); + describe('help extension', () => { it('updates/emits the current help extension', async () => { const { chrome, service } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 67cd43f0647e43..0fe3c1f083cf08 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -34,7 +34,7 @@ import { IUiSettingsClient } from '../ui_settings'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; -import { ChromeNavLinks, NavLinksService } from './nav_links'; +import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; import { NavType } from './ui/header'; @@ -148,6 +148,7 @@ export class ChromeService { const helpExtension$ = new BehaviorSubject(undefined); const breadcrumbs$ = new BehaviorSubject([]); const badge$ = new BehaviorSubject(undefined); + const customNavLink$ = new BehaviorSubject(undefined); const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); @@ -221,6 +222,7 @@ export class ChromeService { badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} + customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} kibanaDocLink={docLinks.links.kibana} forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} @@ -297,6 +299,12 @@ export class ChromeService { getIsNavDrawerLocked$: () => getIsNavDrawerLocked$, getNavType$: () => getNavType$, + + getCustomNavLink$: () => customNavLink$.pipe(takeUntil(this.stop$)), + + setCustomNavLink: (customNavLink?: ChromeNavLink) => { + customNavLink$.next(customNavLink); + }, }; } @@ -423,6 +431,16 @@ export interface ChromeStart { */ setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + /** + * Get an observable of the current custom nav link + */ + getCustomNavLink$(): Observable | undefined>; + + /** + * Override the current set of custom nav link + */ + setCustomNavLink(newCustomNavLink?: Partial): void; + /** * Get an observable of the current custom help conttent */ diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9239811df20653..9fee7b50f371b2 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -61,6 +61,64 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } } closeNav={[Function]} + customNavLink$={ + BehaviorSubject { + "_isScalar": false, + "_value": Object { + "baseUrl": "/", + "category": undefined, + "data-test-subj": "Custom link", + "href": "Custom link", + "id": "Custom link", + "isActive": true, + "legacy": false, + "title": "Custom link", + }, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } homeHref="/" id="collapsibe-nav" isLocked={false} @@ -408,6 +466,46 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` data-test-subj="collapsibleNav" id="collapsibe-nav" > +
+
+ +
+
+
+
+
+ +
+
+
+ +
+ +
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+
    +
  • + +
  • +
+
+
+
+
+
+
+
+
    +
  • + +
  • +
+
+
+
+
+ +
+ +
+
+ +
    + +
  • + +
  • +
    +
+
+
+
+
+
+
+ +
+
{}, closeNav: () => {}, navigateToApp: () => Promise.resolve(), + customNavLink$: new BehaviorSubject(undefined), }; } @@ -120,12 +121,14 @@ describe('CollapsibleNav', () => { mockRecentNavLink({ label: 'recent 1' }), mockRecentNavLink({ label: 'recent 2' }), ]; + const customNavLink = mockLink({ title: 'Custom link' }); const component = mount( ); expect(component).toMatchSnapshot(); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 9494e22920de81..5abd14312f4a66 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -30,7 +30,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { groupBy, sortBy } from 'lodash'; -import React, { useRef } from 'react'; +import React, { Fragment, useRef } from 'react'; import { useObservable } from 'react-use'; import * as Rx from 'rxjs'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; @@ -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) { const allCategories = {} as Record; @@ -88,6 +88,7 @@ interface Props { onIsLockedUpdate: OnIsLockedUpdate; closeNav: () => void; navigateToApp: InternalApplicationStart['navigateToApp']; + customNavLink$: Rx.Observable; } export function CollapsibleNav({ @@ -105,6 +106,7 @@ export function CollapsibleNav({ }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); + const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); @@ -134,6 +136,38 @@ export function CollapsibleNav({ isDocked={isLocked} onClose={closeNav} > + {customNavLink && ( + + + + + + + + + + )} + {/* Pinned items */} ) => { - closeNav(); - if ( - event.isDefaultPrevented() || - event.altKey || - event.metaKey || - event.ctrlKey - ) { + onClick: (event) => { + if (isModifiedOrPrevented(event)) { return; } + event.preventDefault(); + closeNav(); navigateToApp('home'); }, }, @@ -196,7 +226,13 @@ export function CollapsibleNav({ return { ...hydratedLink, 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: closeNav, + onClick: (event) => { + if (isModifiedOrPrevented(event)) { + return; + } + + closeNav(); + }, }; })} maxWidth="none" diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 13e1f6f086ae2a..a9fa15d43182be 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -45,6 +45,7 @@ function mockProps() { isVisible$: new BehaviorSubject(true), kibanaDocLink: '/docs', navLinks$: new BehaviorSubject([]), + customNavLink$: new BehaviorSubject(undefined), recentlyAccessed$: new BehaviorSubject([]), forceAppSwitcherNavigation$: new BehaviorSubject(false), helpExtension$: new BehaviorSubject(undefined), @@ -75,6 +76,12 @@ describe('Header', () => { const navLinks$ = new BehaviorSubject([ { id: 'kibana', title: 'kibana', baseUrl: '', legacy: false }, ]); + const customNavLink$ = new BehaviorSubject({ + id: 'cloud-deployment-link', + title: 'Manage cloud deployment', + baseUrl: '', + legacy: false, + }); const recentlyAccessed$ = new BehaviorSubject([ { link: '', label: 'dashboard', id: 'dashboard' }, ]); @@ -87,6 +94,7 @@ describe('Header', () => { recentlyAccessed$={recentlyAccessed$} isLocked$={isLocked$} navType$={navType$} + customNavLink$={customNavLink$} /> ); expect(component).toMatchSnapshot(); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index d24b342e0386bc..3da3caaaa4a4f8 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -58,6 +58,7 @@ export interface HeaderProps { appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; + customNavLink$: Observable; homeHref: string; isVisible$: Observable; kibanaDocLink: string; @@ -203,6 +204,7 @@ export function Header({ toggleCollapsibleNavRef.current.focus(); } }} + customNavLink$={observables.customNavLink$} /> ) : ( // TODO #64541 diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 969b6728e0263f..c70a40f49643e0 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -17,29 +17,25 @@ * 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) { - return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); -} - -function LinkIcon({ url }: { url: string }) { - return ; -} +export const isModifiedOrPrevented = (event: React.MouseEvent) => + event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented; interface Props { link: ChromeNavLink; legacyMode: boolean; - appId: string | undefined; + appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; onClick?: Function; navigateToApp: CoreStart['application']['navigateToApp']; + externalLink?: boolean; } // TODO #64541 @@ -54,6 +50,7 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, + externalLink = false, }: Props) { const { legacy, active, id, title, disabled, euiIconType, icon, tooltip } = link; let { href } = link; @@ -67,13 +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) { - 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); @@ -85,7 +85,8 @@ export function createEuiListItem({ 'data-test-subj': dataTestSubj, ...(basePath && { iconType: euiIconType, - icon: !euiIconType && icon ? : undefined, + icon: + !euiIconType && icon ? : undefined, }), }; } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index d6172b77d3ca53..00fabc2b6f2f1c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -163,7 +163,7 @@ export class CoreSystem { i18n: this.i18n.getContext(), }); await this.integrations.setup(); - const docLinks = this.docLinks.setup({ injectedMetadata }); + this.docLinks.setup(); const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); @@ -185,7 +185,6 @@ export class CoreSystem { const core: InternalCoreSetup = { application, context, - docLinks, fatalErrors: this.fatalErrorsSetup, http, injectedMetadata, @@ -217,7 +216,7 @@ export class CoreSystem { try { const injectedMetadata = await this.injectedMetadata.start(); const uiSettings = await this.uiSettings.start(); - const docLinks = this.docLinks.start(); + const docLinks = this.docLinks.start({ injectedMetadata }); const http = await this.http.start(); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); diff --git a/src/core/public/doc_links/doc_links_service.mock.ts b/src/core/public/doc_links/doc_links_service.mock.ts index 9edcf2e3c79901..105c13f96cef65 100644 --- a/src/core/public/doc_links/doc_links_service.mock.ts +++ b/src/core/public/doc_links/doc_links_service.mock.ts @@ -18,25 +18,23 @@ */ import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -import { DocLinksService, DocLinksSetup, DocLinksStart } from './doc_links_service'; +import { DocLinksService, DocLinksStart } from './doc_links_service'; -const createSetupContractMock = (): DocLinksSetup => { +const createStartContractMock = (): DocLinksStart => { // This service is so simple that we actually use the real implementation const injectedMetadata = injectedMetadataServiceMock.createStartContract(); injectedMetadata.getKibanaBranch.mockReturnValue('mocked-test-branch'); - return new DocLinksService().setup({ injectedMetadata }); + return new DocLinksService().start({ injectedMetadata }); }; -const createStartContractMock: () => DocLinksStart = createSetupContractMock; - type DocLinksServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ - setup: jest.fn().mockReturnValue(createSetupContractMock()), + setup: jest.fn().mockReturnValue(undefined), start: jest.fn().mockReturnValue(createStartContractMock()), }); export const docLinksServiceMock = { create: createMock, - createSetupContract: createSetupContractMock, + createSetupContract: () => jest.fn(), createStartContract: createStartContractMock, }; diff --git a/src/core/public/doc_links/doc_links_service.test.ts b/src/core/public/doc_links/doc_links_service.test.ts index 4c5d6bcde8b773..c430ae7655040d 100644 --- a/src/core/public/doc_links/doc_links_service.test.ts +++ b/src/core/public/doc_links/doc_links_service.test.ts @@ -20,33 +20,15 @@ import { DocLinksService } from './doc_links_service'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -describe('DocLinksService#setup()', () => { +describe('DocLinksService#start()', () => { it('templates the doc links with the branch information from injectedMetadata', () => { const injectedMetadata = injectedMetadataServiceMock.createStartContract(); injectedMetadata.getKibanaBranch.mockReturnValue('test-branch'); const service = new DocLinksService(); - const setup = service.setup({ injectedMetadata }); - expect(setup.DOC_LINK_VERSION).toEqual('test-branch'); - expect(setup.links.kibana).toEqual( + const api = service.start({ injectedMetadata }); + expect(api.DOC_LINK_VERSION).toEqual('test-branch'); + expect(api.links.kibana).toEqual( 'https://www.elastic.co/guide/en/kibana/test-branch/index.html' ); }); }); - -describe('DocLinksService#start()', () => { - it('returns the same data as setup', () => { - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getKibanaBranch.mockReturnValue('test-branch'); - const service = new DocLinksService(); - const setup = service.setup({ injectedMetadata }); - const start = service.start(); - expect(setup).toEqual(start); - }); - - it('must be called after setup', () => { - const service = new DocLinksService(); - expect(() => { - service.start(); - }).toThrowErrorMatchingInlineSnapshot(`"DocLinksService#setup() must be called first!"`); - }); -}); diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index f2bc90a5b08d48..0662586797164d 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -20,20 +20,19 @@ import { InjectedMetadataSetup } from '../injected_metadata'; import { deepFreeze } from '../../utils'; -interface SetupDeps { +interface StartDeps { injectedMetadata: InjectedMetadataSetup; } /** @internal */ export class DocLinksService { - private service?: DocLinksSetup; - - public setup({ injectedMetadata }: SetupDeps): DocLinksSetup { + public setup() {} + public start({ injectedMetadata }: StartDeps): DocLinksStart { const DOC_LINK_VERSION = injectedMetadata.getKibanaBranch(); const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/'; const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`; - this.service = deepFreeze({ + return deepFreeze({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL, links: { @@ -129,21 +128,11 @@ export class DocLinksService { }, }, }); - - return this.service; - } - - public start(): DocLinksStart { - if (!this.service) { - throw new Error(`DocLinksService#setup() must be called first!`); - } - - return this.service; } } /** @public */ -export interface DocLinksSetup { +export interface DocLinksStart { readonly DOC_LINK_VERSION: string; readonly ELASTIC_WEBSITE_URL: string; readonly links: { @@ -236,6 +225,3 @@ export interface DocLinksSetup { readonly management: Record; }; } - -/** @public */ -export type DocLinksStart = DocLinksSetup; diff --git a/src/core/public/doc_links/index.ts b/src/core/public/doc_links/index.ts index fbfa9db5635ddc..fe49d4a7c6a583 100644 --- a/src/core/public/doc_links/index.ts +++ b/src/core/public/doc_links/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { DocLinksService, DocLinksSetup, DocLinksStart } from './doc_links_service'; +export { DocLinksService, DocLinksStart } from './doc_links_service'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 99b75f85340f31..41af0f1b8395fe 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -67,7 +67,7 @@ import { OverlayStart } from './overlays'; import { Plugin, PluginInitializer, PluginInitializerContext, PluginOpaqueId } from './plugins'; import { UiSettingsState, IUiSettingsClient } from './ui_settings'; import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; -import { DocLinksSetup, DocLinksStart } from './doc_links'; +import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; export { PackageInfo, EnvironmentMode } from '../server/types'; import { @@ -216,8 +216,6 @@ export interface CoreSetup { mockSetupDeps = { application: applicationServiceMock.createInternalSetupContract(), context: contextServiceMock.createSetupContract(), - docLinks: docLinksServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), injectedMetadata: pick(injectedMetadataServiceMock.createStartContract(), 'getInjectedVar'), diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 7970d9f3f86bb2..d10e351f4d13ed 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -154,6 +154,7 @@ export function __kbnBootstrap__(): void; export interface App extends AppBase { appRoute?: string; chromeless?: boolean; + exactRoute?: boolean; mount: AppMount | AppMountDeprecated; } @@ -466,6 +467,7 @@ export interface ChromeStart { getBadge$(): Observable; getBrand$(): Observable; getBreadcrumbs$(): Observable; + getCustomNavLink$(): Observable | undefined>; getHelpExtension$(): Observable; getIsNavDrawerLocked$(): Observable; getIsVisible$(): Observable; @@ -478,6 +480,7 @@ export interface ChromeStart { setBadge(badge?: ChromeBadge): void; setBrand(brand: ChromeBrand): void; setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + setCustomNavLink(newCustomNavLink?: Partial): void; setHelpExtension(helpExtension?: ChromeHelpExtension): void; setHelpSupportUrl(url: string): void; setIsVisible(isVisible: boolean): void; @@ -508,8 +511,6 @@ export interface CoreSetup; @@ -600,7 +601,7 @@ export const DEFAULT_APP_CATEGORIES: Readonly<{ }>; // @public (undocumented) -export interface DocLinksSetup { +export interface DocLinksStart { // (undocumented) readonly DOC_LINK_VERSION: string; // (undocumented) @@ -697,9 +698,6 @@ export interface DocLinksSetup { }; } -// @public (undocumented) -export type DocLinksStart = DocLinksSetup; - // @public (undocumented) export interface EnvironmentMode { // (undocumented) @@ -1594,6 +1592,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:216:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:215:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index 6be9846f5a86a5..b4d620965b0471 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -20,7 +20,7 @@ import supertest from 'supertest'; import { HttpService, InternalHttpServiceSetup } from '../../http'; import { contextServiceMock } from '../../context/context_service.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { Env } from '../../config'; import { getEnvOptions } from '../../config/__mocks__/env'; import { CapabilitiesService, CapabilitiesSetup } from '..'; @@ -44,7 +44,7 @@ describe('CapabilitiesService', () => { service = new CapabilitiesService({ coreId, env, - logger: loggingServiceMock.create(), + logger: loggingSystemMock.create(), configService: {} as any, }); serviceSetup = await service.setup({ http: httpSetup }); diff --git a/src/core/server/config/config_service.test.ts b/src/core/server/config/config_service.test.ts index 5f28fca1371b08..236cf6579d7c80 100644 --- a/src/core/server/config/config_service.test.ts +++ b/src/core/server/config/config_service.test.ts @@ -28,12 +28,12 @@ import { rawConfigServiceMock } from './raw_config_service.mock'; import { schema } from '@kbn/config-schema'; import { ConfigService, Env } from '.'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { getEnvOptions } from './__mocks__/env'; const emptyArgv = getEnvOptions(); const defaultEnv = new Env('/kibana', emptyArgv); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); const getRawConfigProvider = (rawConfig: Record) => rawConfigServiceMock.create({ rawConfig }); @@ -443,9 +443,9 @@ test('logs deprecation warning during validation', async () => { return config; }); - loggingServiceMock.clear(logger); + loggingSystemMock.clear(logger); await configService.validate(); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "some deprecation message", diff --git a/src/core/server/config/integration_tests/config_deprecation.test.mocks.ts b/src/core/server/config/integration_tests/config_deprecation.test.mocks.ts index 58b2da926b7c3b..1d42c7667a34d9 100644 --- a/src/core/server/config/integration_tests/config_deprecation.test.mocks.ts +++ b/src/core/server/config/integration_tests/config_deprecation.test.mocks.ts @@ -17,9 +17,9 @@ * under the License. */ -import { loggingServiceMock } from '../../logging/logging_service.mock'; -export const mockLoggingService = loggingServiceMock.create(); -mockLoggingService.asLoggerFactory.mockImplementation(() => mockLoggingService); -jest.doMock('../../logging/logging_service', () => ({ - LoggingService: jest.fn(() => mockLoggingService), +import { loggingSystemMock } from '../../logging/logging_system.mock'; +export const mockLoggingSystem = loggingSystemMock.create(); +mockLoggingSystem.asLoggerFactory.mockImplementation(() => mockLoggingSystem); +jest.doMock('../../logging/logging_system', () => ({ + LoggingSystem: jest.fn(() => mockLoggingSystem), })); diff --git a/src/core/server/config/integration_tests/config_deprecation.test.ts b/src/core/server/config/integration_tests/config_deprecation.test.ts index 3523b074ea5b42..56385f3b171c93 100644 --- a/src/core/server/config/integration_tests/config_deprecation.test.ts +++ b/src/core/server/config/integration_tests/config_deprecation.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { mockLoggingService } from './config_deprecation.test.mocks'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { mockLoggingSystem } from './config_deprecation.test.mocks'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; describe('configuration deprecations', () => { @@ -35,7 +35,7 @@ describe('configuration deprecations', () => { await root.setup(); - const logs = loggingServiceMock.collect(mockLoggingService); + const logs = loggingSystemMock.collect(mockLoggingSystem); const warnings = logs.warn.flatMap((i) => i); expect(warnings).not.toContain( '"optimize.lazy" is deprecated and has been replaced by "optimize.watch"' @@ -55,7 +55,7 @@ describe('configuration deprecations', () => { await root.setup(); - const logs = loggingServiceMock.collect(mockLoggingService); + const logs = loggingSystemMock.collect(mockLoggingSystem); const warnings = logs.warn.flatMap((i) => i); expect(warnings).toContain( '"optimize.lazy" is deprecated and has been replaced by "optimize.watch"' diff --git a/src/core/server/core_context.mock.ts b/src/core/server/core_context.mock.ts index d287348e19079f..f870d30528df42 100644 --- a/src/core/server/core_context.mock.ts +++ b/src/core/server/core_context.mock.ts @@ -20,17 +20,17 @@ import { CoreContext } from './core_context'; import { getEnvOptions } from './config/__mocks__/env'; import { Env, IConfigService } from './config'; -import { loggingServiceMock } from './logging/logging_service.mock'; +import { loggingSystemMock } from './logging/logging_system.mock'; import { configServiceMock } from './config/config_service.mock'; -import { ILoggingService } from './logging'; +import { ILoggingSystem } from './logging'; function create({ env = Env.createDefault(getEnvOptions()), - logger = loggingServiceMock.create(), + logger = loggingSystemMock.create(), configService = configServiceMock.create(), }: { env?: Env; - logger?: jest.Mocked; + logger?: jest.Mocked; configService?: jest.Mocked; } = {}): DeeplyMockedKeys { return { coreId: Symbol(), env, logger, configService }; diff --git a/src/core/server/elasticsearch/cluster_client.test.ts b/src/core/server/elasticsearch/cluster_client.test.ts index db277fa0e06074..820272bdf14b81 100644 --- a/src/core/server/elasticsearch/cluster_client.test.ts +++ b/src/core/server/elasticsearch/cluster_client.test.ts @@ -28,11 +28,11 @@ import { import { errors } from 'elasticsearch'; import { get } from 'lodash'; import { Logger } from '../logging'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServerMock } from '../http/http_server.mocks'; import { ClusterClient } from './cluster_client'; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); afterEach(() => jest.clearAllMocks()); test('#constructor creates client with parsed config', () => { diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts index 20c10459e0e8a4..77d1e41c9ad833 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts @@ -18,12 +18,12 @@ */ import { duration } from 'moment'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { ElasticsearchClientConfig, parseElasticsearchClientConfig, } from './elasticsearch_client_config'; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); afterEach(() => jest.clearAllMocks()); test('parses minimally specified config', () => { @@ -360,7 +360,7 @@ describe('#log', () => { expect(typeof esLogger.close).toBe('function'); - expect(loggingServiceMock.collect(logger)).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger)).toMatchInlineSnapshot(` Object { "debug": Array [], "error": Array [ @@ -406,7 +406,7 @@ Object { expect(typeof esLogger.close).toBe('function'); - expect(loggingServiceMock.collect(logger)).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger)).toMatchInlineSnapshot(` Object { "debug": Array [ Array [ diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 8bf0df74186a99..0a7068903e15c2 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -26,7 +26,7 @@ import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; import { configServiceMock } from '../config/config_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServiceMock } from '../http/http_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; @@ -55,7 +55,7 @@ configService.atPath.mockReturnValue( let env: Env; let coreContext: CoreContext; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); beforeEach(() => { env = Env.createDefault(getEnvOptions()); diff --git a/src/core/server/elasticsearch/retry_call_cluster.test.ts b/src/core/server/elasticsearch/retry_call_cluster.test.ts index 8be138e6752d2e..18ffa95048c4d9 100644 --- a/src/core/server/elasticsearch/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/retry_call_cluster.test.ts @@ -19,7 +19,7 @@ import * as legacyElasticsearch from 'elasticsearch'; import { retryCallCluster, migrationsRetryCallCluster } from './retry_call_cluster'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; describe('retryCallCluster', () => { it('retries ES API calls that rejects with NoConnections', () => { @@ -69,10 +69,10 @@ describe('migrationsRetryCallCluster', () => { 'Gone', ]; - const mockLogger = loggingServiceMock.create(); + const mockLogger = loggingSystemMock.create(); beforeEach(() => { - loggingServiceMock.clear(mockLogger); + loggingSystemMock.clear(mockLogger); }); errors.forEach((errorName) => { @@ -133,7 +133,7 @@ describe('migrationsRetryCallCluster', () => { callEsApi.mockResolvedValueOnce('done'); const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); await retried('endpoint'); - expect(loggingServiceMock.collect(mockLogger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(mockLogger).warn).toMatchInlineSnapshot(` Array [ Array [ "Unable to connect to Elasticsearch. Error: No Living connections", diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts index a2090ed111ca11..3d1218d4a8e8b9 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts @@ -17,12 +17,12 @@ * under the License. */ import { mapNodesVersionCompatibility, pollEsNodesVersion, NodesInfo } from './ensure_es_version'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { take, delay } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { of } from 'rxjs'; -const mockLoggerFactory = loggingServiceMock.create(); +const mockLoggerFactory = loggingSystemMock.create(); const mockLogger = mockLoggerFactory.get('mock logger'); const KIBANA_VERSION = '5.1.0'; diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 07c153a7a8a200..d48ead3cec8e13 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -83,6 +83,8 @@ Object { exports[`throws if basepath appends a slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; +exports[`throws if basepath is an empty string 1`] = `"[basePath]: must start with a slash, don't end with one"`; + exports[`throws if basepath is missing prepended slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = `"cannot use [rewriteBasePath] when [basePath] is not specified"`; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 3afe5e0c4dfc7b..1fb2b5693bb614 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -29,14 +29,14 @@ import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { configServiceMock } from '../config/config_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServerMock } from './http_server.mocks'; import { createCookieSessionStorageFactory } from './cookie_session_storage'; let server: HttpService; -let logger: ReturnType; +let logger: ReturnType; let env: Env; let coreContext: CoreContext; const configService = configServiceMock.create(); @@ -67,7 +67,7 @@ configService.atPath.mockReturnValue( ); beforeEach(() => { - logger = loggingServiceMock.create(); + logger = loggingSystemMock.create(); env = Env.createDefault(getEnvOptions()); coreContext = { coreId: Symbol(), env, logger, configService: configService as any }; @@ -324,7 +324,7 @@ describe('Cookie based SessionStorage', () => { expect(mockServer.auth.test).toBeCalledTimes(1); expect(mockServer.auth.test).toHaveBeenCalledWith('security-cookie', mockRequest); - expect(loggingServiceMock.collect(logger).warn).toEqual([ + expect(loggingSystemMock.collect(logger).warn).toEqual([ ['Found 2 auth sessions when we were only expecting 1.'], ]); }); @@ -381,7 +381,7 @@ describe('Cookie based SessionStorage', () => { const session = await factory.asScoped(KibanaRequest.from(mockRequest)).get(); expect(session).toBe(null); - expect(loggingServiceMock.collect(logger).debug).toEqual([['Error: Invalid cookie.']]); + expect(loggingSystemMock.collect(logger).debug).toEqual([['Error: Invalid cookie.']]); }); }); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index eaf66219d08dc4..0698f118be03fa 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -78,6 +78,14 @@ test('throws if basepath appends a slash', () => { expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); }); +test('throws if basepath is an empty string', () => { + const httpSchema = config.schema; + const obj = { + basePath: '', + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + test('throws if basepath is not specified, but rewriteBasePath is set', () => { const httpSchema = config.schema; const obj = { diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 289b6539fd7625..83a2e712b424fd 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -23,7 +23,7 @@ import { hostname } from 'os'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; import { SslConfig, sslSchema } from './ssl_config'; -const validBasePathRegex = /(^$|^\/.*[^\/]$)/; +const validBasePathRegex = /^\/.*[^\/]$/; const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const match = (regex: RegExp, errorMsg: string) => (str: string) => diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 9a5deb9b455627..4520851bb460c7 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -31,7 +31,7 @@ import { RouteValidationResultFactory, RouteValidationFunction, } from './router'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'kibana/server'; @@ -48,7 +48,7 @@ let server: HttpServer; let config: HttpConfig; let configWithSSL: HttpConfig; -const loggingService = loggingServiceMock.create(); +const loggingService = loggingSystemMock.create(); const logger = loggingService.get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); @@ -97,7 +97,7 @@ test('log listening address after started', async () => { await server.start(); expect(server.isListening()).toBe(true); - expect(loggingServiceMock.collect(loggingService).info).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` Array [ Array [ "http server running at http://127.0.0.1:10002", @@ -113,7 +113,7 @@ test('log listening address after started when configured with BasePath and rewr await server.start(); expect(server.isListening()).toBe(true); - expect(loggingServiceMock.collect(loggingService).info).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` Array [ Array [ "http server running at http://127.0.0.1:10002", @@ -129,7 +129,7 @@ test('log listening address after started when configured with BasePath and rewr await server.start(); expect(server.isListening()).toBe(true); - expect(loggingServiceMock.collect(loggingService).info).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(loggingService).info).toMatchInlineSnapshot(` Array [ Array [ "http server running at http://127.0.0.1:10002/bar", diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 8b500caf217dc5..3d759b427d9fb0 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -25,12 +25,12 @@ import { HttpService } from '.'; import { HttpConfigType, config } from './http_config'; import { httpServerMock } from './http_server.mocks'; import { ConfigService, Env } from '../config'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { getEnvOptions } from '../config/__mocks__/env'; import { config as cspConfig } from '../csp'; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); const env = Env.createDefault(getEnvOptions()); const coreId = Symbol(); @@ -159,7 +159,7 @@ test('logs error if already set up', async () => { await service.setup(setupDeps); - expect(loggingServiceMock.collect(logger).warn).toMatchSnapshot(); + expect(loggingSystemMock.collect(logger).warn).toMatchSnapshot(); }); test('stops http server', async () => { diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 7d5a7277a767a9..f09d862f9edace 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -34,7 +34,7 @@ import { defaultValidationErrorHandler, HapiValidationError, getServerOptions } import { HttpServer } from './http_server'; import { HttpConfig, config } from './http_config'; import { Router } from './router'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { ByteSizeValue } from '@kbn/config-schema'; const emptyOutput = { @@ -77,7 +77,7 @@ describe('defaultValidationErrorHandler', () => { }); describe('timeouts', () => { - const logger = loggingServiceMock.create(); + const logger = loggingSystemMock.create(); const server = new HttpServer(logger, 'foo'); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); diff --git a/src/core/server/http/https_redirect_server.test.ts b/src/core/server/http/https_redirect_server.test.ts index a7d3cbe41aa3d4..f35456f01c19bb 100644 --- a/src/core/server/http/https_redirect_server.test.ts +++ b/src/core/server/http/https_redirect_server.test.ts @@ -27,7 +27,7 @@ import supertest from 'supertest'; import { ByteSizeValue } from '@kbn/config-schema'; import { HttpConfig } from '.'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { HttpsRedirectServer } from './https_redirect_server'; const chance = new Chance(); @@ -50,7 +50,7 @@ beforeEach(() => { }, } as HttpConfig; - server = new HttpsRedirectServer(loggingServiceMock.create().get()); + server = new HttpsRedirectServer(loggingSystemMock.create().get()); }); afterEach(async () => { diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 73ed4e5de4b046..879cbc689f8e79 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -24,12 +24,12 @@ import { ensureRawRequest } from '../router'; import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; let server: HttpService; -let logger: ReturnType; +let logger: ReturnType; const contextSetup = contextServiceMock.createSetupContract(); @@ -38,7 +38,7 @@ const setupDeps = { }; beforeEach(() => { - logger = loggingServiceMock.create(); + logger = loggingSystemMock.create(); server = createHttpServer({ logger }); }); @@ -167,7 +167,7 @@ describe('OnPreAuth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: reason], @@ -188,7 +188,7 @@ describe('OnPreAuth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: [object Object].], @@ -301,7 +301,7 @@ describe('OnPostAuth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: reason], @@ -321,7 +321,7 @@ describe('OnPostAuth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: [object Object].], @@ -506,7 +506,7 @@ describe('Auth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: reason], @@ -703,7 +703,7 @@ describe('Auth', () => { const response = await supertest(innerServer.listener).get('/').expect(200); expect(response.header['www-authenticate']).toBe('from auth interceptor'); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "onPreResponseHandler rewrote a response header [www-authenticate].", @@ -736,7 +736,7 @@ describe('Auth', () => { const response = await supertest(innerServer.listener).get('/').expect(400); expect(response.header['www-authenticate']).toBe('from auth interceptor'); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "onPreResponseHandler rewrote a response header [www-authenticate].", @@ -798,7 +798,7 @@ describe('Auth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: reason], @@ -818,7 +818,7 @@ describe('Auth', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: [object Object].], @@ -929,7 +929,7 @@ describe('OnPreResponse', () => { await supertest(innerServer.listener).get('/').expect(200); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "onPreResponseHandler rewrote a response header [x-kibana-header].", @@ -953,7 +953,7 @@ describe('OnPreResponse', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: reason], @@ -975,7 +975,7 @@ describe('OnPreResponse', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected result from OnPreResponse. Expected OnPreResponseResult, but given: [object Object].], diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index d33757273042b4..2d018f7f464b5d 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -21,12 +21,12 @@ import supertest from 'supertest'; import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; let server: HttpService; -let logger: ReturnType; +let logger: ReturnType; const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { @@ -34,7 +34,7 @@ const setupDeps = { }; beforeEach(() => { - logger = loggingServiceMock.create(); + logger = loggingSystemMock.create(); server = createHttpServer({ logger }); }); diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 8f3799b12eccb6..bb36fefa96611e 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -24,12 +24,12 @@ import { schema } from '@kbn/config-schema'; import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; let server: HttpService; -let logger: ReturnType; +let logger: ReturnType; const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { @@ -37,7 +37,7 @@ const setupDeps = { }; beforeEach(() => { - logger = loggingServiceMock.create(); + logger = loggingSystemMock.create(); server = createHttpServer({ logger }); }); @@ -347,7 +347,7 @@ describe('Handler', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: unexpected error], @@ -368,7 +368,7 @@ describe('Handler', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unauthorized], @@ -387,7 +387,7 @@ describe('Handler', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected result from Route Handler. Expected KibanaResponse, but given: string.], @@ -763,7 +763,7 @@ describe('Response factory', () => { await supertest(innerServer.listener).get('/').expect(500); // error happens within hapi when route handler already finished execution. - expect(loggingServiceMock.collect(logger).error).toHaveLength(0); + expect(loggingSystemMock.collect(logger).error).toHaveLength(0); }); it('200 OK with body', async () => { @@ -855,7 +855,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: expected 'location' header to be set], @@ -1261,7 +1261,7 @@ describe('Response factory', () => { message: 'An internal server error occurred.', statusCode: 500, }); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected Http status code. Expected from 400 to 599, but given: 200], @@ -1330,7 +1330,7 @@ describe('Response factory', () => { await supertest(innerServer.listener).get('/').expect(500); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: expected 'location' header to be set], @@ -1445,7 +1445,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('reason'); - expect(loggingServiceMock.collect(logger).error).toHaveLength(0); + expect(loggingSystemMock.collect(logger).error).toHaveLength(0); }); it('throws an error if not valid error is provided', async () => { @@ -1464,7 +1464,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: expected error message to be provided], @@ -1488,7 +1488,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: expected error message to be provided], @@ -1511,7 +1511,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: options.statusCode is expected to be set. given options: undefined], @@ -1534,7 +1534,7 @@ describe('Response factory', () => { const result = await supertest(innerServer.listener).get('/').expect(500); expect(result.body.message).toBe('An internal server error occurred.'); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Unexpected Http status code. Expected from 100 to 599, but given: 20.], diff --git a/src/core/server/http/router/router.test.ts b/src/core/server/http/router/router.test.ts index 9655e2153b863e..fa38c7bd6b336d 100644 --- a/src/core/server/http/router/router.test.ts +++ b/src/core/server/http/router/router.test.ts @@ -18,10 +18,10 @@ */ import { Router } from './router'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { schema } from '@kbn/config-schema'; -const logger = loggingServiceMock.create().get(); +const logger = loggingSystemMock.create().get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); describe('Router', () => { diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index 0e639aa72a8254..bda66e1de8168f 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -24,12 +24,12 @@ import { getEnvOptions } from '../config/__mocks__/env'; import { HttpService } from './http_service'; import { CoreContext } from '../core_context'; import { configServiceMock } from '../config/config_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; const coreId = Symbol('core'); const env = Env.createDefault(getEnvOptions()); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); const configService = configServiceMock.create(); configService.atPath.mockReturnValue( diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 0da7e5d66cf2a7..e0afd5e57f0416 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -62,6 +62,12 @@ import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { UuidServiceSetup } from './uuid'; import { MetricsServiceSetup } from './metrics'; import { StatusServiceSetup } from './status'; +import { + LoggingServiceSetup, + appendersSchema, + loggerContextConfigSchema, + loggerSchema, +} from './logging'; export { bootstrap } from './bootstrap'; export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities'; @@ -187,7 +193,17 @@ export { } from './http_resources'; export { IRenderOptions } from './rendering'; -export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; +export { + Logger, + LoggerFactory, + LogMeta, + LogRecord, + LogLevel, + LoggingServiceSetup, + LoggerContextConfigInput, + LoggerConfigType, + AppenderConfigType, +} from './logging'; export { DiscoveredPlugin, @@ -385,6 +401,8 @@ export interface CoreSetup = KbnServer as any; @@ -64,7 +65,7 @@ let setupDeps: LegacyServiceSetupDeps; let startDeps: LegacyServiceStartDeps; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); let configService: ReturnType; let uuidSetup: ReturnType; @@ -100,6 +101,7 @@ beforeEach(() => { metrics: metricsServiceMock.createInternalSetupContract(), uuid: uuidSetup, status: statusServiceMock.createInternalSetupContract(), + logging: loggingServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { @@ -281,7 +283,7 @@ describe('once LegacyService is set up with connection info', () => { const [mockKbnServer] = MockKbnServer.mock.instances as Array>; expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); - expect(loggingServiceMock.collect(logger).error).toEqual([]); + expect(loggingSystemMock.collect(logger).error).toEqual([]); const configError = new Error('something went wrong'); mockKbnServer.applyLoggingConfiguration.mockImplementation(() => { @@ -290,7 +292,7 @@ describe('once LegacyService is set up with connection info', () => { config$.next(new ObjectToConfigAdapter({ logging: { verbose: true } })); - expect(loggingServiceMock.collect(logger).error).toEqual([[configError]]); + expect(loggingSystemMock.collect(logger).error).toEqual([[configError]]); }); test('logs error if config service fails.', async () => { @@ -306,13 +308,13 @@ describe('once LegacyService is set up with connection info', () => { const [mockKbnServer] = MockKbnServer.mock.instances; expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); - expect(loggingServiceMock.collect(logger).error).toEqual([]); + expect(loggingSystemMock.collect(logger).error).toEqual([]); const configError = new Error('something went wrong'); config$.error(configError); expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); - expect(loggingServiceMock.collect(logger).error).toEqual([[configError]]); + expect(loggingSystemMock.collect(logger).error).toEqual([[configError]]); }); }); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index cfc53b10d91f0a..be737f6593c025 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -309,6 +309,9 @@ export class LegacyService implements CoreService { csp: setupDeps.core.http.csp, getServerInfo: setupDeps.core.http.getServerInfo, }, + logging: { + configure: (config$) => setupDeps.core.logging.configure([], config$), + }, metrics: { getOpsMetrics$: setupDeps.core.metrics.getOpsMetrics$, }, diff --git a/src/core/server/logging/__snapshots__/logging_service.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap similarity index 100% rename from src/core/server/logging/__snapshots__/logging_service.test.ts.snap rename to src/core/server/logging/__snapshots__/logging_system.test.ts.snap diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index 3aa86495e4d825..3b90a10a1a76c3 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -26,13 +26,19 @@ import { LogRecord } from '../log_record'; import { ConsoleAppender } from './console/console_appender'; import { FileAppender } from './file/file_appender'; -const appendersSchema = schema.oneOf([ +/** + * Config schema for validting the shape of the `appenders` key in in {@link LoggerContextConfigType} or + * {@link LoggingConfigType}. + * + * @public + */ +export const appendersSchema = schema.oneOf([ ConsoleAppender.configSchema, FileAppender.configSchema, LegacyAppender.configSchema, ]); -/** @internal */ +/** @public */ export type AppenderConfigType = TypeOf; /** diff --git a/src/core/server/logging/index.ts b/src/core/server/logging/index.ts index fd35ed39092b31..94719720302817 100644 --- a/src/core/server/logging/index.ts +++ b/src/core/server/logging/index.ts @@ -21,7 +21,18 @@ export { Logger, LogMeta } from './logger'; export { LoggerFactory } from './logger_factory'; export { LogRecord } from './log_record'; export { LogLevel } from './log_level'; -/** @internal */ -export { config, LoggingConfigType } from './logging_config'; -/** @internal */ -export { LoggingService, ILoggingService } from './logging_service'; +export { + config, + LoggingConfigType, + LoggerContextConfigInput, + LoggerConfigType, + loggerContextConfigSchema, + loggerSchema, +} from './logging_config'; +export { LoggingSystem, ILoggingSystem } from './logging_system'; +export { + InternalLoggingServiceSetup, + LoggingServiceSetup, + LoggingService, +} from './logging_service'; +export { appendersSchema, AppenderConfigType } from './appenders/appenders'; diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index 75f571d34c25c5..e2ce3e1983aa14 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -171,3 +171,127 @@ test('fails if loggers use unknown appenders.', () => { expect(() => new LoggingConfig(validateConfig)).toThrowErrorMatchingSnapshot(); }); + +describe('extend', () => { + it('adds new appenders', () => { + const configValue = new LoggingConfig( + config.schema.validate({ + appenders: { + file1: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + }) + ); + + const mergedConfigValue = configValue.extend( + config.schema.validate({ + appenders: { + file2: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + }) + ); + + expect([...mergedConfigValue.appenders.keys()]).toEqual([ + 'default', + 'console', + 'file1', + 'file2', + ]); + }); + + it('overrides appenders', () => { + const configValue = new LoggingConfig( + config.schema.validate({ + appenders: { + file1: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + }) + ); + + const mergedConfigValue = configValue.extend( + config.schema.validate({ + appenders: { + file1: { + kind: 'file', + layout: { kind: 'json' }, + path: 'updatedPath', + }, + }, + }) + ); + + expect(mergedConfigValue.appenders.get('file1')).toEqual({ + kind: 'file', + layout: { kind: 'json' }, + path: 'updatedPath', + }); + }); + + it('adds new loggers', () => { + const configValue = new LoggingConfig( + config.schema.validate({ + loggers: [ + { + context: 'plugins', + level: 'warn', + }, + ], + }) + ); + + const mergedConfigValue = configValue.extend( + config.schema.validate({ + loggers: [ + { + context: 'plugins.pid', + level: 'trace', + }, + ], + }) + ); + + expect([...mergedConfigValue.loggers.keys()]).toEqual(['root', 'plugins', 'plugins.pid']); + }); + + it('overrides loggers', () => { + const configValue = new LoggingConfig( + config.schema.validate({ + loggers: [ + { + context: 'plugins', + level: 'warn', + }, + ], + }) + ); + + const mergedConfigValue = configValue.extend( + config.schema.validate({ + loggers: [ + { + appenders: ['console'], + context: 'plugins', + level: 'trace', + }, + ], + }) + ); + + expect(mergedConfigValue.loggers.get('plugins')).toEqual({ + appenders: ['console'], + context: 'plugins', + level: 'trace', + }); + }); +}); diff --git a/src/core/server/logging/logging_config.ts b/src/core/server/logging/logging_config.ts index 772909ce584e51..a6aafabeb970cf 100644 --- a/src/core/server/logging/logging_config.ts +++ b/src/core/server/logging/logging_config.ts @@ -39,7 +39,7 @@ const ROOT_CONTEXT_NAME = 'root'; */ const DEFAULT_APPENDER_NAME = 'default'; -const createLevelSchema = schema.oneOf( +const levelSchema = schema.oneOf( [ schema.literal('all'), schema.literal('fatal'), @@ -55,21 +55,26 @@ const createLevelSchema = schema.oneOf( } ); -const createLoggerSchema = schema.object({ +/** + * Config schema for validating the `loggers` key in {@link LoggerContextConfigType} or {@link LoggingConfigType}. + * + * @public + */ +export const loggerSchema = schema.object({ appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), context: schema.string(), - level: createLevelSchema, + level: levelSchema, }); -/** @internal */ -export type LoggerConfigType = TypeOf; +/** @public */ +export type LoggerConfigType = TypeOf; export const config = { path: 'logging', schema: schema.object({ appenders: schema.mapOf(schema.string(), Appenders.configSchema, { defaultValue: new Map(), }), - loggers: schema.arrayOf(createLoggerSchema, { + loggers: schema.arrayOf(loggerSchema, { defaultValue: [], }), root: schema.object( @@ -78,7 +83,7 @@ export const config = { defaultValue: [DEFAULT_APPENDER_NAME], minSize: 1, }), - level: createLevelSchema, + level: levelSchema, }, { validate(rawConfig) { @@ -93,6 +98,29 @@ export const config = { export type LoggingConfigType = TypeOf; +/** + * Config schema for validating the inputs to the {@link LoggingServiceStart.configure} API. + * See {@link LoggerContextConfigType}. + * + * @public + */ +export const loggerContextConfigSchema = schema.object({ + appenders: schema.mapOf(schema.string(), Appenders.configSchema, { + defaultValue: new Map(), + }), + + loggers: schema.arrayOf(loggerSchema, { defaultValue: [] }), +}); + +/** @public */ +export type LoggerContextConfigType = TypeOf; +/** @public */ +export interface LoggerContextConfigInput { + // config-schema knows how to handle either Maps or Records + appenders?: Record | Map; + loggers?: LoggerConfigType[]; +} + /** * Describes the config used to fully setup logging subsystem. * @internal @@ -147,11 +175,35 @@ export class LoggingConfig { */ public readonly loggers: Map = new Map(); - constructor(configType: LoggingConfigType) { + constructor(private readonly configType: LoggingConfigType) { this.fillAppendersConfig(configType); this.fillLoggersConfig(configType); } + /** + * Returns a new LoggingConfig that merges the existing config with the specified config. + * + * @remarks + * Does not support merging the `root` config property. + * + * @param contextConfig + */ + public extend(contextConfig: LoggerContextConfigType) { + // Use a Map to de-dupe any loggers for the same context. contextConfig overrides existing config. + const mergedLoggers = new Map([ + ...this.configType.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), + ...contextConfig.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), + ]); + + const mergedConfig: LoggingConfigType = { + appenders: new Map([...this.configType.appenders, ...contextConfig.appenders]), + loggers: [...mergedLoggers.values()], + root: this.configType.root, + }; + + return new LoggingConfig(mergedConfig); + } + private fillAppendersConfig(loggingConfig: LoggingConfigType) { for (const [appenderKey, appenderSchema] of loggingConfig.appenders) { this.appenders.set(appenderKey, appenderSchema); diff --git a/src/core/server/logging/logging_service.mock.ts b/src/core/server/logging/logging_service.mock.ts index 15d66c2e8535ca..21edbe670eaecd 100644 --- a/src/core/server/logging/logging_service.mock.ts +++ b/src/core/server/logging/logging_service.mock.ts @@ -17,67 +17,35 @@ * under the License. */ -// Test helpers to simplify mocking logs and collecting all their outputs -import { ILoggingService } from './logging_service'; -import { LoggerFactory } from './logger_factory'; -import { loggerMock, MockedLogger } from './logger.mock'; - -const createLoggingServiceMock = () => { - const mockLog = loggerMock.create(); - - mockLog.get.mockImplementation((...context) => ({ - ...mockLog, - context, - })); - - const mocked: jest.Mocked = { - get: jest.fn(), - asLoggerFactory: jest.fn(), - upgrade: jest.fn(), +import { + LoggingService, + LoggingServiceSetup, + InternalLoggingServiceSetup, +} from './logging_service'; + +const createInternalSetupMock = (): jest.Mocked => ({ + configure: jest.fn(), +}); + +const createSetupMock = (): jest.Mocked => ({ + configure: jest.fn(), +}); + +type LoggingServiceContract = PublicMethodsOf; +const createMock = (): jest.Mocked => { + const service: jest.Mocked = { + setup: jest.fn(), + start: jest.fn(), stop: jest.fn(), }; - mocked.get.mockImplementation((...context) => ({ - ...mockLog, - context, - })); - mocked.asLoggerFactory.mockImplementation(() => mocked); - mocked.stop.mockResolvedValue(); - return mocked; -}; - -const collectLoggingServiceMock = (loggerFactory: LoggerFactory) => { - const mockLog = loggerFactory.get() as MockedLogger; - return { - debug: mockLog.debug.mock.calls, - error: mockLog.error.mock.calls, - fatal: mockLog.fatal.mock.calls, - info: mockLog.info.mock.calls, - log: mockLog.log.mock.calls, - trace: mockLog.trace.mock.calls, - warn: mockLog.warn.mock.calls, - }; -}; -const clearLoggingServiceMock = (loggerFactory: LoggerFactory) => { - const mockedLoggerFactory = (loggerFactory as unknown) as jest.Mocked; - mockedLoggerFactory.get.mockClear(); - mockedLoggerFactory.asLoggerFactory.mockClear(); - mockedLoggerFactory.upgrade.mockClear(); - mockedLoggerFactory.stop.mockClear(); + service.setup.mockReturnValue(createInternalSetupMock()); - const mockLog = loggerFactory.get() as MockedLogger; - mockLog.debug.mockClear(); - mockLog.info.mockClear(); - mockLog.warn.mockClear(); - mockLog.error.mockClear(); - mockLog.trace.mockClear(); - mockLog.fatal.mockClear(); - mockLog.log.mockClear(); + return service; }; export const loggingServiceMock = { - create: createLoggingServiceMock, - collect: collectLoggingServiceMock, - clear: clearLoggingServiceMock, - createLogger: loggerMock.create, + create: createMock, + createSetupContract: createSetupMock, + createInternalSetupContract: createInternalSetupMock, }; diff --git a/src/core/server/logging/logging_service.test.ts b/src/core/server/logging/logging_service.test.ts index 1e6c253c56c7b1..5107db77304fcc 100644 --- a/src/core/server/logging/logging_service.test.ts +++ b/src/core/server/logging/logging_service.test.ts @@ -16,167 +16,85 @@ * specific language governing permissions and limitations * under the License. */ - -const mockStreamWrite = jest.fn(); -jest.mock('fs', () => ({ - constants: {}, - createWriteStream: jest.fn(() => ({ write: mockStreamWrite })), -})); - -const dynamicProps = { pid: expect.any(Number) }; - -jest.mock('../../../legacy/server/logging/rotate', () => ({ - setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), -})); - -const timestamp = new Date(Date.UTC(2012, 1, 1, 14, 33, 22, 11)); -let mockConsoleLog: jest.SpyInstance; - -import { createWriteStream } from 'fs'; -const mockCreateWriteStream = (createWriteStream as unknown) as jest.Mock; - -import { LoggingService, config } from '.'; - -let service: LoggingService; -beforeEach(() => { - mockConsoleLog = jest.spyOn(global.console, 'log').mockReturnValue(undefined); - jest.spyOn(global, 'Date').mockImplementation(() => timestamp); - service = new LoggingService(); -}); - -afterEach(() => { - jest.restoreAllMocks(); - mockCreateWriteStream.mockClear(); - mockStreamWrite.mockClear(); -}); - -test('uses default memory buffer logger until config is provided', () => { - const bufferAppendSpy = jest.spyOn((service as any).bufferAppender, 'append'); - - const logger = service.get('test', 'context'); - logger.trace('trace message'); - - // We shouldn't create new buffer appender for another context. - const anotherLogger = service.get('test', 'context2'); - anotherLogger.fatal('fatal message', { some: 'value' }); - - expect(bufferAppendSpy).toHaveBeenCalledTimes(2); - expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot(dynamicProps); - expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot(dynamicProps); -}); - -test('flushes memory buffer logger and switches to real logger once config is provided', () => { - const logger = service.get('test', 'context'); - - logger.trace('buffered trace message'); - logger.info('buffered info message', { some: 'value' }); - logger.fatal('buffered fatal message'); - - const bufferAppendSpy = jest.spyOn((service as any).bufferAppender, 'append'); - - // Switch to console appender with `info` level, so that `trace` message won't go through. - service.upgrade( - config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, - root: { level: 'info' }, - }) - ); - - expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot( - dynamicProps, - 'buffered messages' - ); - mockConsoleLog.mockClear(); - - // Now message should go straight to thew newly configured appender, not buffered one. - logger.info('some new info message'); - expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot(dynamicProps, 'new messages'); - expect(bufferAppendSpy).not.toHaveBeenCalled(); -}); - -test('appends records via multiple appenders.', () => { - const loggerWithoutConfig = service.get('some-context'); - const testsLogger = service.get('tests'); - const testsChildLogger = service.get('tests', 'child'); - - loggerWithoutConfig.info('You know, just for your info.'); - testsLogger.warn('Config is not ready!'); - testsChildLogger.error('Too bad that config is not ready :/'); - testsChildLogger.info('Just some info that should not be logged.'); - - expect(mockConsoleLog).not.toHaveBeenCalled(); - expect(mockCreateWriteStream).not.toHaveBeenCalled(); - - service.upgrade( - config.schema.validate({ - appenders: { - default: { kind: 'console', layout: { kind: 'pattern' } }, - file: { kind: 'file', layout: { kind: 'pattern' }, path: 'path' }, - }, - loggers: [ - { appenders: ['file'], context: 'tests', level: 'warn' }, - { context: 'tests.child', level: 'error' }, - ], - }) - ); - - // Now all logs should added to configured appenders. - expect(mockConsoleLog).toHaveBeenCalledTimes(1); - expect(mockConsoleLog.mock.calls[0][0]).toMatchSnapshot('console logs'); - - expect(mockStreamWrite).toHaveBeenCalledTimes(2); - expect(mockStreamWrite.mock.calls[0][0]).toMatchSnapshot('file logs'); - expect(mockStreamWrite.mock.calls[1][0]).toMatchSnapshot('file logs'); -}); - -test('uses `root` logger if context is not specified.', () => { - service.upgrade( - config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'pattern' } } }, - }) - ); - - const rootLogger = service.get(); - rootLogger.info('This message goes to a root context.'); - - expect(mockConsoleLog.mock.calls).toMatchSnapshot(); -}); - -test('`stop()` disposes all appenders.', async () => { - service.upgrade( - config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, - root: { level: 'info' }, - }) - ); - - const bufferDisposeSpy = jest.spyOn((service as any).bufferAppender, 'dispose'); - const consoleDisposeSpy = jest.spyOn((service as any).appenders.get('default'), 'dispose'); - - await service.stop(); - - expect(bufferDisposeSpy).toHaveBeenCalledTimes(1); - expect(consoleDisposeSpy).toHaveBeenCalledTimes(1); -}); - -test('asLoggerFactory() only allows to create new loggers.', () => { - const logger = service.asLoggerFactory().get('test', 'context'); - - service.upgrade( - config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, - root: { level: 'all' }, - }) - ); - - logger.trace('buffered trace message'); - logger.info('buffered info message', { some: 'value' }); - logger.fatal('buffered fatal message'); - - expect(Object.keys(service.asLoggerFactory())).toEqual(['get']); - - expect(mockConsoleLog).toHaveBeenCalledTimes(3); - expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot(dynamicProps); - expect(JSON.parse(mockConsoleLog.mock.calls[1][0])).toMatchSnapshot(dynamicProps); - expect(JSON.parse(mockConsoleLog.mock.calls[2][0])).toMatchSnapshot(dynamicProps); +import { of, Subject } from 'rxjs'; + +import { LoggingService, InternalLoggingServiceSetup } from './logging_service'; +import { loggingSystemMock } from './logging_system.mock'; +import { LoggerContextConfigType } from './logging_config'; + +describe('LoggingService', () => { + let loggingSystem: ReturnType; + let service: LoggingService; + let setup: InternalLoggingServiceSetup; + + beforeEach(() => { + loggingSystem = loggingSystemMock.create(); + service = new LoggingService({ logger: loggingSystem.asLoggerFactory() } as any); + setup = service.setup({ loggingSystem }); + }); + afterEach(() => { + service.stop(); + }); + + describe('setup', () => { + it('forwards configuration changes to logging system', () => { + const config1: LoggerContextConfigType = { + appenders: new Map(), + loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + }; + const config2: LoggerContextConfigType = { + appenders: new Map(), + loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + }; + + setup.configure(['test', 'context'], of(config1, config2)); + expect(loggingSystem.setContextConfig).toHaveBeenNthCalledWith( + 1, + ['test', 'context'], + config1 + ); + expect(loggingSystem.setContextConfig).toHaveBeenNthCalledWith( + 2, + ['test', 'context'], + config2 + ); + }); + + it('stops forwarding first observable when called a second time', () => { + const updates$ = new Subject(); + const config1: LoggerContextConfigType = { + appenders: new Map(), + loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + }; + const config2: LoggerContextConfigType = { + appenders: new Map(), + loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + }; + + setup.configure(['test', 'context'], updates$); + setup.configure(['test', 'context'], of(config1)); + updates$.next(config2); + expect(loggingSystem.setContextConfig).toHaveBeenNthCalledWith( + 1, + ['test', 'context'], + config1 + ); + expect(loggingSystem.setContextConfig).not.toHaveBeenCalledWith(['test', 'context'], config2); + }); + }); + + describe('stop', () => { + it('stops forwarding updates to logging system', () => { + const updates$ = new Subject(); + const config1: LoggerContextConfigType = { + appenders: new Map(), + loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + }; + + setup.configure(['test', 'context'], updates$); + service.stop(); + updates$.next(config1); + expect(loggingSystem.setContextConfig).not.toHaveBeenCalledWith(['test', 'context'], config1); + }); + }); }); diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts index 2e6f8957241228..09051f8f077023 100644 --- a/src/core/server/logging/logging_service.ts +++ b/src/core/server/logging/logging_service.ts @@ -16,112 +16,88 @@ * specific language governing permissions and limitations * under the License. */ -import { Appenders, DisposableAppender } from './appenders/appenders'; -import { BufferAppender } from './appenders/buffer/buffer_appender'; -import { LogLevel } from './log_level'; -import { BaseLogger, Logger } from './logger'; -import { LoggerAdapter } from './logger_adapter'; -import { LoggerFactory } from './logger_factory'; -import { LoggingConfigType, LoggerConfigType, LoggingConfig } from './logging_config'; -export type ILoggingService = PublicMethodsOf; +import { Observable, Subscription } from 'rxjs'; +import { CoreService } from '../../types'; +import { LoggingConfig, LoggerContextConfigInput } from './logging_config'; +import { ILoggingSystem } from './logging_system'; +import { Logger } from './logger'; +import { CoreContext } from '../core_context'; + /** - * Service that is responsible for maintaining loggers and logger appenders. - * @internal + * Provides APIs to plugins for customizing the plugin's logger. + * @public */ -export class LoggingService implements LoggerFactory { - private config?: LoggingConfig; - private readonly appenders: Map = new Map(); - private readonly bufferAppender = new BufferAppender(); - private readonly loggers: Map = new Map(); - - public get(...contextParts: string[]): Logger { - const context = LoggingConfig.getLoggerContext(contextParts); - if (!this.loggers.has(context)) { - this.loggers.set(context, new LoggerAdapter(this.createLogger(context, this.config))); - } - return this.loggers.get(context)!; - } - - /** - * Safe wrapper that allows passing logging service as immutable LoggerFactory. - */ - public asLoggerFactory(): LoggerFactory { - return { get: (...contextParts: string[]) => this.get(...contextParts) }; - } - +export interface LoggingServiceSetup { /** - * Updates all current active loggers with the new config values. - * @param rawConfig New config instance. + * Customizes the logging config for the plugin's context. + * + * @remarks + * Assumes that that the `context` property of the individual `logger` items emitted by `config$` + * are relative to the plugin's logging context (defaults to `plugins.`). + * + * @example + * Customize the configuration for the plugins.data.search context. + * ```ts + * core.logging.configure( + * of({ + * appenders: new Map(), + * loggers: [{ context: 'search', appenders: ['default'] }] + * }) + * ) + * ``` + * + * @param config$ */ - public upgrade(rawConfig: LoggingConfigType) { - const config = new LoggingConfig(rawConfig); - // Config update is asynchronous and may require some time to complete, so we should invalidate - // config so that new loggers will be using BufferAppender until newly configured appenders are ready. - this.config = undefined; - - // Appenders must be reset, so we first dispose of the current ones, then - // build up a new set of appenders. - for (const appender of this.appenders.values()) { - appender.dispose(); - } - this.appenders.clear(); + configure(config$: Observable): void; +} - for (const [appenderKey, appenderConfig] of config.appenders) { - this.appenders.set(appenderKey, Appenders.create(appenderConfig)); - } +/** @internal */ +export interface InternalLoggingServiceSetup { + configure(contextParts: string[], config$: Observable): void; +} - for (const [loggerKey, loggerAdapter] of this.loggers) { - loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); - } +interface SetupDeps { + loggingSystem: ILoggingSystem; +} - this.config = config; +/** @internal */ +export class LoggingService implements CoreService { + private readonly subscriptions = new Map(); + private readonly log: Logger; - // Re-log all buffered log records with newly configured appenders. - for (const logRecord of this.bufferAppender.flush()) { - this.get(logRecord.context).log(logRecord); - } + constructor(coreContext: CoreContext) { + this.log = coreContext.logger.get('logging'); } - /** - * Disposes all loggers (closes log files, clears buffers etc.). Service is not usable after - * calling of this method until new config is provided via `upgrade` method. - * @returns Promise that is resolved once all loggers are successfully disposed. - */ - public async stop() { - for (const appender of this.appenders.values()) { - await appender.dispose(); - } - - await this.bufferAppender.dispose(); - - this.appenders.clear(); - this.loggers.clear(); + public setup({ loggingSystem }: SetupDeps) { + return { + configure: (contextParts: string[], config$: Observable) => { + const contextName = LoggingConfig.getLoggerContext(contextParts); + this.log.debug(`Setting custom config for context [${contextName}]`); + + const existingSubscription = this.subscriptions.get(contextName); + if (existingSubscription) { + existingSubscription.unsubscribe(); + } + + // Might be fancier way to do this with rxjs, but this works and is simple to understand + this.subscriptions.set( + contextName, + config$.subscribe((config) => { + this.log.debug(`Updating logging config for context [${contextName}]`); + loggingSystem.setContextConfig(contextParts, config); + }) + ); + }, + }; } - private createLogger(context: string, config: LoggingConfig | undefined) { - if (config === undefined) { - // If we don't have config yet, use `buffered` appender that will store all logged messages in the memory - // until the config is ready. - return new BaseLogger(context, LogLevel.All, [this.bufferAppender], this.asLoggerFactory()); - } - - const { level, appenders } = this.getLoggerConfigByContext(config, context); - const loggerLevel = LogLevel.fromId(level); - const loggerAppenders = appenders.map((appenderKey) => this.appenders.get(appenderKey)!); + public start() {} - return new BaseLogger(context, loggerLevel, loggerAppenders, this.asLoggerFactory()); - } - - private getLoggerConfigByContext(config: LoggingConfig, context: string): LoggerConfigType { - const loggerConfig = config.loggers.get(context); - if (loggerConfig !== undefined) { - return loggerConfig; + public stop() { + for (const [, subscription] of this.subscriptions) { + subscription.unsubscribe(); } - - // If we don't have configuration for the specified context and it's the "nested" one (eg. `foo.bar.baz`), - // let's move up to the parent context (eg. `foo.bar`) and check if it has config we can rely on. Otherwise - // we fallback to the `root` context that should always be defined (enforced by configuration schema). - return this.getLoggerConfigByContext(config, LoggingConfig.getParentLoggerContext(context)); } } diff --git a/src/core/server/logging/logging_system.mock.ts b/src/core/server/logging/logging_system.mock.ts new file mode 100644 index 00000000000000..ac1e9b5196002e --- /dev/null +++ b/src/core/server/logging/logging_system.mock.ts @@ -0,0 +1,84 @@ +/* + * 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. + */ + +// Test helpers to simplify mocking logs and collecting all their outputs +import { ILoggingSystem } from './logging_system'; +import { LoggerFactory } from './logger_factory'; +import { loggerMock, MockedLogger } from './logger.mock'; + +const createLoggingSystemMock = () => { + const mockLog = loggerMock.create(); + + mockLog.get.mockImplementation((...context) => ({ + ...mockLog, + context, + })); + + const mocked: jest.Mocked = { + get: jest.fn(), + asLoggerFactory: jest.fn(), + setContextConfig: jest.fn(), + upgrade: jest.fn(), + stop: jest.fn(), + }; + mocked.get.mockImplementation((...context) => ({ + ...mockLog, + context, + })); + mocked.asLoggerFactory.mockImplementation(() => mocked); + mocked.stop.mockResolvedValue(); + return mocked; +}; + +const collectLoggingSystemMock = (loggerFactory: LoggerFactory) => { + const mockLog = loggerFactory.get() as MockedLogger; + return { + debug: mockLog.debug.mock.calls, + error: mockLog.error.mock.calls, + fatal: mockLog.fatal.mock.calls, + info: mockLog.info.mock.calls, + log: mockLog.log.mock.calls, + trace: mockLog.trace.mock.calls, + warn: mockLog.warn.mock.calls, + }; +}; + +const clearLoggingSystemMock = (loggerFactory: LoggerFactory) => { + const mockedLoggerFactory = (loggerFactory as unknown) as jest.Mocked; + mockedLoggerFactory.get.mockClear(); + mockedLoggerFactory.asLoggerFactory.mockClear(); + mockedLoggerFactory.upgrade.mockClear(); + mockedLoggerFactory.stop.mockClear(); + + const mockLog = loggerFactory.get() as MockedLogger; + mockLog.debug.mockClear(); + mockLog.info.mockClear(); + mockLog.warn.mockClear(); + mockLog.error.mockClear(); + mockLog.trace.mockClear(); + mockLog.fatal.mockClear(); + mockLog.log.mockClear(); +}; + +export const loggingSystemMock = { + create: createLoggingSystemMock, + collect: collectLoggingSystemMock, + clear: clearLoggingSystemMock, + createLogger: loggerMock.create, +}; diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts new file mode 100644 index 00000000000000..f73e40fe320dc3 --- /dev/null +++ b/src/core/server/logging/logging_system.test.ts @@ -0,0 +1,348 @@ +/* + * 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. + */ + +const mockStreamWrite = jest.fn(); +jest.mock('fs', () => ({ + constants: {}, + createWriteStream: jest.fn(() => ({ write: mockStreamWrite })), +})); + +const dynamicProps = { pid: expect.any(Number) }; + +jest.mock('../../../legacy/server/logging/rotate', () => ({ + setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), +})); + +const timestamp = new Date(Date.UTC(2012, 1, 1, 14, 33, 22, 11)); +let mockConsoleLog: jest.SpyInstance; + +import { createWriteStream } from 'fs'; +const mockCreateWriteStream = (createWriteStream as unknown) as jest.Mock; + +import { LoggingSystem, config } from '.'; + +let system: LoggingSystem; +beforeEach(() => { + mockConsoleLog = jest.spyOn(global.console, 'log').mockReturnValue(undefined); + jest.spyOn(global, 'Date').mockImplementation(() => timestamp); + system = new LoggingSystem(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + mockCreateWriteStream.mockClear(); + mockStreamWrite.mockClear(); +}); + +test('uses default memory buffer logger until config is provided', () => { + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + const logger = system.get('test', 'context'); + logger.trace('trace message'); + + // We shouldn't create new buffer appender for another context. + const anotherLogger = system.get('test', 'context2'); + anotherLogger.fatal('fatal message', { some: 'value' }); + + expect(bufferAppendSpy).toHaveBeenCalledTimes(2); + expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot(dynamicProps); + expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot(dynamicProps); +}); + +test('flushes memory buffer logger and switches to real logger once config is provided', () => { + const logger = system.get('test', 'context'); + + logger.trace('buffered trace message'); + logger.info('buffered info message', { some: 'value' }); + logger.fatal('buffered fatal message'); + + const bufferAppendSpy = jest.spyOn((system as any).bufferAppender, 'append'); + + // Switch to console appender with `info` level, so that `trace` message won't go through. + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot( + dynamicProps, + 'buffered messages' + ); + mockConsoleLog.mockClear(); + + // Now message should go straight to thew newly configured appender, not buffered one. + logger.info('some new info message'); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot(dynamicProps, 'new messages'); + expect(bufferAppendSpy).not.toHaveBeenCalled(); +}); + +test('appends records via multiple appenders.', () => { + const loggerWithoutConfig = system.get('some-context'); + const testsLogger = system.get('tests'); + const testsChildLogger = system.get('tests', 'child'); + + loggerWithoutConfig.info('You know, just for your info.'); + testsLogger.warn('Config is not ready!'); + testsChildLogger.error('Too bad that config is not ready :/'); + testsChildLogger.info('Just some info that should not be logged.'); + + expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(mockCreateWriteStream).not.toHaveBeenCalled(); + + system.upgrade( + config.schema.validate({ + appenders: { + default: { kind: 'console', layout: { kind: 'pattern' } }, + file: { kind: 'file', layout: { kind: 'pattern' }, path: 'path' }, + }, + loggers: [ + { appenders: ['file'], context: 'tests', level: 'warn' }, + { context: 'tests.child', level: 'error' }, + ], + }) + ); + + // Now all logs should added to configured appenders. + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog.mock.calls[0][0]).toMatchSnapshot('console logs'); + + expect(mockStreamWrite).toHaveBeenCalledTimes(2); + expect(mockStreamWrite.mock.calls[0][0]).toMatchSnapshot('file logs'); + expect(mockStreamWrite.mock.calls[1][0]).toMatchSnapshot('file logs'); +}); + +test('uses `root` logger if context is not specified.', () => { + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'pattern' } } }, + }) + ); + + const rootLogger = system.get(); + rootLogger.info('This message goes to a root context.'); + + expect(mockConsoleLog.mock.calls).toMatchSnapshot(); +}); + +test('`stop()` disposes all appenders.', async () => { + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + const bufferDisposeSpy = jest.spyOn((system as any).bufferAppender, 'dispose'); + const consoleDisposeSpy = jest.spyOn((system as any).appenders.get('default'), 'dispose'); + + await system.stop(); + + expect(bufferDisposeSpy).toHaveBeenCalledTimes(1); + expect(consoleDisposeSpy).toHaveBeenCalledTimes(1); +}); + +test('asLoggerFactory() only allows to create new loggers.', () => { + const logger = system.asLoggerFactory().get('test', 'context'); + + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'all' }, + }) + ); + + logger.trace('buffered trace message'); + logger.info('buffered info message', { some: 'value' }); + logger.fatal('buffered fatal message'); + + expect(Object.keys(system.asLoggerFactory())).toEqual(['get']); + + expect(mockConsoleLog).toHaveBeenCalledTimes(3); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchSnapshot(dynamicProps); + expect(JSON.parse(mockConsoleLog.mock.calls[1][0])).toMatchSnapshot(dynamicProps); + expect(JSON.parse(mockConsoleLog.mock.calls[2][0])).toMatchSnapshot(dynamicProps); +}); + +test('setContextConfig() updates config with relative contexts', () => { + const testsLogger = system.get('tests'); + const testsChildLogger = system.get('tests', 'child'); + const testsGrandchildLogger = system.get('tests', 'child', 'grandchild'); + + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + system.setContextConfig(['tests', 'child'], { + appenders: new Map([ + [ + 'custom', + { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + ], + ]), + loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + }); + + testsLogger.warn('tests log to default!'); + testsChildLogger.error('tests.child log to default!'); + testsGrandchildLogger.debug('tests.child.grandchild log to default and custom!'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(4); + // Parent contexts are unaffected + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ + context: 'tests', + message: 'tests log to default!', + level: 'WARN', + }); + expect(JSON.parse(mockConsoleLog.mock.calls[1][0])).toMatchObject({ + context: 'tests.child', + message: 'tests.child log to default!', + level: 'ERROR', + }); + // Customized context is logged in both appender formats + expect(JSON.parse(mockConsoleLog.mock.calls[2][0])).toMatchObject({ + context: 'tests.child.grandchild', + message: 'tests.child.grandchild log to default and custom!', + level: 'DEBUG', + }); + expect(mockConsoleLog.mock.calls[3][0]).toMatchInlineSnapshot( + `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` + ); +}); + +test('custom context configs are applied on subsequent calls to update()', () => { + system.setContextConfig(['tests', 'child'], { + appenders: new Map([ + [ + 'custom', + { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + ], + ]), + loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + }); + + // Calling upgrade after setContextConfig should not throw away the context-specific config + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + system + .get('tests', 'child', 'grandchild') + .debug('tests.child.grandchild log to default and custom!'); + + // Customized context is logged in both appender formats still + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ + context: 'tests.child.grandchild', + message: 'tests.child.grandchild log to default and custom!', + level: 'DEBUG', + }); + expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( + `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` + ); +}); + +test('subsequent calls to setContextConfig() for the same context override the previous config', () => { + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + system.setContextConfig(['tests', 'child'], { + appenders: new Map([ + [ + 'custom', + { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + ], + ]), + loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + }); + + // Call again, this time with level: 'warn' and a different pattern + system.setContextConfig(['tests', 'child'], { + appenders: new Map([ + [ + 'custom', + { + kind: 'console', + layout: { kind: 'pattern', pattern: '[%level][%logger] second pattern! %message' }, + }, + ], + ]), + loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'warn' }], + }); + + const logger = system.get('tests', 'child', 'grandchild'); + logger.debug('this should not show anywhere!'); + logger.warn('tests.child.grandchild log to default and custom!'); + + // Only the warn log should have been logged + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ + context: 'tests.child.grandchild', + message: 'tests.child.grandchild log to default and custom!', + level: 'WARN', + }); + expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( + `"[WARN ][tests.child.grandchild] second pattern! tests.child.grandchild log to default and custom!"` + ); +}); + +test('subsequent calls to setContextConfig() for the same context can disable the previous config', () => { + system.upgrade( + config.schema.validate({ + appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + root: { level: 'info' }, + }) + ); + + system.setContextConfig(['tests', 'child'], { + appenders: new Map([ + [ + 'custom', + { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + ], + ]), + loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + }); + + // Call again, this time no customizations (effectively disabling) + system.setContextConfig(['tests', 'child'], {}); + + const logger = system.get('tests', 'child', 'grandchild'); + logger.debug('this should not show anywhere!'); + logger.warn('tests.child.grandchild log to default!'); + + // Only the warn log should have been logged once on the default appender + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ + context: 'tests.child.grandchild', + message: 'tests.child.grandchild log to default!', + level: 'WARN', + }); +}); diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts new file mode 100644 index 00000000000000..0bab9534d2d053 --- /dev/null +++ b/src/core/server/logging/logging_system.ts @@ -0,0 +1,185 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Appenders, DisposableAppender } from './appenders/appenders'; +import { BufferAppender } from './appenders/buffer/buffer_appender'; +import { LogLevel } from './log_level'; +import { BaseLogger, Logger } from './logger'; +import { LoggerAdapter } from './logger_adapter'; +import { LoggerFactory } from './logger_factory'; +import { + LoggingConfigType, + LoggerConfigType, + LoggingConfig, + LoggerContextConfigType, + LoggerContextConfigInput, + loggerContextConfigSchema, +} from './logging_config'; + +export type ILoggingSystem = PublicMethodsOf; + +/** + * System that is responsible for maintaining loggers and logger appenders. + * @internal + */ +export class LoggingSystem implements LoggerFactory { + /** The configuration set by the user. */ + private baseConfig?: LoggingConfig; + /** The fully computed configuration extended by context-specific configurations set programmatically */ + private computedConfig?: LoggingConfig; + private readonly appenders: Map = new Map(); + private readonly bufferAppender = new BufferAppender(); + private readonly loggers: Map = new Map(); + private readonly contextConfigs = new Map(); + + public get(...contextParts: string[]): Logger { + const context = LoggingConfig.getLoggerContext(contextParts); + if (!this.loggers.has(context)) { + this.loggers.set(context, new LoggerAdapter(this.createLogger(context, this.computedConfig))); + } + return this.loggers.get(context)!; + } + + /** + * Safe wrapper that allows passing logging service as immutable LoggerFactory. + */ + public asLoggerFactory(): LoggerFactory { + return { get: (...contextParts: string[]) => this.get(...contextParts) }; + } + + /** + * Updates all current active loggers with the new config values. + * @param rawConfig New config instance. + */ + public upgrade(rawConfig: LoggingConfigType) { + const config = new LoggingConfig(rawConfig)!; + this.applyBaseConfig(config); + } + + /** + * Customizes the logging config for a specific context. + * + * @remarks + * Assumes that that the `context` property of the individual items in `rawConfig.loggers` + * are relative to the `baseContextParts`. + * + * @example + * Customize the configuration for the plugins.data.search context. + * ```ts + * loggingSystem.setContextConfig( + * ['plugins', 'data'], + * { + * loggers: [{ context: 'search', appenders: ['default'] }] + * } + * ) + * ``` + * + * @param baseContextParts + * @param rawConfig + */ + public setContextConfig(baseContextParts: string[], rawConfig: LoggerContextConfigInput) { + const context = LoggingConfig.getLoggerContext(baseContextParts); + const contextConfig = loggerContextConfigSchema.validate(rawConfig); + this.contextConfigs.set(context, { + ...contextConfig, + // Automatically prepend the base context to the logger sub-contexts + loggers: contextConfig.loggers.map((l) => ({ + ...l, + context: LoggingConfig.getLoggerContext([context, l.context]), + })), + }); + + // If we already have a base config, apply the config. If not, custom context configs + // will be picked up on next call to `upgrade`. + if (this.baseConfig) { + this.applyBaseConfig(this.baseConfig); + } + } + + /** + * Disposes all loggers (closes log files, clears buffers etc.). Service is not usable after + * calling of this method until new config is provided via `upgrade` method. + * @returns Promise that is resolved once all loggers are successfully disposed. + */ + public async stop() { + await Promise.all([...this.appenders.values()].map((a) => a.dispose())); + + await this.bufferAppender.dispose(); + + this.appenders.clear(); + this.loggers.clear(); + } + + private createLogger(context: string, config: LoggingConfig | undefined) { + if (config === undefined) { + // If we don't have config yet, use `buffered` appender that will store all logged messages in the memory + // until the config is ready. + return new BaseLogger(context, LogLevel.All, [this.bufferAppender], this.asLoggerFactory()); + } + + const { level, appenders } = this.getLoggerConfigByContext(config, context); + const loggerLevel = LogLevel.fromId(level); + const loggerAppenders = appenders.map((appenderKey) => this.appenders.get(appenderKey)!); + + return new BaseLogger(context, loggerLevel, loggerAppenders, this.asLoggerFactory()); + } + + private getLoggerConfigByContext(config: LoggingConfig, context: string): LoggerConfigType { + const loggerConfig = config.loggers.get(context); + if (loggerConfig !== undefined) { + return loggerConfig; + } + + // If we don't have configuration for the specified context and it's the "nested" one (eg. `foo.bar.baz`), + // let's move up to the parent context (eg. `foo.bar`) and check if it has config we can rely on. Otherwise + // we fallback to the `root` context that should always be defined (enforced by configuration schema). + return this.getLoggerConfigByContext(config, LoggingConfig.getParentLoggerContext(context)); + } + + private applyBaseConfig(newBaseConfig: LoggingConfig) { + const computedConfig = [...this.contextConfigs.values()].reduce( + (baseConfig, contextConfig) => baseConfig.extend(contextConfig), + newBaseConfig + ); + + // Appenders must be reset, so we first dispose of the current ones, then + // build up a new set of appenders. + for (const appender of this.appenders.values()) { + appender.dispose(); + } + this.appenders.clear(); + + for (const [appenderKey, appenderConfig] of computedConfig.appenders) { + this.appenders.set(appenderKey, Appenders.create(appenderConfig)); + } + + for (const [loggerKey, loggerAdapter] of this.loggers) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, computedConfig)); + } + + // We keep a reference to the base config so we can properly extend it + // on each config change. + this.baseConfig = newBaseConfig; + this.computedConfig = computedConfig; + + // Re-log all buffered log records with newly configured appenders. + for (const logRecord of this.bufferAppender.flush()) { + this.get(logRecord.context).log(logRecord); + } + } +} diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index f3ae5462f16316..2ac5bd98f7ed45 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -19,6 +19,7 @@ import { of } from 'rxjs'; import { duration } from 'moment'; import { PluginInitializerContext, CoreSetup, CoreStart, StartServicesAccessor } from '.'; +import { loggingSystemMock } from './logging/logging_system.mock'; import { loggingServiceMock } from './logging/logging_service.mock'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -42,9 +43,10 @@ export { sessionStorageMock } from './http/cookie_session_storage.mocks'; export { configServiceMock } from './config/config_service.mock'; export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; export { httpServiceMock } from './http/http_service.mock'; -export { loggingServiceMock } from './logging/logging_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'; @@ -78,7 +80,7 @@ export function pluginInitializerContextConfigMock(config: T) { function pluginInitializerContextMock(config: T = {} as T) { const mock: PluginInitializerContext = { opaqueId: Symbol(), - logger: loggingServiceMock.create(), + logger: loggingSystemMock.create(), env: { mode: { dev: true, @@ -130,6 +132,7 @@ function createCoreSetupMock({ metrics: metricsServiceMock.createSetupContract(), uiSettings: uiSettingsMock, uuid: uuidServiceMock.createSetupContract(), + logging: loggingServiceMock.createSetupContract(), getStartServices: jest .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), @@ -163,6 +166,7 @@ function createInternalCoreSetupMock() { httpResources: httpResourcesMock.createSetupContract(), rendering: renderingMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + logging: loggingServiceMock.createInternalSetupContract(), }; return setupDeps; } diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 979accb1f769ef..5ffdef88104c83 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -20,12 +20,12 @@ import { PluginDiscoveryErrorType } from './plugin_discovery_error'; import { mockReadFile } from './plugin_manifest_parser.test.mocks'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { resolve } from 'path'; import { parseManifest } from './plugin_manifest_parser'; -const logger = loggingServiceMock.createLogger(); +const logger = loggingSystemMock.createLogger(); const pluginPath = resolve('path', 'existent-dir'); const pluginManifestPath = resolve(pluginPath, 'kibana.json'); const packageInfo = { @@ -105,9 +105,9 @@ test('logs warning if pluginId is not in camelCase format', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); }); - expect(loggingServiceMock.collect(logger).warn).toHaveLength(0); + expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); await parseManifest(pluginPath, packageInfo, logger); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Expect plugin \\"id\\" in camelCase, but found: some_name", diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 73f274957cbc46..1c42f5dcfc7a70 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -19,7 +19,7 @@ import { mockPackage, mockReaddir, mockReadFile, mockStat } from './plugins_discovery.test.mocks'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { resolve } from 'path'; import { first, map, toArray } from 'rxjs/operators'; @@ -37,7 +37,7 @@ const TEST_PLUGIN_SEARCH_PATHS = { }; const TEST_EXTRA_PLUGIN_PATH = resolve(process.cwd(), 'my-extra-plugin'); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); beforeEach(() => { mockReaddir.mockImplementation((path, cb) => { @@ -221,7 +221,7 @@ test('logs a warning about --plugin-path when used in development', async () => logger, }); - expect(loggingServiceMock.collect(logger).warn).toEqual([ + expect(loggingSystemMock.collect(logger).warn).toEqual([ [ `Explicit plugin paths [${TEST_EXTRA_PLUGIN_PATH}] should only be used in development. Relative imports may not work properly in production.`, ], @@ -263,5 +263,5 @@ test('does not log a warning about --plugin-path when used in production', async logger, }); - expect(loggingServiceMock.collect(logger).warn).toEqual([]); + expect(loggingSystemMock.collect(logger).warn).toEqual([]); }); diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index 04f570cca489bd..e676c789449caf 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -27,13 +27,13 @@ import { getEnvOptions } from '../../config/__mocks__/env'; import { BehaviorSubject, from } from 'rxjs'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; import { config } from '../plugins_config'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { coreMock } from '../../mocks'; import { Plugin } from '../types'; import { PluginWrapper } from '../plugin'; describe('PluginsService', () => { - const logger = loggingServiceMock.create(); + const logger = loggingSystemMock.create(); let pluginsService: PluginsService; const createPlugin = ( diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 8d82d96f949c71..ec0a3986b48775 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -26,14 +26,14 @@ import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; import { coreMock } from '../mocks'; import { configServiceMock } from '../config/config_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginWrapper } from './plugin'; import { PluginManifest } from './types'; import { createPluginInitializerContext, createPluginSetupContext } from './plugin_context'; const mockPluginInitializer = jest.fn(); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); jest.doMock( join('plugin-with-initializer-path', 'server'), () => ({ plugin: mockPluginInitializer }), diff --git a/src/core/server/plugins/plugin.ts b/src/core/server/plugins/plugin.ts index d7cfaa14d23434..2e5881c6518439 100644 --- a/src/core/server/plugins/plugin.ts +++ b/src/core/server/plugins/plugin.ts @@ -95,8 +95,6 @@ export class PluginWrapper< public async setup(setupContext: CoreSetup, plugins: TPluginsSetup) { this.instance = this.createPluginInstance(); - this.log.debug('Setting up plugin'); - return this.instance.setup(setupContext, plugins); } @@ -112,8 +110,6 @@ export class PluginWrapper< throw new Error(`Plugin "${this.name}" can't be started since it isn't set up.`); } - this.log.debug('Starting plugin'); - const startContract = await this.instance.start(startContext, plugins); this.startDependencies$.next([startContext, plugins, startContract]); return startContract; @@ -127,8 +123,6 @@ export class PluginWrapper< throw new Error(`Plugin "${this.name}" can't be stopped since it isn't set up.`); } - this.log.info('Stopping plugin'); - if (typeof this.instance.stop === 'function') { await this.instance.stop(); } diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 54350d96984b41..69b354661abc9d 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -22,14 +22,14 @@ import { first } from 'rxjs/operators'; import { createPluginInitializerContext } from './plugin_context'; import { CoreContext } from '../core_context'; import { Env } from '../config'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { rawConfigServiceMock } from '../config/raw_config_service.mock'; import { getEnvOptions } from '../config/__mocks__/env'; import { PluginManifest } from './types'; import { Server } from '../server'; import { fromRoot } from '../utils'; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); let coreId: symbol; let env: Env; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 31e36db49223a7..32bc8dc088cad1 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -166,6 +166,9 @@ export function createPluginSetupContext( csp: deps.http.csp, getServerInfo: deps.http.getServerInfo, }, + logging: { + configure: (config$) => deps.logging.configure(['plugins', plugin.name], config$), + }, metrics: { getOpsMetrics$: deps.metrics.getOpsMetrics$, }, diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 6f8d15838641f9..c277dc85e5e048 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -28,7 +28,7 @@ import { ConfigPath, ConfigService, Env } from '../config'; import { rawConfigServiceMock } from '../config/raw_config_service.mock'; import { getEnvOptions } from '../config/__mocks__/env'; import { coreMock } from '../mocks'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginDiscoveryError } from './discovery'; import { PluginWrapper } from './plugin'; import { PluginsService } from './plugins_service'; @@ -47,7 +47,7 @@ let env: Env; let mockPluginSystem: jest.Mocked; const setupDeps = coreMock.createInternalSetup(); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); expect.addSnapshotSerializer(createAbsolutePathSerializer()); @@ -138,7 +138,7 @@ describe('PluginsService', () => { [Error: Failed to initialize plugins: Invalid JSON (invalid-manifest, path-1)] `); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Invalid JSON (invalid-manifest, path-1)], @@ -159,7 +159,7 @@ describe('PluginsService', () => { [Error: Failed to initialize plugins: Incompatible version (incompatible-version, path-3)] `); - expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ [Error: Incompatible version (incompatible-version, path-3)], @@ -238,7 +238,7 @@ describe('PluginsService', () => { expect(mockPluginSystem.setupPlugins).toHaveBeenCalledTimes(1); expect(mockPluginSystem.setupPlugins).toHaveBeenCalledWith(setupDeps); - expect(loggingServiceMock.collect(logger).info).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).info).toMatchInlineSnapshot(` Array [ Array [ "Plugin \\"explicitly-disabled-plugin\\" is disabled.", @@ -360,7 +360,7 @@ describe('PluginsService', () => { { coreId, env, logger, configService } ); - const logs = loggingServiceMock.collect(logger); + const logs = loggingSystemMock.collect(logger); expect(logs.info).toHaveLength(0); expect(logs.error).toHaveLength(0); }); diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 70983e4fd087b5..a40df70228ff3c 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -28,7 +28,7 @@ import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; import { configServiceMock } from '../config/config_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginWrapper } from './plugin'; import { PluginName } from './types'; @@ -36,7 +36,7 @@ import { PluginsSystem } from './plugins_system'; import { coreMock } from '../mocks'; import { Logger } from '../logging'; -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); function createPlugin( id: string, { diff --git a/src/core/server/root/index.test.mocks.ts b/src/core/server/root/index.test.mocks.ts index 1d3add66d7c22c..ef4a40fa3db2d0 100644 --- a/src/core/server/root/index.test.mocks.ts +++ b/src/core/server/root/index.test.mocks.ts @@ -17,10 +17,10 @@ * under the License. */ -import { loggingServiceMock } from '../logging/logging_service.mock'; -export const logger = loggingServiceMock.create(); -jest.doMock('../logging/logging_service', () => ({ - LoggingService: jest.fn(() => logger), +import { loggingSystemMock } from '../logging/logging_system.mock'; +export const logger = loggingSystemMock.create(); +jest.doMock('../logging/logging_system', () => ({ + LoggingSystem: jest.fn(() => logger), })); import { configServiceMock } from '../config/config_service.mock'; diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index d6d0c641e00b09..5e9722de03dee0 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -21,7 +21,7 @@ import { ConnectableObservable, Subscription } from 'rxjs'; import { first, map, publishReplay, switchMap, tap } from 'rxjs/operators'; import { Env, RawConfigurationProvider } from '../config'; -import { Logger, LoggerFactory, LoggingConfigType, LoggingService } from '../logging'; +import { Logger, LoggerFactory, LoggingConfigType, LoggingSystem } from '../logging'; import { Server } from '../server'; /** @@ -30,7 +30,7 @@ import { Server } from '../server'; export class Root { public readonly logger: LoggerFactory; private readonly log: Logger; - private readonly loggingService: LoggingService; + private readonly loggingSystem: LoggingSystem; private readonly server: Server; private loggingConfigSubscription?: Subscription; @@ -39,10 +39,10 @@ export class Root { env: Env, private readonly onShutdown?: (reason?: Error | string) => void ) { - this.loggingService = new LoggingService(); - this.logger = this.loggingService.asLoggerFactory(); + this.loggingSystem = new LoggingSystem(); + this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('root'); - this.server = new Server(rawConfigProvider, env, this.logger); + this.server = new Server(rawConfigProvider, env, this.loggingSystem); } public async setup() { @@ -86,7 +86,7 @@ export class Root { this.loggingConfigSubscription.unsubscribe(); this.loggingConfigSubscription = undefined; } - await this.loggingService.stop(); + await this.loggingSystem.stop(); if (this.onShutdown !== undefined) { this.onShutdown(reason); @@ -99,7 +99,7 @@ export class Root { const update$ = configService.getConfig$().pipe( // always read the logging config when the underlying config object is re-read switchMap(() => configService.atPath('logging')), - map((config) => this.loggingService.upgrade(config)), + map((config) => this.loggingSystem.upgrade(config)), // This specifically console.logs because we were not able to configure the logger. // eslint-disable-next-line no-console tap({ error: (err) => console.error('Configuring logger failed:', err) }), diff --git a/src/core/server/saved_objects/mappings/types.ts b/src/core/server/saved_objects/mappings/types.ts index 8362d1f16bd2a0..c037ed733549e6 100644 --- a/src/core/server/saved_objects/mappings/types.ts +++ b/src/core/server/saved_objects/mappings/types.ts @@ -145,10 +145,14 @@ export interface SavedObjectsCoreFieldMapping { /** * See {@link SavedObjectsFieldMapping} for documentation. * + * Note: this type intentially doesn't include a type definition for defining + * the `dynamic` mapping parameter. Saved Object fields should always inherit + * the `dynamic: 'strict'` paramater. If you are unsure of the shape of your + * data use `type: 'object', enabled: false` instead. + * * @public */ export interface SavedObjectsComplexFieldMapping { - dynamic?: string; type?: string; properties: SavedObjectsMappingProperties; } diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index c2a7b11e057cd3..4561f4d30e104f 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -130,6 +130,8 @@ function defaultMapping(): IndexMapping { dynamic: 'strict', properties: { migrationVersion: { + // Saved Objects can't redefine dynamic, but we cheat here to support migrations + // @ts-expect-error dynamic: 'true', type: 'object', }, diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index a3647103225240..6287d47f99f623 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -20,11 +20,11 @@ import _ from 'lodash'; import { SavedObjectUnsanitizedDoc } from '../../serialization'; import { DocumentMigrator } from './document_migrator'; -import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectsType } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; -const mockLoggerFactory = loggingServiceMock.create(); +const mockLoggerFactory = loggingSystemMock.create(); const mockLogger = mockLoggerFactory.get('mock logger'); const createRegistry = (...types: Array>) => { @@ -572,7 +572,7 @@ describe('DocumentMigrator', () => { expect('Did not throw').toEqual('But it should have!'); } catch (error) { expect(error.message).toMatch(/Dang diggity!/); - const warning = loggingServiceMock.collect(mockLoggerFactory).warn[0][0]; + const warning = loggingSystemMock.collect(mockLoggerFactory).warn[0][0]; expect(warning).toContain(JSON.stringify(failedDoc)); expect(warning).toContain('dog:1.2.3'); } @@ -601,8 +601,8 @@ describe('DocumentMigrator', () => { migrationVersion: {}, }; migrator.migrate(doc); - expect(loggingServiceMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); - expect(loggingServiceMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); + expect(loggingSystemMock.collect(mockLoggerFactory).info[0][0]).toEqual(logTestMsg); + expect(loggingSystemMock.collect(mockLoggerFactory).warn[1][0]).toEqual(logTestMsg); }); test('extracts the latest migration version info', () => { diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 392089c69f5a08..86c79cbfb58249 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -21,7 +21,7 @@ import _ from 'lodash'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { IndexMigrator } from './index_migrator'; -import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../../logging/logging_system.mock'; describe('IndexMigrator', () => { let testOpts: any; @@ -31,7 +31,7 @@ describe('IndexMigrator', () => { batchSize: 10, callCluster: jest.fn(), index: '.kibana', - log: loggingServiceMock.create().get(), + log: loggingSystemMock.create().get(), mappingProperties: {}, pollInterval: 1, scrollDuration: '1m', diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index b2ffe2ad04a880..e588eb7877322a 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -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) ); } } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index e55b72be2436d9..6e4dd9615d4230 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -26,7 +26,7 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { const transform = jest.fn((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((doc: any) => _.set(_.cloneDeep(doc), 'attributes.name', 'TADA') ); - const result = migrateRawDocs( + const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, [ diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index a2b72ea76c1a28..2bdf59d25dc74d 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -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 { + 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); } + } + return processedDocs; +} - 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; - }); +/** + * 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 { + // promises aren't enough to unblock the event loop + return (doc: SavedObjectUnsanitizedDoc) => + new Promise((resolve) => { + // set immediate is though + setImmediate(() => { + resolve(transform(doc)); + }); + }); } diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 7a5c044924d0ee..01b0d1cd0ba3af 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -19,7 +19,7 @@ import { take } from 'rxjs/operators'; import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; -import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; @@ -110,7 +110,7 @@ describe('KibanaMigrator', () => { function mockOptions(): KibanaMigratorOptions { const callCluster = jest.fn(); return { - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), kibanaVersion: '8.2.3', savedObjectValidations: {}, typeRegistry: createRegistry([ diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts index 0fe07245dda202..8d021580da36c7 100644 --- a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -20,7 +20,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerLogLegacyImportRoute } from '../log_legacy_import'; -import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; @@ -28,11 +28,11 @@ type setupServerReturn = UnwrapPromise>; describe('POST /api/saved_objects/_log_legacy_import', () => { let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; - let logger: ReturnType; + let logger: ReturnType; beforeEach(async () => { ({ server, httpSetup } = await setupServer()); - logger = loggingServiceMock.createLogger(); + logger = loggingSystemMock.createLogger(); const router = httpSetup.createRouter('/api/saved_objects/'); registerLogLegacyImportRoute(router, logger); @@ -50,7 +50,7 @@ describe('POST /api/saved_objects/_log_legacy_import', () => { .expect(200); expect(result.body).toEqual({ success: true }); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Importing saved objects from a .json file has been deprecated", diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9dc3ac9b94d96d..00ec217bc85862 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -388,6 +388,11 @@ export interface APICaller { (endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; } +// Warning: (ae-forgotten-export) The symbol "appendersSchema" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type AppenderConfigType = TypeOf; + // @public export function assertNever(x: never): never; @@ -574,6 +579,72 @@ export const config: { ignoreVersionMismatch: import("@kbn/config-schema/target/types/types").ConditionalType; }>; }; + logging: { + appenders: import("@kbn/config-schema").Type | Readonly<{ + pattern?: string | undefined; + highlight?: boolean | undefined; + } & { + kind: "pattern"; + }>; + kind: "console"; + }> | Readonly<{} & { + path: string; + layout: Readonly<{} & { + kind: "json"; + }> | Readonly<{ + pattern?: string | undefined; + highlight?: boolean | undefined; + } & { + kind: "pattern"; + }>; + kind: "file"; + }> | Readonly<{ + legacyLoggingConfig?: any; + } & { + kind: "legacy-appender"; + }>>; + loggers: import("@kbn/config-schema").ObjectType<{ + appenders: import("@kbn/config-schema").Type; + context: import("@kbn/config-schema").Type; + level: import("@kbn/config-schema").Type; + }>; + loggerContext: import("@kbn/config-schema").ObjectType<{ + appenders: import("@kbn/config-schema").Type | Readonly<{ + pattern?: string | undefined; + highlight?: boolean | undefined; + } & { + kind: "pattern"; + }>; + kind: "console"; + }> | Readonly<{} & { + path: string; + layout: Readonly<{} & { + kind: "json"; + }> | Readonly<{ + pattern?: string | undefined; + highlight?: boolean | undefined; + } & { + kind: "pattern"; + }>; + kind: "file"; + }> | Readonly<{ + legacyLoggingConfig?: any; + } & { + kind: "legacy-appender"; + }>>>; + loggers: import("@kbn/config-schema").Type[]>; + }>; + }; }; // @public @@ -639,6 +710,8 @@ export interface CoreSetup; + +// @public (undocumented) +export interface LoggerContextConfigInput { + // (undocumented) + appenders?: Record | Map; + // (undocumented) + loggers?: LoggerConfigType[]; +} + // @public export interface LoggerFactory { get(...contextParts: string[]): Logger; } +// @public +export interface LoggingServiceSetup { + configure(config$: Observable): void; +} + // @internal export class LogLevel { // (undocumented) @@ -1879,8 +1970,6 @@ export interface SavedObjectsClientWrapperOptions { // @public export interface SavedObjectsComplexFieldMapping { - // (undocumented) - dynamic?: string; // (undocumented) properties: SavedObjectsMappingProperties; // (undocumented) diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 5d535c98457249..e5e710d54e04b7 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -91,3 +91,9 @@ export const mockStatusService = statusServiceMock.create(); jest.doMock('./status/status_service', () => ({ StatusService: jest.fn(() => mockStatusService), })); + +import { loggingServiceMock } from './logging/logging_service.mock'; +export const mockLoggingService = loggingServiceMock.create(); +jest.doMock('./logging/logging_service', () => ({ + LoggingService: jest.fn(() => mockLoggingService), +})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 1e3e1638cf2a04..1f507a85d3ddf0 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -30,6 +30,7 @@ import { mockRenderingService, mockMetricsService, mockStatusService, + mockLoggingService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -37,11 +38,11 @@ import { Env } from './config'; import { Server } from './server'; import { getEnvOptions } from './config/__mocks__/env'; -import { loggingServiceMock } from './logging/logging_service.mock'; +import { loggingSystemMock } from './logging/logging_system.mock'; import { rawConfigServiceMock } from './config/raw_config_service.mock'; const env = new Env('.', getEnvOptions()); -const logger = loggingServiceMock.create(); +const logger = loggingSystemMock.create(); const rawConfigService = rawConfigServiceMock.create({}); beforeEach(() => { @@ -68,6 +69,7 @@ test('sets up services on "setup"', async () => { expect(mockRenderingService.setup).not.toHaveBeenCalled(); expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); + expect(mockLoggingService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -80,6 +82,7 @@ test('sets up services on "setup"', async () => { expect(mockRenderingService.setup).toHaveBeenCalledTimes(1); expect(mockMetricsService.setup).toHaveBeenCalledTimes(1); expect(mockStatusService.setup).toHaveBeenCalledTimes(1); + expect(mockLoggingService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -151,6 +154,7 @@ test('stops services on "stop"', async () => { expect(mockUiSettingsService.stop).not.toHaveBeenCalled(); expect(mockMetricsService.stop).not.toHaveBeenCalled(); expect(mockStatusService.stop).not.toHaveBeenCalled(); + expect(mockLoggingService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -162,6 +166,7 @@ test('stops services on "stop"', async () => { expect(mockUiSettingsService.stop).toHaveBeenCalledTimes(1); expect(mockMetricsService.stop).toHaveBeenCalledTimes(1); expect(mockStatusService.stop).toHaveBeenCalledTimes(1); + expect(mockLoggingService.stop).toHaveBeenCalledTimes(1); }); test(`doesn't setup core services if config validation fails`, async () => { @@ -179,6 +184,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockRenderingService.setup).not.toHaveBeenCalled(); expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); + expect(mockLoggingService.setup).not.toHaveBeenCalled(); }); test(`doesn't setup core services if legacy config validation fails`, async () => { @@ -200,4 +206,5 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); + expect(mockLoggingService.setup).not.toHaveBeenCalled(); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index ae1a02cf71b886..3bbcd0e37e142a 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -32,7 +32,7 @@ import { HttpService } from './http'; import { HttpResourcesService } from './http_resources'; import { RenderingService } from './rendering'; import { LegacyService, ensureValidConfiguration } from './legacy'; -import { Logger, LoggerFactory } from './logging'; +import { Logger, LoggerFactory, LoggingService, ILoggingSystem } from './logging'; import { UiSettingsService } from './ui_settings'; import { PluginsService, config as pluginsConfig } from './plugins'; import { SavedObjectsService } from '../server/saved_objects'; @@ -74,20 +74,23 @@ export class Server { private readonly metrics: MetricsService; private readonly httpResources: HttpResourcesService; private readonly status: StatusService; + private readonly logging: LoggingService; private readonly coreApp: CoreApp; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; + private readonly logger: LoggerFactory; constructor( rawConfigProvider: RawConfigurationProvider, public readonly env: Env, - private readonly logger: LoggerFactory + private readonly loggingSystem: ILoggingSystem ) { + this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('server'); - this.configService = new ConfigService(rawConfigProvider, env, logger); + this.configService = new ConfigService(rawConfigProvider, env, this.logger); - const core = { coreId, configService: this.configService, env, logger }; + const core = { coreId, configService: this.configService, env, logger: this.logger }; this.context = new ContextService(core); this.http = new HttpService(core); this.rendering = new RenderingService(core); @@ -102,6 +105,7 @@ export class Server { this.status = new StatusService(core); this.coreApp = new CoreApp(core); this.httpResources = new HttpResourcesService(core); + this.logging = new LoggingService(core); } public async setup() { @@ -164,6 +168,10 @@ export class Server { savedObjects: savedObjectsSetup, }); + const loggingSetup = this.logging.setup({ + loggingSystem: this.loggingSystem, + }); + const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -176,6 +184,7 @@ export class Server { metrics: metricsSetup, rendering: renderingSetup, httpResources: httpResourcesSetup, + logging: loggingSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -244,6 +253,7 @@ export class Server { await this.rendering.stop(); await this.metrics.stop(); await this.status.stop(); + await this.logging.stop(); } private registerCoreContext(coreSetup: InternalCoreSetup) { diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts index 9c5a0625e8fd0c..10a30db038174d 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts @@ -21,7 +21,7 @@ import Chance from 'chance'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; import { savedObjectsClientMock } from '../../saved_objects/service/saved_objects_client.mock'; -import { loggingServiceMock } from '../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../logging/logging_system.mock'; import { getUpgradeableConfigMock } from './get_upgradeable_config.test.mock'; import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config'; @@ -35,7 +35,7 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { const buildNum = chance.integer({ min: 1000, max: 5000 }); function setup() { - const logger = loggingServiceMock.create(); + const logger = loggingSystemMock.create(); const getUpgradeableConfig = getUpgradeableConfigMock; const savedObjectsClient = savedObjectsClientMock.create(); savedObjectsClient.create.mockImplementation( @@ -137,7 +137,7 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { }); await run(); - expect(loggingServiceMock.collect(logger).debug).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "Upgrade config from 4.0.0 to 4.0.1", @@ -169,7 +169,7 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { expect(error.message).toBe('foo'); } - expect(loggingServiceMock.collect(logger).debug).toHaveLength(0); + expect(loggingSystemMock.collect(logger).debug).toHaveLength(0); }); }); diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts index adf36e4491b795..d2e31dad58e55e 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/integration_tests/create_or_upgrade.test.ts @@ -26,10 +26,10 @@ import { TestUtils, } from '../../../../../test_utils/kbn_server'; import { createOrUpgradeSavedConfig } from '../create_or_upgrade_saved_config'; -import { loggingServiceMock } from '../../../logging/logging_service.mock'; +import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { httpServerMock } from '../../../http/http_server.mocks'; -const logger = loggingServiceMock.create().get(); +const logger = loggingSystemMock.create().get(); describe('createOrUpgradeSavedConfig()', () => { let savedObjectsClient: SavedObjectsClientContract; let servers: TestUtils; diff --git a/src/core/server/ui_settings/ui_settings_client.test.ts b/src/core/server/ui_settings/ui_settings_client.test.ts index 4ce33eed267a34..a38fb2ab7e06c6 100644 --- a/src/core/server/ui_settings/ui_settings_client.test.ts +++ b/src/core/server/ui_settings/ui_settings_client.test.ts @@ -20,7 +20,7 @@ import Chance from 'chance'; import { schema } from '@kbn/config-schema'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { createOrUpgradeSavedConfigMock } from './create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.mock'; import { SavedObjectsClient } from '../saved_objects'; @@ -28,7 +28,7 @@ import { savedObjectsClientMock } from '../saved_objects/service/saved_objects_c import { UiSettingsClient } from './ui_settings_client'; import { CannotOverrideError } from './ui_settings_errors'; -const logger = loggingServiceMock.create().get(); +const logger = loggingSystemMock.create().get(); const TYPE = 'config'; const ID = 'kibana-version'; @@ -375,7 +375,7 @@ describe('ui settings', () => { }, }); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].", @@ -517,7 +517,7 @@ describe('ui settings', () => { user: 'foo', }); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].", @@ -645,7 +645,7 @@ describe('ui settings', () => { expect(await uiSettings.get('id')).toBe(42); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].", diff --git a/src/core/server/uuid/resolve_uuid.test.ts b/src/core/server/uuid/resolve_uuid.test.ts index eab027b532ddbc..1a873a1cea0cfe 100644 --- a/src/core/server/uuid/resolve_uuid.test.ts +++ b/src/core/server/uuid/resolve_uuid.test.ts @@ -21,7 +21,7 @@ import { join } from 'path'; import { readFile, writeFile } from './fs'; import { resolveInstanceUuid, UUID_7_6_0_BUG } from './resolve_uuid'; import { configServiceMock } from '../config/config_service.mock'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { BehaviorSubject } from 'rxjs'; import { Logger } from '../logging'; @@ -93,7 +93,7 @@ describe('resolveInstanceUuid', () => { mockReadFile({ uuid: DEFAULT_FILE_UUID }); mockWriteFile(); configService = getConfigService(DEFAULT_CONFIG_UUID); - logger = loggingServiceMock.create().get() as any; + logger = loggingSystemMock.create().get() as any; }); describe('when file is present and config property is set', () => { diff --git a/src/core/server/uuid/uuid_service.test.ts b/src/core/server/uuid/uuid_service.test.ts index a61061ff842630..092216303080e3 100644 --- a/src/core/server/uuid/uuid_service.test.ts +++ b/src/core/server/uuid/uuid_service.test.ts @@ -21,7 +21,7 @@ import { UuidService } from './uuid_service'; import { resolveInstanceUuid } from './resolve_uuid'; import { CoreContext } from '../core_context'; -import { loggingServiceMock } from '../logging/logging_service.mock'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { mockCoreContext } from '../core_context.mock'; import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; @@ -31,12 +31,12 @@ jest.mock('./resolve_uuid', () => ({ })); describe('UuidService', () => { - let logger: ReturnType; + let logger: ReturnType; let coreContext: CoreContext; beforeEach(() => { jest.clearAllMocks(); - logger = loggingServiceMock.create(); + logger = loggingSystemMock.create(); coreContext = mockCoreContext.create({ logger }); }); diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.js index ddc6d000bca194..32eb7bf8712e31 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.js @@ -34,9 +34,7 @@ export const CopySourceTask = { '!src/test_utils/**', '!src/fixtures/**', '!src/legacy/core_plugins/tests_bundle/**', - '!src/legacy/core_plugins/testbed/**', '!src/legacy/core_plugins/console/public/tests/**', - '!src/plugins/testbed/**', '!src/cli/cluster/**', '!src/cli/repl/**', '!src/es_archiver/**', diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 1e0b631308d9ec..065321e355256a 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -34,6 +34,10 @@ export const PROJECTS = [ name: 'apm/cypress', disableTypeCheck: true, }), + new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/scripts/tsconfig.json'), { + name: 'apm/scripts', + disableTypeCheck: true, + }), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/src/fixtures/stubbed_saved_object_index_pattern.js b/src/fixtures/stubbed_saved_object_index_pattern.js index 15e47b40eb203f..8e0e230ef33dd8 100644 --- a/src/fixtures/stubbed_saved_object_index_pattern.js +++ b/src/fixtures/stubbed_saved_object_index_pattern.js @@ -27,6 +27,7 @@ export function stubbedSavedObjectIndexPattern(id) { id, type: 'index-pattern', attributes: { + timeFieldName: 'timestamp', customFormats: '{}', fields: mockLogstashFields, }, diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index ae613e0e809048..9648ff29a95e72 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -21,13 +21,8 @@ import Fs from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; -import { importApi } from './server/routes/api/import'; -import { exportApi } from './server/routes/api/export'; import { getUiSettingDefaults } from './server/ui_setting_defaults'; import { registerCspCollector } from './server/lib/csp_usage_collector'; -import { injectVars } from './inject_vars'; - -import { kbnBaseUrl } from '../../../plugins/kibana_legacy/server'; const mkdirAsync = promisify(Fs.mkdir); @@ -45,35 +40,7 @@ export default function (kibana) { }, uiExports: { - app: { - id: 'kibana', - title: 'Kibana', - listed: false, - main: 'plugins/kibana/kibana', - }, styleSheetPaths: resolve(__dirname, 'public/index.scss'), - links: [], - - injectDefaultVars(server, options) { - const mapConfig = server.config().get('map'); - const tilemap = mapConfig.tilemap; - - return { - kbnIndex: options.index, - kbnBaseUrl, - - // required on all pages due to hacks that use these values - mapConfig, - tilemapsConfig: { - deprecated: { - // If url is set, old settings must be used for backward compatibility - isOverridden: typeof tilemap.url === 'string' && tilemap.url !== '', - config: tilemap, - }, - }, - }; - }, - uiSettingDefaults: getUiSettingDefaults(), }, @@ -91,11 +58,7 @@ export default function (kibana) { init: async function (server) { const { usageCollection } = server.newPlatform.setup.plugins; - // routes - importApi(server); - exportApi(server); registerCspCollector(usageCollection, server); - server.injectUiAppVars('kibana', () => injectVars(server)); }, }); } diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_table/legacy.ts b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_table/legacy.ts index c6467a5beae686..216afe5920408a 100644 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_table/legacy.ts +++ b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_table/legacy.ts @@ -35,4 +35,5 @@ const pluginInstance = new TableVisPlugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, plugins); export const start = pluginInstance.start(npStart.core, { data: npStart.plugins.data, + kibanaLegacy: npStart.plugins.kibanaLegacy, }); diff --git a/src/legacy/core_plugins/kibana/public/index.scss b/src/legacy/core_plugins/kibana/public/index.scss index 56a2543dbca788..e9810a747c8c78 100644 --- a/src/legacy/core_plugins/kibana/public/index.scss +++ b/src/legacy/core_plugins/kibana/public/index.scss @@ -7,12 +7,3 @@ // Public UI styles @import 'src/legacy/ui/public/index'; -// Has to come after visualize because of some -// bad cascading in the Editor layout -@import '../../../../plugins/maps_legacy/public/index'; - -// Management styles -@import './management/index'; - -// Local application mount wrapper styles -@import 'local_application_service/index'; diff --git a/src/legacy/core_plugins/kibana/public/kibana.js b/src/legacy/core_plugins/kibana/public/kibana.js deleted file mode 100644 index 51dedcc629c768..00000000000000 --- a/src/legacy/core_plugins/kibana/public/kibana.js +++ /dev/null @@ -1,59 +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. - */ - -// autoloading - -// preloading (for faster webpack builds) -import routes from 'ui/routes'; -import { npSetup } from 'ui/new_platform'; - -// import the uiExports that we want to "use" -import 'uiExports/savedObjectTypes'; -import 'uiExports/fieldFormatEditors'; -import 'uiExports/navbarExtensions'; -import 'uiExports/contextMenuActions'; -import 'uiExports/managementSections'; -import 'uiExports/indexManagement'; -import 'uiExports/embeddableFactories'; -import 'uiExports/embeddableActions'; -import 'uiExports/inspectorViews'; -import 'uiExports/search'; -import 'uiExports/shareContextMenuExtensions'; -import 'uiExports/interpreter'; - -import 'ui/autoload/all'; - -import { localApplicationService } from './local_application_service'; - -npSetup.plugins.kibanaLegacy.registerLegacyAppAlias('doc', 'discover', { keepPrefix: true }); -npSetup.plugins.kibanaLegacy.registerLegacyAppAlias('context', 'discover', { keepPrefix: true }); - -npSetup.plugins.kibanaLegacy.forwardApp('management', 'management', (path) => { - return path.replace('/management', ''); -}); - -localApplicationService.attachToAngular(routes); - -routes.enable(); - -const { config } = npSetup.plugins.kibanaLegacy; - -routes.otherwise({ - redirectTo: `/${config.defaultAppId || 'discover'}`, -}); diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss b/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss deleted file mode 100644 index 12cc1444101e71..00000000000000 --- a/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'local_application_service'; diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss b/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss deleted file mode 100644 index 33a6100c439759..00000000000000 --- a/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss +++ /dev/null @@ -1,5 +0,0 @@ -.kbnLocalApplicationWrapper { - display: flex; - flex-direction: column; - flex-grow: 1; -} diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts deleted file mode 100644 index 59e5238578d25d..00000000000000 --- a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { App, AppUnmount, AppMountDeprecated } from 'kibana/public'; -import { UIRoutes } from 'ui/routes'; -import { ILocationService, IScope } from 'angular'; -import { npStart } from 'ui/new_platform'; -import { htmlIdGenerator } from '@elastic/eui'; - -const matchAllWithPrefix = (prefixOrApp: string | App) => - `/${typeof prefixOrApp === 'string' ? prefixOrApp : prefixOrApp.id}/:tail*?`; - -/** - * To be able to migrate and shim parts of the Kibana app plugin - * while still running some parts of it in the legacy world, this - * service emulates the core application service while using the global - * angular router to switch between apps without page reload. - * - * The id of the apps is used as prefix of the route - when switching between - * to apps, the current application is unmounted. - * - * This service becomes unnecessary once the platform provides a central - * router that handles switching between applications without page reload. - */ -export class LocalApplicationService { - private idGenerator = htmlIdGenerator('kibanaAppLocalApp'); - - /** - * Wires up listeners to handle mounting and unmounting of apps to - * the legacy angular route manager. Once all apps within the Kibana - * plugin are using the local route manager, this implementation can - * be switched to a more lightweight implementation. - * - * @param angularRouteManager The current `ui/routes` instance - */ - attachToAngular(angularRouteManager: UIRoutes) { - npStart.plugins.kibanaLegacy.getApps().forEach((app) => { - const wrapperElementId = this.idGenerator(); - angularRouteManager.when(matchAllWithPrefix(app), { - outerAngularWrapperRoute: true, - reloadOnSearch: false, - reloadOnUrl: false, - template: `
`, - controller($scope: IScope) { - const element = document.getElementById(wrapperElementId)!; - let unmountHandler: AppUnmount | null = null; - let isUnmounted = false; - $scope.$on('$destroy', () => { - if (unmountHandler) { - unmountHandler(); - } - isUnmounted = true; - }); - (async () => { - const params = { - element, - appBasePath: '', - onAppLeave: () => undefined, - // TODO: adapt to use Core's ScopedHistory - history: {} as any, - }; - unmountHandler = isAppMountDeprecated(app.mount) - ? await app.mount({ core: npStart.core }, params) - : await app.mount(params); - // immediately unmount app if scope got destroyed in the meantime - if (isUnmounted) { - unmountHandler(); - } - })(); - }, - }); - - if (app.updater$) { - app.updater$.subscribe((updater) => { - const updatedFields = updater(app); - if (updatedFields && updatedFields.activeUrl) { - npStart.core.chrome.navLinks.update(app.navLinkId || app.id, { - url: updatedFields.activeUrl, - }); - } - }); - } - }); - - npStart.plugins.kibanaLegacy.getForwards().forEach((forwardDefinition) => { - angularRouteManager.when(matchAllWithPrefix(forwardDefinition.legacyAppId), { - outerAngularWrapperRoute: true, - reloadOnSearch: false, - reloadOnUrl: false, - template: '', - controller($location: ILocationService) { - const newPath = forwardDefinition.rewritePath($location.url()); - window.location.replace( - npStart.core.http.basePath.prepend(`/app/${forwardDefinition.newAppId}${newPath}`) - ); - }, - }); - }); - - npStart.plugins.kibanaLegacy - .getLegacyAppAliases() - .forEach(({ legacyAppId, newAppId, keepPrefix }) => { - angularRouteManager.when(matchAllWithPrefix(legacyAppId), { - resolveRedirectTo: ($location: ILocationService) => { - const url = $location.url(); - return `/${newAppId}${keepPrefix ? url : url.replace(legacyAppId, '')}`; - }, - }); - }); - } -} - -export const localApplicationService = new LocalApplicationService(); - -function isAppMountDeprecated(mount: (...args: any[]) => any): mount is AppMountDeprecated { - // Mount functions with two arguments are assumed to expect deprecated `context` object. - return mount.length === 2; -} diff --git a/src/legacy/core_plugins/kibana/public/management/index.scss b/src/legacy/core_plugins/kibana/public/management/index.scss deleted file mode 100644 index fb267b714f1c96..00000000000000 --- a/src/legacy/core_plugins/kibana/public/management/index.scss +++ /dev/null @@ -1,13 +0,0 @@ -// This file is imported into src/core_plugings/kibana/publix/index.scss - -// Prefix all styles with "dsh" to avoid conflicts. -// Examples -// mgtChart -// mgtChart__legend -// mgtChart__legend--small -// mgtChart__legend-isLoading - -// Core -@import '../../../../../plugins/advanced_settings/public/index'; - -@import 'sections/index_patterns/index'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/index.scss b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/index.scss deleted file mode 100644 index c5cf844ebdc342..00000000000000 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/index.scss +++ /dev/null @@ -1,25 +0,0 @@ -#indexPatternListReact { - display: flex; - - .indexPatternList__headerWrapper { - padding-bottom: $euiSizeS; - } - - .euiButtonEmpty__content { - justify-content: left; - padding: 0; - - span { - text-overflow: ellipsis; - overflow: hidden; - } - } - - .indexPatternListPrompt__descList { - text-align: left; - } -} - -.indexPatternList__badge { - margin-left: $euiSizeS; -} diff --git a/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js b/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js deleted file mode 100644 index 4df0e7a140205e..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js +++ /dev/null @@ -1,645 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import { findRelationships } from '../management/saved_objects/relationships'; - -function getManagementaMock(savedObjectSchemas) { - return { - isImportAndExportable(type) { - return ( - !savedObjectSchemas[type] || savedObjectSchemas[type].isImportableAndExportable !== false - ); - }, - getDefaultSearchField(type) { - return savedObjectSchemas[type] && savedObjectSchemas[type].defaultSearchField; - }, - getIcon(type) { - return savedObjectSchemas[type] && savedObjectSchemas[type].icon; - }, - getTitle(savedObject) { - const { type } = savedObject; - const getTitle = savedObjectSchemas[type] && savedObjectSchemas[type].getTitle; - if (getTitle) { - return getTitle(savedObject); - } - }, - getEditUrl(savedObject) { - const { type } = savedObject; - const getEditUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getEditUrl; - if (getEditUrl) { - return getEditUrl(savedObject); - } - }, - getInAppUrl(savedObject) { - const { type } = savedObject; - const getInAppUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getInAppUrl; - if (getInAppUrl) { - return getInAppUrl(savedObject); - } - }, - }; -} - -const savedObjectsManagement = getManagementaMock({ - 'index-pattern': { - icon: 'indexPatternApp', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/management/kibana/indexPatterns/patterns/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: `/app/management/kibana/indexPatterns/patterns/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'management.kibana.index_patterns', - }; - }, - }, - visualization: { - icon: 'visualizeApp', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/management/kibana/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: `/app/visualize#/edit/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'visualize.show', - }; - }, - }, - search: { - icon: 'search', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/management/kibana/objects/savedSearches/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: `/app/discover#//${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'discover.show', - }; - }, - }, - dashboard: { - icon: 'dashboardApp', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/management/kibana/objects/savedDashboards/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: `/app/kibana#/dashboard/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'dashboard.show', - }; - }, - }, -}); - -describe('findRelationships', () => { - it('should find relationships for dashboards', async () => { - const type = 'dashboard'; - const id = 'foo'; - const size = 10; - - const savedObjectsClient = { - get: () => ({ - attributes: { - panelsJSON: JSON.stringify([ - { panelRefName: 'panel_0' }, - { panelRefName: 'panel_1' }, - { panelRefName: 'panel_2' }, - ]), - }, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '1', - }, - { - name: 'panel_1', - type: 'visualization', - id: '2', - }, - { - name: 'panel_2', - type: 'visualization', - id: '3', - }, - ], - }), - bulkGet: () => ({ saved_objects: [] }), - find: () => ({ - saved_objects: [ - { - id: '1', - type: 'visualization', - attributes: { - title: 'Foo', - }, - }, - { - id: '2', - type: 'visualization', - attributes: { - title: 'Bar', - }, - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'FooBar', - }, - }, - ], - }), - }; - const result = await findRelationships(type, id, { - size, - savedObjectsClient, - savedObjectsManagement, - savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], - }); - expect(result).to.eql([ - { - id: '1', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Foo', - editUrl: '/management/kibana/objects/savedVisualizations/1', - inAppUrl: { - path: '/app/visualize#/edit/1', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '2', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Bar', - editUrl: '/management/kibana/objects/savedVisualizations/2', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '3', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'FooBar', - editUrl: '/management/kibana/objects/savedVisualizations/3', - inAppUrl: { - path: '/app/visualize#/edit/3', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - ]); - }); - - it('should find relationships for visualizations', async () => { - const type = 'visualization'; - const id = 'foo'; - const size = 10; - - const savedObjectsClient = { - get: () => ({ - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - }), - }, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: '1', - }, - ], - }), - bulkGet: () => ({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - }, - ], - }), - find: () => ({ - saved_objects: [ - { - id: '1', - type: 'dashboard', - attributes: { - title: 'My Dashboard', - panelsJSON: JSON.stringify([ - { - type: 'visualization', - id, - }, - { - type: 'visualization', - id: 'foobar', - }, - ]), - }, - }, - { - id: '2', - type: 'dashboard', - attributes: { - title: 'Your Dashboard', - panelsJSON: JSON.stringify([ - { - type: 'visualization', - id, - }, - { - type: 'visualization', - id: 'foobar', - }, - ]), - }, - }, - ], - }), - }; - - const result = await findRelationships(type, id, { - size, - savedObjectsClient, - savedObjectsManagement, - savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], - }); - expect(result).to.eql([ - { - id: '1', - type: 'index-pattern', - relationship: 'child', - meta: { - icon: 'indexPatternApp', - title: 'My Index Pattern', - editUrl: '/management/kibana/indexPatterns/patterns/1', - inAppUrl: { - path: '/app/management/kibana/indexPatterns/patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - { - id: '1', - type: 'dashboard', - relationship: 'parent', - meta: { - icon: 'dashboardApp', - title: 'My Dashboard', - editUrl: '/management/kibana/objects/savedDashboards/1', - inAppUrl: { - path: '/app/kibana#/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', - }, - }, - }, - { - id: '2', - type: 'dashboard', - relationship: 'parent', - meta: { - icon: 'dashboardApp', - title: 'Your Dashboard', - editUrl: '/management/kibana/objects/savedDashboards/2', - inAppUrl: { - path: '/app/kibana#/dashboard/2', - uiCapabilitiesPath: 'dashboard.show', - }, - }, - }, - ]); - }); - - it('should find relationships for saved searches', async () => { - const type = 'search'; - const id = 'foo'; - const size = 10; - - const savedObjectsClient = { - get: () => ({ - id: '1', - type: 'search', - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - }), - }, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: '1', - }, - ], - }), - bulkGet: () => ({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - }, - ], - }), - find: () => ({ - saved_objects: [ - { - id: '1', - type: 'visualization', - attributes: { - title: 'Foo', - }, - }, - { - id: '2', - type: 'visualization', - attributes: { - title: 'Bar', - }, - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'FooBar', - }, - }, - ], - }), - }; - - const result = await findRelationships(type, id, { - size, - savedObjectsClient, - savedObjectsManagement, - savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], - }); - expect(result).to.eql([ - { - id: '1', - type: 'index-pattern', - relationship: 'child', - meta: { - icon: 'indexPatternApp', - title: 'My Index Pattern', - editUrl: '/management/kibana/indexPatterns/patterns/1', - inAppUrl: { - path: '/app/management/kibana/indexPatterns/patterns/1', - uiCapabilitiesPath: 'management.kibana.index_patterns', - }, - }, - }, - { - id: '1', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Foo', - editUrl: '/management/kibana/objects/savedVisualizations/1', - inAppUrl: { - path: '/app/visualize#/edit/1', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '2', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Bar', - editUrl: '/management/kibana/objects/savedVisualizations/2', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '3', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'FooBar', - editUrl: '/management/kibana/objects/savedVisualizations/3', - inAppUrl: { - path: '/app/visualize#/edit/3', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - ]); - }); - - it('should find relationships for index patterns', async () => { - const type = 'index-pattern'; - const id = 'foo'; - const size = 10; - - const savedObjectsClient = { - get: () => ({ - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - }), - find: () => ({ - saved_objects: [ - { - id: '1', - type: 'visualization', - attributes: { - title: 'Foo', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, - }, - }, - { - id: '2', - type: 'visualization', - attributes: { - title: 'Bar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, - }, - }, - { - id: '3', - type: 'visualization', - attributes: { - title: 'FooBar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo2', - }), - }, - }, - }, - { - id: '1', - type: 'search', - attributes: { - title: 'My Saved Search', - }, - }, - ], - }), - }; - - const result = await findRelationships(type, id, { - size, - savedObjectsClient, - savedObjectsManagement, - savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], - }); - expect(result).to.eql([ - { - id: '1', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Foo', - editUrl: '/management/kibana/objects/savedVisualizations/1', - inAppUrl: { - path: '/app/visualize#/edit/1', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '2', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'Bar', - editUrl: '/management/kibana/objects/savedVisualizations/2', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '3', - type: 'visualization', - relationship: 'parent', - meta: { - icon: 'visualizeApp', - title: 'FooBar', - editUrl: '/management/kibana/objects/savedVisualizations/3', - inAppUrl: { - path: '/app/visualize#/edit/3', - uiCapabilitiesPath: 'visualize.show', - }, - }, - }, - { - id: '1', - type: 'search', - relationship: 'parent', - meta: { - icon: 'search', - title: 'My Saved Search', - editUrl: '/management/kibana/objects/savedSearches/1', - inAppUrl: { - path: '/app/discover#//1', - uiCapabilitiesPath: 'discover.show', - }, - }, - }, - ]); - }); - - it('should return an empty object for non related objects', async () => { - const type = 'invalid'; - const id = 'foo'; - const size = 10; - - const savedObjectsClient = { - get: () => ({ - id: '1', - type: 'index-pattern', - attributes: { - title: 'My Index Pattern', - }, - references: [], - }), - find: () => ({ saved_objects: [] }), - }; - - const result = await findRelationships(type, id, { - size, - savedObjectsClient, - savedObjectsManagement, - savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], - }); - expect(result).to.eql({}); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.test.js b/src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.test.js deleted file mode 100644 index 13e04e1e9e16e2..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.test.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { importDashboards } from './import_dashboards'; -import sinon from 'sinon'; - -describe('importDashboards(req)', () => { - let req; - let bulkCreateStub; - beforeEach(() => { - bulkCreateStub = sinon.stub().returns(Promise.resolve({ saved_objects: [] })); - req = { - query: {}, - payload: { - version: '6.0.0', - objects: [ - { id: 'dashboard-01', type: 'dashboard', attributes: { panelJSON: '{}' } }, - { id: 'panel-01', type: 'visualization', attributes: { visState: '{}' } }, - ], - }, - getSavedObjectsClient() { - return { - bulkCreate: bulkCreateStub, - }; - }, - }; - }); - - test('should call bulkCreate with each asset', () => { - return importDashboards(req).then(() => { - expect(bulkCreateStub.calledOnce).toEqual(true); - expect(bulkCreateStub.args[0][0]).toEqual([ - { - id: 'dashboard-01', - type: 'dashboard', - attributes: { panelJSON: '{}' }, - migrationVersion: {}, - }, - { - id: 'panel-01', - type: 'visualization', - attributes: { visState: '{}' }, - migrationVersion: {}, - }, - ]); - }); - }); - - test('should call bulkCreate with overwrite true if force is truthy', () => { - req.query = { force: 'true' }; - return importDashboards(req).then(() => { - expect(bulkCreateStub.calledOnce).toEqual(true); - expect(bulkCreateStub.args[0][1]).toEqual({ overwrite: true }); - }); - }); - - test('should exclude types based on exclude argument', () => { - req.query = { exclude: 'visualization' }; - return importDashboards(req).then(() => { - expect(bulkCreateStub.calledOnce).toEqual(true); - expect(bulkCreateStub.args[0][0]).toEqual([ - { - id: 'dashboard-01', - type: 'dashboard', - attributes: { panelJSON: '{}' }, - migrationVersion: {}, - }, - ]); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.test.js b/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.test.js deleted file mode 100644 index b98160346011ae..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.test.js +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { injectMetaAttributes } from './inject_meta_attributes'; - -function getManagementMock(savedObjectSchemas) { - return { - isImportAndExportable(type) { - return ( - !savedObjectSchemas[type] || savedObjectSchemas[type].isImportableAndExportable !== false - ); - }, - getDefaultSearchField(type) { - return savedObjectSchemas[type] && savedObjectSchemas[type].defaultSearchField; - }, - getIcon(type) { - return savedObjectSchemas[type] && savedObjectSchemas[type].icon; - }, - getTitle(savedObject) { - const { type } = savedObject; - const getTitle = savedObjectSchemas[type] && savedObjectSchemas[type].getTitle; - if (getTitle) { - return getTitle(savedObject); - } - }, - getEditUrl(savedObject) { - const { type } = savedObject; - const getEditUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getEditUrl; - if (getEditUrl) { - return getEditUrl(savedObject); - } - }, - getInAppUrl(savedObject) { - const { type } = savedObject; - const getInAppUrl = savedObjectSchemas[type] && savedObjectSchemas[type].getInAppUrl; - if (getInAppUrl) { - return getInAppUrl(savedObject); - } - }, - }; -} - -test('works when no schema is defined for the type', () => { - const savedObject = { type: 'a' }; - const savedObjectsManagement = getManagementMock({}); - const result = injectMetaAttributes(savedObject, savedObjectsManagement); - expect(result).toEqual({ type: 'a', meta: {} }); -}); - -test('inject icon into meta attribute', () => { - const savedObject = { - type: 'a', - }; - const savedObjectsManagement = getManagementMock({ - a: { - icon: 'my-icon', - }, - }); - const result = injectMetaAttributes(savedObject, savedObjectsManagement); - expect(result).toEqual({ - type: 'a', - meta: { - icon: 'my-icon', - }, - }); -}); - -test('injects title into meta attribute', () => { - const savedObject = { - type: 'a', - }; - const savedObjectsManagement = getManagementMock({ - a: { - getTitle() { - return 'my-title'; - }, - }, - }); - const result = injectMetaAttributes(savedObject, savedObjectsManagement); - expect(result).toEqual({ - type: 'a', - meta: { - title: 'my-title', - }, - }); -}); - -test('injects editUrl into meta attribute', () => { - const savedObject = { - type: 'a', - }; - const savedObjectsManagement = getManagementMock({ - a: { - getEditUrl() { - return 'my-edit-url'; - }, - }, - }); - const result = injectMetaAttributes(savedObject, savedObjectsManagement); - expect(result).toEqual({ - type: 'a', - meta: { - editUrl: 'my-edit-url', - }, - }); -}); - -test('injects inAppUrl meta attribute', () => { - const savedObject = { - type: 'a', - }; - const savedObjectsManagement = getManagementMock({ - a: { - getInAppUrl() { - return { - path: 'my-in-app-url', - uiCapabilitiesPath: 'ui.path', - }; - }, - }, - }); - const result = injectMetaAttributes(savedObject, savedObjectsManagement); - expect(result).toEqual({ - type: 'a', - meta: { - inAppUrl: { - path: 'my-in-app-url', - uiCapabilitiesPath: 'ui.path', - }, - }, - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js b/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js deleted file mode 100644 index e0a6c574b7ad87..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { pick } from 'lodash'; -import { injectMetaAttributes } from './inject_meta_attributes'; - -export async function findRelationships(type, id, options = {}) { - const { size, savedObjectsClient, savedObjectTypes, savedObjectsManagement } = options; - - const { references = [] } = await savedObjectsClient.get(type, id); - - // Use a map to avoid duplicates, it does happen but have a different "name" in the reference - const referencedToBulkGetOpts = new Map( - references.map(({ type, id }) => [`${type}:${id}`, { id, type }]) - ); - - const [referencedObjects, referencedResponse] = await Promise.all([ - referencedToBulkGetOpts.size > 0 - ? savedObjectsClient.bulkGet([...referencedToBulkGetOpts.values()]) - : Promise.resolve({ saved_objects: [] }), - savedObjectsClient.find({ - hasReference: { type, id }, - perPage: size, - type: savedObjectTypes, - }), - ]); - - return [].concat( - referencedObjects.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map((obj) => ({ - ...obj, - relationship: 'child', - })), - referencedResponse.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map((obj) => ({ - ...obj, - relationship: 'parent', - })) - ); -} - -function extractCommonProperties(savedObject) { - return pick(savedObject, ['id', 'type', 'meta']); -} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/export/index.js b/src/legacy/core_plugins/kibana/server/routes/api/export/index.js deleted file mode 100644 index ef556ed53f4fce..00000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/export/index.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Joi from 'joi'; -import moment from 'moment'; - -import { exportDashboards } from '../../../lib/export/export_dashboards'; - -export function exportApi(server) { - server.route({ - path: '/api/kibana/dashboards/export', - config: { - validate: { - query: Joi.object().keys({ - dashboard: Joi.alternatives() - .try(Joi.string(), Joi.array().items(Joi.string())) - .required(), - }), - }, - tags: ['api'], - }, - method: ['GET'], - handler: async (req, h) => { - const currentDate = moment.utc(); - return exportDashboards(req).then((resp) => { - const json = JSON.stringify(resp, null, ' '); - const filename = `kibana-dashboards.${currentDate.format('YYYY-MM-DD-HH-mm-ss')}.json`; - return h - .response(json) - .header('Content-Disposition', `attachment; filename="${filename}"`) - .header('Content-Type', 'application/json') - .header('Content-Length', Buffer.byteLength(json, 'utf8')); - }); - }, - }); -} diff --git a/src/legacy/core_plugins/testbed/README.md b/src/legacy/core_plugins/testbed/README.md deleted file mode 100644 index ac50ffbb804b5c..00000000000000 --- a/src/legacy/core_plugins/testbed/README.md +++ /dev/null @@ -1,8 +0,0 @@ -## Kibana Testbed - -Sometimes when developing for Kibana, it is useful to have an isolated routable space to demonstrate new functionality. This Testbed provides such a space. - -To make use of the testbed, edit the testbed.js, testbed.html, and testbed.less files as necessary. When you are done demonstrating -your new functionality, remember to cleanup your changes and restore the testbed to its pristine state for the next person. - -To access the testbed, visit `http://localhost:5601/app/kibana#/testbed` diff --git a/src/legacy/core_plugins/testbed/package.json b/src/legacy/core_plugins/testbed/package.json deleted file mode 100644 index 98fcaf7eda95da..00000000000000 --- a/src/legacy/core_plugins/testbed/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "testbed", - "version": "kibana" -} \ No newline at end of file diff --git a/src/legacy/core_plugins/testbed/public/testbed.html b/src/legacy/core_plugins/testbed/public/testbed.html deleted file mode 100644 index 52455beb02360d..00000000000000 --- a/src/legacy/core_plugins/testbed/public/testbed.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
- -
{{ testbed.data }}
- - - - - - -
-
diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts index acb95e80fe18c0..7980291e2d4628 100644 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ b/src/legacy/core_plugins/timelion/public/legacy.ts @@ -18,7 +18,7 @@ */ import { PluginInitializerContext } from 'kibana/public'; -import { npSetup } from 'ui/new_platform'; +import { npSetup, npStart } from 'ui/new_platform'; import { plugin } from '.'; import { TimelionPluginSetupDependencies } from './plugin'; import { LegacyDependenciesPlugin } from './shim'; @@ -32,4 +32,4 @@ const setupPlugins: Readonly = { const pluginInstance = plugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(); +export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts index 8b021cda4bfb0b..1f837303a2b3df 100644 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ b/src/legacy/core_plugins/timelion/public/plugin.ts @@ -16,10 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import { CoreSetup, Plugin, PluginInitializerContext, IUiSettingsClient } from 'kibana/public'; +import { + CoreSetup, + Plugin, + PluginInitializerContext, + IUiSettingsClient, + CoreStart, +} from 'kibana/public'; import { getTimeChart } from './panels/timechart/timechart'; import { Panel } from './panels/panel'; import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; +import { KibanaLegacyStart } from '../../../../plugins/kibana_legacy/public'; /** @internal */ export interface TimelionVisualizationDependencies extends LegacyDependenciesPluginSetup { @@ -59,7 +66,9 @@ export class TimelionPlugin implements Plugin, void> { dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); } - public start() {} + public start(core: CoreStart, { kibanaLegacy }: { kibanaLegacy: KibanaLegacyStart }) { + kibanaLegacy.loadFontAwesome(); + } public stop(): void {} } diff --git a/src/legacy/ui/public/autoload/all.js b/src/legacy/ui/public/autoload/all.js index 5c95afb7a0628d..be9b29aa944c95 100644 --- a/src/legacy/ui/public/autoload/all.js +++ b/src/legacy/ui/public/autoload/all.js @@ -20,4 +20,3 @@ import './accessibility'; import './modules'; import './settings'; -import './styles'; diff --git a/src/legacy/ui/public/autoload/styles.js b/src/legacy/ui/public/autoload/styles.js deleted file mode 100644 index c623acca07b015..00000000000000 --- a/src/legacy/ui/public/autoload/styles.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import 'ui/styles/font_awesome.less'; diff --git a/src/legacy/ui/public/directives/__tests__/input_focus.js b/src/legacy/ui/public/directives/__tests__/input_focus.js index 45b1821cbfd217..a9cd9b3c87974c 100644 --- a/src/legacy/ui/public/directives/__tests__/input_focus.js +++ b/src/legacy/ui/public/directives/__tests__/input_focus.js @@ -21,6 +21,7 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import $ from 'jquery'; import '../input_focus'; +import uiRoutes from 'ui/routes'; describe('Input focus directive', function () { let $compile; @@ -32,6 +33,8 @@ describe('Input focus directive', function () { let selectedText; const inputValue = 'Input Text Value'; + uiRoutes.enable(); + beforeEach(ngMock.module('kibana')); beforeEach( ngMock.inject(function (_$compile_, _$rootScope_, _$timeout_) { diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index d98770842a0f09..35380ada51a0ad 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -263,7 +263,6 @@ export const npSetup = { }, kibanaLegacy: { registerLegacyApp: () => {}, - registerLegacyAppAlias: () => {}, forwardApp: () => {}, config: { defaultAppId: 'home', @@ -379,9 +378,8 @@ export const npStart = { registerType: sinon.fake(), }, kibanaLegacy: { - getApps: () => [], getForwards: () => [], - getLegacyAppAliases: () => [], + loadFontAwesome: () => {}, config: { defaultAppId: 'home', }, diff --git a/src/legacy/ui/public/styles/font_awesome.less b/src/legacy/ui/public/styles/font_awesome.less deleted file mode 100644 index 428e3c6b83f89f..00000000000000 --- a/src/legacy/ui/public/styles/font_awesome.less +++ /dev/null @@ -1,10 +0,0 @@ -// Needs to remain a LESS file to point to the correct path for the fonts themeselves -@import "~font-awesome/less/font-awesome"; - -// new file icon -.@{fa-css-prefix}-file-new-o:before { content: @fa-var-file-o; } -.@{fa-css-prefix}-file-new-o:after { content: @fa-var-plus; position: relative; margin-left: -1.0em; font-size: 0.5em; } - -// alias for alert types - allows class="fa fa-{{alertType}}" -.fa-success:before { content: @fa-var-check; } -.fa-danger:before { content: @fa-var-exclamation-circle; } diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts deleted file mode 100644 index cb25c66dfd2fe6..00000000000000 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { npStart } from 'ui/new_platform'; -import { fieldFormats, IFieldFormat } from '../../../../../../plugins/data/public'; -import { SerializedFieldFormat } from '../../../../../../plugins/expressions/common/types'; - -type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; - -const createFormat = fieldFormats.serialize; -const getFormat: FormatFactory = (mapping?) => { - return npStart.plugins.data.fieldFormats.deserialize(mapping as any); -}; - -export { getFormat, createFormat, FormatFactory }; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index b4f82552972401..0cfcb91aa94efe 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -130,7 +130,6 @@ export function uiRenderMixin(kbnServer, server, config) { `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, `${regularBundlePath}/light_theme.style.css`, ]), - `${regularBundlePath}/commons.style.css`, ...(isCore ? [] : [ @@ -155,13 +154,7 @@ export function uiRenderMixin(kbnServer, server, config) { (filename) => `${regularBundlePath}/kbn-ui-shared-deps/${filename}` ), `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.jsFilename}`, - ...(isCore - ? [] - : [ - `${dllBundlePath}/vendors_runtime.bundle.dll.js`, - ...dllJsChunks, - `${regularBundlePath}/commons.bundle.js`, - ]), + ...(isCore ? [] : [`${dllBundlePath}/vendors_runtime.bundle.dll.js`, ...dllJsChunks]), `${regularBundlePath}/core/core.entry.js`, ...kpPluginIds.map( diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 12131b89e03c10..55752db55e28a4 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -266,13 +266,6 @@ export default class BaseOptimizer { optimization: { splitChunks: { cacheGroups: { - commons: { - name: 'commons', - chunks: (chunk) => - chunk.canBeInitial() && chunk.name !== 'light_theme' && chunk.name !== 'dark_theme', - minChunks: 2, - reuseExistingChunk: true, - }, light_theme: { name: 'light_theme', test: (m) => diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.scss b/src/plugins/advanced_settings/public/management_app/_advanced_settings.scss similarity index 100% rename from src/plugins/advanced_settings/public/management_app/advanced_settings.scss rename to src/plugins/advanced_settings/public/management_app/_advanced_settings.scss diff --git a/src/plugins/advanced_settings/public/management_app/_index.scss b/src/plugins/advanced_settings/public/management_app/index.scss similarity index 100% rename from src/plugins/advanced_settings/public/management_app/_index.scss rename to src/plugins/advanced_settings/public/management_app/index.scss diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index b4779d051ab027..ab348451b1eef8 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -29,6 +29,8 @@ import { AdvancedSettings } from './advanced_settings'; import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; +import './index.scss'; + const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced Settings', }); diff --git a/src/plugins/dashboard/public/application/application.ts b/src/plugins/dashboard/public/application/application.ts index 543450916c5056..08eeb19dcda930 100644 --- a/src/plugins/dashboard/public/application/application.ts +++ b/src/plugins/dashboard/public/application/application.ts @@ -68,11 +68,12 @@ export interface RenderDeps { embeddable: EmbeddableStart; localStorage: Storage; share?: SharePluginStart; - config: KibanaLegacyStart['config']; usageCollection?: UsageCollectionSetup; navigateToDefaultApp: KibanaLegacyStart['navigateToDefaultApp']; + navigateToLegacyKibanaUrl: KibanaLegacyStart['navigateToLegacyKibanaUrl']; scopedHistory: () => ScopedHistory; savedObjects: SavedObjectsStart; + restorePreviousUrl: () => void; } let angularModuleInstance: IModule | null = null; diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 206ef4f3d4313a..3c559a6cde211d 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -75,7 +75,7 @@ import { getDashboardTitle } from './dashboard_strings'; import { DashboardAppScope } from './dashboard_app'; import { convertSavedDashboardPanelToPanelState } from './lib/embeddable_saved_object_converters'; import { RenderDeps } from './application'; -import { IKbnUrlStateStorage, removeQueryParam, unhashUrl } from '../../../kibana_utils/public'; +import { IKbnUrlStateStorage, unhashUrl } from '../../../kibana_utils/public'; import { addFatalError, AngularHttpError, @@ -132,6 +132,7 @@ export class DashboardAppController { embeddable, share, dashboardCapabilities, + scopedHistory, embeddableCapabilities: { visualizeCapabilities, mapsCapabilities }, data: { query: queryService }, core: { @@ -425,15 +426,13 @@ export class DashboardAppController { refreshDashboardContainer(); }); - // This code needs to be replaced with a better mechanism for adding new embeddables of - // any type from the add panel. Likely this will happen via creating a visualization "inline", - // without navigating away from the UX. - if ($routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]) { - const type = $routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]; - const id = $routeParams[DashboardConstants.ADD_EMBEDDABLE_ID]; - container.addNewEmbeddable(type, { savedObjectId: id }); - removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_TYPE); - removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_ID); + const incomingState = embeddable + .getStateTransfer(scopedHistory()) + .getIncomingEmbeddablePackage(); + if (incomingState) { + container.addNewEmbeddable(incomingState.type, { + savedObjectId: incomingState.id, + }); } } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 27bbc2ef675a14..f1ecd0f221926b 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -46,6 +46,7 @@ import { } from '../../../../kibana_react/public'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; +import { EmbeddableStateTransfer } from '../../../../embeddable/public'; export interface DashboardContainerInput extends ContainerInput { viewMode: ViewMode; @@ -98,9 +99,12 @@ export class DashboardContainer extends Container React.ReactNode); + private embeddablePanel: EmbeddableStart['EmbeddablePanel']; + constructor( initialInput: DashboardContainerInput, private readonly options: DashboardContainerOptions, + stateTransfer?: EmbeddableStateTransfer, parent?: Container ) { super( @@ -111,6 +115,7 @@ export class DashboardContainer extends Container , diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 514b4e7ca8588d..4107a00ba80cef 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { CoreStart } from 'src/core/public'; +import { CoreStart, ScopedHistory } from 'src/core/public'; import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { EmbeddableFactory, EmbeddableStart } from '../../../../embeddable/public'; import { @@ -54,7 +54,10 @@ export class DashboardContainerFactoryDefinition public readonly isContainerType = true; public readonly type = DASHBOARD_CONTAINER_TYPE; - constructor(private readonly getStartServices: () => Promise) {} + constructor( + private readonly getStartServices: () => Promise, + private getHistory: () => ScopedHistory + ) {} public isEditable = async () => { const { capabilities } = await this.getStartServices(); @@ -81,6 +84,7 @@ export class DashboardContainerFactoryDefinition parent?: Container ): Promise => { const services = await this.getStartServices(); - return new DashboardContainer(initialInput, services, parent); + const stateTransfer = services.embeddable.getStateTransfer(this.getHistory()); + return new DashboardContainer(initialInput, services, stateTransfer, parent); }; } diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx index 9a2610a82b97d0..493ae8eb517977 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx @@ -64,6 +64,7 @@ function prepare(props?: Partial) { embeddable: { getTriggerCompatibleActions: (() => []) as any, getEmbeddableFactories: start.getEmbeddableFactories, + getEmbeddablePanel: jest.fn(), getEmbeddableFactory, } as any, notifications: {} as any, diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 25e451dc7f793b..733ea11c1eb64c 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -54,6 +54,7 @@ function getProps( application: applicationServiceMock.createStartContract(), embeddable: { getTriggerCompatibleActions: (() => []) as any, + getEmbeddablePanel: jest.fn(), getEmbeddableFactories: start.getEmbeddableFactories, getEmbeddableFactory: start.getEmbeddableFactory, } as any, diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 9ee50426b19bbf..15a486f99a37f5 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -26,8 +26,8 @@ import { context } from '../../../../../kibana_react/public'; export interface DashboardViewportProps { container: DashboardContainer; - renderEmpty?: () => React.ReactNode; PanelComponent: EmbeddableStart['EmbeddablePanel']; + renderEmpty?: () => React.ReactNode; } interface State { diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js index 2d99a2c6a22533..8b8fdcb7a76acc 100644 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ b/src/plugins/dashboard/public/application/legacy_app.js @@ -242,9 +242,17 @@ export function initDashboardApp(app, deps) { }, }) .otherwise({ - template: '', - controller: function () { - deps.navigateToDefaultApp(); + resolveRedirectTo: function ($rootScope) { + const path = window.location.hash.substr(1); + deps.restorePreviousUrl(); + $rootScope.$applyAsync(() => { + const { navigated } = deps.navigateToLegacyKibanaUrl(path); + if (!navigated) { + deps.navigateToDefaultApp(); + } + }); + // prevent angular from completing the navigation + return new Promise(() => {}); }, }); }); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index e99b8ad616cb92..041a02a251e8ab 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -206,13 +206,22 @@ export class DashboardPlugin }; }; - const factory = new DashboardContainerFactoryDefinition(getStartServices); + const factory = new DashboardContainerFactoryDefinition( + getStartServices, + () => this.currentHistory! + ); embeddable.registerEmbeddableFactory(factory.type, factory); const placeholderFactory = new PlaceholderEmbeddableFactory(); embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); - const { appMounted, appUnMounted, stop: stopUrlTracker, getActiveUrl } = createKbnUrlTracker({ + const { + appMounted, + appUnMounted, + stop: stopUrlTracker, + getActiveUrl, + restorePreviousUrl, + } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/dashboards'), defaultSubUrl: `#${DashboardConstants.LANDING_PAGE_PATH}`, storageKey: `lastUrl:${core.http.basePath.get()}:dashboard`, @@ -257,7 +266,7 @@ export class DashboardPlugin navigation, share: shareStart, data: dataStart, - kibanaLegacy: { dashboardConfig, navigateToDefaultApp }, + kibanaLegacy: { dashboardConfig, navigateToDefaultApp, navigateToLegacyKibanaUrl }, savedObjects, } = pluginsStart; @@ -266,6 +275,7 @@ export class DashboardPlugin core: coreStart, dashboardConfig, navigateToDefaultApp, + navigateToLegacyKibanaUrl, navigation, share: shareStart, data: dataStart, @@ -274,7 +284,6 @@ export class DashboardPlugin chrome: coreStart.chrome, addBasePath: coreStart.http.basePath.prepend, uiSettings: coreStart.uiSettings, - config: kibanaLegacy.config, savedQueryService: dataStart.query.savedQueries, embeddable: embeddableStart, dashboardCapabilities: coreStart.application.capabilities.dashboard, @@ -286,6 +295,7 @@ export class DashboardPlugin usageCollection, scopedHistory: () => this.currentHistory!, savedObjects, + restorePreviousUrl, }; // make sure the index pattern list is up to date await dataStart.indexPatterns.clearCache(); @@ -302,6 +312,15 @@ export class DashboardPlugin initAngularBootstrap(); core.application.register(app); + kibanaLegacy.forwardApp( + DashboardConstants.DASHBOARDS_ID, + DashboardConstants.DASHBOARDS_ID, + (path) => { + const [, tail] = /(\?.*)/.exec(path) || []; + // carry over query if it exists + return `#/list${tail || ''}`; + } + ); kibanaLegacy.forwardApp( DashboardConstants.DASHBOARD_ID, DashboardConstants.DASHBOARDS_ID, @@ -319,15 +338,6 @@ export class DashboardPlugin return `#/view/${id}${tail || ''}`; } ); - kibanaLegacy.forwardApp( - DashboardConstants.DASHBOARDS_ID, - DashboardConstants.DASHBOARDS_ID, - (path) => { - const [, tail] = /(\?.*)/.exec(path) || []; - // carry over query if it exists - return `#/list${tail || ''}`; - } - ); if (home) { home.featureCatalogue.register({ diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts index 5c67073c07dd54..104ff030873aa6 100644 --- a/src/plugins/data/common/field_formats/index.ts +++ b/src/plugins/data/common/field_formats/index.ts @@ -40,7 +40,7 @@ export { TruncateFormat, } from './converters'; -export { getHighlightRequest, serializeFieldFormat } from './utils'; +export { getHighlightRequest } from './utils'; export { DEFAULT_CONVERTER_COLOR } from './constants/color_default'; export { FIELD_FORMAT_IDS } from './types'; diff --git a/src/plugins/data/common/field_formats/utils/index.ts b/src/plugins/data/common/field_formats/utils/index.ts index 3832c941ffad75..eb020c17ca09c9 100644 --- a/src/plugins/data/common/field_formats/utils/index.ts +++ b/src/plugins/data/common/field_formats/utils/index.ts @@ -22,6 +22,5 @@ import { IFieldFormat } from '../index'; export { asPrettyString } from './as_pretty_string'; export { getHighlightHtml, getHighlightRequest } from './highlight'; -export { serializeFieldFormat } from './serialize'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; diff --git a/src/plugins/data/common/field_formats/utils/serialize.ts b/src/plugins/data/common/field_formats/utils/serialize.ts deleted file mode 100644 index 1092c90d19451a..00000000000000 --- a/src/plugins/data/common/field_formats/utils/serialize.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { IAggConfig } from 'src/plugins/data/public'; -import { SerializedFieldFormat } from '../../../../expressions/common/types'; - -export const serializeFieldFormat = (agg: IAggConfig): SerializedFieldFormat => { - const format: SerializedFieldFormat = agg.params.field ? agg.params.field.format.toJSON() : {}; - const formats: Record SerializedFieldFormat> = { - date_range: () => ({ id: 'date_range', params: format }), - ip_range: () => ({ id: 'ip_range', params: format }), - percentile_ranks: () => ({ id: 'percent' }), - count: () => ({ id: 'number' }), - cardinality: () => ({ id: 'number' }), - date_histogram: () => ({ - id: 'date', - params: { - pattern: (agg as any).buckets.getScaledDateFormat(), - }, - }), - terms: () => ({ - id: 'terms', - params: { - id: format.id, - otherBucketLabel: agg.params.otherBucketLabel, - missingBucketLabel: agg.params.missingBucketLabel, - ...format.params, - }, - }), - range: () => ({ - id: 'range', - params: { id: format.id, ...format.params }, - }), - }; - - return formats[agg.type.name] ? formats[agg.type.name]() : format; -}; diff --git a/src/plugins/data/common/index_patterns/fields/__snapshots__/field.test.ts.snap b/src/plugins/data/common/index_patterns/fields/__snapshots__/field.test.ts.snap index 4593349a408a7d..e61593f6bfb27b 100644 --- a/src/plugins/data/common/index_patterns/fields/__snapshots__/field.test.ts.snap +++ b/src/plugins/data/common/index_patterns/fields/__snapshots__/field.test.ts.snap @@ -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", +} +`; diff --git a/src/plugins/data/common/index_patterns/fields/field.test.ts b/src/plugins/data/common/index_patterns/fields/field.test.ts index 711c176fed9ccb..910f22088f43a8 100644 --- a/src/plugins/data/common/index_patterns/fields/field.test.ts +++ b/src/plugins/data/common/index_patterns/fields/field.test.ts @@ -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) { @@ -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(); + }); }); diff --git a/src/plugins/data/common/index_patterns/fields/field.ts b/src/plugins/data/common/index_patterns/fields/field.ts index c53e3f2b1f621f..81c7aff8a0faaa 100644 --- a/src/plugins/data/common/index_patterns/fields/field.ts +++ b/src/plugins/data/common/index_patterns/fields/field.ts @@ -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; - interface FieldDependencies { fieldFormats: FieldFormatsStartCommon; onNotification: OnNotification; @@ -59,11 +62,11 @@ export class Field implements IFieldType { readFromDocValues?: boolean; format: any; $$spec: FieldSpec; - conflictDescriptions?: Record; + 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); } diff --git a/src/plugins/data/common/index_patterns/fields/field_list.ts b/src/plugins/data/common/index_patterns/fields/field_list.ts index 173a629863a716..c1ca5341328ce1 100644 --- a/src/plugins/data/common/index_patterns/fields/field_list.ts +++ b/src/plugins/data/common/index_patterns/fields/field_list.ts @@ -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; @@ -102,6 +102,10 @@ export const getIndexPatternFieldListCreator = ({ this.removeByGroup(newField); this.setByGroup(newField); }; + + toSpec = () => { + return [...this.map((field) => field.toSpec())]; + }; } return new FieldList(...fieldListParams); diff --git a/src/plugins/data/common/index_patterns/fields/types.ts b/src/plugins/data/common/index_patterns/fields/types.ts index c336472a1e7d6f..558b5b57dce40a 100644 --- a/src/plugins/data/common/index_patterns/fields/types.ts +++ b/src/plugins/data/common/index_patterns/fields/types.ts @@ -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; } diff --git a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap new file mode 100644 index 00000000000000..047ac836a87d1f --- /dev/null +++ b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap @@ -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, +} +`; diff --git a/src/plugins/data/common/index_patterns/index_patterns/index.ts b/src/plugins/data/common/index_patterns/index_patterns/index.ts index 5fae08f3bb7755..77527857ed0caa 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index.ts @@ -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'; diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index cea476781ad3bf..ba8e4f6fb36955 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -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'); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index 666d99362ce803..e9ac5a09b9db3a 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -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,9 +197,38 @@ export class IndexPattern implements IIndexPattern { this.initFields(); } + public initFromSpec(spec: IndexPatternSpec) { + // create fieldFormatMap from field list + const fieldFormatMap: Record = {}; + 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, 'kibana#/management/kibana/indexPatterns'); + throw new SavedObjectNotFound(type, this.id, 'management/kibana/indexPatterns'); } _.forOwn(this.mapping, (fieldMapping: FieldMappingSpec, name: string | undefined) => { @@ -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 { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 22d1765d793488..5e51897d133727 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -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 => { const indexPattern = new IndexPattern(id, { getConfig: (cfg: any) => this.config.get(cfg), diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 7399bbbc10a7e1..94121a274d686e 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -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; + [key: string]: any; +} + +export type FieldSpecConflictDescriptions = Record; + +// 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; +} diff --git a/src/plugins/data/public/field_formats/utils/deserialize.ts b/src/plugins/data/public/field_formats/utils/deserialize.ts index d9c713c8b1eb4b..26baa5fdeb1e4b 100644 --- a/src/plugins/data/public/field_formats/utils/deserialize.ts +++ b/src/plugins/data/public/field_formats/utils/deserialize.ts @@ -18,107 +18,42 @@ */ import { identity } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { convertDateRangeToString, DateRangeKey } from '../../search/aggs/buckets/lib/date_range'; -import { convertIPRangeToString, IpRangeKey } from '../../search/aggs/buckets/lib/ip_range'; + import { SerializedFieldFormat } from '../../../../expressions/common/types'; -import { FieldFormatId, FieldFormatsContentType, IFieldFormat } from '../..'; + import { FieldFormat } from '../../../common'; -import { DataPublicPluginStart } from '../../../public'; -import { getUiSettings } from '../../../public/services'; import { FormatFactory } from '../../../common/field_formats/utils'; - -interface TermsFieldFormatParams { - otherBucketLabel: string; - missingBucketLabel: string; - id: string; -} - -function isTermsFieldFormat( - serializedFieldFormat: SerializedFieldFormat -): serializedFieldFormat is SerializedFieldFormat { - return serializedFieldFormat.id === 'terms'; -} +import { DataPublicPluginStart, IFieldFormat } from '../../../public'; +import { getUiSettings } from '../../../public/services'; +import { getFormatWithAggs } from '../../search/aggs/utils'; const getConfig = (key: string, defaultOverride?: any): any => getUiSettings().get(key, defaultOverride); const DefaultFieldFormat = FieldFormat.from(identity); -const getFieldFormat = ( - fieldFormatsService: DataPublicPluginStart['fieldFormats'], - id?: FieldFormatId, - params: object = {} -): IFieldFormat => { - if (id) { - const Format = fieldFormatsService.getType(id); - - if (Format) { - return new Format(params, getConfig); - } - } - - return new DefaultFieldFormat(); -}; - export const deserializeFieldFormat: FormatFactory = function ( this: DataPublicPluginStart['fieldFormats'], - mapping?: SerializedFieldFormat + serializedFieldFormat?: SerializedFieldFormat ) { - if (!mapping) { + if (!serializedFieldFormat) { return new DefaultFieldFormat(); } - const { id } = mapping; - if (id === 'range') { - const RangeFormat = FieldFormat.from((range: any) => { - const nestedFormatter = mapping.params as SerializedFieldFormat; - const format = getFieldFormat(this, nestedFormatter.id, nestedFormatter.params); - const gte = '\u2265'; - const lt = '\u003c'; - return i18n.translate('data.aggTypes.buckets.ranges.rangesFormatMessage', { - defaultMessage: '{gte} {from} and {lt} {to}', - values: { - gte, - from: format.convert(range.gte), - lt, - to: format.convert(range.lt), - }, - }); - }); - return new RangeFormat(); - } else if (id === 'date_range') { - const nestedFormatter = mapping.params as SerializedFieldFormat; - const DateRangeFormat = FieldFormat.from((range: DateRangeKey) => { - const format = getFieldFormat(this, nestedFormatter.id, nestedFormatter.params); - return convertDateRangeToString(range, format.convert.bind(format)); - }); - return new DateRangeFormat(); - } else if (id === 'ip_range') { - const nestedFormatter = mapping.params as SerializedFieldFormat; - const IpRangeFormat = FieldFormat.from((range: IpRangeKey) => { - const format = getFieldFormat(this, nestedFormatter.id, nestedFormatter.params); - return convertIPRangeToString(range, format.convert.bind(format)); - }); - return new IpRangeFormat(); - } else if (isTermsFieldFormat(mapping) && mapping.params) { - const { params } = mapping; - const convert = (val: string, type: FieldFormatsContentType) => { - const format = getFieldFormat(this, params.id, mapping.params); - if (val === '__other__') { - return params.otherBucketLabel; - } - if (val === '__missing__') { - return params.missingBucketLabel; + const getFormat = (mapping: SerializedFieldFormat): IFieldFormat => { + const { id, params = {} } = mapping; + if (id) { + const Format = this.getType(id); + + if (Format) { + return new Format(params, getConfig); } + } + + return new DefaultFieldFormat(); + }; - return format.convert(val, type); - }; + // decorate getFormat to handle custom types created by aggs + const getFieldFormat = getFormatWithAggs(getFormat); - return { - convert, - getConverterFor: (type: FieldFormatsContentType) => (val: string) => convert(val, type), - } as IFieldFormat; - } else { - return getFieldFormat(this, id, mapping.params); - } + return getFieldFormat(serializedFieldFormat); }; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 1554ac71f8c552..3665d9dc2b46e7 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -168,7 +168,6 @@ import { UrlFormat, StringFormat, TruncateFormat, - serializeFieldFormat, } from '../common/field_formats'; import { DateFormat } from './field_formats'; @@ -179,8 +178,6 @@ export const fieldFormats = { FieldFormat, FieldFormatsRegistry, // exported only for tests. Consider mock. - serialize: serializeFieldFormat, - DEFAULT_CONVERTER_COLOR, HTML_CONTEXT_TYPE, TEXT_CONTEXT_TYPE, @@ -252,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'; @@ -266,6 +261,8 @@ export { KBN_FIELD_TYPES, IndexPatternAttributes, UI_SETTINGS, + TypeMeta as IndexPatternTypeMeta, + AggregationRestrictions as IndexPatternAggRestrictions, } from '../common'; /* diff --git a/src/plugins/data/public/index_patterns/index.ts b/src/plugins/data/public/index_patterns/index.ts index 0a8397467807c6..2c540527f468d2 100644 --- a/src/plugins/data/public/index_patterns/index.ts +++ b/src/plugins/data/public/index_patterns/index.ts @@ -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'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 23213d4d1165a6..25c9b0718050ac 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -154,6 +154,7 @@ import { SearchParams } from 'elasticsearch'; import { SearchResponse as SearchResponse_2 } from 'elasticsearch'; import { SearchShardsParams } from 'elasticsearch'; import { SearchTemplateParams } from 'elasticsearch'; +import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/public'; import { SimpleSavedObject } from 'src/core/public'; import { SnapshotCreateParams } from 'elasticsearch'; import { SnapshotCreateRepositoryParams } from 'elasticsearch'; @@ -621,7 +622,6 @@ export type FieldFormatInstanceType = (new (params?: any, getConfig?: FieldForma export const fieldFormats: { FieldFormat: typeof FieldFormat; FieldFormatsRegistry: typeof FieldFormatsRegistry; - serialize: (agg: import("./search").AggConfig) => import("../../expressions").SerializedFieldFormat; DEFAULT_CONVERTER_COLOR: { range: string; regex: string; @@ -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 { - // 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; + // 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; + 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; @@ -1954,41 +1967,41 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:376:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:373:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:373:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:373:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:373:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:375:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:40:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:53:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/aggs/agg_config.test.ts b/src/plugins/data/public/search/aggs/agg_config.test.ts index 6a0dad07b69bb5..95e0b2cd27186b 100644 --- a/src/plugins/data/public/search/aggs/agg_config.test.ts +++ b/src/plugins/data/public/search/aggs/agg_config.test.ts @@ -25,7 +25,11 @@ import { AggType } from './agg_type'; import { AggTypesRegistryStart } from './agg_types_registry'; import { mockDataServices, mockAggTypesRegistry } from './test_helpers'; import { MetricAggType } from './metrics/metric_agg_type'; -import { Field as IndexPatternField, IndexPattern } from '../../index_patterns'; +import { + Field as IndexPatternField, + IndexPattern, + IIndexPatternFieldList, +} from '../../index_patterns'; import { stubIndexPatternWithFields } from '../../../public/stubs'; import { FieldFormatsStart } from '../../field_formats'; import { fieldFormatsServiceMock } from '../../field_formats/mocks'; @@ -370,6 +374,109 @@ describe('AggConfig', () => { }); }); + describe('#toSerializedFieldFormat', () => { + beforeEach(() => { + indexPattern.fields.getByName = identity as IIndexPatternFieldList['getByName']; + }); + + it('works with aggs that have a special format type', () => { + const configStates = [ + { + type: 'count', + params: {}, + }, + { + type: 'date_histogram', + params: { field: '@timestamp' }, + }, + { + type: 'terms', + params: { field: 'machine.os.keyword' }, + }, + ]; + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, fieldFormats }); + + expect(ac.aggs.map((agg) => agg.toSerializedFieldFormat())).toMatchInlineSnapshot(` + Array [ + Object { + "id": "number", + }, + Object { + "id": "date", + "params": Object { + "pattern": "HH:mm:ss.SSS", + }, + }, + Object { + "id": "terms", + "params": Object { + "id": undefined, + "missingBucketLabel": "Missing", + "otherBucketLabel": "Other", + }, + }, + ] + `); + }); + + it('works with pipeline aggs', () => { + const configStates = [ + { + type: 'max_bucket', + params: { + customMetric: { + type: 'cardinality', + params: { + field: 'bytes', + }, + }, + }, + }, + { + type: 'cumulative_sum', + params: { + buckets_path: '1', + customMetric: { + type: 'cardinality', + params: { + field: 'bytes', + }, + }, + }, + }, + { + type: 'percentile_ranks', + id: 'myMetricAgg', + params: {}, + }, + { + type: 'cumulative_sum', + params: { + metricAgg: 'myMetricAgg', + }, + }, + ]; + const ac = new AggConfigs(indexPattern, configStates, { typesRegistry, fieldFormats }); + + expect(ac.aggs.map((agg) => agg.toSerializedFieldFormat())).toMatchInlineSnapshot(` + Array [ + Object { + "id": "number", + }, + Object { + "id": "number", + }, + Object { + "id": "percent", + }, + Object { + "id": "percent", + }, + ] + `); + }); + }); + describe('#toExpressionAst', () => { beforeEach(() => { fieldFormats.getDefaultInstance = (() => ({ diff --git a/src/plugins/data/public/search/aggs/agg_config.ts b/src/plugins/data/public/search/aggs/agg_config.ts index ee4116eefc0e27..a2b74eca584766 100644 --- a/src/plugins/data/public/search/aggs/agg_config.ts +++ b/src/plugins/data/public/search/aggs/agg_config.ts @@ -20,7 +20,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { Assign, Ensure } from '@kbn/utility-types'; -import { ExpressionAstFunction, ExpressionAstArgument } from 'src/plugins/expressions/public'; +import { + ExpressionAstFunction, + ExpressionAstArgument, + SerializedFieldFormat, +} from 'src/plugins/expressions/public'; import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; import { IAggConfigs } from './agg_configs'; @@ -42,7 +46,7 @@ export type AggConfigSerialized = Ensure< type: string; enabled?: boolean; id?: string; - params?: SerializableState; + params?: {} | SerializableState; schema?: string; }, SerializableState @@ -298,8 +302,8 @@ export class AggConfig { id: this.id, enabled: this.enabled, type: this.type && this.type.name, - schema: this.schema, params: outParams as SerializableState, + ...(this.schema && { schema: this.schema }), }; } @@ -310,6 +314,19 @@ export class AggConfig { return this.serialize(); } + /** + * Returns a serialized field format for the field used in this agg. + * This can be passed to fieldFormats.deserialize to get the field + * format instance. + * + * @public + */ + toSerializedFieldFormat(): + | {} + | Ensure, SerializableState> { + return this.type ? this.type.getSerializedFormat(this) : {}; + } + /** * @returns Returns an ExpressionAst representing the function for this agg type. */ diff --git a/src/plugins/data/public/search/aggs/agg_type.test.ts b/src/plugins/data/public/search/aggs/agg_type.test.ts index 18783bbd9a7605..cc45b935d45b54 100644 --- a/src/plugins/data/public/search/aggs/agg_type.test.ts +++ b/src/plugins/data/public/search/aggs/agg_type.test.ts @@ -199,5 +199,73 @@ describe('AggType Class', () => { expect(aggType.getFormat(aggConfig)).toBe('default'); }); }); + + describe('getSerializedFormat', () => { + test('returns the default serialized field format if it exists', () => { + const aggConfig = ({ + params: { + field: { + format: { + toJSON: () => ({ id: 'format' }), + }, + }, + }, + } as unknown) as IAggConfig; + const aggType = new AggType( + { + name: 'name', + title: 'title', + }, + dependencies + ); + expect(aggType.getSerializedFormat(aggConfig)).toMatchInlineSnapshot(` + Object { + "id": "format", + } + `); + }); + + test('returns an empty object if a field param does not exist', () => { + const aggConfig = ({ + params: {}, + } as unknown) as IAggConfig; + const aggType = new AggType( + { + name: 'name', + title: 'title', + }, + dependencies + ); + expect(aggType.getSerializedFormat(aggConfig)).toMatchInlineSnapshot(`Object {}`); + }); + + test('uses a custom getSerializedFormat function if defined', () => { + const aggConfig = ({ + params: { + field: { + format: { + toJSON: () => ({ id: 'format' }), + }, + }, + }, + } as unknown) as IAggConfig; + const getSerializedFormat = jest.fn().mockReturnValue({ id: 'hello' }); + const aggType = new AggType( + { + name: 'name', + title: 'title', + getSerializedFormat, + }, + dependencies + ); + const serialized = aggType.getSerializedFormat(aggConfig); + expect(getSerializedFormat).toHaveBeenCalledWith(aggConfig); + expect(serialized).toMatchInlineSnapshot(` + Object { + "id": "hello", + } + `); + }); + }); }); }); diff --git a/src/plugins/data/public/search/aggs/agg_type.ts b/src/plugins/data/public/search/aggs/agg_type.ts index fb0cb609a08cfe..e909cd8134e83f 100644 --- a/src/plugins/data/public/search/aggs/agg_type.ts +++ b/src/plugins/data/public/search/aggs/agg_type.ts @@ -19,8 +19,10 @@ import { constant, noop, identity } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { initParams } from './agg_params'; +import { SerializedFieldFormat } from 'src/plugins/expressions/public'; + +import { initParams } from './agg_params'; import { AggConfig } from './agg_config'; import { IAggConfigs } from './agg_configs'; import { Adapters } from '../../../../../plugins/inspector/public'; @@ -57,6 +59,7 @@ export interface AggTypeConfig< abortSignal?: AbortSignal ) => Promise; getFormat?: (agg: TAggConfig) => IFieldFormat; + getSerializedFormat?: (agg: TAggConfig) => SerializedFieldFormat; getValue?: (agg: TAggConfig, bucket: any) => any; getKey?: (bucket: any, key: any, agg: TAggConfig) => any; } @@ -204,6 +207,17 @@ export class AggType< */ getFormat: (agg: TAggConfig) => IFieldFormat; + /** + * Get the serialized format for the values produced by this agg type, + * overridden by several metrics that always output a simple number. + * You can pass this output to fieldFormatters.deserialize to get + * the formatter instance. + * + * @param {agg} agg - the agg to pick a format for + * @return {SerializedFieldFormat} + */ + getSerializedFormat: (agg: TAggConfig) => SerializedFieldFormat; + getValue: (agg: TAggConfig, bucket: any) => any; getKey?: (bucket: any, key: any, agg: TAggConfig) => any; @@ -277,6 +291,13 @@ export class AggType< return field ? field.format : fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING); }); + + this.getSerializedFormat = + config.getSerializedFormat || + ((agg: TAggConfig) => { + return agg.params.field ? agg.params.field.format.toJSON() : {}; + }); + this.getValue = config.getValue || ((agg: TAggConfig, bucket: any) => {}); } } diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts index 8a5596f669cb78..fc42d43b2fea81 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -152,6 +152,14 @@ export const getDateHistogramBucketAgg = ({ (key: string) => uiSettings.get(key) ); }, + getSerializedFormat(agg) { + return { + id: 'date', + params: { + pattern: agg.buckets.getScaledDateFormat(), + }, + }; + }, params: [ { name: 'field', diff --git a/src/plugins/data/public/search/aggs/buckets/date_range.ts b/src/plugins/data/public/search/aggs/buckets/date_range.ts index 447347dbfbe109..3e14ab422ccbee 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_range.ts @@ -70,6 +70,12 @@ export const getDateRangeBucketAgg = ({ }); return new DateRangeFormat(); }, + getSerializedFormat(agg) { + return { + id: 'date_range', + params: agg.params.field ? agg.params.field.format.toJSON() : {}, + }; + }, makeLabel(aggConfig) { return aggConfig.getFieldDisplayName() + ' date ranges'; }, diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range.ts b/src/plugins/data/public/search/aggs/buckets/ip_range.ts index 10fdb2d93b56ea..b3e90bdf9b56a1 100644 --- a/src/plugins/data/public/search/aggs/buckets/ip_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/ip_range.ts @@ -78,6 +78,12 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA }); return new IpRangeFormat(); }, + getSerializedFormat(agg) { + return { + id: 'ip_range', + params: agg.params.field ? agg.params.field.format.toJSON() : {}, + }; + }, makeLabel(aggConfig) { return i18n.translate('data.search.aggs.buckets.ipRangeLabel', { defaultMessage: '{fieldName} IP ranges', diff --git a/src/plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts b/src/plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts index b8d6586652d6b1..12197c85f4a961 100644 --- a/src/plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts +++ b/src/plugins/data/public/search/aggs/buckets/lib/time_buckets/time_buckets.ts @@ -48,7 +48,7 @@ function isValidMoment(m: any): boolean { return m && 'isValid' in m && m.isValid(); } -export interface TimeBucketsConfig { +export interface TimeBucketsConfig extends Record { 'histogram:maxBars': number; 'histogram:barTarget': number; dateFormat: string; diff --git a/src/plugins/data/public/search/aggs/buckets/range.test.ts b/src/plugins/data/public/search/aggs/buckets/range.test.ts index 4c2d3af1ab7347..2b8a36f2fbdbcb 100644 --- a/src/plugins/data/public/search/aggs/buckets/range.test.ts +++ b/src/plugins/data/public/search/aggs/buckets/range.test.ts @@ -104,7 +104,7 @@ describe('Range Agg', () => { ); }; - describe('formating', () => { + describe('formatting', () => { test('formats bucket keys properly', () => { const aggConfigs = getAggConfigs(); const agg = aggConfigs.aggs[0]; @@ -115,4 +115,22 @@ describe('Range Agg', () => { expect(format(buckets[2])).toBe('≥ 2.5 KB and < +∞'); }); }); + + describe('getSerializedFormat', () => { + test('generates a serialized field format in the expected shape', () => { + const aggConfigs = getAggConfigs(); + const agg = aggConfigs.aggs[0]; + expect(agg.type.getSerializedFormat(agg)).toMatchInlineSnapshot(` + Object { + "id": "range", + "params": Object { + "id": "number", + "params": Object { + "pattern": "0,0.[000] b", + }, + }, + } + `); + }); + }); }); diff --git a/src/plugins/data/public/search/aggs/buckets/range.ts b/src/plugins/data/public/search/aggs/buckets/range.ts index 02aad3bd5fed12..543e5d66b9fa81 100644 --- a/src/plugins/data/public/search/aggs/buckets/range.ts +++ b/src/plugins/data/public/search/aggs/buckets/range.ts @@ -101,6 +101,16 @@ export const getRangeBucketAgg = ({ getInternalStartServices }: RangeBucketAggDe formats.set(agg, aggFormat); return aggFormat; }, + getSerializedFormat(agg) { + const format = agg.params.field ? agg.params.field.format.toJSON() : {}; + return { + id: 'range', + params: { + id: format.id, + params: format.params, + }, + }; + }, params: [ { name: 'field', diff --git a/src/plugins/data/public/search/aggs/buckets/terms.ts b/src/plugins/data/public/search/aggs/buckets/terms.ts index 45a76f08ddd13d..1e8e9ab4ef9d09 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms.ts @@ -104,6 +104,18 @@ export const getTermsBucketAgg = ({ getInternalStartServices }: TermsBucketAggDe }, } as IFieldFormat; }, + getSerializedFormat(agg) { + const format = agg.params.field ? agg.params.field.format.toJSON() : {}; + return { + id: 'terms', + params: { + id: format.id, + otherBucketLabel: agg.params.otherBucketLabel, + missingBucketLabel: agg.params.missingBucketLabel, + ...format.params, + }, + }; + }, createFilter: createFilterTerms, postFlightRequest: async ( resp: any, diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts b/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts index 927e9a7ae44586..38312ec5cfa81c 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_avg.ts @@ -46,14 +46,17 @@ const averageBucketTitle = i18n.translate('data.search.aggs.metrics.averageBucke export const getBucketAvgMetricAgg = ({ getInternalStartServices, }: BucketAvgMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = siblingPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.AVG_BUCKET, title: averageBucketTitle, makeLabel: (agg) => makeNestedLabel(agg, overallAverageLabel), - subtype: siblingPipelineAggHelper.subtype, - params: [...siblingPipelineAggHelper.params()], - getFormat: siblingPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, getValue(agg, bucket) { const customMetric = agg.getParam('customMetric'); const customBucket = agg.getParam('customBucket'); diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_max.ts b/src/plugins/data/public/search/aggs/metrics/bucket_max.ts index 2b171fcbd24fd2..e2c6a5105bac66 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_max.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_max.ts @@ -45,14 +45,17 @@ const maxBucketTitle = i18n.translate('data.search.aggs.metrics.maxBucketTitle', export const getBucketMaxMetricAgg = ({ getInternalStartServices, }: BucketMaxMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = siblingPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.MAX_BUCKET, title: maxBucketTitle, makeLabel: (agg) => makeNestedLabel(agg, overallMaxLabel), - subtype: siblingPipelineAggHelper.subtype, - params: [...siblingPipelineAggHelper.params()], - getFormat: siblingPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_min.ts b/src/plugins/data/public/search/aggs/metrics/bucket_min.ts index e6a523eeea374b..c46a3eb9425d1f 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_min.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_min.ts @@ -45,14 +45,17 @@ const minBucketTitle = i18n.translate('data.search.aggs.metrics.minBucketTitle', export const getBucketMinMetricAgg = ({ getInternalStartServices, }: BucketMinMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = siblingPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.MIN_BUCKET, title: minBucketTitle, makeLabel: (agg) => makeNestedLabel(agg, overallMinLabel), - subtype: siblingPipelineAggHelper.subtype, - params: [...siblingPipelineAggHelper.params()], - getFormat: siblingPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts b/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts index 71c88596ea5693..57212ec9ff91b2 100644 --- a/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts +++ b/src/plugins/data/public/search/aggs/metrics/bucket_sum.ts @@ -45,14 +45,17 @@ const sumBucketTitle = i18n.translate('data.search.aggs.metrics.sumBucketTitle', export const getBucketSumMetricAgg = ({ getInternalStartServices, }: BucketSumMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = siblingPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.SUM_BUCKET, title: sumBucketTitle, makeLabel: (agg) => makeNestedLabel(agg, overallSumLabel), - subtype: siblingPipelineAggHelper.subtype, - params: [...siblingPipelineAggHelper.params()], - getFormat: siblingPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/cardinality.ts b/src/plugins/data/public/search/aggs/metrics/cardinality.ts index 9ff3e84c38cd8f..2855cc1b6b16eb 100644 --- a/src/plugins/data/public/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/public/search/aggs/metrics/cardinality.ts @@ -54,6 +54,11 @@ export const getCardinalityMetricAgg = ({ return fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.NUMBER); }, + getSerializedFormat(agg) { + return { + id: 'number', + }; + }, params: [ { name: 'field', diff --git a/src/plugins/data/public/search/aggs/metrics/count.ts b/src/plugins/data/public/search/aggs/metrics/count.ts index bd0b83798c7db6..4c7b8139b01628 100644 --- a/src/plugins/data/public/search/aggs/metrics/count.ts +++ b/src/plugins/data/public/search/aggs/metrics/count.ts @@ -45,6 +45,11 @@ export const getCountMetricAgg = ({ getInternalStartServices }: CountMetricAggDe return fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.NUMBER); }, + getSerializedFormat(agg) { + return { + id: 'number', + }; + }, getValue(agg, bucket) { return bucket.doc_count; }, diff --git a/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts b/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts index 44bfca1b6fb87c..c392f44a7961ed 100644 --- a/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts +++ b/src/plugins/data/public/search/aggs/metrics/cumulative_sum.ts @@ -46,14 +46,17 @@ const cumulativeSumTitle = i18n.translate('data.search.aggs.metrics.cumulativeSu export const getCumulativeSumMetricAgg = ({ getInternalStartServices, }: CumulativeSumMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = parentPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.CUMULATIVE_SUM, title: cumulativeSumTitle, - subtype: parentPipelineAggHelper.subtype, makeLabel: (agg) => makeNestedLabel(agg, cumulativeSumLabel), - params: [...parentPipelineAggHelper.params()], - getFormat: parentPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/derivative.ts b/src/plugins/data/public/search/aggs/metrics/derivative.ts index edb907ca4ed416..f3c1cc9bc29773 100644 --- a/src/plugins/data/public/search/aggs/metrics/derivative.ts +++ b/src/plugins/data/public/search/aggs/metrics/derivative.ts @@ -46,16 +46,19 @@ const derivativeTitle = i18n.translate('data.search.aggs.metrics.derivativeTitle export const getDerivativeMetricAgg = ({ getInternalStartServices, }: DerivativeMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = parentPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.DERIVATIVE, title: derivativeTitle, - subtype: parentPipelineAggHelper.subtype, makeLabel(agg) { return makeNestedLabel(agg, derivativeLabel); }, - params: [...parentPipelineAggHelper.params()], - getFormat: parentPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts b/src/plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts index 947394c97bdcd2..2a74a446ce84e0 100644 --- a/src/plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts +++ b/src/plugins/data/public/search/aggs/metrics/lib/parent_pipeline_agg_helper.ts @@ -84,4 +84,16 @@ export const parentPipelineAggHelper = { } return subAgg ? subAgg.type.getFormat(subAgg) : new (FieldFormat.from(identity))(); }, + + getSerializedFormat(agg: IMetricAggConfig) { + let subAgg; + const customMetric = agg.getParam('customMetric'); + + if (customMetric) { + subAgg = customMetric; + } else { + subAgg = agg.aggConfigs.byId(agg.getParam('metricAgg')); + } + return subAgg ? subAgg.type.getSerializedFormat(subAgg) : {}; + }, }; diff --git a/src/plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts b/src/plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts index cee7841a8c3b98..8e3e0143bf9154 100644 --- a/src/plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts +++ b/src/plugins/data/public/search/aggs/metrics/lib/sibling_pipeline_agg_helper.ts @@ -93,4 +93,9 @@ export const siblingPipelineAggHelper = { ? customMetric.type.getFormat(customMetric) : new (FieldFormat.from(identity))(); }, + + getSerializedFormat(agg: IMetricAggConfig) { + const customMetric = agg.getParam('customMetric'); + return customMetric ? customMetric.type.getSerializedFormat(customMetric) : {}; + }, }; diff --git a/src/plugins/data/public/search/aggs/metrics/moving_avg.ts b/src/plugins/data/public/search/aggs/metrics/moving_avg.ts index 1173ae5358ee70..abad2782f9d201 100644 --- a/src/plugins/data/public/search/aggs/metrics/moving_avg.ts +++ b/src/plugins/data/public/search/aggs/metrics/moving_avg.ts @@ -48,15 +48,19 @@ const movingAvgLabel = i18n.translate('data.search.aggs.metrics.movingAvgLabel', export const getMovingAvgMetricAgg = ({ getInternalStartServices, }: MovingAvgMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = parentPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.MOVING_FN, dslName: 'moving_fn', title: movingAvgTitle, - subtype: parentPipelineAggHelper.subtype, makeLabel: (agg) => makeNestedLabel(agg, movingAvgLabel), + subtype, + getFormat, + getSerializedFormat, params: [ - ...parentPipelineAggHelper.params(), + ...params(), { name: 'window', default: 5, @@ -78,7 +82,6 @@ export const getMovingAvgMetricAgg = ({ */ return bucket[agg.id] ? bucket[agg.id].value : null; }, - getFormat: parentPipelineAggHelper.getFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts index c8383f6bcc3d9c..c5aee380b97762 100644 --- a/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts +++ b/src/plugins/data/public/search/aggs/metrics/percentile_ranks.ts @@ -103,6 +103,11 @@ export const getPercentileRanksMetricAgg = ({ fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.NUMBER) ); }, + getSerializedFormat(agg) { + return { + id: 'percent', + }; + }, getValue(agg, bucket) { return getPercentileValue(agg, bucket) / 100; }, diff --git a/src/plugins/data/public/search/aggs/metrics/serial_diff.ts b/src/plugins/data/public/search/aggs/metrics/serial_diff.ts index 00bc631cefab81..7cb1b194fed1c1 100644 --- a/src/plugins/data/public/search/aggs/metrics/serial_diff.ts +++ b/src/plugins/data/public/search/aggs/metrics/serial_diff.ts @@ -46,14 +46,17 @@ const serialDiffLabel = i18n.translate('data.search.aggs.metrics.serialDiffLabel export const getSerialDiffMetricAgg = ({ getInternalStartServices, }: SerialDiffMetricAggDependencies) => { + const { subtype, params, getFormat, getSerializedFormat } = parentPipelineAggHelper; + return new MetricAggType( { name: METRIC_TYPES.SERIAL_DIFF, title: serialDiffTitle, - subtype: parentPipelineAggHelper.subtype, makeLabel: (agg) => makeNestedLabel(agg, serialDiffLabel), - params: [...parentPipelineAggHelper.params()], - getFormat: parentPipelineAggHelper.getFormat, + subtype, + params: [...params()], + getFormat, + getSerializedFormat, }, { getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts b/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts index 5549ffd2583b18..836aaad2cda0c3 100644 --- a/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts +++ b/src/plugins/data/public/search/aggs/test_helpers/mock_agg_types_registry.ts @@ -25,6 +25,25 @@ import { MetricAggType } from '../metrics/metric_agg_type'; import { queryServiceMock } from '../../../query/mocks'; import { fieldFormatsServiceMock } from '../../../field_formats/mocks'; import { InternalStartServices } from '../../../types'; +import { TimeBucketsConfig } from '../buckets/lib/time_buckets/time_buckets'; + +// Mocked uiSettings shared among aggs unit tests +const mockUiSettings = jest.fn().mockImplementation((key: string) => { + const config: TimeBucketsConfig = { + 'histogram:maxBars': 4, + 'histogram:barTarget': 3, + dateFormat: 'YYYY-MM-DD', + 'dateFormat:scaled': [ + ['', 'HH:mm:ss.SSS'], + ['PT1S', 'HH:mm:ss'], + ['PT1M', 'HH:mm'], + ['PT1H', 'YYYY-MM-DD HH:mm'], + ['P1DT', 'YYYY-MM-DD'], + ['P1YT', 'YYYY'], + ], + }; + return config[key] ?? key; +}); /** * Testing utility which creates a new instance of AggTypesRegistry, @@ -54,7 +73,10 @@ export function mockAggTypesRegistry | MetricAggTyp }); } else { const coreSetup = coreMock.createSetup(); + coreSetup.uiSettings.get = mockUiSettings; + const coreStart = coreMock.createStart(); + coreSetup.uiSettings.get = mockUiSettings; const aggTypes = getAggTypes({ uiSettings: coreSetup.uiSettings, diff --git a/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.test.ts b/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.test.ts new file mode 100644 index 00000000000000..3b440bc50c93b8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.test.ts @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { identity } from 'lodash'; + +import { SerializedFieldFormat } from '../../../../../expressions/common/types'; +import { FieldFormat } from '../../../../common'; +import { IFieldFormat } from '../../../../public'; + +import { getFormatWithAggs } from './get_format_with_aggs'; + +describe('getFormatWithAggs', () => { + let getFormat: jest.MockedFunction<(mapping: SerializedFieldFormat) => IFieldFormat>; + + beforeEach(() => { + getFormat = jest.fn().mockImplementation(() => { + const DefaultFieldFormat = FieldFormat.from(identity); + return new DefaultFieldFormat(); + }); + }); + + test('calls provided getFormat if no matching aggs exist', () => { + const mapping = { id: 'foo', params: {} }; + const getFieldFormat = getFormatWithAggs(getFormat); + getFieldFormat(mapping); + + expect(getFormat).toHaveBeenCalledTimes(1); + expect(getFormat).toHaveBeenCalledWith(mapping); + }); + + test('creates custom format for date_range', () => { + const mapping = { id: 'date_range', params: {} }; + const getFieldFormat = getFormatWithAggs(getFormat); + const format = getFieldFormat(mapping); + + expect(format.convert({ from: '2020-05-01', to: '2020-06-01' })).toBe( + '2020-05-01 to 2020-06-01' + ); + expect(format.convert({ to: '2020-06-01' })).toBe('Before 2020-06-01'); + expect(format.convert({ from: '2020-06-01' })).toBe('After 2020-06-01'); + expect(getFormat).toHaveBeenCalledTimes(3); + }); + + test('creates custom format for ip_range', () => { + const mapping = { id: 'ip_range', params: {} }; + const getFieldFormat = getFormatWithAggs(getFormat); + const format = getFieldFormat(mapping); + + expect(format.convert({ type: 'range', from: '10.0.0.1', to: '10.0.0.10' })).toBe( + '10.0.0.1 to 10.0.0.10' + ); + expect(format.convert({ type: 'range', to: '10.0.0.10' })).toBe('-Infinity to 10.0.0.10'); + expect(format.convert({ type: 'range', from: '10.0.0.10' })).toBe('10.0.0.10 to Infinity'); + format.convert({ type: 'mask', mask: '10.0.0.1/24' }); + expect(getFormat).toHaveBeenCalledTimes(4); + }); + + test('creates custom format for range', () => { + const mapping = { id: 'range', params: {} }; + const getFieldFormat = getFormatWithAggs(getFormat); + const format = getFieldFormat(mapping); + + expect(format.convert({ gte: 1, lt: 20 })).toBe('≥ 1 and < 20'); + expect(getFormat).toHaveBeenCalledTimes(1); + }); + + test('creates custom format for terms', () => { + const mapping = { + id: 'terms', + params: { + otherBucketLabel: 'other bucket', + missingBucketLabel: 'missing bucket', + }, + }; + const getFieldFormat = getFormatWithAggs(getFormat); + const format = getFieldFormat(mapping); + + expect(format.convert('machine.os.keyword')).toBe('machine.os.keyword'); + expect(format.convert('__other__')).toBe(mapping.params.otherBucketLabel); + expect(format.convert('__missing__')).toBe(mapping.params.missingBucketLabel); + expect(getFormat).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.ts b/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.ts new file mode 100644 index 00000000000000..e0db249c7cf865 --- /dev/null +++ b/src/plugins/data/public/search/aggs/utils/get_format_with_aggs.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; + +import { SerializedFieldFormat } from '../../../../../expressions/common/types'; +import { FieldFormat } from '../../../../common'; +import { FieldFormatsContentType, IFieldFormat } from '../../../../public'; +import { convertDateRangeToString, DateRangeKey } from '../buckets/lib/date_range'; +import { convertIPRangeToString, IpRangeKey } from '../buckets/lib/ip_range'; + +type GetFieldFormat = (mapping: SerializedFieldFormat) => IFieldFormat; + +/** + * Certain aggs have custom field formats that are not part of the field formats + * registry. This function will take the `getFormat` function which is used inside + * `deserializeFieldFormat` and decorate it with the additional custom formats + * that the field formats service doesn't know anything about. + * + * This function is internal to the data plugin, and only exists for use inside + * the field formats service. + * + * @internal + */ +export function getFormatWithAggs(getFieldFormat: GetFieldFormat): GetFieldFormat { + return (mapping) => { + const { id, params = {} } = mapping; + + const customFormats: Record IFieldFormat> = { + range: () => { + const RangeFormat = FieldFormat.from((range: any) => { + const nestedFormatter = params as SerializedFieldFormat; + const format = getFieldFormat({ + id: nestedFormatter.id, + params: nestedFormatter.params, + }); + const gte = '\u2265'; + const lt = '\u003c'; + return i18n.translate('data.aggTypes.buckets.ranges.rangesFormatMessage', { + defaultMessage: '{gte} {from} and {lt} {to}', + values: { + gte, + from: format.convert(range.gte), + lt, + to: format.convert(range.lt), + }, + }); + }); + return new RangeFormat(); + }, + date_range: () => { + const nestedFormatter = params as SerializedFieldFormat; + const DateRangeFormat = FieldFormat.from((range: DateRangeKey) => { + const format = getFieldFormat({ + id: nestedFormatter.id, + params: nestedFormatter.params, + }); + return convertDateRangeToString(range, format.convert.bind(format)); + }); + return new DateRangeFormat(); + }, + ip_range: () => { + const nestedFormatter = params as SerializedFieldFormat; + const IpRangeFormat = FieldFormat.from((range: IpRangeKey) => { + const format = getFieldFormat({ + id: nestedFormatter.id, + params: nestedFormatter.params, + }); + return convertIPRangeToString(range, format.convert.bind(format)); + }); + return new IpRangeFormat(); + }, + terms: () => { + const convert = (val: string, type: FieldFormatsContentType) => { + const format = getFieldFormat({ id: params.id, params }); + + if (val === '__other__') { + return params.otherBucketLabel; + } + if (val === '__missing__') { + return params.missingBucketLabel; + } + + return format.convert(val, type); + }; + + return { + convert, + getConverterFor: (type: FieldFormatsContentType) => (val: string) => convert(val, type), + } as IFieldFormat; + }, + }; + + if (!id || !(id in customFormats)) { + return getFieldFormat(mapping); + } + + return customFormats[id](); + }; +} diff --git a/src/plugins/data/public/search/aggs/utils/index.ts b/src/plugins/data/public/search/aggs/utils/index.ts index 169d872b17d3aa..5a889ee9ead9d9 100644 --- a/src/plugins/data/public/search/aggs/utils/index.ts +++ b/src/plugins/data/public/search/aggs/utils/index.ts @@ -18,5 +18,6 @@ */ export * from './calculate_auto_time_expression'; +export * from './get_format_with_aggs'; export * from './prop_filter'; export * from './to_angular_json'; diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 153eb7de6f2de4..77475cd3ce88b9 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -32,14 +32,7 @@ import { Adapters } from '../../../../../plugins/inspector/public'; import { IAggConfigs } from '../aggs'; import { ISearchSource } from '../search_source'; import { tabifyAggResponse } from '../tabify'; -import { - Filter, - Query, - serializeFieldFormat, - TimeRange, - IIndexPattern, - isRangeFilter, -} from '../../../common'; +import { Filter, Query, TimeRange, IIndexPattern, isRangeFilter } from '../../../common'; import { FilterManager, calculateBounds, getTime } from '../../query'; import { getSearchService, getQueryService, getIndexPatterns } from '../../services'; import { buildTabularInspectorData } from './build_tabular_inspector_data'; @@ -313,7 +306,7 @@ export const esaggs = (): ExpressionFunctionDefinition import("../../expressions").SerializedFieldFormat; BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; @@ -393,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) @@ -631,7 +634,7 @@ export class Plugin implements Plugin_2 { setup(core: CoreSetup, { usageCollection }: DataPluginSetupDependencies): { search: ISearchSetup; fieldFormats: { - register: (customFieldFormat: import("../public").FieldFormatInstanceType) => number; + register: (customFieldFormat: import("../common").FieldFormatInstanceType) => number; }; }; // Warning: (ae-forgotten-export) The symbol "CoreStart" needs to be exported by the entry point index.d.ts @@ -774,30 +777,30 @@ export const UI_SETTINGS: { // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:103:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:131:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:131:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:187:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:189:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:190:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:193:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:184:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:185:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:187:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:191:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/discover/public/application/_hacks.scss b/src/plugins/discover/public/application/_hacks.scss index baf27bb9f82da1..9bbe9cd14fd913 100644 --- a/src/plugins/discover/public/application/_hacks.scss +++ b/src/plugins/discover/public/application/_hacks.scss @@ -2,5 +2,3 @@ .tab-discover { overflow: hidden; } - - diff --git a/src/plugins/discover/public/application/angular/directives/histogram.tsx b/src/plugins/discover/public/application/angular/directives/histogram.tsx index 8b646106fe52f0..9afe5e48bc5b82 100644 --- a/src/plugins/discover/public/application/angular/directives/histogram.tsx +++ b/src/plugins/discover/public/application/angular/directives/histogram.tsx @@ -323,6 +323,7 @@ export class DiscoverHistogram extends Component { }; }, }; - $routeProvider.when('/:id?', { + const discoverRoute = { ...defaults, template: indexTemplate, reloadOnSearch: false, @@ -177,7 +177,10 @@ app.config(($routeProvider) => { }); }, }, - }); + }; + + $routeProvider.when('/view/:id?', discoverRoute); + $routeProvider.when('/', discoverRoute); }); app.directive('discoverApp', function () { @@ -415,7 +418,7 @@ function discoverController( testId: 'discoverOpenButton', run: () => { showOpenSearchPanel({ - makeUrl: (searchId) => `#/${encodeURIComponent(searchId)}`, + makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, I18nContext: core.i18n.Context, }); }, @@ -747,7 +750,7 @@ function discoverController( }); if (savedSearch.id !== $route.current.params.id) { - history.push(`/${encodeURIComponent(savedSearch.id)}`); + history.push(`/view/${encodeURIComponent(savedSearch.id)}`); } else { // Update defaults so that "reload saved query" functions correctly setAppState(getStateDefaults()); @@ -782,6 +785,10 @@ function discoverController( if (!init.complete) return; $scope.fetchCounter++; $scope.fetchError = undefined; + if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { + $scope.resultState = 'none'; + return; + } // Abort any in-progress requests before fetching again if (abortController) abortController.abort(); @@ -848,7 +855,7 @@ function discoverController( x: { accessor: 0, label: agg.makeLabel(), - format: fieldFormats.serialize(agg), + format: agg.toSerializedFieldFormat(), params: { date: true, interval: moment.duration(esValue, esUnit), @@ -860,7 +867,7 @@ function discoverController( }, y: { accessor: 1, - format: fieldFormats.serialize(metric), + format: metric.toSerializedFieldFormat(), label: metric.makeLabel(), }, }; @@ -914,19 +921,25 @@ function discoverController( } $scope.updateTime = function () { - //this is the timerange for the histogram, should be refactored + const { from, to } = timefilter.getTime(); + // this is the timerange for the histogram, should be refactored $scope.timeRange = { - from: dateMath.parse(timefilter.getTime().from), - to: dateMath.parse(timefilter.getTime().to, { roundUp: true }), + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), }; }; $scope.toMoment = function (datetime) { + if (!datetime) { + return; + } return moment(datetime).format(config.get('dateFormat')); }; $scope.resetQuery = function () { - history.push(`/${encodeURIComponent($route.current.params.id)}`); + history.push( + $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' + ); $route.reload(); }; diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts index b1e6d27d766563..2fb6fb1e3a3079 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts @@ -144,8 +144,7 @@ export function createTableRowDirective($compile: ng.ICompileService, $httpParam cellTemplate({ timefield: true, formatted: _displayField(row, indexPattern.timeFieldName), - filterable: - mapping(indexPattern.timeFieldName).filterable && _.isFunction($scope.filter), + filterable: mapping(indexPattern.timeFieldName).filterable && $scope.filter, column: indexPattern.timeFieldName, }) ); @@ -156,7 +155,7 @@ export function createTableRowDirective($compile: ng.ICompileService, $httpParam $scope.flattenedRow[column] !== undefined && mapping(column) && mapping(column).filterable && - _.isFunction($scope.filter); + $scope.filter; newHtmls.push( cellTemplate({ diff --git a/src/plugins/discover/public/application/angular/index.ts b/src/plugins/discover/public/application/angular/index.ts index f3fd3fb6622ee8..20a22d4ae634d7 100644 --- a/src/plugins/discover/public/application/angular/index.ts +++ b/src/plugins/discover/public/application/angular/index.ts @@ -25,4 +25,5 @@ import './discover'; import './doc'; import './context'; import './doc_viewer'; +import './redirect'; import './directives'; diff --git a/src/legacy/core_plugins/kibana/server/routes/api/import/index.js b/src/plugins/discover/public/application/angular/redirect.ts similarity index 55% rename from src/legacy/core_plugins/kibana/server/routes/api/import/index.js rename to src/plugins/discover/public/application/angular/redirect.ts index b7efb7da3c5a92..bfa2f07f852e93 100644 --- a/src/legacy/core_plugins/kibana/server/routes/api/import/index.js +++ b/src/plugins/discover/public/application/angular/redirect.ts @@ -16,30 +16,22 @@ * specific language governing permissions and limitations * under the License. */ +import { getAngularModule, getServices, getUrlTracker } from '../../kibana_services'; -import Joi from 'joi'; -import { importDashboards } from '../../../lib/import/import_dashboards'; - -export function importApi(server) { - server.route({ - path: '/api/kibana/dashboards/import', - method: ['POST'], - config: { - validate: { - payload: Joi.object().keys({ - objects: Joi.array(), - version: Joi.string(), - }), - query: Joi.object().keys({ - force: Joi.boolean().default(false), - exclude: [Joi.string(), Joi.array().items(Joi.string())], - }), - }, - tags: ['api'], - }, - - handler: async (req) => { - return await importDashboards(req); +getAngularModule().config(($routeProvider: any) => { + $routeProvider.otherwise({ + resolveRedirectTo: ($rootScope: any) => { + const path = window.location.hash.substr(1); + getUrlTracker().restorePreviousUrl(); + $rootScope.$applyAsync(() => { + const { kibanaLegacy } = getServices(); + const { navigated } = kibanaLegacy.navigateToLegacyKibanaUrl(path); + if (!navigated) { + kibanaLegacy.navigateToDefaultApp(); + } + }); + // prevent angular from completing the navigation + return new Promise(() => {}); }, }); -} +}); diff --git a/src/plugins/discover/public/application/application.ts b/src/plugins/discover/public/application/application.ts index 8167d4f9031956..b49cefd2fcb16f 100644 --- a/src/plugins/discover/public/application/application.ts +++ b/src/plugins/discover/public/application/application.ts @@ -19,11 +19,14 @@ import './index.scss'; import angular from 'angular'; +import { getServices } from '../kibana_services'; /** * Here's where Discover's inner angular is mounted and rendered */ export async function renderApp(moduleName: string, element: HTMLElement) { + // do not wait for fontawesome + getServices().kibanaLegacy.loadFontAwesome(); await import('./angular'); const $injector = mountDiscoverApp(moduleName, element); return () => $injector.get('$rootScope').$destroy(); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index 8c527475b7480f..099ec2e5b1ffcb 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -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 = { diff --git a/src/plugins/discover/public/application/helpers/validate_time_range.test.ts b/src/plugins/discover/public/application/helpers/validate_time_range.test.ts new file mode 100644 index 00000000000000..a61a729caa22b7 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/validate_time_range.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { validateTimeRange } from './validate_time_range'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; + +describe('Discover validateTimeRange', () => { + test('validates given time ranges correctly', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + [ + { from: '', to: '', result: false }, + { from: 'now', to: 'now+1h', result: true }, + { from: 'now', to: 'lala+1h', result: false }, + { from: '', to: 'now', result: false }, + { from: 'now', to: '', result: false }, + { from: ' 2020-06-02T13:36:13.689Z', to: 'now', result: true }, + { from: ' 2020-06-02T13:36:13.689Z', to: '2020-06-02T13:36:13.690Z', result: true }, + ].map((test) => { + expect(validateTimeRange({ from: test.from, to: test.to }, toasts)).toEqual(test.result); + }); + }); + + test('displays a toast when invalid data is entered', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + expect(validateTimeRange({ from: 'now', to: 'null' }, toasts)).toEqual(false); + expect(toasts.addDanger).toHaveBeenCalledWith({ + title: 'Invalid time range', + text: "The provided time range is invalid. (from: 'now', to: 'null')", + }); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/validate_time_range.ts b/src/plugins/discover/public/application/helpers/validate_time_range.ts new file mode 100644 index 00000000000000..411147f827333d --- /dev/null +++ b/src/plugins/discover/public/application/helpers/validate_time_range.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import dateMath from '@elastic/datemath'; +import { i18n } from '@kbn/i18n'; +import { ToastsStart } from 'kibana/public'; + +/** + * Validates a given time filter range, provided by URL or UI + * Unless valid, it returns false and displays a notification + */ +export function validateTimeRange( + { from, to }: { from: string; to: string }, + toastNotifications: ToastsStart +): boolean { + const fromMoment = dateMath.parse(from); + const toMoment = dateMath.parse(to); + if (!fromMoment || !toMoment || !fromMoment.isValid() || !toMoment.isValid()) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.invalidTimeRangeTitle', { + defaultMessage: `Invalid time range`, + }), + text: i18n.translate('discover.notifications.invalidTimeRangeText', { + defaultMessage: `The provided time range is invalid. (from: '{from}', to: '{to}')`, + values: { + from, + to, + }, + }), + }); + return false; + } + return true; +} diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 6d3e0b55140ba0..75c83e30d80ad3 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -42,6 +42,7 @@ import { SavedObjectKibanaServices } from 'src/plugins/saved_objects/public'; import { DiscoverStartPlugins } from './plugin'; import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; import { getHistory } from './kibana_services'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -57,6 +58,7 @@ export interface DiscoverServices { inspector: InspectorPublicPluginStart; metadata: { branch: string }; share?: SharePluginStart; + kibanaLegacy: KibanaLegacyStart; timefilter: TimefilterContract; toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; @@ -97,6 +99,7 @@ export async function buildServices( branch: context.env.packageInfo.branch, }, share: plugins.share, + kibanaLegacy: plugins.kibanaLegacy, timefilter: plugins.data.query.timefilter.timefilter, toastNotifications: core.notifications.toasts, uiSettings: core.uiSettings, diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index bbd0357f41ed4e..cca63cd880b600 100644 --- a/src/plugins/discover/public/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -53,6 +53,7 @@ export function setServices(newServices: any) { export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ setTrackedUrl: (url: string) => void; + restorePreviousUrl: () => void; }>('urlTracker'); export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 091288e3e65aa4..ba97efa55068d7 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -36,7 +36,7 @@ import { ChartsPluginStart } from 'src/plugins/charts/public'; import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import { SharePluginStart, SharePluginSetup, UrlGeneratorContract } from 'src/plugins/share/public'; import { VisualizationsStart, VisualizationsSetup } from 'src/plugins/visualizations/public'; -import { KibanaLegacySetup } from 'src/plugins/kibana_legacy/public'; +import { KibanaLegacySetup, KibanaLegacyStart } from 'src/plugins/kibana_legacy/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; @@ -55,6 +55,7 @@ import { setServices, setScopedHistory, getScopedHistory, + getServices, } from './kibana_services'; import { createSavedSearchesLoader } from './saved_searches'; import { registerFeature } from './register_feature'; @@ -130,6 +131,7 @@ export interface DiscoverStartPlugins { charts: ChartsPluginStart; data: DataPublicPluginStart; share?: SharePluginStart; + kibanaLegacy: KibanaLegacyStart; inspector: InspectorPublicPluginStart; visualizations: VisualizationsStart; } @@ -195,6 +197,7 @@ export class DiscoverPlugin appUnMounted, stop: stopUrlTracker, setActiveUrl: setTrackedUrl, + restorePreviousUrl, } = createKbnUrlTracker({ // we pass getter here instead of plain `history`, // so history is lazily created (when app is mounted) @@ -220,7 +223,7 @@ export class DiscoverPlugin }, ], }); - setUrlTracker({ setTrackedUrl }); + setUrlTracker({ setTrackedUrl, restorePreviousUrl }); this.stopUrlTracking = () => { stopUrlTracker(); }; @@ -260,7 +263,19 @@ export class DiscoverPlugin }, }); - plugins.kibanaLegacy.forwardApp('discover', 'discover'); + plugins.kibanaLegacy.forwardApp('doc', 'discover', (path) => { + return `#${path}`; + }); + plugins.kibanaLegacy.forwardApp('context', 'discover', (path) => { + return `#${path}`; + }); + plugins.kibanaLegacy.forwardApp('discover', 'discover', (path) => { + const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; + if (!id) { + return `#${path.replace('/discover', '') || '/'}`; + } + return `#/view/${id}${tail || ''}`; + }); if (plugins.home) { registerFeature(plugins.home); @@ -356,6 +371,7 @@ export class DiscoverPlugin throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); } const { core, plugins } = await this.initializeServices(); + getServices().kibanaLegacy.loadFontAwesome(); const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); getInnerAngularModuleEmbeddable( embeddableAngularName, diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 9eda4f6ce9d167..2b8574a8fa1183 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -66,7 +66,7 @@ export function createSavedSearchClass(services: SavedObjectKibanaServices) { }); this.showInRecentlyAccessed = true; this.id = id; - this.getFullPath = () => `/app/discover#/${String(id)}`; + this.getFullPath = () => `/app/discover#/view/${String(id)}`; } } diff --git a/src/plugins/discover/public/saved_searches/saved_searches.ts b/src/plugins/discover/public/saved_searches/saved_searches.ts index 1d1d4f17742a2e..09be10b1374943 100644 --- a/src/plugins/discover/public/saved_searches/saved_searches.ts +++ b/src/plugins/discover/public/saved_searches/saved_searches.ts @@ -34,7 +34,7 @@ export function createSavedSearchesLoader(services: SavedObjectKibanaServices) { nouns: 'saved searches', }; - savedSearchLoader.urlFor = (id: string) => `#/${encodeURIComponent(id)}`; + savedSearchLoader.urlFor = (id: string) => (id ? `#/view/${encodeURIComponent(id)}` : '#/'); return savedSearchLoader; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index d595197373a21d..1d1dc79121937b 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -22,7 +22,6 @@ import './index.scss'; import { PluginInitializerContext } from 'src/core/public'; import { EmbeddablePublicPlugin } from './plugin'; -export { EMBEDDABLE_ORIGINATING_APP_PARAM } from './types'; export { ACTION_ADD_PANEL, ACTION_APPLY_FILTER, @@ -69,6 +68,9 @@ export { isSavedObjectEmbeddableInput, isRangeSelectTriggerContext, isValueClickTriggerContext, + EmbeddableStateTransfer, + EmbeddableOriginatingAppState, + EmbeddablePackageState, EmbeddableRenderer, EmbeddableRendererProps, } from './lib'; @@ -82,4 +84,5 @@ export { EmbeddableStart, EmbeddableSetupDependencies, EmbeddableStartDependencies, + EmbeddablePanelHOC, } from './plugin'; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index 196bd593eb8d54..4b602efb027177 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -23,11 +23,13 @@ import { ViewMode } from '../types'; import { ContactCardEmbeddable } from '../test_samples'; import { embeddablePluginMock } from '../../mocks'; import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { of } from 'rxjs'; const { doStart } = embeddablePluginMock.createInstance(); const start = doStart(); const getFactory = start.getEmbeddableFactory; const applicationMock = applicationServiceMock.createStartContract(); +const stateTransferMock = embeddablePluginMock.createStartContract().getStateTransfer(); class EditableEmbeddable extends Embeddable { public readonly type = 'EDITABLE_EMBEDDABLE'; @@ -43,7 +45,7 @@ class EditableEmbeddable extends Embeddable { } test('is compatible when edit url is available, in edit mode and editable', async () => { - const action = new EditPanelAction(getFactory, applicationMock); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true), @@ -51,8 +53,20 @@ test('is compatible when edit url is available, in edit mode and editable', asyn ).toBe(true); }); +test('redirects to app using state transfer', async () => { + applicationMock.currentAppId$ = of('superCoolCurrentApp'); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); + const embeddable = new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true); + embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); + await action.execute({ embeddable }); + expect(stateTransferMock.navigateToWithOriginatingApp).toHaveBeenCalledWith('ultraVisualize', { + path: '/123', + state: { originatingApp: 'superCoolCurrentApp' }, + }); +}); + test('getHref returns the edit urls', async () => { - const action = new EditPanelAction(getFactory, applicationMock); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); expect(action.getHref).toBeDefined(); if (action.getHref) { @@ -66,7 +80,7 @@ test('getHref returns the edit urls', async () => { }); test('is not compatible when edit url is not available', async () => { - const action = new EditPanelAction(getFactory, applicationMock); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -85,7 +99,7 @@ test('is not compatible when edit url is not available', async () => { }); test('is not visible when edit url is available but in view mode', async () => { - const action = new EditPanelAction(getFactory, applicationMock); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( @@ -100,7 +114,7 @@ test('is not visible when edit url is available but in view mode', async () => { }); test('is not compatible when edit url is available, in edit mode, but not editable', async () => { - const action = new EditPanelAction(getFactory, applicationMock); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); expect( await action.isCompatible({ embeddable: new EditableEmbeddable( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 0bb2206988d830..d983dc9f418535 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -24,7 +24,7 @@ import { take } from 'rxjs/operators'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; -import { EMBEDDABLE_ORIGINATING_APP_PARAM, IEmbeddable } from '../..'; +import { IEmbeddable, EmbeddableOriginatingAppState, EmbeddableStateTransfer } from '../..'; export const ACTION_EDIT_PANEL = 'editPanel'; @@ -32,6 +32,12 @@ interface ActionContext { embeddable: IEmbeddable; } +interface NavigationContext { + app: string; + path: string; + state?: EmbeddableOriginatingAppState; +} + export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; @@ -40,7 +46,8 @@ export class EditPanelAction implements Action { constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], - private readonly application: ApplicationStart + private readonly application: ApplicationStart, + private readonly stateTransfer?: EmbeddableStateTransfer ) { if (this.application?.currentAppId$) { this.application.currentAppId$ @@ -79,9 +86,15 @@ export class EditPanelAction implements Action { public async execute(context: ActionContext) { const appTarget = this.getAppTarget(context); - if (appTarget) { - await this.application.navigateToApp(appTarget.app, { path: appTarget.path }); + if (this.stateTransfer && appTarget.state) { + await this.stateTransfer.navigateToWithOriginatingApp(appTarget.app, { + path: appTarget.path, + state: appTarget.state, + }); + } else { + await this.application.navigateToApp(appTarget.app, { path: appTarget.path }); + } return; } @@ -92,22 +105,17 @@ export class EditPanelAction implements Action { } } - public getAppTarget({ embeddable }: ActionContext): { app: string; path: string } | undefined { + public getAppTarget({ embeddable }: ActionContext): NavigationContext | undefined { const app = embeddable ? embeddable.getOutput().editApp : undefined; - let path = embeddable ? embeddable.getOutput().editPath : undefined; + const path = embeddable ? embeddable.getOutput().editPath : undefined; if (app && path) { - if (this.currentAppId) { - path += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`; - } - return { app, path }; + const state = this.currentAppId ? { originatingApp: this.currentAppId } : undefined; + return { app, path, state }; } } public async getHref({ embeddable }: ActionContext): Promise { - let editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; - if (editUrl && this.currentAppId) { - editUrl += `?${EMBEDDABLE_ORIGINATING_APP_PARAM}=${this.currentAppId}`; - } + const editUrl = embeddable ? embeddable.getOutput().editUrl : undefined; return editUrl ? editUrl : ''; } } diff --git a/src/plugins/embeddable/public/lib/index.ts b/src/plugins/embeddable/public/lib/index.ts index 172d9bdc9ec47e..b757fa59a7f3a6 100644 --- a/src/plugins/embeddable/public/lib/index.ts +++ b/src/plugins/embeddable/public/lib/index.ts @@ -24,3 +24,4 @@ export * from './actions'; export * from './triggers'; export * from './containers'; export * from './panel'; +export * from './state_transfer'; diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 9948d5a2db3db8..8cf2e015f88cf1 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -43,6 +43,7 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; import { EmbeddableErrorLabel } from './embeddable_error_label'; +import { EmbeddableStateTransfer } from '..'; const sortByOrderField = ( { order: orderA }: { order?: number }, @@ -62,6 +63,7 @@ interface Props { application: CoreStart['application']; inspector: InspectorStartContract; SavedObjectFinder: React.ComponentType; + stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; } @@ -299,7 +301,11 @@ export class EmbeddablePanel extends React.Component { ), new InspectPanelAction(this.props.inspector), new RemovePanelAction(), - new EditPanelAction(this.props.getEmbeddableFactory, this.props.application), + new EditPanelAction( + this.props.getEmbeddableFactory, + this.props.application, + this.props.stateTransfer + ), ]; const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts new file mode 100644 index 00000000000000..0d5ae6be68185b --- /dev/null +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -0,0 +1,167 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; +import { EmbeddableStateTransfer } from '.'; +import { ApplicationStart, ScopedHistory } from '../../../../../core/public'; + +function mockHistoryState(state: unknown) { + return scopedHistoryMock.create({ state }); +} + +describe('embeddable state transfer', () => { + let application: jest.Mocked; + let stateTransfer: EmbeddableStateTransfer; + const destinationApp = 'superUltraVisualize'; + const originatingApp = 'superUltraTestDashboard'; + + beforeEach(() => { + const core = coreMock.createStart(); + application = core.application; + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp); + }); + + it('can send an outgoing originating app state', async () => { + await stateTransfer.navigateToWithOriginatingApp(destinationApp, { state: { originatingApp } }); + expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { + state: { originatingApp: 'superUltraTestDashboard' }, + }); + }); + + it('can send an outgoing originating app state in append mode', async () => { + const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + await stateTransfer.navigateToWithOriginatingApp(destinationApp, { + state: { originatingApp }, + appendToExistingState: true, + }); + expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { + path: undefined, + state: { + kibanaIsNowForSports: 'extremeSportsKibana', + originatingApp: 'superUltraTestDashboard', + }, + }); + }); + + it('can send an outgoing embeddable package state', async () => { + await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { + state: { type: 'coolestType', id: '150' }, + }); + expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { + state: { type: 'coolestType', id: '150' }, + }); + }); + + it('can send an outgoing embeddable package state in append mode', async () => { + const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { + state: { type: 'coolestType', id: '150' }, + appendToExistingState: true, + }); + expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { + path: undefined, + state: { kibanaIsNowForSports: 'extremeSportsKibana', type: 'coolestType', id: '150' }, + }); + }); + + it('can fetch an incoming originating app state', async () => { + const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + const fetchedState = stateTransfer.getIncomingOriginatingApp(); + expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); + }); + + it('returns undefined with originating app state is not in the right shape', async () => { + const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + const fetchedState = stateTransfer.getIncomingOriginatingApp(); + expect(fetchedState).toBeUndefined(); + }); + + it('can fetch an incoming embeddable package state', async () => { + const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); + expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' }); + }); + + it('returns undefined when embeddable package is not in the right shape', async () => { + const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); + expect(fetchedState).toBeUndefined(); + }); + + it('removes all keys in the keysToRemoveAfterFetch array', async () => { + const historyMock = mockHistoryState({ + type: 'skisEmbeddable', + id: '123', + test1: 'test1', + test2: 'test2', + }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] }); + expect(historyMock.replace).toHaveBeenCalledWith( + expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } }) + ); + }); + + it('leaves state as is when no keysToRemove are supplied', async () => { + const historyMock = mockHistoryState({ + type: 'skisEmbeddable', + id: '123', + test1: 'test1', + test2: 'test2', + }); + stateTransfer = new EmbeddableStateTransfer( + application.navigateToApp, + (historyMock as unknown) as ScopedHistory + ); + stateTransfer.getIncomingEmbeddablePackage(); + expect(historyMock.location.state).toEqual({ + type: 'skisEmbeddable', + id: '123', + test1: 'test1', + test2: 'test2', + }); + }); +}); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts new file mode 100644 index 00000000000000..57b425d2df45c2 --- /dev/null +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { cloneDeep } from 'lodash'; +import { ScopedHistory, ApplicationStart } from '../../../../../core/public'; +import { + EmbeddableOriginatingAppState, + isEmbeddableOriginatingAppState, + EmbeddablePackageState, + isEmbeddablePackageState, +} from './types'; + +/** + * A wrapper around the state object in {@link ScopedHistory | core scoped history} which provides + * strongly typed helper methods for common incoming and outgoing states used by the embeddable infrastructure. + * + * @public + */ +export class EmbeddableStateTransfer { + constructor( + private navigateToApp: ApplicationStart['navigateToApp'], + private scopedHistory?: ScopedHistory + ) {} + + /** + * Fetches an {@link EmbeddableOriginatingAppState | originating app} argument from the scoped + * history's location state. + * + * @param history - the scoped history to fetch from + * @param options.keysToRemoveAfterFetch - an array of keys to be removed from the state after they are retrieved + */ + public getIncomingOriginatingApp(options?: { + keysToRemoveAfterFetch?: string[]; + }): EmbeddableOriginatingAppState | undefined { + return this.getIncomingState(isEmbeddableOriginatingAppState, { + keysToRemoveAfterFetch: options?.keysToRemoveAfterFetch, + }); + } + + /** + * Fetches an {@link EmbeddablePackageState | embeddable package} argument from the scoped + * history's location state. + * + * @param history - the scoped history to fetch from + * @param options.keysToRemoveAfterFetch - an array of keys to be removed from the state after they are retrieved + */ + public getIncomingEmbeddablePackage(options?: { + keysToRemoveAfterFetch?: string[]; + }): EmbeddablePackageState | undefined { + return this.getIncomingState(isEmbeddablePackageState, { + keysToRemoveAfterFetch: options?.keysToRemoveAfterFetch, + }); + } + + /** + * A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId + * with {@link EmbeddableOriginatingAppState | originating app state} + */ + public async navigateToWithOriginatingApp( + appId: string, + options?: { + path?: string; + state: EmbeddableOriginatingAppState; + appendToExistingState?: boolean; + } + ): Promise { + await this.navigateToWithState(appId, options); + } + + /** + * A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId + * with {@link EmbeddablePackageState | embeddable package state} + */ + public async navigateToWithEmbeddablePackage( + appId: string, + options?: { path?: string; state: EmbeddablePackageState; appendToExistingState?: boolean } + ): Promise { + await this.navigateToWithState(appId, options); + } + + private getIncomingState( + guard: (state: unknown) => state is IncomingStateType, + options?: { + keysToRemoveAfterFetch?: string[]; + } + ): IncomingStateType | undefined { + if (!this.scopedHistory) { + throw new TypeError('ScopedHistory is required to fetch incoming state'); + } + const incomingState = this.scopedHistory.location?.state; + const castState = + !guard || guard(incomingState) ? (cloneDeep(incomingState) as IncomingStateType) : undefined; + if (castState && options?.keysToRemoveAfterFetch) { + const stateReplace = { ...(this.scopedHistory.location.state as { [key: string]: unknown }) }; + options.keysToRemoveAfterFetch.forEach((key: string) => { + delete stateReplace[key]; + }); + this.scopedHistory.replace({ ...this.scopedHistory.location, state: stateReplace }); + } + return castState; + } + + private async navigateToWithState( + appId: string, + options?: { path?: string; state?: OutgoingStateType; appendToExistingState?: boolean } + ): Promise { + const stateObject = + options?.appendToExistingState && this.scopedHistory + ? { + ...(this.scopedHistory?.location.state as { [key: string]: unknown }), + ...options.state, + } + : options?.state; + await this.navigateToApp(appId, { path: options?.path, state: stateObject }); + } +} diff --git a/src/plugins/advanced_settings/public/_index.scss b/src/plugins/embeddable/public/lib/state_transfer/index.ts similarity index 84% rename from src/plugins/advanced_settings/public/_index.scss rename to src/plugins/embeddable/public/lib/state_transfer/index.ts index d13c37bff32d00..e51efc5dcca26b 100644 --- a/src/plugins/advanced_settings/public/_index.scss +++ b/src/plugins/embeddable/public/lib/state_transfer/index.ts @@ -17,4 +17,5 @@ * under the License. */ -@import './management_app/index'; +export { EmbeddableStateTransfer } from './embeddable_state_transfer'; +export { EmbeddableOriginatingAppState, EmbeddablePackageState } from './types'; diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts new file mode 100644 index 00000000000000..8eae441d1be23c --- /dev/null +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -0,0 +1,56 @@ +/* + * 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. + */ + +/** + * Represents a state package that contains the last active app id. + * @public + */ +export interface EmbeddableOriginatingAppState { + originatingApp: string; +} + +export function isEmbeddableOriginatingAppState( + state: unknown +): state is EmbeddableOriginatingAppState { + return ensureFieldOfTypeExists('originatingApp', state, 'string'); +} + +/** + * Represents a state package that contains all fields necessary to create an embeddable in a container. + * @public + */ +export interface EmbeddablePackageState { + type: string; + id: string; +} + +export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState { + return ( + ensureFieldOfTypeExists('type', state, 'string') && + ensureFieldOfTypeExists('id', state, 'string') + ); +} + +function ensureFieldOfTypeExists(key: string, obj: unknown, type?: string): boolean { + return ( + obj && + key in (obj as { [key: string]: unknown }) && + (!type || typeof (obj as { [key: string]: unknown })[key] === type) + ); +} diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index 9da0b7602c4f8f..49910525c7ab18 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -22,6 +22,7 @@ import { EmbeddableSetup, EmbeddableSetupDependencies, EmbeddableStartDependencies, + EmbeddableStateTransfer, IEmbeddable, EmbeddablePanel, } from '.'; @@ -75,6 +76,15 @@ export const createEmbeddablePanelMock = ({ ); }; +export const createEmbeddableStateTransferMock = (): Partial => { + return { + getIncomingOriginatingApp: jest.fn(), + getIncomingEmbeddablePackage: jest.fn(), + navigateToWithOriginatingApp: jest.fn(), + navigateToWithEmbeddablePackage: jest.fn(), + }; +}; + const createSetupContract = (): Setup => { const setupContract: Setup = { registerEmbeddableFactory: jest.fn(), @@ -88,6 +98,8 @@ const createStartContract = (): Start => { getEmbeddableFactories: jest.fn(), getEmbeddableFactory: jest.fn(), EmbeddablePanel: jest.fn(), + getEmbeddablePanel: jest.fn(), + getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), }; return startContract; }; diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 269b1ffed829b1..c4e0ca44a4e7e3 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -20,7 +20,13 @@ import React from 'react'; import { getSavedObjectFinder } from '../../saved_objects/public'; import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; import { Start as InspectorStart } from '../../inspector/public'; -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + ScopedHistory, +} from '../../../core/public'; import { EmbeddableFactoryRegistry, EmbeddableFactoryProvider } from './types'; import { bootstrap } from './bootstrap'; import { @@ -32,6 +38,7 @@ import { EmbeddablePanel, } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; +import { EmbeddableStateTransfer } from './lib/state_transfer'; export interface EmbeddableSetupDependencies { uiActions: UiActionsSetup; @@ -63,9 +70,13 @@ export interface EmbeddableStart { embeddableFactoryId: string ) => EmbeddableFactory | undefined; getEmbeddableFactories: () => IterableIterator; - EmbeddablePanel: React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>; + EmbeddablePanel: EmbeddablePanelHOC; + getEmbeddablePanel: (stateTransfer?: EmbeddableStateTransfer) => EmbeddablePanelHOC; + getStateTransfer: (history?: ScopedHistory) => EmbeddableStateTransfer; } +export type EmbeddablePanelHOC = React.FC<{ embeddable: IEmbeddable; hideHeader?: boolean }>; + export class EmbeddablePublicPlugin implements Plugin { private readonly embeddableFactoryDefinitions: Map< string, @@ -73,6 +84,7 @@ export class EmbeddablePublicPlugin implements Plugin = new Map(); private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map(); private customEmbeddableFactoryProvider?: EmbeddableFactoryProvider; + private outgoingOnlyStateTransfer: EmbeddableStateTransfer = {} as EmbeddableStateTransfer; private isRegistryReady = false; constructor(initializerContext: PluginInitializerContext) {} @@ -105,31 +117,42 @@ export class EmbeddablePublicPlugin implements Plugin ({ + embeddable, + hideHeader, + }: { + embeddable: IEmbeddable; + hideHeader?: boolean; + }) => ( + + ); + return { getEmbeddableFactory: this.getEmbeddableFactory, getEmbeddableFactories: this.getEmbeddableFactories, - EmbeddablePanel: ({ - embeddable, - hideHeader, - }: { - embeddable: IEmbeddable; - hideHeader?: boolean; - }) => ( - - ), + getStateTransfer: (history?: ScopedHistory) => { + return history + ? new EmbeddableStateTransfer(core.application.navigateToApp, history) + : this.outgoingOnlyStateTransfer; + }, + EmbeddablePanel: getEmbeddablePanelHoc(), + getEmbeddablePanel: getEmbeddablePanelHoc, }; } diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index 6a388fe61782aa..e6162748fdb689 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -82,6 +82,7 @@ async function creatHelloWorldContainerAndEmbeddable( getEmbeddableFactory: start.getEmbeddableFactory, panelComponent: testPanel, }); + const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, diff --git a/src/plugins/embeddable/public/types.ts b/src/plugins/embeddable/public/types.ts index a57af862f2a34b..2d112b23598180 100644 --- a/src/plugins/embeddable/public/types.ts +++ b/src/plugins/embeddable/public/types.ts @@ -26,8 +26,6 @@ import { EmbeddableFactoryDefinition, } from './lib/embeddables'; -export const EMBEDDABLE_ORIGINATING_APP_PARAM = 'embeddableOriginatingApp'; - export type EmbeddableFactoryRegistry = Map; export type EmbeddableFactoryProvider = < diff --git a/src/plugins/expressions/common/types/common.ts b/src/plugins/expressions/common/types/common.ts index f532f9708940ec..040979e4264b54 100644 --- a/src/plugins/expressions/common/types/common.ts +++ b/src/plugins/expressions/common/types/common.ts @@ -61,7 +61,7 @@ export type UnmappedTypeStrings = 'date' | 'filter'; * Is used to carry information about how to format data in * a data table as part of the column definition. */ -export interface SerializedFieldFormat { +export interface SerializedFieldFormat> { id?: string; params?: TParams; } diff --git a/src/plugins/kibana_legacy/public/font_awesome/font_awesome.scss b/src/plugins/kibana_legacy/public/font_awesome/font_awesome.scss new file mode 100644 index 00000000000000..876a920269c499 --- /dev/null +++ b/src/plugins/kibana_legacy/public/font_awesome/font_awesome.scss @@ -0,0 +1,23 @@ +@font-face { + font-family: 'FontAwesome'; + src: url('~font-awesome/fonts/fontawesome-webfont.eot?v=4.7.0'); + src: url('~font-awesome/fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), + url('~font-awesome/fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), + url('~font-awesome/fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), + url('~font-awesome/fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), + url('~font-awesome/fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg'); + font-weight: normal; + font-style: normal; +} + +@import "font-awesome/scss/variables"; +@import "font-awesome/scss/core"; +@import "font-awesome/scss/icons"; + +// new file icon +.#{$fa-css-prefix}-file-new-o:before { content: $fa-var-file-o; } +.#{$fa-css-prefix}-file-new-o:after { content: $fa-var-plus; position: relative; margin-left: -1.0em; font-size: 0.5em; } + +// alias for alert types - allows class="fa fa-{{alertType}}" +.fa-success:before { content: $fa-var-check; } +.fa-danger:before { content: $fa-var-exclamation-circle; } diff --git a/src/legacy/core_plugins/testbed/public/index.js b/src/plugins/kibana_legacy/public/font_awesome/index.ts similarity index 96% rename from src/legacy/core_plugins/testbed/public/index.js rename to src/plugins/kibana_legacy/public/font_awesome/index.ts index c6687de249cf2a..318d44a3abfefe 100644 --- a/src/legacy/core_plugins/testbed/public/index.js +++ b/src/plugins/kibana_legacy/public/font_awesome/index.ts @@ -17,4 +17,4 @@ * under the License. */ -import './testbed'; +import './font_awesome.scss'; diff --git a/src/plugins/kibana_legacy/public/forward_app/forward_app.ts b/src/plugins/kibana_legacy/public/forward_app/forward_app.ts index a5bccfc7d725de..89018df1ca7e17 100644 --- a/src/plugins/kibana_legacy/public/forward_app/forward_app.ts +++ b/src/plugins/kibana_legacy/public/forward_app/forward_app.ts @@ -22,10 +22,8 @@ import { AppNavLinkStatus } from '../../../../core/public'; import { navigateToLegacyKibanaUrl } from './navigate_to_legacy_kibana_url'; import { ForwardDefinition } from '../plugin'; -export const createLegacyUrlForwardApp = ( - core: CoreSetup<{}, { getForwards: () => ForwardDefinition[] }> -): App => ({ - id: 'url_migrate', +export const createLegacyUrlForwardApp = (core: CoreSetup, forwards: ForwardDefinition[]): App => ({ + id: 'kibana', chromeless: true, title: 'Legacy URL migration', navLinkStatus: AppNavLinkStatus.hidden, @@ -33,7 +31,7 @@ export const createLegacyUrlForwardApp = ( const hash = params.history.location.hash.substr(1); if (!hash) { - throw new Error('Could not forward URL'); + core.fatalErrors.add('Could not forward URL'); } const [ @@ -41,11 +39,13 @@ export const createLegacyUrlForwardApp = ( application, http: { basePath }, }, - , - { getForwards }, ] = await core.getStartServices(); - navigateToLegacyKibanaUrl(hash, getForwards(), basePath, application, window.location); + const result = await navigateToLegacyKibanaUrl(hash, forwards, basePath, application); + + if (!result.navigated) { + core.fatalErrors.add('Could not forward URL'); + } return () => {}; }, diff --git a/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.test.ts b/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.test.ts index de8fa9de7241eb..30583aa95fc8c6 100644 --- a/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.test.ts +++ b/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.test.ts @@ -25,7 +25,6 @@ import { coreMock } from '../../../../core/public/mocks'; describe('migrate legacy kibana urls', () => { let forwardDefinitions: ForwardDefinition[]; let coreStart: CoreStart; - let locationMock: Location; beforeEach(() => { coreStart = coreMock.createStart({ basePath: '/base/path' }); @@ -36,34 +35,33 @@ describe('migrate legacy kibana urls', () => { rewritePath: jest.fn(() => '/new/path'), }, ]; - locationMock = { href: '' } as Location; }); - it('should redirect to kibana if no forward definition is found', () => { - navigateToLegacyKibanaUrl( + it('should do nothing if no forward definition is found', () => { + const result = navigateToLegacyKibanaUrl( '/myOtherApp/deep/path', forwardDefinitions, coreStart.http.basePath, - coreStart.application, - locationMock + coreStart.application ); - expect(locationMock.href).toEqual('/base/path/app/kibana#/myOtherApp/deep/path'); + expect(result).toEqual({ navigated: false }); + expect(coreStart.application.navigateToApp).not.toHaveBeenCalled(); }); it('should call navigateToApp with migrated URL', () => { - navigateToLegacyKibanaUrl( + const result = navigateToLegacyKibanaUrl( '/myApp/deep/path', forwardDefinitions, coreStart.http.basePath, - coreStart.application, - locationMock + coreStart.application ); expect(coreStart.application.navigateToApp).toHaveBeenCalledWith('updatedApp', { path: '/new/path', + replace: true, }); expect(forwardDefinitions[0].rewritePath).toHaveBeenCalledWith('/myApp/deep/path'); - expect(locationMock.href).toEqual(''); + expect(result).toEqual({ navigated: true }); }); }); diff --git a/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.ts b/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.ts index a6aee351fde529..1df991f66747ce 100644 --- a/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.ts +++ b/src/plugins/kibana_legacy/public/forward_app/navigate_to_legacy_kibana_url.ts @@ -19,30 +19,26 @@ import { ApplicationStart, IBasePath } from 'kibana/public'; import { ForwardDefinition } from '../index'; +import { normalizePath } from '../utils/normalize_path'; export const navigateToLegacyKibanaUrl = ( path: string, forwards: ForwardDefinition[], basePath: IBasePath, - application: ApplicationStart, - location: Location -) => { - // navigate to the respective path in the legacy kibana plugin by default (for unmigrated plugins) - let targetAppId = 'kibana'; - let targetAppPath = path; + application: ApplicationStart +): { navigated: boolean } => { + const normalizedPath = normalizePath(path); // try to find an existing redirect for the target path if possible // this avoids having to load the legacy app just to get redirected to a core application again afterwards - const relevantForward = forwards.find((forward) => path.startsWith(`/${forward.legacyAppId}`)); - if (relevantForward) { - targetAppPath = relevantForward.rewritePath(path); - targetAppId = relevantForward.newAppId; - } - - if (targetAppId === 'kibana') { - // exception for kibana app because redirect won't work right otherwise - location.href = basePath.prepend(`/app/kibana#${targetAppPath}`); - } else { - application.navigateToApp(targetAppId, { path: targetAppPath }); + const relevantForward = forwards.find((forward) => + normalizedPath.startsWith(`/${forward.legacyAppId}`) + ); + if (!relevantForward) { + return { navigated: false }; } + const targetAppPath = relevantForward.rewritePath(normalizedPath); + const targetAppId = relevantForward.newAppId; + application.navigateToApp(targetAppId, { path: targetAppPath, replace: true }); + return { navigated: true }; }; diff --git a/src/plugins/kibana_legacy/public/mocks.ts b/src/plugins/kibana_legacy/public/mocks.ts index 5bdc76d44e4bf6..a3cdb2106523c7 100644 --- a/src/plugins/kibana_legacy/public/mocks.ts +++ b/src/plugins/kibana_legacy/public/mocks.ts @@ -17,7 +17,6 @@ * under the License. */ -import { EnvironmentMode, PackageInfo } from 'kibana/server'; import { KibanaLegacyPlugin } from './plugin'; export type Setup = jest.Mocked>; @@ -25,20 +24,9 @@ export type Start = jest.Mocked>; const createSetupContract = (): Setup => ({ forwardApp: jest.fn(), - registerLegacyAppAlias: jest.fn(), - registerLegacyApp: jest.fn(), - config: { - defaultAppId: 'home', - }, - env: {} as { - mode: Readonly; - packageInfo: Readonly; - }, }); const createStartContract = (): Start => ({ - getApps: jest.fn(), - getLegacyAppAliases: jest.fn(), getForwards: jest.fn(), config: { defaultAppId: 'home', @@ -48,6 +36,8 @@ const createStartContract = (): Start => ({ getHideWriteControls: jest.fn(), }, navigateToDefaultApp: jest.fn(), + navigateToLegacyKibanaUrl: jest.fn(), + loadFontAwesome: jest.fn(), }); export const kibanaLegacyPluginMock = { diff --git a/src/plugins/kibana_legacy/public/navigate_to_default_app.ts b/src/plugins/kibana_legacy/public/navigate_to_default_app.ts index 80b8343d3b229a..cea901e9ba6b41 100644 --- a/src/plugins/kibana_legacy/public/navigate_to_default_app.ts +++ b/src/plugins/kibana_legacy/public/navigate_to_default_app.ts @@ -43,12 +43,7 @@ export function navigateToDefaultApp( // when the correct app is already loaded, just set the hash to the right value // otherwise use navigateToApp (or setting href in case of kibana app) if (currentAppId !== targetAppId) { - if (targetAppId === 'kibana') { - // exception for kibana app because redirect won't work right otherwise - window.location.href = basePath.prepend(`/app/kibana${targetAppPath}`); - } else { - application.navigateToApp(targetAppId, { path: targetAppPath }); - } + application.navigateToApp(targetAppId, { path: targetAppPath, replace: true }); } else if (overwriteHash) { window.location.hash = targetAppPath; } diff --git a/src/plugins/kibana_legacy/public/plugin.ts b/src/plugins/kibana_legacy/public/plugin.ts index c1a93f180db6fe..59ce88c07f4f48 100644 --- a/src/plugins/kibana_legacy/public/plugin.ts +++ b/src/plugins/kibana_legacy/public/plugin.ts @@ -17,26 +17,14 @@ * under the License. */ -import { - App, - AppBase, - PluginInitializerContext, - AppUpdatableFields, - CoreStart, - CoreSetup, -} from 'kibana/public'; -import { Observable, Subscription } from 'rxjs'; +import { PluginInitializerContext, CoreStart, CoreSetup } from 'kibana/public'; +import { Subscription } from 'rxjs'; import { ConfigSchema } from '../config'; import { getDashboardConfig } from './dashboard_config'; import { navigateToDefaultApp } from './navigate_to_default_app'; import { createLegacyUrlForwardApp } from './forward_app'; import { injectHeaderStyle } from './utils/inject_header_style'; - -interface LegacyAppAliasDefinition { - legacyAppId: string; - newAppId: string; - keepPrefix: boolean; -} +import { navigateToLegacyKibanaUrl } from './forward_app/navigate_to_legacy_kibana_url'; export interface ForwardDefinition { legacyAppId: string; @@ -44,27 +32,7 @@ export interface ForwardDefinition { rewritePath: (legacyPath: string) => string; } -export type AngularRenderedAppUpdater = ( - app: AppBase -) => Partial | undefined; - -export interface AngularRenderedApp extends App { - /** - * Angular rendered apps are able to update the active url in the nav link (which is currently not - * possible for actual NP apps). When regular applications have the same functionality, this type - * override can be removed. - */ - updater$?: Observable; - /** - * If the active url is updated via the updater$ subject, the app id is assumed to be identical with - * the nav link id. If this is not the case, it is possible to provide another nav link id here. - */ - navLinkId?: string; -} - export class KibanaLegacyPlugin { - private apps: AngularRenderedApp[] = []; - private legacyAppAliases: LegacyAppAliasDefinition[] = []; private forwardDefinitions: ForwardDefinition[] = []; private currentAppId: string | undefined; private currentAppIdSubscription: Subscription | undefined; @@ -72,57 +40,8 @@ export class KibanaLegacyPlugin { constructor(private readonly initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup<{}, KibanaLegacyStart>) { - core.application.register(createLegacyUrlForwardApp(core)); + core.application.register(createLegacyUrlForwardApp(core, this.forwardDefinitions)); return { - /** - * @deprecated - * Register an app to be managed by the application service. - * This method works exactly as `core.application.register`. - * - * When an app is mounted, it is responsible for routing. The app - * won't be mounted again if the route changes within the prefix - * of the app (its id). It is fine to use whatever means for handling - * routing within the app. - * - * When switching to a URL outside of the current prefix, the app router - * shouldn't do anything because it doesn't own the routing anymore - - * the local application service takes over routing again, - * unmounts the current app and mounts the next app. - * - * @param app The app descriptor - */ - registerLegacyApp: (app: AngularRenderedApp) => { - this.apps.push(app); - }, - - /** - * @deprecated - * Forwards every URL starting with `legacyAppId` to the same URL starting - * with `newAppId` - e.g. `/legacy/my/legacy/path?q=123` gets forwarded to - * `/newApp/my/legacy/path?q=123`. - * - * When setting the `keepPrefix` option, the new app id is simply prepended. - * The example above would become `/newApp/legacy/my/legacy/path?q=123`. - * - * This method can be used to provide backwards compatibility for URLs when - * renaming or nesting plugins. For route changes after the prefix, please - * use the routing mechanism of your app. - * - * This method just redirects URLs within the legacy `kibana` app. - * - * @param legacyAppId The name of the old app to forward URLs from - * @param newAppId The name of the new app that handles the URLs now - * @param options Whether the prefix of the old app is kept to nest the legacy - * path into the new path - */ - registerLegacyAppAlias: ( - legacyAppId: string, - newAppId: string, - options: { keepPrefix: boolean } = { keepPrefix: false } - ) => { - this.legacyAppAliases.push({ legacyAppId, newAppId, ...options }); - }, - /** * Forwards URLs within the legacy `kibana` app to a new platform application. * @@ -164,18 +83,6 @@ export class KibanaLegacyPlugin { rewritePath: rewritePath || ((path) => `#${path.replace(`/${legacyAppId}`, '') || '/'}`), }); }, - - /** - * @deprecated - * The `defaultAppId` config key is temporarily exposed to be used in the legacy platform. - * As this setting is going away, no new code should depend on it. - */ - config: this.initializerContext.config.get(), - /** - * @deprecated - * Temporarily exposing the NP env to simulate initializer contexts in the LP. - */ - env: this.initializerContext.env, }; } @@ -186,21 +93,9 @@ export class KibanaLegacyPlugin { injectHeaderStyle(uiSettings); return { /** + * Used to power dashboard mode. Should be removed when dashboard mode is removed eventually. * @deprecated - * Just exported for wiring up with legacy platform, should not be used. - */ - getApps: () => this.apps, - /** - * @deprecated - * Just exported for wiring up with legacy platform, should not be used. - */ - getLegacyAppAliases: () => this.legacyAppAliases, - /** - * @deprecated - * Just exported for wiring up with legacy platform, should not be used. */ - getForwards: () => this.forwardDefinitions, - config: this.initializerContext.config.get(), dashboardConfig: getDashboardConfig(!application.capabilities.dashboard.showWriteControls), /** * Navigates to the app defined as kibana.defaultAppId. @@ -218,6 +113,32 @@ export class KibanaLegacyPlugin { overwriteHash ); }, + /** + * Resolves the provided hash using the registered forwards and navigates to the target app. + * If a navigation happened, `{ navigated: true }` will be returned. + * If no matching forward is found, `{ navigated: false }` will be returned. + * @param hash + */ + navigateToLegacyKibanaUrl: (hash: string) => { + return navigateToLegacyKibanaUrl(hash, this.forwardDefinitions, basePath, application); + }, + /** + * Loads the font-awesome icon font. Should be removed once the last consumer has migrated to EUI + * @deprecated + */ + loadFontAwesome: async () => { + await import('./font_awesome'); + }, + /** + * @deprecated + * Just exported for wiring up with legacy platform, should not be used. + */ + getForwards: () => this.forwardDefinitions, + /** + * @deprecated + * Just exported for wiring up with dashboard mode, should not be used. + */ + config: this.initializerContext.config.get(), }; } diff --git a/src/plugins/kibana_legacy/public/utils/index.ts b/src/plugins/kibana_legacy/public/utils/index.ts index 339079d3ac3523..e7dd55ec5582b4 100644 --- a/src/plugins/kibana_legacy/public/utils/index.ts +++ b/src/plugins/kibana_legacy/public/utils/index.ts @@ -19,6 +19,7 @@ export * from './migrate_legacy_query'; export * from './system_api'; +export * from './normalize_path'; // @ts-ignore export { KbnAccessibleClickProvider } from './kbn_accessible_click'; // @ts-ignore diff --git a/src/legacy/core_plugins/testbed/index.js b/src/plugins/kibana_legacy/public/utils/normalize_path.ts similarity index 73% rename from src/legacy/core_plugins/testbed/index.js rename to src/plugins/kibana_legacy/public/utils/normalize_path.ts index f0b61ea0c3de77..ece6c89cb7cdd5 100644 --- a/src/legacy/core_plugins/testbed/index.js +++ b/src/plugins/kibana_legacy/public/utils/normalize_path.ts @@ -17,14 +17,11 @@ * under the License. */ -import { resolve } from 'path'; +import { normalize } from 'path'; -export default function (kibana) { - return new kibana.Plugin({ - id: 'testbed', - publicDir: resolve(__dirname, 'public'), - uiExports: { - hacks: ['plugins/testbed'], - }, - }); +export function normalizePath(path: string) { + // resolve ../ within the path + const normalizedPath = normalize(path); + // strip any leading slashes and dots and replace with single leading slash + return normalizedPath.replace(/(\.?\.?\/?)*/, '/'); } diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts index d9ff3ef36abafb..ce00b2bf68d932 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts @@ -38,6 +38,10 @@ export interface KbnUrlTracker { stop: () => void; setActiveUrl: (newUrl: string) => void; getActiveUrl: () => string; + /** + * Resets internal state to the last active url, discarding the most recent change + */ + restorePreviousUrl: () => void; } /** @@ -122,6 +126,8 @@ export function createKbnUrlTracker({ }): KbnUrlTracker { const storageInstance = storage || sessionStorage; + // local state storing previous active url to make restore possible + let previousActiveUrl: string = ''; // local state storing current listeners and active url let activeUrl: string = ''; let unsubscribeURLHistory: UnregisterCallback | undefined; @@ -157,6 +163,7 @@ export function createKbnUrlTracker({ toastNotifications.addDanger(e.message); } + previousActiveUrl = activeUrl; activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes); storageInstance.setItem(storageKey, activeUrl); } @@ -183,6 +190,7 @@ export function createKbnUrlTracker({ { useHash: false }, baseUrl + (activeUrl || defaultSubUrl) ); + previousActiveUrl = activeUrl; // remove baseUrl prefix (just storing the sub url part) activeUrl = getActiveSubUrl(updatedUrl); storageInstance.setItem(storageKey, activeUrl); @@ -198,6 +206,7 @@ export function createKbnUrlTracker({ const storedUrl = storageInstance.getItem(storageKey); if (storedUrl) { activeUrl = storedUrl; + previousActiveUrl = storedUrl; setNavLink(storedUrl); } @@ -217,5 +226,8 @@ export function createKbnUrlTracker({ getActiveUrl() { return activeUrl; }, + restorePreviousUrl() { + activeUrl = previousActiveUrl; + }, }; } diff --git a/src/plugins/legacy_export/kibana.json b/src/plugins/legacy_export/kibana.json new file mode 100644 index 00000000000000..f382cab1e655d9 --- /dev/null +++ b/src/plugins/legacy_export/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "legacyExport", + "version": "kibana", + "server": true, + "ui": false +} diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts b/src/plugins/legacy_export/server/index.ts similarity index 80% rename from src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts rename to src/plugins/legacy_export/server/index.ts index f9a2234d6e5a46..52d4da168b71dd 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts +++ b/src/plugins/legacy_export/server/index.ts @@ -17,5 +17,7 @@ * under the License. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -export { buildPipeline } from '../../../../../../plugins/visualizations/public/legacy/build_pipeline'; +import { PluginInitializer } from 'src/core/server'; +import { LegacyExportPlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}> = (context) => new LegacyExportPlugin(context); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts b/src/plugins/legacy_export/server/lib/export/collect_references_deep.test.ts similarity index 98% rename from src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts rename to src/plugins/legacy_export/server/lib/export/collect_references_deep.test.ts index d1be3d64fdb3fc..603afe364aba2c 100644 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts +++ b/src/plugins/legacy_export/server/lib/export/collect_references_deep.test.ts @@ -18,8 +18,8 @@ */ import { SavedObject, SavedObjectAttributes } from 'src/core/server'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { collectReferencesDeep } from './collect_references_deep'; -import { savedObjectsClientMock } from '../../../../../../core/server/mocks'; const data: Array> = [ { diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts b/src/plugins/legacy_export/server/lib/export/collect_references_deep.ts similarity index 98% rename from src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts rename to src/plugins/legacy_export/server/lib/export/collect_references_deep.ts index e44db901a0cb80..8e8ae1332d74b6 100644 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts +++ b/src/plugins/legacy_export/server/lib/export/collect_references_deep.ts @@ -29,7 +29,7 @@ interface ObjectsToCollect { export async function collectReferencesDeep( savedObjectClient: SavedObjectsClientContract, objects: ObjectsToCollect[] -) { +): Promise { let result: SavedObject[] = []; const queue = [...objects]; while (queue.length !== 0) { diff --git a/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js b/src/plugins/legacy_export/server/lib/export/export_dashboards.ts similarity index 80% rename from src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js rename to src/plugins/legacy_export/server/lib/export/export_dashboards.ts index 913ebff588f84e..4b2e548f3f7fdf 100644 --- a/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js +++ b/src/plugins/legacy_export/server/lib/export/export_dashboards.ts @@ -17,19 +17,19 @@ * under the License. */ -import _ from 'lodash'; +import { SavedObjectsClientContract } from 'src/core/server'; import { collectReferencesDeep } from './collect_references_deep'; -export async function exportDashboards(req) { - const ids = _.flatten([req.query.dashboard]); - const config = req.server.config(); - - const savedObjectsClient = req.getSavedObjectsClient(); +export async function exportDashboards( + ids: string[], + savedObjectsClient: SavedObjectsClientContract, + kibanaVersion: string +) { const objectsToExport = ids.map((id) => ({ id, type: 'dashboard' })); const objects = await collectReferencesDeep(savedObjectsClient, objectsToExport); return { - version: config.get('pkg.version'), + version: kibanaVersion, objects, }; } diff --git a/src/plugins/legacy_export/server/lib/import/import_dashboards.test.ts b/src/plugins/legacy_export/server/lib/import/import_dashboards.test.ts new file mode 100644 index 00000000000000..9d4dbb60679461 --- /dev/null +++ b/src/plugins/legacy_export/server/lib/import/import_dashboards.test.ts @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { SavedObject } from '../../../../../core/server'; +import { importDashboards } from './import_dashboards'; + +describe('importDashboards(req)', () => { + let savedObjectClient: ReturnType; + let importedObjects: SavedObject[]; + + beforeEach(() => { + savedObjectClient = savedObjectsClientMock.create(); + savedObjectClient.bulkCreate.mockResolvedValue({ saved_objects: [] }); + + importedObjects = [ + { id: 'dashboard-01', type: 'dashboard', attributes: { panelJSON: '{}' }, references: [] }, + { id: 'panel-01', type: 'visualization', attributes: { visState: '{}' }, references: [] }, + ]; + }); + + test('should call bulkCreate with each asset', async () => { + await importDashboards(savedObjectClient, importedObjects, { overwrite: false, exclude: [] }); + + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + id: 'dashboard-01', + type: 'dashboard', + attributes: { panelJSON: '{}' }, + references: [], + migrationVersion: {}, + }, + { + id: 'panel-01', + type: 'visualization', + attributes: { visState: '{}' }, + references: [], + migrationVersion: {}, + }, + ], + { overwrite: false } + ); + }); + + test('should call bulkCreate with overwrite true if force is truthy', async () => { + await importDashboards(savedObjectClient, importedObjects, { overwrite: true, exclude: [] }); + + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith(expect.any(Array), { + overwrite: true, + }); + }); + + test('should exclude types based on exclude argument', async () => { + await importDashboards(savedObjectClient, importedObjects, { + overwrite: false, + exclude: ['visualization'], + }); + + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + id: 'dashboard-01', + type: 'dashboard', + attributes: { panelJSON: '{}' }, + references: [], + migrationVersion: {}, + }, + ], + { overwrite: false } + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.js b/src/plugins/legacy_export/server/lib/import/import_dashboards.ts similarity index 81% rename from src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.js rename to src/plugins/legacy_export/server/lib/import/import_dashboards.ts index 7c28b184144f1a..7b7562aecd7bd8 100644 --- a/src/legacy/core_plugins/kibana/server/lib/import/import_dashboards.js +++ b/src/plugins/legacy_export/server/lib/import/import_dashboards.ts @@ -17,21 +17,19 @@ * under the License. */ -import { flatten } from 'lodash'; - -export async function importDashboards(req) { - const { payload } = req; - const overwrite = 'force' in req.query && req.query.force !== false; - const exclude = flatten([req.query.exclude]); - - const savedObjectsClient = req.getSavedObjectsClient(); +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +export async function importDashboards( + savedObjectsClient: SavedObjectsClientContract, + objects: SavedObject[], + { overwrite, exclude }: { overwrite: boolean; exclude: string[] } +) { // The server assumes that documents with no migrationVersion are up to date. // That assumption enables Kibana and other API consumers to not have to build // up migrationVersion prior to creating new objects. But it means that imports // need to set migrationVersion to something other than undefined, so that imported // docs are not seen as automatically up-to-date. - const docs = payload.objects + const docs = objects .filter((item) => !exclude.includes(item.type)) .map((doc) => ({ ...doc, migrationVersion: doc.migrationVersion || {} })); diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/index.ts b/src/plugins/legacy_export/server/lib/index.ts similarity index 86% rename from src/legacy/core_plugins/kibana/public/local_application_service/index.ts rename to src/plugins/legacy_export/server/lib/index.ts index 2128355ca906ad..ceabdc76322e99 100644 --- a/src/legacy/core_plugins/kibana/public/local_application_service/index.ts +++ b/src/plugins/legacy_export/server/lib/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export * from './local_application_service'; +export { exportDashboards } from './export/export_dashboards'; +export { importDashboards } from './import/import_dashboards'; diff --git a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.js b/src/plugins/legacy_export/server/plugin.ts similarity index 59% rename from src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.js rename to src/plugins/legacy_export/server/plugin.ts index 25f6341cf25da9..22c7c1a05dddbb 100644 --- a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/inject_meta_attributes.js +++ b/src/plugins/legacy_export/server/plugin.ts @@ -17,17 +17,26 @@ * under the License. */ -export function injectMetaAttributes(savedObject, savedObjectsManagement) { - const result = { - ...savedObject, - meta: savedObject.meta || {}, - }; +import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/server'; +import { registerRoutes } from './routes'; - // Add extra meta information - result.meta.icon = savedObjectsManagement.getIcon(savedObject.type); - result.meta.title = savedObjectsManagement.getTitle(savedObject); - result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); - result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); +export class LegacyExportPlugin implements Plugin<{}, {}> { + private readonly kibanaVersion: string; - return result; + constructor(context: PluginInitializerContext) { + this.kibanaVersion = context.env.packageInfo.version; + } + + public setup({ http }: CoreSetup) { + const router = http.createRouter(); + registerRoutes(router, this.kibanaVersion); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} } diff --git a/src/plugins/legacy_export/server/routes/export.ts b/src/plugins/legacy_export/server/routes/export.ts new file mode 100644 index 00000000000000..2064302eca432e --- /dev/null +++ b/src/plugins/legacy_export/server/routes/export.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { exportDashboards } from '../lib'; + +export const registerExportRoute = (router: IRouter, kibanaVersion: string) => { + router.get( + { + path: '/api/kibana/dashboards/export', + validate: { + query: schema.object({ + dashboard: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + }), + }, + options: { + tags: ['api'], + }, + }, + async (ctx, req, res) => { + const ids = Array.isArray(req.query.dashboard) ? req.query.dashboard : [req.query.dashboard]; + const { client } = ctx.core.savedObjects; + + const exported = await exportDashboards(ids, client, kibanaVersion); + const filename = `kibana-dashboards.${moment.utc().format('YYYY-MM-DD-HH-mm-ss')}.json`; + const body = JSON.stringify(exported, null, ' '); + + return res.ok({ + body, + headers: { + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Type': 'application/json', + 'Content-Length': `${Buffer.byteLength(body, 'utf8')}`, + }, + }); + } + ); +}; diff --git a/src/plugins/legacy_export/server/routes/import.ts b/src/plugins/legacy_export/server/routes/import.ts new file mode 100644 index 00000000000000..cf6f28683be176 --- /dev/null +++ b/src/plugins/legacy_export/server/routes/import.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter, SavedObject } from 'src/core/server'; +import { importDashboards } from '../lib'; + +export const registerImportRoute = (router: IRouter) => { + router.post( + { + path: '/api/kibana/dashboards/import', + validate: { + body: schema.object({ + objects: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + version: schema.string(), + }), + query: schema.object({ + force: schema.boolean({ defaultValue: false }), + exclude: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { + defaultValue: [], + }), + }), + }, + options: { + tags: ['api'], + }, + }, + async (ctx, req, res) => { + const { client } = ctx.core.savedObjects; + const objects = req.body.objects as SavedObject[]; + const { force, exclude } = req.query; + const result = await importDashboards(client, objects, { + overwrite: force, + exclude: Array.isArray(exclude) ? exclude : [exclude], + }); + return res.ok({ + body: result, + }); + } + ); +}; diff --git a/src/legacy/core_plugins/testbed/public/testbed.js b/src/plugins/legacy_export/server/routes/index.ts similarity index 73% rename from src/legacy/core_plugins/testbed/public/testbed.js rename to src/plugins/legacy_export/server/routes/index.ts index 13005a6106ca4e..7b9de7f016b6ba 100644 --- a/src/legacy/core_plugins/testbed/public/testbed.js +++ b/src/plugins/legacy_export/server/routes/index.ts @@ -17,13 +17,11 @@ * under the License. */ -import uiRoutes from 'ui/routes'; -import template from './testbed.html'; +import { IRouter } from 'src/core/server'; +import { registerImportRoute } from './import'; +import { registerExportRoute } from './export'; -uiRoutes.when('/testbed', { - template: template, - controllerAs: 'testbed', - controller: class TestbedController { - constructor() {} - }, -}); +export const registerRoutes = (router: IRouter, kibanaVersion: string) => { + registerExportRoute(router, kibanaVersion); + registerImportRoute(router); +}; diff --git a/src/plugins/maps_legacy/public/_index.scss b/src/plugins/maps_legacy/public/_index.scss deleted file mode 100644 index 28cf4289bb0481..00000000000000 --- a/src/plugins/maps_legacy/public/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './map/index'; diff --git a/src/plugins/maps_legacy/public/index.ts b/src/plugins/maps_legacy/public/index.ts index a7f5427909334a..cbe8b9213d577d 100644 --- a/src/plugins/maps_legacy/public/index.ts +++ b/src/plugins/maps_legacy/public/index.ts @@ -44,6 +44,8 @@ import { // @ts-ignore import { mapTooltipProvider } from './tooltip_provider'; +import './map/index.scss'; + export interface MapsLegacyConfigType { regionmap: any; emsTileLayerId: string; diff --git a/src/plugins/maps_legacy/public/map/_index.scss b/src/plugins/maps_legacy/public/map/index.scss similarity index 100% rename from src/plugins/maps_legacy/public/map/_index.scss rename to src/plugins/maps_legacy/public/map/index.scss diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json index 3a6f64e92bcba6..ac7e1f8659d66d 100644 --- a/src/plugins/region_map/kibana.json +++ b/src/plugins/region_map/kibana.json @@ -9,6 +9,7 @@ "visualizations", "expressions", "mapsLegacy", + "kibanaLegacy", "data" ] } diff --git a/src/plugins/region_map/public/kibana_services.ts b/src/plugins/region_map/public/kibana_services.ts index 1ef58c69c5bef4..8367325c7415b8 100644 --- a/src/plugins/region_map/public/kibana_services.ts +++ b/src/plugins/region_map/public/kibana_services.ts @@ -20,6 +20,7 @@ import { NotificationsStart } from 'kibana/public'; import { createGetterSetter } from '../../kibana_utils/public'; import { DataPublicPluginStart } from '../../data/public'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export const [getFormatService, setFormatService] = createGetterSetter< DataPublicPluginStart['fieldFormats'] @@ -28,3 +29,7 @@ export const [getFormatService, setFormatService] = createGetterSetter< export const [getNotifications, setNotifications] = createGetterSetter( 'Notifications' ); + +export const [getKibanaLegacy, setKibanaLegacy] = createGetterSetter( + 'KibanaLegacy' +); diff --git a/src/plugins/region_map/public/plugin.ts b/src/plugins/region_map/public/plugin.ts index 09a13fbe9774e7..6b31de758a4caa 100644 --- a/src/plugins/region_map/public/plugin.ts +++ b/src/plugins/region_map/public/plugin.ts @@ -31,10 +31,11 @@ import { createRegionMapFn } from './region_map_fn'; // @ts-ignore import { createRegionMapTypeDefinition } from './region_map_type'; import { getBaseMapsVis, IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; -import { setFormatService, setNotifications } from './kibana_services'; +import { setFormatService, setNotifications, setKibanaLegacy } from './kibana_services'; import { DataPublicPluginStart } from '../../data/public'; import { RegionMapsConfigType } from './index'; import { ConfigSchema } from '../../maps_legacy/config'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; /** @private */ interface RegionMapVisualizationDependencies { @@ -55,6 +56,7 @@ export interface RegionMapPluginSetupDependencies { export interface RegionMapPluginStartDependencies { data: DataPublicPluginStart; notifications: NotificationsStart; + kibanaLegacy: KibanaLegacyStart; } /** @internal */ @@ -107,8 +109,9 @@ export class RegionMapPlugin implements Plugin { const ID = 'bf00ad16941fc51420f91a93428b27a0'; @@ -35,7 +35,7 @@ describe('shortUrlLookupProvider', () => { savedObjects = savedObjectsClientMock.create(); savedObjects.create.mockResolvedValue({ id: ID } as SavedObject); deps = { savedObjects }; - shortUrl = shortUrlLookupProvider({ logger: loggingServiceMock.create().get() }); + shortUrl = shortUrlLookupProvider({ logger: loggingSystemMock.create().get() }); }); describe('generateUrlId', () => { diff --git a/src/plugins/share/server/saved_objects/kibana_app_migration.ts b/src/plugins/share/server/saved_objects/kibana_app_migration.ts deleted file mode 100644 index 413b48d7fa6de7..00000000000000 --- a/src/plugins/share/server/saved_objects/kibana_app_migration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { SavedObjectMigrationFn } from 'kibana/server'; - -/** - * To avoid loading the client twice for old short urls pointing to the /app/kibana app, - * this PR rewrites them to point to the new platform app url_migrate instead. This app will - * migrate the url on the fly and redirect the user to the actual new location of the short url - * without loading the page again. - * @param doc - */ -export const migrateLegacyKibanaAppShortUrls: SavedObjectMigrationFn = (doc) => ({ - ...doc, - attributes: { - ...doc.attributes, - url: - typeof doc.attributes.url === 'string' && doc.attributes.url.startsWith('/app/kibana') - ? doc.attributes.url.replace('/app/kibana', '/app/url_migrate') - : doc.attributes.url, - }, -}); diff --git a/src/plugins/share/server/saved_objects/url.ts b/src/plugins/share/server/saved_objects/url.ts index 3103777179741d..3ea64ad4719f78 100644 --- a/src/plugins/share/server/saved_objects/url.ts +++ b/src/plugins/share/server/saved_objects/url.ts @@ -16,9 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { SavedObjectMigrationFn, SavedObjectsType } from 'kibana/server'; -import { flow } from 'lodash'; -import { migrateLegacyKibanaAppShortUrls } from './kibana_app_migration'; +import { SavedObjectsType } from 'kibana/server'; export const url: SavedObjectsType = { name: 'url', @@ -32,9 +30,6 @@ export const url: SavedObjectsType = { return `/goto/${encodeURIComponent(obj.id)}`; }, }, - migrations: { - '7.9.0': flow(migrateLegacyKibanaAppShortUrls), - }, mappings: { properties: { accessCount: { @@ -51,6 +46,7 @@ export const url: SavedObjectsType = { fields: { keyword: { type: 'keyword', + ignore_above: 2048, }, }, }, diff --git a/src/plugins/testbed/kibana.json b/src/plugins/testbed/kibana.json deleted file mode 100644 index 9afe357b7a0104..00000000000000 --- a/src/plugins/testbed/kibana.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "testbed", - "version": "0.0.1", - "kibanaVersion": "kibana", - "configPath": ["core", "testbed"], - "server": true, - "ui": true -} diff --git a/src/plugins/testbed/public/index.ts b/src/plugins/testbed/public/index.ts deleted file mode 100644 index 601db10f6f8bb0..00000000000000 --- a/src/plugins/testbed/public/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; -import { TestbedPlugin, TestbedPluginSetup, TestbedPluginStart } from './plugin'; - -export const plugin: PluginInitializer = ( - initializerContext: PluginInitializerContext -) => new TestbedPlugin(initializerContext); diff --git a/src/plugins/testbed/public/plugin.ts b/src/plugins/testbed/public/plugin.ts deleted file mode 100644 index 8c70485d9ee8b3..00000000000000 --- a/src/plugins/testbed/public/plugin.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/public'; - -interface ConfigType { - uiProp: string; -} - -export class TestbedPlugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} - - public async setup(core: CoreSetup, deps: {}) { - const config = this.initializerContext.config.get(); - - // eslint-disable-next-line no-console - console.log(`Testbed plugin set up. uiProp: '${config.uiProp}'`); - return { - foo: 'bar', - }; - } - - public start() { - // eslint-disable-next-line no-console - console.log(`Testbed plugin started`); - } - - public stop() {} -} - -export type TestbedPluginSetup = ReturnType; -export type TestbedPluginStart = ReturnType; diff --git a/src/plugins/testbed/server/index.ts b/src/plugins/testbed/server/index.ts deleted file mode 100644 index 21f97259c97f44..00000000000000 --- a/src/plugins/testbed/server/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { map } from 'rxjs/operators'; -import { schema, TypeOf } from '@kbn/config-schema'; - -import { - CoreSetup, - CoreStart, - Logger, - PluginInitializerContext, - PluginConfigDescriptor, - PluginName, -} from 'kibana/server'; - -const configSchema = schema.object({ - secret: schema.string({ defaultValue: 'Not really a secret :/' }), - uiProp: schema.string({ defaultValue: 'Accessible from client' }), -}); - -type ConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - exposeToBrowser: { - uiProp: true, - }, - schema: configSchema, - deprecations: ({ rename, unused, renameFromRoot }) => [ - rename('securityKey', 'secret'), - renameFromRoot('oldtestbed.uiProp', 'testbed.uiProp'), - unused('deprecatedProperty'), - ], -}; - -class Plugin { - private readonly log: Logger; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = this.initializerContext.logger.get(); - } - - public setup(core: CoreSetup, deps: Record) { - this.log.debug( - `Setting up TestBed with core contract [${Object.keys(core)}] and deps [${Object.keys(deps)}]` - ); - - const router = core.http.createRouter(); - router.get( - { path: '/requestcontext/elasticsearch', validate: false }, - async (context, req, res) => { - const response = await context.core.elasticsearch.legacy.client.callAsInternalUser('ping'); - return res.ok({ body: `Elasticsearch: ${response}` }); - } - ); - - router.get( - { path: '/requestcontext/savedobjectsclient', validate: false }, - async (context, req, res) => { - const response = await context.core.savedObjects.client.find({ type: 'TYPE' }); - return res.ok({ body: `SavedObjects client: ${JSON.stringify(response)}` }); - } - ); - - return { - data$: this.initializerContext.config.create().pipe( - map((configValue) => { - this.log.debug(`I've got value from my config: ${configValue.secret}`); - return `Some exposed data derived from config: ${configValue.secret}`; - }) - ), - pingElasticsearch: async () => { - const [coreStart] = await core.getStartServices(); - return coreStart.elasticsearch.legacy.client.callAsInternalUser('ping'); - }, - }; - } - - public start(core: CoreStart, deps: Record) { - this.log.debug( - `Starting up TestBed testbed with core contract [${Object.keys( - core - )}] and deps [${Object.keys(deps)}]` - ); - - return { - getStartContext() { - return core; - }, - }; - } - - public stop() { - this.log.debug(`Stopping TestBed`); - } -} - -export const plugin = (initializerContext: PluginInitializerContext) => - new Plugin(initializerContext); diff --git a/src/plugins/tile_map/kibana.json b/src/plugins/tile_map/kibana.json index 71ae0bb29d17f1..bb8ef5a2465494 100644 --- a/src/plugins/tile_map/kibana.json +++ b/src/plugins/tile_map/kibana.json @@ -9,6 +9,7 @@ "visualizations", "expressions", "mapsLegacy", + "kibanaLegacy", "data" ] } diff --git a/src/plugins/tile_map/public/plugin.ts b/src/plugins/tile_map/public/plugin.ts index e55f7189929dfc..20a45c586074a5 100644 --- a/src/plugins/tile_map/public/plugin.ts +++ b/src/plugins/tile_map/public/plugin.ts @@ -35,6 +35,8 @@ import { createTileMapTypeDefinition } from './tile_map_type'; import { getBaseMapsVis, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { DataPublicPluginStart } from '../../data/public'; import { setFormatService, setQueryService } from './services'; +import { setKibanaLegacy } from './services'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export interface TileMapConfigType { tilemap: any; @@ -58,6 +60,7 @@ export interface TileMapPluginSetupDependencies { /** @internal */ export interface TileMapPluginStartDependencies { data: DataPublicPluginStart; + kibanaLegacy: KibanaLegacyStart; } export interface TileMapPluginSetup { @@ -96,9 +99,10 @@ export class TileMapPlugin implements Plugin('Query'); + +export const [getKibanaLegacy, setKibanaLegacy] = createGetterSetter( + 'KibanaLegacy' +); diff --git a/src/plugins/tile_map/public/tile_map_visualization.js b/src/plugins/tile_map/public/tile_map_visualization.js index 1f4e5f09a9aa45..2ebb76d05c2195 100644 --- a/src/plugins/tile_map/public/tile_map_visualization.js +++ b/src/plugins/tile_map/public/tile_map_visualization.js @@ -19,7 +19,7 @@ import { get } from 'lodash'; import { GeohashLayer } from './geohash_layer'; -import { getFormatService, getQueryService } from './services'; +import { getFormatService, getQueryService, getKibanaLegacy } from './services'; import { scaleBounds, geoContains, mapTooltipProvider } from '../../maps_legacy/public'; import { tooltipFormatter } from './tooltip_formatter'; @@ -60,6 +60,11 @@ export const createTileMapVisualization = (dependencies) => { this.vis.eventsSubject.next(updateVarsObject); }; + async render(esResponse, visParams) { + getKibanaLegacy().loadFontAwesome(); + await super.render(esResponse, visParams); + } + async _makeKibanaMap() { await super._makeKibanaMap(); diff --git a/src/plugins/usage_collection/public/plugin.ts b/src/plugins/usage_collection/public/plugin.ts index cf2f6af1507c0e..40f27f82699288 100644 --- a/src/plugins/usage_collection/public/plugin.ts +++ b/src/plugins/usage_collection/public/plugin.ts @@ -52,12 +52,17 @@ export interface UsageCollectionSetup { }; } +export interface UsageCollectionStart { + reportUiStats: Reporter['reportUiStats']; + METRIC_TYPE: typeof METRIC_TYPE; +} + export function isUnauthenticated(http: HttpSetup) { const { anonymousPaths } = http; return anonymousPaths.isAnonymous(window.location.pathname); } -export class UsageCollectionPlugin implements Plugin { +export class UsageCollectionPlugin implements Plugin { private readonly legacyAppId$ = new Subject(); private trackUserAgent: boolean = true; private reporter?: Reporter; @@ -90,7 +95,7 @@ export class UsageCollectionPlugin implements Plugin { public start({ http, application }: CoreStart) { if (!this.reporter) { - return; + throw new Error('Usage collection reporter not set up correctly'); } if (this.config.uiMetric.enabled && !isUnauthenticated(http)) { @@ -100,7 +105,13 @@ export class UsageCollectionPlugin implements Plugin { if (this.trackUserAgent) { this.reporter.reportUserAgent('kibana'); } + reportApplicationUsage(merge(application.currentAppId$, this.legacyAppId$), this.reporter); + + return { + reportUiStats: this.reporter.reportUiStats, + METRIC_TYPE, + }; } public stop() {} diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index ced5206cee318d..50919ecb3d83f8 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -21,9 +21,9 @@ import { noop } from 'lodash'; import { Collector } from './collector'; import { CollectorSet } from './collector_set'; import { UsageCollector } from './usage_collector'; -import { loggingServiceMock } from '../../../../core/server/mocks'; +import { loggingSystemMock } from '../../../../core/server/mocks'; -const logger = loggingServiceMock.createLogger(); +const logger = loggingSystemMock.createLogger(); const loggerSpies = { debug: jest.spyOn(logger, 'debug'), diff --git a/src/plugins/usage_collection/server/mocks.ts b/src/plugins/usage_collection/server/mocks.ts index ca3710c62cd893..e1f13304165a19 100644 --- a/src/plugins/usage_collection/server/mocks.ts +++ b/src/plugins/usage_collection/server/mocks.ts @@ -17,14 +17,14 @@ * under the License. */ -import { loggingServiceMock } from '../../../core/server/mocks'; +import { loggingSystemMock } from '../../../core/server/mocks'; import { UsageCollectionSetup } from './plugin'; import { CollectorSet } from './collector'; const createSetupContract = () => { return { ...new CollectorSet({ - logger: loggingServiceMock.createLogger(), + logger: loggingSystemMock.createLogger(), maximumWaitTimeForAllCollectorsInS: 1, }), } as UsageCollectionSetup; diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index bb0f6478a42408..ed098d71614033 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -6,6 +6,7 @@ "requiredPlugins": [ "expressions", "visualizations", - "data" + "data", + "kibanaLegacy" ] } diff --git a/src/plugins/vis_type_table/public/plugin.ts b/src/plugins/vis_type_table/public/plugin.ts index a41d939523bcca..28f823df79d919 100644 --- a/src/plugins/vis_type_table/public/plugin.ts +++ b/src/plugins/vis_type_table/public/plugin.ts @@ -23,7 +23,8 @@ import { VisualizationsSetup } from '../../visualizations/public'; import { createTableVisFn } from './table_vis_fn'; import { getTableVisTypeDefinition } from './table_vis_type'; import { DataPublicPluginStart } from '../../data/public'; -import { setFormatService } from './services'; +import { setFormatService, setKibanaLegacy } from './services'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; /** @internal */ export interface TablePluginSetupDependencies { @@ -34,6 +35,7 @@ export interface TablePluginSetupDependencies { /** @internal */ export interface TablePluginStartDependencies { data: DataPublicPluginStart; + kibanaLegacy: KibanaLegacyStart; } /** @internal */ @@ -55,7 +57,8 @@ export class TableVisPlugin implements Plugin, void> { ); } - public start(core: CoreStart, { data }: TablePluginStartDependencies) { + public start(core: CoreStart, { data, kibanaLegacy }: TablePluginStartDependencies) { setFormatService(data.fieldFormats); + setKibanaLegacy(kibanaLegacy); } } diff --git a/src/plugins/vis_type_table/public/services.ts b/src/plugins/vis_type_table/public/services.ts index 3aaffe75e27f14..b4f996f078f6be 100644 --- a/src/plugins/vis_type_table/public/services.ts +++ b/src/plugins/vis_type_table/public/services.ts @@ -19,7 +19,12 @@ import { createGetterSetter } from '../../kibana_utils/public'; import { DataPublicPluginStart } from '../../data/public'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export const [getFormatService, setFormatService] = createGetterSetter< DataPublicPluginStart['fieldFormats'] >('table data.fieldFormats'); + +export const [getKibanaLegacy, setKibanaLegacy] = createGetterSetter( + 'table kibanaLegacy' +); diff --git a/src/plugins/vis_type_table/public/vis_controller.ts b/src/plugins/vis_type_table/public/vis_controller.ts index d49dd32c8c89c3..a5086e0c9a2d80 100644 --- a/src/plugins/vis_type_table/public/vis_controller.ts +++ b/src/plugins/vis_type_table/public/vis_controller.ts @@ -22,6 +22,7 @@ import $ from 'jquery'; import { VisParams, ExprVis } from '../../visualizations/public'; import { getAngularModule } from './get_inner_angular'; +import { getKibanaLegacy } from './services'; import { initTableVisLegacyModule } from './table_vis_legacy_module'; const innerAngularName = 'kibana/table_vis'; @@ -64,6 +65,7 @@ export function getTableVisualizationControllerClass( } async render(esResponse: object, visParams: VisParams) { + getKibanaLegacy().loadFontAwesome(); await this.initLocalAngular(); return new Promise(async (resolve, reject) => { diff --git a/src/legacy/core_plugins/kibana/inject_vars.js b/src/plugins/vis_type_timeseries/common/types.ts similarity index 74% rename from src/legacy/core_plugins/kibana/inject_vars.js rename to src/plugins/vis_type_timeseries/common/types.ts index c3b906ee842e33..4520069244527b 100644 --- a/src/legacy/core_plugins/kibana/inject_vars.js +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -17,11 +17,9 @@ * under the License. */ -export function injectVars(server) { - const serverConfig = server.config(); +import { TypeOf } from '@kbn/config-schema'; +import { metricsItems, panel, seriesItems } from './vis_schema'; - return { - autocompleteTerminateAfter: serverConfig.get('kibana.autocompleteTerminateAfter'), - autocompleteTimeout: serverConfig.get('kibana.autocompleteTimeout'), - }; -} +export type SeriesItemsSchema = TypeOf; +export type MetricsItemsSchema = TypeOf; +export type PanelSchema = TypeOf; diff --git a/src/plugins/vis_type_timeseries/common/ui_restrictions.js b/src/plugins/vis_type_timeseries/common/ui_restrictions.ts similarity index 73% rename from src/plugins/vis_type_timeseries/common/ui_restrictions.js rename to src/plugins/vis_type_timeseries/common/ui_restrictions.ts index 96726d51e4a7c9..4508735f39ff90 100644 --- a/src/plugins/vis_type_timeseries/common/ui_restrictions.js +++ b/src/plugins/vis_type_timeseries/common/ui_restrictions.ts @@ -22,21 +22,30 @@ * @constant * @public */ -export const RESTRICTIONS_KEYS = { +export enum RESTRICTIONS_KEYS { /** * Key for getting the white listed group by fields from the UIRestrictions object. */ - WHITE_LISTED_GROUP_BY_FIELDS: 'whiteListedGroupByFields', + WHITE_LISTED_GROUP_BY_FIELDS = 'whiteListedGroupByFields', /** * Key for getting the white listed metrics from the UIRestrictions object. */ - WHITE_LISTED_METRICS: 'whiteListedMetrics', + WHITE_LISTED_METRICS = 'whiteListedMetrics', /** * Key for getting the white listed Time Range modes from the UIRestrictions object. */ - WHITE_LISTED_TIMERANGE_MODES: 'whiteListedTimerangeModes', + WHITE_LISTED_TIMERANGE_MODES = 'whiteListedTimerangeModes', +} + +export interface UIRestrictions { + '*': boolean; + [restriction: string]: boolean; +} + +export type TimeseriesUIRestrictions = { + [key in RESTRICTIONS_KEYS]: Record; }; /** @@ -44,6 +53,6 @@ export const RESTRICTIONS_KEYS = { * @constant * @public */ -export const DEFAULT_UI_RESTRICTION = { +export const DEFAULT_UI_RESTRICTION: UIRestrictions = { '*': true, }; diff --git a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts similarity index 73% rename from src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts rename to src/plugins/vis_type_timeseries/common/vis_schema.ts index bf2ea8651c5a2c..7161c197b69409 100644 --- a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -76,7 +76,7 @@ const gaugeColorRulesItems = schema.object({ operator: stringOptionalNullable, value: schema.maybe(schema.nullable(schema.number())), }); -const metricsItems = schema.object({ +export const metricsItems = schema.object({ field: stringOptionalNullable, id: stringRequired, metric_agg: stringOptionalNullable, @@ -133,7 +133,7 @@ const splitFiltersItems = schema.object({ label: stringOptionalNullable, }); -const seriesItems = schema.object({ +export const seriesItems = schema.object({ aggregate_by: stringOptionalNullable, aggregate_function: stringOptionalNullable, axis_position: stringRequired, @@ -195,66 +195,66 @@ const seriesItems = schema.object({ var_name: stringOptionalNullable, }); +export const panel = schema.object({ + annotations: schema.maybe(schema.arrayOf(annotationsItems)), + axis_formatter: stringRequired, + axis_position: stringRequired, + axis_scale: stringRequired, + axis_min: stringOrNumberOptionalNullable, + axis_max: stringOrNumberOptionalNullable, + bar_color_rules: schema.maybe(arrayNullable), + background_color: stringOptionalNullable, + background_color_rules: schema.maybe(schema.arrayOf(backgroundColorRulesItems)), + default_index_pattern: stringOptionalNullable, + default_timefield: stringOptionalNullable, + drilldown_url: stringOptionalNullable, + drop_last_bucket: numberIntegerOptional, + filter: schema.nullable( + schema.oneOf([ + stringOptionalNullable, + schema.object({ + language: stringOptionalNullable, + query: stringOptionalNullable, + }), + ]) + ), + gauge_color_rules: schema.maybe(schema.arrayOf(gaugeColorRulesItems)), + gauge_width: schema.nullable(schema.oneOf([stringOptionalNullable, numberOptional])), + gauge_inner_color: stringOptionalNullable, + gauge_inner_width: stringOrNumberOptionalNullable, + gauge_style: stringOptionalNullable, + gauge_max: stringOrNumberOptionalNullable, + id: stringRequired, + ignore_global_filters: numberOptional, + ignore_global_filter: numberOptional, + index_pattern: stringRequired, + interval: stringRequired, + isModelInvalid: schema.maybe(schema.boolean()), + legend_position: stringOptionalNullable, + markdown: stringOptionalNullable, + markdown_scrollbars: numberIntegerOptional, + markdown_openLinksInNewTab: numberIntegerOptional, + markdown_vertical_align: stringOptionalNullable, + markdown_less: stringOptionalNullable, + markdown_css: stringOptionalNullable, + pivot_id: stringOptionalNullable, + pivot_label: stringOptionalNullable, + pivot_type: stringOptionalNullable, + pivot_rows: stringOptionalNullable, + series: schema.arrayOf(seriesItems), + show_grid: numberIntegerRequired, + show_legend: numberIntegerRequired, + tooltip_mode: schema.maybe( + schema.oneOf([schema.literal('show_all'), schema.literal('show_focused')]) + ), + time_field: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + type: stringRequired, +}); + export const visPayloadSchema = schema.object({ filters: arrayNullable, - panels: schema.arrayOf( - schema.object({ - annotations: schema.maybe(schema.arrayOf(annotationsItems)), - axis_formatter: stringRequired, - axis_position: stringRequired, - axis_scale: stringRequired, - axis_min: stringOrNumberOptionalNullable, - axis_max: stringOrNumberOptionalNullable, - bar_color_rules: schema.maybe(arrayNullable), - background_color: stringOptionalNullable, - background_color_rules: schema.maybe(schema.arrayOf(backgroundColorRulesItems)), - default_index_pattern: stringOptionalNullable, - default_timefield: stringOptionalNullable, - drilldown_url: stringOptionalNullable, - drop_last_bucket: numberIntegerOptional, - filter: schema.nullable( - schema.oneOf([ - stringOptionalNullable, - schema.object({ - language: stringOptionalNullable, - query: stringOptionalNullable, - }), - ]) - ), - gauge_color_rules: schema.maybe(schema.arrayOf(gaugeColorRulesItems)), - gauge_width: schema.nullable(schema.oneOf([stringOptionalNullable, numberOptional])), - gauge_inner_color: stringOptionalNullable, - gauge_inner_width: stringOrNumberOptionalNullable, - gauge_style: stringOptionalNullable, - gauge_max: stringOrNumberOptionalNullable, - id: stringRequired, - ignore_global_filters: numberOptional, - ignore_global_filter: numberOptional, - index_pattern: stringRequired, - interval: stringRequired, - isModelInvalid: schema.maybe(schema.boolean()), - legend_position: stringOptionalNullable, - markdown: stringOptionalNullable, - markdown_scrollbars: numberIntegerOptional, - markdown_openLinksInNewTab: numberIntegerOptional, - markdown_vertical_align: stringOptionalNullable, - markdown_less: stringOptionalNullable, - markdown_css: stringOptionalNullable, - pivot_id: stringOptionalNullable, - pivot_label: stringOptionalNullable, - pivot_type: stringOptionalNullable, - pivot_rows: stringOptionalNullable, - series: schema.arrayOf(seriesItems), - show_grid: numberIntegerRequired, - show_legend: numberIntegerRequired, - tooltip_mode: schema.maybe( - schema.oneOf([schema.literal('show_all'), schema.literal('show_focused')]) - ), - time_field: stringOptionalNullable, - time_range_mode: stringOptionalNullable, - type: stringRequired, - }) - ), + panels: schema.arrayOf(panel), // general query: schema.nullable(schema.arrayOf(queryObject)), state: schema.object({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.js b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.tsx similarity index 77% rename from src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.js rename to src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.tsx index 7afa71d6ba38ff..0fb3e80344e2bf 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.test.tsx @@ -18,51 +18,49 @@ */ import React from 'react'; -import { expect } from 'chai'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import sinon from 'sinon'; import { AddDeleteButtons } from './add_delete_buttons'; describe('AddDeleteButtons', () => { it('calls onAdd={handleAdd}', () => { - const handleAdd = sinon.spy(); + const handleAdd = jest.fn(); const wrapper = shallowWithIntl(); wrapper.find('EuiButtonIcon').at(0).simulate('click'); - expect(handleAdd.calledOnce).to.equal(true); + expect(handleAdd).toHaveBeenCalled(); }); it('calls onDelete={handleDelete}', () => { - const handleDelete = sinon.spy(); + const handleDelete = jest.fn(); const wrapper = shallowWithIntl(); wrapper.find('EuiButtonIcon').at(1).simulate('click'); - expect(handleDelete.calledOnce).to.equal(true); + expect(handleDelete).toHaveBeenCalled(); }); it('calls onClone={handleClone}', () => { - const handleClone = sinon.spy(); + const handleClone = jest.fn(); const wrapper = shallowWithIntl(); wrapper.find('EuiButtonIcon').at(0).simulate('click'); - expect(handleClone.calledOnce).to.equal(true); + expect(handleClone).toHaveBeenCalled(); }); it('disableDelete={true}', () => { const wrapper = shallowWithIntl(); - expect(wrapper.find({ text: 'Delete' })).to.have.length(0); + expect(wrapper.find({ text: 'Delete' })).toHaveLength(0); }); it('disableAdd={true}', () => { const wrapper = shallowWithIntl(); - expect(wrapper.find({ text: 'Add' })).to.have.length(0); + expect(wrapper.find({ text: 'Add' })).toHaveLength(0); }); it('should not display clone by default', () => { const wrapper = shallowWithIntl(); - expect(wrapper.find({ text: 'Clone' })).to.have.length(0); + expect(wrapper.find({ text: 'Clone' })).toHaveLength(0); }); it('should not display clone when disableAdd={true}', () => { - const fn = sinon.spy(); + const fn = jest.fn(); const wrapper = shallowWithIntl(); - expect(wrapper.find({ text: 'Clone' })).to.have.length(0); + expect(wrapper.find({ text: 'Clone' })).toHaveLength(0); }); }); diff --git a/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.js b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.tsx similarity index 87% rename from src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.js rename to src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.tsx index 798d16947c3d9c..7502de1cb1aa49 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.js +++ b/src/plugins/vis_type_timeseries/public/application/components/add_delete_buttons.tsx @@ -17,13 +17,29 @@ * under the License. */ -import PropTypes from 'prop-types'; -import React from 'react'; -import { EuiToolTip, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { MouseEvent } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isBoolean } from 'lodash'; -export function AddDeleteButtons(props) { +interface AddDeleteButtonsProps { + addTooltip: string; + deleteTooltip: string; + cloneTooltip: string; + activatePanelTooltip: string; + deactivatePanelTooltip: string; + isPanelActive?: boolean; + disableAdd?: boolean; + disableDelete?: boolean; + responsive?: boolean; + testSubj: string; + togglePanelActivation?: () => void; + onClone?: () => void; + onAdd?: () => void; + onDelete?: (event: MouseEvent) => void; +} + +export function AddDeleteButtons(props: AddDeleteButtonsProps) { const { testSubj } = props; const createDelete = () => { if (props.disableDelete) { @@ -147,19 +163,3 @@ AddDeleteButtons.defaultProps = { } ), }; - -AddDeleteButtons.propTypes = { - addTooltip: PropTypes.string, - deleteTooltip: PropTypes.string, - cloneTooltip: PropTypes.string, - activatePanelTooltip: PropTypes.string, - deactivatePanelTooltip: PropTypes.string, - togglePanelActivation: PropTypes.func, - isPanelActive: PropTypes.bool, - disableAdd: PropTypes.bool, - disableDelete: PropTypes.bool, - onClone: PropTypes.func, - onAdd: PropTypes.func, - onDelete: PropTypes.func, - responsive: PropTypes.bool, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx similarity index 70% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx index d547f64f13f672..e5236c3833b19b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg.tsx @@ -17,15 +17,33 @@ * under the License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { HTMLAttributes } from 'react'; +// @ts-ignore import { aggToComponent } from '../lib/agg_to_component'; +// @ts-ignore +import { isMetricEnabled } from '../../lib/check_ui_restrictions'; import { UnsupportedAgg } from './unsupported_agg'; import { TemporaryUnsupportedAgg } from './temporary_unsupported_agg'; +import { MetricsItemsSchema, PanelSchema, SeriesItemsSchema } from '../../../../common/types'; +import { DragHandleProps } from '../../../types'; +import { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; +import { IFieldType } from '../../../../../data/common/index_patterns/fields'; -import { isMetricEnabled } from '../../lib/check_ui_restrictions'; +interface AggProps extends HTMLAttributes { + disableDelete: boolean; + fields: IFieldType[]; + model: MetricsItemsSchema; + panel: PanelSchema; + series: SeriesItemsSchema; + siblings: MetricsItemsSchema[]; + uiRestrictions: TimeseriesUIRestrictions; + dragHandleProps: DragHandleProps; + onAdd: () => void; + onChange: () => void; + onDelete: () => void; +} -export function Agg(props) { +export function Agg(props: AggProps) { const { model, uiRestrictions } = props; let Component = aggToComponent[model.type]; @@ -59,17 +77,3 @@ export function Agg(props) { ); } - -Agg.propTypes = { - disableDelete: PropTypes.bool, - fields: PropTypes.object, - model: PropTypes.object, - onAdd: PropTypes.func, - onChange: PropTypes.func, - onDelete: PropTypes.func, - panel: PropTypes.object, - series: PropTypes.object, - siblings: PropTypes.array, - uiRestrictions: PropTypes.object, - dragHandleProps: PropTypes.object, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.tsx similarity index 86% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.tsx index a2f1640904dd02..0363ba486a7753 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_row.tsx @@ -17,15 +17,26 @@ * under the License. */ -import PropTypes from 'prop-types'; import React from 'react'; import { last } from 'lodash'; -import { AddDeleteButtons } from '../add_delete_buttons'; import { EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { SeriesDragHandler } from '../series_drag_handler'; import { i18n } from '@kbn/i18n'; +import { AddDeleteButtons } from '../add_delete_buttons'; +import { SeriesDragHandler } from '../series_drag_handler'; +import { MetricsItemsSchema } from '../../../../common/types'; +import { DragHandleProps } from '../../../types'; -export function AggRow(props) { +interface AggRowProps { + disableDelete: boolean; + model: MetricsItemsSchema; + siblings: MetricsItemsSchema[]; + dragHandleProps: DragHandleProps; + children: React.ReactNode; + onAdd: () => void; + onDelete: () => void; +} + +export function AggRow(props: AggRowProps) { let iconType = 'eyeClosed'; let iconColor = 'subdued'; const lastSibling = last(props.siblings); @@ -71,12 +82,3 @@ export function AggRow(props) { ); } - -AggRow.propTypes = { - disableDelete: PropTypes.bool, - model: PropTypes.object, - onAdd: PropTypes.func, - onDelete: PropTypes.func, - siblings: PropTypes.array, - dragHandleProps: PropTypes.object, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx similarity index 88% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx index 7ff6b6eb566922..6fa1a2adaa08e9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx @@ -17,14 +17,17 @@ * under the License. */ -import PropTypes from 'prop-types'; import React from 'react'; -import { EuiComboBox } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { injectI18n } from '@kbn/i18n/react'; +// @ts-ignore import { isMetricEnabled } from '../../lib/check_ui_restrictions'; +import { MetricsItemsSchema } from '../../../../common/types'; +import { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; -const metricAggs = [ +type AggSelectOption = EuiComboBoxOptionOption; + +const metricAggs: AggSelectOption[] = [ { label: i18n.translate('visTypeTimeseries.aggSelect.metricsAggs.averageLabel', { defaultMessage: 'Average', @@ -123,7 +126,7 @@ const metricAggs = [ }, ]; -const pipelineAggs = [ +const pipelineAggs: AggSelectOption[] = [ { label: i18n.translate('visTypeTimeseries.aggSelect.pipelineAggs.bucketScriptLabel', { defaultMessage: 'Bucket Script', @@ -162,7 +165,7 @@ const pipelineAggs = [ }, ]; -const siblingAggs = [ +const siblingAggs: AggSelectOption[] = [ { label: i18n.translate('visTypeTimeseries.aggSelect.siblingAggs.overallAverageLabel', { defaultMessage: 'Overall Average', @@ -207,7 +210,7 @@ const siblingAggs = [ }, ]; -const specialAggs = [ +const specialAggs: AggSelectOption[] = [ { label: i18n.translate('visTypeTimeseries.aggSelect.specialAggs.seriesAggLabel', { defaultMessage: 'Series Agg', @@ -224,14 +227,23 @@ const specialAggs = [ const allAggOptions = [...metricAggs, ...pipelineAggs, ...siblingAggs, ...specialAggs]; -function filterByPanelType(panelType) { - return (agg) => { +function filterByPanelType(panelType: string) { + return (agg: AggSelectOption) => { if (panelType === 'table') return agg.value !== 'series_agg'; return true; }; } -function AggSelectUi(props) { +interface AggSelectUiProps { + id: string; + panelType: string; + siblings: MetricsItemsSchema[]; + value: string; + uiRestrictions?: TimeseriesUIRestrictions; + onChange: (currentlySelectedOptions: AggSelectOption[]) => void; +} + +export function AggSelect(props: AggSelectUiProps) { const { siblings, panelType, value, onChange, uiRestrictions, ...rest } = props; const selectedOptions = allAggOptions.filter((option) => { @@ -242,11 +254,11 @@ function AggSelectUi(props) { if (siblings.length <= 1) enablePipelines = false; - let options; + let options: EuiComboBoxOptionOption[]; if (panelType === 'metrics') { options = metricAggs; } else { - const disableSiblingAggs = (agg) => ({ + const disableSiblingAggs = (agg: AggSelectOption) => ({ ...agg, disabled: !enablePipelines || !isMetricEnabled(agg.value, uiRestrictions), }); @@ -282,9 +294,9 @@ function AggSelectUi(props) { ]; } - const handleChange = (selectedOptions) => { - if (!selectedOptions || selectedOptions.length <= 0) return; - onChange(selectedOptions); + const handleChange = (currentlySelectedOptions: AggSelectOption[]) => { + if (!currentlySelectedOptions || currentlySelectedOptions.length <= 0) return; + onChange(currentlySelectedOptions); }; return ( @@ -303,13 +315,3 @@ function AggSelectUi(props) { ); } - -AggSelectUi.propTypes = { - onChange: PropTypes.func, - panelType: PropTypes.string, - siblings: PropTypes.array, - value: PropTypes.string, - uiRestrictions: PropTypes.object, -}; - -export const AggSelect = injectI18n(AggSelectUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx similarity index 83% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx index 772b62b14f8112..af3e42a59612b6 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/aggs.tsx @@ -18,18 +18,29 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; import { EuiDraggable, EuiDroppable } from '@elastic/eui'; import { Agg } from './agg'; -import { newMetricAggFn } from '../lib/new_metric_agg_fn'; +// @ts-ignore import { seriesChangeHandler } from '../lib/series_change_handler'; +// @ts-ignore import { handleAdd, handleDelete } from '../lib/collection_actions'; +import { newMetricAggFn } from '../lib/new_metric_agg_fn'; +import { PanelSchema, SeriesItemsSchema } from '../../../../common/types'; +import { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; +import { IFieldType } from '../../../../../data/common/index_patterns/fields'; const DROPPABLE_ID = 'aggs_dnd'; -export class Aggs extends PureComponent { +export interface AggsProps { + panel: PanelSchema; + model: SeriesItemsSchema; + fields: IFieldType[]; + uiRestrictions: TimeseriesUIRestrictions; +} + +export class Aggs extends PureComponent { render() { const { panel, model, fields, uiRestrictions } = this.props; const list = model.metrics; @@ -68,12 +79,3 @@ export class Aggs extends PureComponent { ); } } - -Aggs.propTypes = { - name: PropTypes.string.isRequired, - fields: PropTypes.object.isRequired, - model: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - panel: PropTypes.object.isRequired, - dragHandleProps: PropTypes.object, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx similarity index 79% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx index fd64559cc1ec22..ef8876a19b1a65 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx @@ -16,8 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { ChangeEvent } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -31,10 +30,29 @@ import { import { AddDeleteButtons } from '../../add_delete_buttons'; -export const MultiValueRow = ({ model, onChange, onDelete, onAdd, disableAdd, disableDelete }) => { +interface MultiValueRowProps { + model: { + id: number; + value: string; + }; + disableAdd: boolean; + disableDelete: boolean; + onChange: ({ value, id }: { id: number; value: string }) => void; + onDelete: (model: { id: number; value: string }) => void; + onAdd: () => void; +} + +export const MultiValueRow = ({ + model, + onChange, + onDelete, + onAdd, + disableAdd, + disableDelete, +}: MultiValueRowProps) => { const htmlId = htmlIdGenerator(); - const onFieldNumberChange = (event) => + const onFieldNumberChange = (event: ChangeEvent) => onChange({ ...model, value: get(event, 'target.value'), @@ -54,7 +72,7 @@ export const MultiValueRow = ({ model, onChange, onDelete, onAdd, disableAdd, di @@ -78,12 +96,3 @@ MultiValueRow.defaultProps = { value: '', }, }; - -MultiValueRow.propTypes = { - model: PropTypes.object, - onChange: PropTypes.func, - onDelete: PropTypes.func, - onAdd: PropTypes.func, - defaultAddValue: PropTypes.string, - disableDelete: PropTypes.bool, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx similarity index 75% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx index c8af4089ed7837..a16f5aeefc49c5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx @@ -17,16 +17,7 @@ * under the License. */ -import PropTypes from 'prop-types'; import React from 'react'; -import { assign } from 'lodash'; -import { AggSelect } from '../agg_select'; -import { FieldSelect } from '../field_select'; -import { AggRow } from '../agg_row'; -import { createChangeHandler } from '../../lib/create_change_handler'; -import { createSelectHandler } from '../../lib/create_select_handler'; -import { PercentileRankValues } from './percentile_rank_values'; - import { htmlIdGenerator, EuiFlexGroup, @@ -36,11 +27,36 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { KBN_FIELD_TYPES } from '../../../../../../../plugins/data/public'; +import { AggSelect } from '../agg_select'; +// @ts-ignore +import { FieldSelect } from '../field_select'; +// @ts-ignore +import { createChangeHandler } from '../../lib/create_change_handler'; +// @ts-ignore +import { createSelectHandler } from '../../lib/create_select_handler'; +import { AggRow } from '../agg_row'; +import { PercentileRankValues } from './percentile_rank_values'; + +import { IFieldType, KBN_FIELD_TYPES } from '../../../../../../../plugins/data/public'; +import { MetricsItemsSchema, PanelSchema, SeriesItemsSchema } from '../../../../../common/types'; +import { DragHandleProps } from '../../../../types'; const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER]; -export const PercentileRankAgg = (props) => { +interface PercentileRankAggProps { + disableDelete: boolean; + fields: IFieldType[]; + model: MetricsItemsSchema; + panel: PanelSchema; + series: SeriesItemsSchema; + siblings: MetricsItemsSchema[]; + dragHandleProps: DragHandleProps; + onAdd(): void; + onChange(): void; + onDelete(): void; +} + +export const PercentileRankAgg = (props: PercentileRankAggProps) => { const { series, panel, fields } = props; const defaults = { values: [''] }; const model = { ...defaults, ...props.model }; @@ -52,12 +68,11 @@ export const PercentileRankAgg = (props) => { const handleChange = createChangeHandler(props.onChange, model); const handleSelectChange = createSelectHandler(handleChange); - const handlePercentileRankValuesChange = (values) => { - handleChange( - assign({}, model, { - values, - }) - ); + const handlePercentileRankValuesChange = (values: MetricsItemsSchema['values']) => { + handleChange({ + ...model, + values, + }); }; return ( @@ -108,25 +123,15 @@ export const PercentileRankAgg = (props) => { - + {model.values && ( + + )} ); }; - -PercentileRankAgg.propTypes = { - disableDelete: PropTypes.bool, - fields: PropTypes.object, - model: PropTypes.object, - onAdd: PropTypes.func, - onChange: PropTypes.func, - onDelete: PropTypes.func, - panel: PropTypes.object, - series: PropTypes.object, - siblings: PropTypes.array, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx similarity index 67% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx index 6d52eb9e3515c6..b66d79d67f4277 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx @@ -16,34 +16,49 @@ * specific language governing permissions and limitations * under the License. */ -import PropTypes from 'prop-types'; import React from 'react'; import { last } from 'lodash'; import { EuiFlexGroup } from '@elastic/eui'; import { MultiValueRow } from './multi_value_row'; -export const PercentileRankValues = (props) => { +interface PercentileRankValuesProps { + model: Array; + disableDelete: boolean; + disableAdd: boolean; + showOnlyLastRow: boolean; + onChange: (values: any[]) => void; +} + +export const PercentileRankValues = (props: PercentileRankValuesProps) => { const model = props.model || []; const { onChange, disableAdd, disableDelete, showOnlyLastRow } = props; - const onChangeValue = ({ value, id }) => { + const onChangeValue = ({ value, id }: { value: string; id: number }) => { model[id] = value; onChange(model); }; - const onDeleteValue = ({ id }) => + const onDeleteValue = ({ id }: { id: number }) => onChange(model.filter((item, currentIndex) => id !== currentIndex)); const onAddValue = () => onChange([...model, '']); - const renderRow = ({ rowModel, disableDelete, disableAdd }) => ( + const renderRow = ({ + rowModel, + disableDeleteRow, + disableAddRow, + }: { + rowModel: { id: number; value: string }; + disableDeleteRow: boolean; + disableAddRow: boolean; + }) => ( ); @@ -54,10 +69,10 @@ export const PercentileRankValues = (props) => { renderRow({ rowModel: { id: model.length - 1, - value: last(model), + value: last(model) || '', }, - disableAdd: true, - disableDelete: true, + disableAddRow: true, + disableDeleteRow: true, })} {!showOnlyLastRow && @@ -65,20 +80,12 @@ export const PercentileRankValues = (props) => { renderRow({ rowModel: { id, - value, + value: value || '', }, - disableAdd, - disableDelete: disableDelete || array.length < 2, + disableAddRow: disableAdd, + disableDeleteRow: disableDelete || array.length < 2, }) )} ); }; - -PercentileRankValues.propTypes = { - model: PropTypes.array, - onChange: PropTypes.func, - disableDelete: PropTypes.bool, - disableAdd: PropTypes.bool, - showOnlyLastRow: PropTypes.bool, -}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.tsx similarity index 79% rename from src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.js rename to src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.tsx index bae0491d978a28..d10c7ea7a7e36e 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/temporary_unsupported_agg.tsx @@ -17,12 +17,23 @@ * under the License. */ -import { AggRow } from './agg_row'; import React from 'react'; import { EuiCode, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { AggRow } from './agg_row'; +import { MetricsItemsSchema } from '../../../../common/types'; +import { DragHandleProps } from '../../../types'; + +interface TemporaryUnsupportedAggProps { + disableDelete: boolean; + model: MetricsItemsSchema; + siblings: MetricsItemsSchema[]; + dragHandleProps: DragHandleProps; + onAdd: () => void; + onDelete: () => void; +} -export function TemporaryUnsupportedAgg(props) { +export function TemporaryUnsupportedAgg(props: TemporaryUnsupportedAggProps) { return ( void; + onDelete: () => void; +} -export function UnsupportedAgg(props) { +export function UnsupportedAgg(props: UnsupportedAggProps) { return ( { const defaultProps: ColorPickerProps = { @@ -42,6 +44,22 @@ describe('ColorPicker', () => { expect(component.find('.tvbColorPicker__clear').length).toBe(0); }); + it('should render the correct value to the input text if the prop value is hex', () => { + const props = { ...defaultProps, value: '#68BC00' }; + component = mount(); + component.find('.tvbColorPicker button').simulate('click'); + const input = findTestSubject(component, 'topColorPickerInput'); + expect(input.props().value).toBe('#68BC00'); + }); + + it('should render the correct value to the input text if the prop value is rgba', () => { + const props = { ...defaultProps, value: 'rgba(85,66,177,1)' }; + component = mount(); + component.find('.tvbColorPicker button').simulate('click'); + const input = findTestSubject(component, 'topColorPickerInput'); + expect(input.props().value).toBe('85,66,177,1'); + }); + it('should render the correct aria label to the color swatch button', () => { const props = { ...defaultProps, value: 'rgba(85,66,177,0.59)' }; component = mount(); diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx index be580c80d5941b..444e5c90c7a6db 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx @@ -43,8 +43,10 @@ export interface ColorPickerProps { } export function ColorPicker({ name, value, disableTrash = false, onChange }: ColorPickerProps) { - const initialColorValue = value ? value.replace(COMMAS_NUMS_ONLY_RE, '') : ''; - const [color, setColor] = useState(initialColorValue); + const initialColorValue = value?.includes('rgba') + ? value.replace(COMMAS_NUMS_ONLY_RE, '') + : value; + const [color, setColor] = useState(initialColorValue || ''); const handleColorChange: EuiColorPickerProps['onChange'] = (text: string, { rgba, hex }) => { setColor(text); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.js b/src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.ts similarity index 87% rename from src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.js rename to src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.ts index a518d37f35b74c..9436eac5b6ed21 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/new_metric_agg_fn.ts @@ -18,8 +18,9 @@ */ import uuid from 'uuid'; +import { MetricsItemsSchema } from '../../../../common/types'; -export const newMetricAggFn = () => { +export const newMetricAggFn = (): MetricsItemsSchema => { return { id: uuid.v1(), type: 'count', diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.js b/src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.tsx similarity index 85% rename from src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.js rename to src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.tsx index f978348a5e45cc..73293a0d330fdb 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_drag_handler.tsx @@ -18,11 +18,20 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; import { EuiFlexItem, EuiToolTip, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { DragHandleProps } from '../../types'; + +interface SeriesDragHandlerProps { + hideDragHandler: boolean; + dragHandleProps: DragHandleProps; +} + +export class SeriesDragHandler extends PureComponent { + static defaultProps = { + hideDragHandler: true, + }; -export class SeriesDragHandler extends PureComponent { render() { const { dragHandleProps, hideDragHandler } = this.props; @@ -49,12 +58,3 @@ export class SeriesDragHandler extends PureComponent { ); } } - -SeriesDragHandler.defaultProps = { - hideDragHandler: true, -}; - -SeriesDragHandler.propTypes = { - hideDragHandler: PropTypes.bool, - dragHandleProps: PropTypes.object.isRequired, -}; diff --git a/src/plugins/data/common/index_patterns/index_patterns/types.ts b/src/plugins/vis_type_timeseries/public/types.ts similarity index 65% rename from src/plugins/data/common/index_patterns/index_patterns/types.ts rename to src/plugins/vis_type_timeseries/public/types.ts index b2060dd1d48bac..338118dcdc5aa1 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/types.ts +++ b/src/plugins/vis_type_timeseries/public/types.ts @@ -17,19 +17,13 @@ * under the License. */ -export type AggregationRestrictions = Record< - string, - { - agg?: string; - interval?: number; - fixed_interval?: string; - calendar_interval?: string; - delay?: string; - time_zone?: string; - } ->; +import React from 'react'; +import { EuiDraggable } from '@elastic/eui'; -export interface TypeMeta { - aggs?: Record; - [key: string]: any; -} +type PropsOf = T extends React.ComponentType ? ComponentProps : never; +type FirstArgumentOf = Func extends (arg1: infer FirstArgument, ...rest: any[]) => any + ? FirstArgument + : never; +export type DragHandleProps = FirstArgumentOf< + Exclude['children'], React.ReactElement> +>['dragHandleProps']; diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 744020b5838827..48efd4398e4d41 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -20,7 +20,7 @@ import { IRouter, KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData, GetVisDataOptions } from '../lib/get_vis_data'; -import { visPayloadSchema } from './post_vis_schema'; +import { visPayloadSchema } from '../../common/vis_schema'; import { Framework, ValidationTelemetryServiceSetup } from '../index'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index 5b3088b399ebff..cad0ebe01494a0 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -3,6 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["charts", "data", "expressions", "visualizations"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], "optionalPlugins": ["visTypeXy"] } diff --git a/src/plugins/vis_type_vislib/public/plugin.ts b/src/plugins/vis_type_vislib/public/plugin.ts index 19bbf89ee0243e..c6a6b6f82592b5 100644 --- a/src/plugins/vis_type_vislib/public/plugin.ts +++ b/src/plugins/vis_type_vislib/public/plugin.ts @@ -44,7 +44,8 @@ import { } from './vis_type_vislib_vis_types'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; -import { setFormatService, setDataActions } from './services'; +import { setFormatService, setDataActions, setKibanaLegacy } from './services'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export interface VisTypeVislibDependencies { uiSettings: IUiSettingsClient; @@ -62,6 +63,7 @@ export interface VisTypeVislibPluginSetupDependencies { /** @internal */ export interface VisTypeVislibPluginStartDependencies { data: DataPublicPluginStart; + kibanaLegacy: KibanaLegacyStart; } type VisTypeVislibCoreSetup = CoreSetup; @@ -109,8 +111,9 @@ export class VisTypeVislibPlugin implements Plugin { ); } - public start(core: CoreStart, { data }: VisTypeVislibPluginStartDependencies) { + public start(core: CoreStart, { data, kibanaLegacy }: VisTypeVislibPluginStartDependencies) { setFormatService(data.fieldFormats); setDataActions(data.actions); + setKibanaLegacy(kibanaLegacy); } } diff --git a/src/plugins/vis_type_vislib/public/services.ts b/src/plugins/vis_type_vislib/public/services.ts index 633fae9c7f2a61..7257b98f2e9f5b 100644 --- a/src/plugins/vis_type_vislib/public/services.ts +++ b/src/plugins/vis_type_vislib/public/services.ts @@ -19,6 +19,7 @@ import { createGetterSetter } from '../../kibana_utils/public'; import { DataPublicPluginStart } from '../../data/public'; +import { KibanaLegacyStart } from '../../kibana_legacy/public'; export const [getDataActions, setDataActions] = createGetterSetter< DataPublicPluginStart['actions'] @@ -27,3 +28,7 @@ export const [getDataActions, setDataActions] = createGetterSetter< export const [getFormatService, setFormatService] = createGetterSetter< DataPublicPluginStart['fieldFormats'] >('vislib data.fieldFormats'); + +export const [getKibanaLegacy, setKibanaLegacy] = createGetterSetter( + 'vislib kibanalegacy' +); diff --git a/src/plugins/vis_type_vislib/public/vis_controller.tsx b/src/plugins/vis_type_vislib/public/vis_controller.tsx index 262290b071abce..86ef98de045d7c 100644 --- a/src/plugins/vis_type_vislib/public/vis_controller.tsx +++ b/src/plugins/vis_type_vislib/public/vis_controller.tsx @@ -27,6 +27,7 @@ import { VisTypeVislibDependencies } from './plugin'; import { mountReactNode } from '../../../core/public/utils'; import { VisLegend, CUSTOM_LEGEND_VIS_TYPES } from './vislib/components/legend'; import { VisParams, ExprVis } from '../../visualizations/public'; +import { getKibanaLegacy } from './services'; const legendClassName = { top: 'visLib--legend-top', @@ -72,6 +73,8 @@ export const createVislibVisController = (deps: VisTypeVislibDependencies) => { this.destroy(); } + getKibanaLegacy().loadFontAwesome(); + return new Promise(async (resolve) => { if (this.el.clientWidth === 0 || this.el.clientHeight === 0) { return resolve(); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index c4267c9a36f784..eb4b66401820f1 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -26,7 +26,6 @@ import { EmbeddableOutput, ErrorEmbeddable, IContainer, - EMBEDDABLE_ORIGINATING_APP_PARAM, } from '../../../embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; import { VisualizeEmbeddable, VisualizeInput, VisualizeOutput } from './visualize_embeddable'; @@ -50,7 +49,7 @@ interface VisualizationAttributes extends SavedObjectAttributes { } export interface VisualizeEmbeddableFactoryDeps { - start: StartServicesGetter>; + start: StartServicesGetter>; } export class VisualizeEmbeddableFactory @@ -103,15 +102,7 @@ export class VisualizeEmbeddableFactory } public async getCurrentAppId() { - let currentAppId = await this.deps - .start() - .core.application.currentAppId$.pipe(first()) - .toPromise(); - // TODO: Remove this after https://github.com/elastic/kibana/pull/63443 - if (currentAppId === 'kibana') { - currentAppId += `:${window.location.hash.split(/[\/\?]/)[1]}`; - } - return currentAppId; + return await this.deps.start().core.application.currentAppId$.pipe(first()).toPromise(); } public async createFromSavedObject( @@ -136,9 +127,8 @@ export class VisualizeEmbeddableFactory public async create() { // TODO: This is a bit of a hack to preserve the original functionality. Ideally we will clean this up // to allow for in place creation of visualizations without having to navigate away to a new URL. - const originatingAppParam = await this.getCurrentAppId(); showNewVisModal({ - editorParams: [`${EMBEDDABLE_ORIGINATING_APP_PARAM}=${originatingAppParam}`], + originatingApp: await this.getCurrentAppId(), outsideVisualizeApp: true, }); return undefined; diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.test.ts b/src/plugins/visualizations/public/legacy/build_pipeline.test.ts index 9130581963800b..9ecd321963e8a4 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.test.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.test.ts @@ -28,7 +28,7 @@ import { } from './build_pipeline'; import { Vis } from '..'; import { dataPluginMock } from '../../../../plugins/data/public/mocks'; -import { IAggConfig } from '../../../../plugins/data/public'; +import { IndexPattern, IAggConfigs } from '../../../../plugins/data/public'; describe('visualize loader pipeline helpers: build pipeline', () => { describe('prepareJson', () => { @@ -344,23 +344,20 @@ describe('visualize loader pipeline helpers: build pipeline', () => { describe('buildVislibDimensions', () => { const dataStart = dataPluginMock.createStartContract(); - let aggs: IAggConfig[]; + let aggs: IAggConfigs; let vis: Vis; let params: any; beforeEach(() => { - aggs = [ + aggs = dataStart.search.aggs.createAggConfigs({} as IndexPattern, [ { id: '0', enabled: true, - type: { - type: 'metrics', - name: 'count', - }, + type: 'count', schema: 'metric', params: {}, - } as IAggConfig, - ]; + }, + ]); params = { searchSource: null, @@ -393,11 +390,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { ], }, data: { - aggs: { - getResponseAggs: () => { - return aggs; - }, - } as any, + aggs, searchSource: {} as any, }, isHierarchical: () => { @@ -422,8 +415,13 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }); it('with two numeric metrics, mixed normal and percent mode should have corresponding formatters', async () => { - const aggConfig = aggs[0]; - aggs = [{ ...aggConfig } as IAggConfig, { ...aggConfig, id: '5' } as IAggConfig]; + aggs.createAggConfig({ + id: '5', + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }); vis.params = { seriesParams: [ @@ -469,11 +467,7 @@ describe('visualize loader pipeline helpers: build pipeline', () => { }, params: { gauge: {} }, data: { - aggs: { - getResponseAggs: () => { - return aggs; - }, - } as any, + aggs, searchSource: {} as any, }, isHierarchical: () => { diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index 5d74cb3d3b1e5d..62ff1f83426b9c 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -20,12 +20,7 @@ import { get } from 'lodash'; import moment from 'moment'; import { SerializedFieldFormat } from '../../../../plugins/expressions/public'; -import { - IAggConfig, - fieldFormats, - search, - TimefilterContract, -} from '../../../../plugins/data/public'; +import { IAggConfig, search, TimefilterContract } from '../../../../plugins/data/public'; import { Vis, VisParams } from '../types'; const { isDateHistogramBucketAggConfig } = search.aggs; @@ -113,11 +108,9 @@ const getSchemas = ( 'max_bucket', ].includes(agg.type.name); - const format = fieldFormats.serialize( - hasSubAgg - ? agg.params.customMetric || agg.aggConfigs.getRequestAggById(agg.params.metricAgg) - : agg - ); + const formatAgg = hasSubAgg + ? agg.params.customMetric || agg.aggConfigs.getRequestAggById(agg.params.metricAgg) + : agg; const params: SchemaConfigParams = {}; @@ -130,7 +123,7 @@ const getSchemas = ( return { accessor, - format, + format: formatAgg.toSerializedFieldFormat(), params, label, aggType: agg.type.name, diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index 57e30631becbc8..dd89e98fb8fe53 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -22,6 +22,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypesStart, VisType } from '../vis_types'; import { NewVisModal } from './new_vis_modal'; import { ApplicationStart, SavedObjectsStart } from '../../../../core/public'; +import { embeddablePluginMock } from '../../../embeddable/public/mocks'; describe('NewVisModal', () => { const defaultVisTypeParams = { @@ -144,30 +145,34 @@ describe('NewVisModal', () => { ); }); - it('closes and redirects properly if visualization with aliasPath and addToDashboard in editorParams', () => { + it('closes and redirects properly if visualization with aliasPath and originatingApp in props', () => { const onClose = jest.fn(); const navigateToApp = jest.fn(); + const stateTransfer = embeddablePluginMock.createStartContract().getStateTransfer(); const wrapper = mountWithIntl( ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(navigateToApp).toBeCalledWith('otherApp', { - path: '#/aliasUrl?embeddableOriginatingApp=notAnApp', + expect(stateTransfer.navigateToWithOriginatingApp).toBeCalledWith('otherApp', { + path: '#/aliasUrl', + state: { originatingApp: 'coolJestTestApp' }, }); expect(onClose).toHaveBeenCalled(); }); - it('closes and redirects properly if visualization with aliasApp and without addToDashboard in editorParams', () => { + it('closes and redirects properly if visualization with aliasApp and without originatingApp in props', () => { const onClose = jest.fn(); const navigateToApp = jest.fn(); const wrapper = mountWithIntl( diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index 1a970e505b7c71..84a5bca0ed0edd 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -28,7 +28,7 @@ import { SearchSelection } from './search_selection'; import { TypeSelection } from './type_selection'; import { TypesStart, VisType, VisTypeAlias } from '../vis_types'; import { UsageCollectionSetup } from '../../../../plugins/usage_collection/public'; -import { EMBEDDABLE_ORIGINATING_APP_PARAM } from '../../../embeddable/public'; +import { EmbeddableStateTransfer } from '../../../embeddable/public'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; interface TypeSelectionProps { @@ -42,6 +42,8 @@ interface TypeSelectionProps { usageCollection?: UsageCollectionSetup; application: ApplicationStart; outsideVisualizeApp?: boolean; + stateTransfer?: EmbeddableStateTransfer; + originatingApp?: string; } interface TypeSelectionState { @@ -148,14 +150,8 @@ class NewVisModal extends React.Component - param.startsWith(EMBEDDABLE_ORIGINATING_APP_PARAM) - ); - params = originatingAppParam ? `${params}?${originatingAppParam}` : params; - } this.props.onClose(); - this.props.application.navigateToApp(visType.aliasApp, { path: params }); + this.navigate(visType.aliasApp, visType.aliasPath); return; } @@ -168,13 +164,24 @@ class NewVisModal extends React.Component void; + originatingApp?: string; outsideVisualizeApp?: boolean; } @@ -45,6 +47,7 @@ export interface ShowNewVisModalParams { export function showNewVisModal({ editorParams = [], onClose, + originatingApp, outsideVisualizeApp, }: ShowNewVisModalParams = {}) { const container = document.createElement('div'); @@ -65,6 +68,8 @@ export function showNewVisModal({ originatingApp; const visStateToEditorState = () => { @@ -647,7 +646,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState */ function doSave(saveOptions) { // vis.title was not bound and it's needed to reflect title into visState - const firstSave = !Boolean(savedVis.id); + const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; stateContainer.transitions.setVis({ title: savedVis.title, type: savedVis.type || stateContainer.getState().vis.type, @@ -680,16 +679,14 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState // Manually insert a new url so the back button will open the saved visualization. history.replace(appPath); setActiveUrl(appPath); - const lastAppType = $scope.getOriginatingApp(); - - if (lastAppType === 'dashboards') { - const savedVisId = firstSave || savedVis.copyOnSave ? savedVis.id : ''; - dashboard.addEmbeddableToDashboard({ - embeddableId: savedVisId, - embeddableType: VISUALIZE_EMBEDDABLE_TYPE, - }); + if (newlyCreated && embeddable) { + embeddable + .getStateTransfer() + .navigateToWithEmbeddablePackage($scope.getOriginatingApp(), { + state: { id: savedVis.id, type: VISUALIZE_EMBEDDABLE_TYPE }, + }); } else { - application.navigateToApp(lastAppType); + application.navigateToApp($scope.getOriginatingApp()); } } else if (savedVis.id === $route.current.params.id) { chrome.docTitle.change(savedVis.lastSavedTitle); diff --git a/src/plugins/visualize/public/application/legacy_app.js b/src/plugins/visualize/public/application/legacy_app.js index 42e8b07ee63103..452118f8097da0 100644 --- a/src/plugins/visualize/public/application/legacy_app.js +++ b/src/plugins/visualize/public/application/legacy_app.js @@ -244,9 +244,17 @@ export function initVisualizeApp(app, deps) { }, }) .otherwise({ - template: '', - controller: function () { - deps.kibanaLegacy.navigateToDefaultApp(); + resolveRedirectTo: function ($rootScope) { + const path = window.location.hash.substr(1); + deps.restorePreviousUrl(); + $rootScope.$applyAsync(() => { + const { navigated } = deps.kibanaLegacy.navigateToLegacyKibanaUrl(path); + if (!navigated) { + deps.kibanaLegacy.navigateToDefaultApp(); + } + }); + // prevent angular from completing the navigation + return new Promise(() => {}); }, }); }); diff --git a/src/plugins/visualize/public/kibana_services.ts b/src/plugins/visualize/public/kibana_services.ts index 8d714f18443455..f1e6b0b37d55b7 100644 --- a/src/plugins/visualize/public/kibana_services.ts +++ b/src/plugins/visualize/public/kibana_services.ts @@ -34,8 +34,8 @@ import { DataPublicPluginStart } from '../../data/public'; import { VisualizationsStart } from '../../visualizations/public'; import { SavedVisualizations } from './application/types'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { DashboardStart } from '../../dashboard/public'; import { SavedObjectsStart } from '../../saved_objects/public'; +import { EmbeddableStart } from '../../embeddable/public'; export interface VisualizeKibanaServices { pluginInitializerContext: PluginInitializerContext; @@ -52,9 +52,10 @@ export interface VisualizeKibanaServices { kibanaLegacy: KibanaLegacyStart; visualizeCapabilities: any; visualizations: VisualizationsStart; - dashboard: DashboardStart; + embeddable: EmbeddableStart; I18nContext: I18nStart['Context']; setActiveUrl: (newUrl: string) => void; + restorePreviousUrl: () => void; createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject']; scopedHistory: () => ScopedHistory; savedObjects: SavedObjectsStart; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 8a05adc18964a0..bec082642d3d46 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -40,16 +40,16 @@ import { VisualizationsStart } from '../../visualizations/public'; import { VisualizeConstants } from './application/visualize_constants'; import { setServices, VisualizeKibanaServices } from './kibana_services'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public'; -import { DashboardStart } from '../../dashboard/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; +import { EmbeddableStart } from '../../embeddable/public'; export interface VisualizePluginStartDependencies { data: DataPublicPluginStart; navigation: NavigationStart; share?: SharePluginStart; visualizations: VisualizationsStart; - dashboard: DashboardStart; + embeddable: EmbeddableStart; kibanaLegacy: KibanaLegacyStart; savedObjects: SavedObjectsStart; } @@ -73,7 +73,13 @@ export class VisualizePlugin core: CoreSetup, { home, kibanaLegacy, data }: VisualizePluginSetupDependencies ) { - const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({ + const { + appMounted, + appUnMounted, + stop: stopUrlTracker, + setActiveUrl, + restorePreviousUrl, + } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/visualize'), defaultSubUrl: '#/', storageKey: `lastUrl:${core.http.basePath.get()}:visualize`, @@ -129,13 +135,14 @@ export class VisualizePlugin toastNotifications: coreStart.notifications.toasts, visualizeCapabilities: coreStart.application.capabilities.visualize, visualizations: pluginsStart.visualizations, + embeddable: pluginsStart.embeddable, I18nContext: coreStart.i18n.Context, setActiveUrl, createVisEmbeddableFromObject: pluginsStart.visualizations.__LEGACY.createVisEmbeddableFromObject, - dashboard: pluginsStart.dashboard, scopedHistory: () => this.currentHistory!, savedObjects: pluginsStart.savedObjects, + restorePreviousUrl, }; setServices(deps); diff --git a/test/api_integration/apis/core/index.js b/test/api_integration/apis/core/index.js index c522acaea25a31..ab9bb8d33c2dc3 100644 --- a/test/api_integration/apis/core/index.js +++ b/test/api_integration/apis/core/index.js @@ -22,19 +22,6 @@ export default function ({ getService }) { const supertest = getService('supertest'); describe('core', () => { - describe('request context', () => { - it('provides access to elasticsearch', async () => - await supertest.get('/requestcontext/elasticsearch').expect(200, 'Elasticsearch: true')); - - it('provides access to SavedObjects client', async () => - await supertest - .get('/requestcontext/savedobjectsclient') - .expect( - 200, - 'SavedObjects client: {"page":1,"per_page":20,"total":0,"saved_objects":[]}' - )); - }); - describe('compression', () => { it(`uses compression when there isn't a referer`, async () => { await supertest diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 4d9f1c1658139e..235d789a388df2 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -221,7 +221,7 @@ export default function ({ getService }: FtrProviderContext) { editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/discover#/960372e0-3224-11e8-a572-ffca06da1357', + path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, }); diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 1db4df181e0e98..225fc5456e7436 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -283,7 +283,7 @@ export default function ({ getService }: FtrProviderContext) { editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/discover#/960372e0-3224-11e8-a572-ffca06da1357', + path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, }, @@ -323,7 +323,7 @@ export default function ({ getService }: FtrProviderContext) { editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/discover#/960372e0-3224-11e8-a572-ffca06da1357', + path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, }, @@ -366,7 +366,7 @@ export default function ({ getService }: FtrProviderContext) { editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/discover#/960372e0-3224-11e8-a572-ffca06da1357', + path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, }, @@ -406,7 +406,7 @@ export default function ({ getService }: FtrProviderContext) { editUrl: '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/discover#/960372e0-3224-11e8-a572-ffca06da1357', + path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, }, diff --git a/test/functional/apps/bundles/index.js b/test/functional/apps/bundles/index.js index ead6412564751f..f106ea895c2e6c 100644 --- a/test/functional/apps/bundles/index.js +++ b/test/functional/apps/bundles/index.js @@ -65,7 +65,7 @@ export default function ({ getService }) { it('returns gzip files when no brotli version exists', () => supertest - .get(`/${buildNum}/bundles/commons.style.css`) // legacy optimizer does not create brotli outputs + .get(`/${buildNum}/bundles/light_theme.style.css`) // legacy optimizer does not create brotli outputs .set('Accept-Encoding', 'gzip, br') .expect(200) .expect('Content-Encoding', 'gzip')); diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 92b109c7263ff5..babefe488d7bcf 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -17,12 +17,8 @@ * under the License. */ -import expect from '@kbn/expect'; - -const TEST_COLUMN_NAMES = ['@message']; const TEST_FILTER_COLUMN_NAMES = [ ['extension', 'jpg'], - ['geo.src', 'IN'], [ 'agent', 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24', @@ -30,20 +26,15 @@ const TEST_FILTER_COLUMN_NAMES = [ ]; export default function ({ getService, getPageObjects }) { + const retry = getService('retry'); const browser = getService('browser'); const docTable = getService('docTable'); const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/62866 - describe.skip('context link in discover', function contextSize() { + describe('discover - context - back navigation', function contextSize() { before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); - await PageObjects.timePicker.setDefaultAbsoluteRange(); - await Promise.all( - TEST_COLUMN_NAMES.map((columnName) => - PageObjects.discover.clickFieldListItemAdd(columnName) - ) - ); for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { await PageObjects.discover.clickFieldListItem(columnName); await PageObjects.discover.clickFieldListPlusFilter(columnName, value); @@ -51,18 +42,22 @@ export default function ({ getService, getPageObjects }) { }); it('should go back after loading', async function () { - // navigate to the context view - await docTable.clickRowToggle({ rowIndex: 0 }); - await (await docTable.getRowActions({ rowIndex: 0 }))[0].click(); - await PageObjects.context.waitUntilContextLoadingHasFinished(); - await PageObjects.context.clickSuccessorLoadMoreButton(); - await PageObjects.context.clickSuccessorLoadMoreButton(); - await PageObjects.context.clickSuccessorLoadMoreButton(); - await PageObjects.context.waitUntilContextLoadingHasFinished(); - await browser.goBack(); - await PageObjects.discover.waitForDocTableLoadingComplete(); - const hitCount = await PageObjects.discover.getHitCount(); - expect(hitCount).to.be('522'); + await retry.waitFor('user navigating to context and returning to discover', async () => { + // navigate to the context view + const initialHitCount = await PageObjects.discover.getHitCount(); + await docTable.clickRowToggle({ rowIndex: 0 }); + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + await rowActions[0].click(); + await PageObjects.context.waitUntilContextLoadingHasFinished(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.waitUntilContextLoadingHasFinished(); + await browser.goBack(); + await PageObjects.discover.waitForDocTableLoadingComplete(); + const hitCount = await PageObjects.discover.getHitCount(); + return initialHitCount === hitCount; + }); }); }); } diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index 5bba2447cde286..3656c824394f46 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -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); diff --git a/test/functional/apps/dashboard/empty_dashboard.js b/test/functional/apps/dashboard/empty_dashboard.js index e7ebbcf09e828b..7f13aca438842a 100644 --- a/test/functional/apps/dashboard/empty_dashboard.js +++ b/test/functional/apps/dashboard/empty_dashboard.js @@ -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 () => { diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index a8d0e03c9421e8..1e310c1ddd2684 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -58,6 +58,7 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./embed_mode')); loadTestFile(require.resolve('./dashboard_back_button')); loadTestFile(require.resolve('./dashboard_error_handling')); + loadTestFile(require.resolve('./legacy_urls')); // Note: This one must be last because it unloads some data for one of its tests! // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched diff --git a/test/functional/apps/dashboard/legacy_urls.ts b/test/functional/apps/dashboard/legacy_urls.ts new file mode 100644 index 00000000000000..e606649c1df9f5 --- /dev/null +++ b/test/functional/apps/dashboard/legacy_urls.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'dashboard', + 'header', + 'common', + 'timePicker', + 'visualize', + 'visEditor', + ]); + const pieChart = getService('pieChart'); + const browser = getService('browser'); + const find = getService('find'); + const log = getService('log'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const listingTable = getService('listingTable'); + const esArchiver = getService('esArchiver'); + + let kibanaLegacyBaseUrl: string; + let kibanaVisualizeBaseUrl: string; + let testDashboardId: string; + + describe('legacy urls', function describeIndexTests() { + before(async function () { + await esArchiver.load('dashboard/current/kibana'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await PageObjects.dashboard.saveDashboard('legacyTest', { waitDialogIsClosed: true }); + await PageObjects.header.waitUntilLoadingHasFinished(); + const currentUrl = await browser.getCurrentUrl(); + await log.debug(`Current url is ${currentUrl}`); + testDashboardId = /#\/view\/(.+)\?/.exec(currentUrl)![1]; + kibanaLegacyBaseUrl = + currentUrl.substring(0, currentUrl.indexOf('/app/dashboards')) + '/app/kibana'; + kibanaVisualizeBaseUrl = + currentUrl.substring(0, currentUrl.indexOf('/app/dashboards')) + '/app/visualize'; + await log.debug(`id is ${testDashboardId}`); + }); + + after(async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.deleteItem('legacyTest', testDashboardId); + }); + + describe('kibana link redirect', () => { + it('redirects from old kibana app URL', async () => { + const url = `${kibanaLegacyBaseUrl}#/dashboard/${testDashboardId}`; + await browser.get(url, true); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.timePicker.setDefaultDataRange(); + + await PageObjects.dashboard.waitForRenderComplete(); + await pieChart.expectPieSliceCount(5); + }); + + it('redirects from legacy hash in wrong app', async () => { + const url = `${kibanaVisualizeBaseUrl}#/dashboard/${testDashboardId}`; + await browser.get(url, true); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.timePicker.setDefaultDataRange(); + + await PageObjects.dashboard.waitForRenderComplete(); + await pieChart.expectPieSliceCount(5); + }); + + it('resolves markdown link', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickMarkdownWidget(); + await PageObjects.visEditor.setMarkdownTxt(`[abc](#/dashboard/${testDashboardId})`); + await PageObjects.visEditor.clickGo(); + (await find.byLinkText('abc')).click(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.timePicker.setDefaultDataRange(); + + await PageObjects.dashboard.waitForRenderComplete(); + await pieChart.expectPieSliceCount(5); + }); + + it('back button works', async () => { + // back to default time range + await browser.goBack(); + // back to last app + await browser.goBack(); + await PageObjects.visEditor.expectMarkdownTextArea(); + await browser.goForward(); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index ecaa5aa2da97fe..de9606f3d02ed1 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -257,5 +257,16 @@ export default function ({ getService, getPageObjects }) { expect(refreshedTimeString).not.to.be(initialTimeString); }); }); + + describe('invalid time range in URL', function () { + it('should display a "Invalid time range toast"', async function () { + await PageObjects.common.navigateToUrl('discover', '#/?_g=(time:(from:now-15m,to:null))', { + useActualUrl: true, + }); + await PageObjects.header.awaitKibanaChrome(); + const toastMessage = await PageObjects.common.closeToast(); + expect(toastMessage).to.be('Invalid time range'); + }); + }); }); } diff --git a/test/functional/apps/discover/_errors.js b/test/functional/apps/discover/_errors.js index 5113fc8568d526..f3936d06bb6dfc 100644 --- a/test/functional/apps/discover/_errors.js +++ b/test/functional/apps/discover/_errors.js @@ -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'); diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index 38d8812fa3103b..5c6a70450a0aa1 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -109,7 +109,7 @@ export default function ({ getService, getPageObjects }) { const expectedUrl = baseUrl + '/app/discover#' + - '/ab12e3c0-f231-11e6-9486-733b1ac9221a' + + '/view/ab12e3c0-f231-11e6-9486-733b1ac9221a' + '?_g=(filters%3A!()%2CrefreshInterval%3A(pause%3A!t%2Cvalue%3A0)' + "%2Ctime%3A(from%3A'2015-09-19T06%3A31%3A44.000Z'%2C" + "to%3A'2015-09-23T18%3A31%3A44.000Z'))"; diff --git a/test/functional/apps/visualize/_data_table_nontimeindex.js b/test/functional/apps/visualize/_data_table_nontimeindex.js index 4ae66d14ec30da..d64629a65c2c30 100644 --- a/test/functional/apps/visualize/_data_table_nontimeindex.js +++ b/test/functional/apps/visualize/_data_table_nontimeindex.js @@ -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']); + }); }); }); } diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index f1c5c916a89bf0..7e22f543bc7dbf 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -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'); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index d08e88ecf47ea9..8c5a99204bab69 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -378,14 +378,12 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo async isChromeVisible() { const globalNavShown = await globalNav.exists(); - const topNavShown = await testSubjects.exists('top-nav'); - return globalNavShown && topNavShown; + return globalNavShown; } async isChromeHidden() { const globalNavShown = await globalNav.exists(); - const topNavShown = await testSubjects.exists('top-nav'); - return !globalNavShown && !topNavShown; + return !globalNavShown; } async waitForTopNavToBeVisible() { @@ -401,7 +399,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo const toast = await find.byCssSelector('.euiToast', 2 * defaultFindTimeout); await toast.moveMouseTo(); const title = await (await find.byCssSelector('.euiToastHeader__title')).getVisibleText(); - log.debug(`Toast title: ${title}`); + await find.clickByCssSelector('.euiToast__closeButton'); return title; } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index c4c7c2aaffabd9..9fcb38efce0db9 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -230,6 +230,10 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('dropPartialBucketsCheckbox'); } + public async expectMarkdownTextArea() { + await testSubjects.existOrFail('markdownTextarea'); + } + public async setMarkdownTxt(markdownTxt: string) { const input = await testSubjects.find('markdownTextarea'); await input.clearValue(); diff --git a/test/plugin_functional/plugins/core_logging/kibana.json b/test/plugin_functional/plugins/core_logging/kibana.json new file mode 100644 index 00000000000000..3289c2c627b9a7 --- /dev/null +++ b/test/plugin_functional/plugins/core_logging/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "core_logging", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_logging"], + "server": true +} diff --git a/test/plugin_functional/plugins/core_logging/server/.gitignore b/test/plugin_functional/plugins/core_logging/server/.gitignore new file mode 100644 index 00000000000000..9a3d2811791937 --- /dev/null +++ b/test/plugin_functional/plugins/core_logging/server/.gitignore @@ -0,0 +1 @@ +/*debug.log diff --git a/src/legacy/core_plugins/kibana/public/index.ts b/test/plugin_functional/plugins/core_logging/server/index.ts similarity index 78% rename from src/legacy/core_plugins/kibana/public/index.ts rename to test/plugin_functional/plugins/core_logging/server/index.ts index 6b1b7f0d249ff4..ca1d9da95b495c 100644 --- a/src/legacy/core_plugins/kibana/public/index.ts +++ b/test/plugin_functional/plugins/core_logging/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -export { - ProcessedImportResponse, - processImportResponse, -} from '../../../../plugins/saved_objects_management/public/lib'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import type { PluginInitializerContext } from '../../../../../src/core/server'; +import { CoreLoggingPlugin } from './plugin'; + +export const plugin = (init: PluginInitializerContext) => new CoreLoggingPlugin(init); diff --git a/test/plugin_functional/plugins/core_logging/server/plugin.ts b/test/plugin_functional/plugins/core_logging/server/plugin.ts new file mode 100644 index 00000000000000..a7820a0f675250 --- /dev/null +++ b/test/plugin_functional/plugins/core_logging/server/plugin.ts @@ -0,0 +1,118 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; +import { Subject } from 'rxjs'; +import { schema } from '@kbn/config-schema'; +import type { + PluginInitializerContext, + Plugin, + CoreSetup, + LoggerContextConfigInput, + Logger, +} from '../../../../../src/core/server'; + +const CUSTOM_LOGGING_CONFIG: LoggerContextConfigInput = { + appenders: { + customJsonFile: { + kind: 'file', + path: resolve(__dirname, 'json_debug.log'), // use 'debug.log' suffix so file watcher does not restart server + layout: { + kind: 'json', + }, + }, + customPatternFile: { + kind: 'file', + path: resolve(__dirname, 'pattern_debug.log'), + layout: { + kind: 'pattern', + pattern: 'CUSTOM - PATTERN [%logger][%level] %message', + }, + }, + }, + + loggers: [ + { context: 'debug_json', appenders: ['customJsonFile'], level: 'debug' }, + { context: 'debug_pattern', appenders: ['customPatternFile'], level: 'debug' }, + { context: 'info_json', appenders: ['customJsonFile'], level: 'info' }, + { context: 'info_pattern', appenders: ['customPatternFile'], level: 'info' }, + { context: 'all', appenders: ['customJsonFile', 'customPatternFile'], level: 'debug' }, + ], +}; + +export class CoreLoggingPlugin implements Plugin { + private readonly logger: Logger; + + constructor(init: PluginInitializerContext) { + this.logger = init.logger.get(); + } + + public setup(core: CoreSetup) { + const loggingConfig$ = new Subject(); + core.logging.configure(loggingConfig$); + + const router = core.http.createRouter(); + + // Expose a route that allows our test suite to write logs as this plugin + router.post( + { + path: '/internal/core-logging/write-log', + validate: { + body: schema.object({ + level: schema.oneOf([schema.literal('debug'), schema.literal('info')]), + message: schema.string(), + context: schema.arrayOf(schema.string()), + }), + }, + }, + (ctx, req, res) => { + const { level, message, context } = req.body; + const logger = this.logger.get(...context); + + if (level === 'debug') { + logger.debug(message); + } else if (level === 'info') { + logger.info(message); + } + + return res.ok(); + } + ); + + // Expose a route to toggle on and off the custom config + router.post( + { + path: '/internal/core-logging/update-config', + validate: { body: schema.object({ enableCustomConfig: schema.boolean() }) }, + }, + (ctx, req, res) => { + if (req.body.enableCustomConfig) { + loggingConfig$.next(CUSTOM_LOGGING_CONFIG); + } else { + loggingConfig$.next({}); + } + + return res.ok({ body: `Updated config: ${req.body.enableCustomConfig}` }); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/core_logging/tsconfig.json b/test/plugin_functional/plugins/core_logging/tsconfig.json new file mode 100644 index 00000000000000..7389eb6ce159be --- /dev/null +++ b/test/plugin_functional/plugins/core_logging/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 8f54ec6c0f4cd9..8f7c2267d34b44 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -30,5 +30,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./application_leave_confirm')); loadTestFile(require.resolve('./application_status')); loadTestFile(require.resolve('./rendering')); + loadTestFile(require.resolve('./logging')); }); } diff --git a/test/plugin_functional/test_suites/core_plugins/logging.ts b/test/plugin_functional/test_suites/core_plugins/logging.ts new file mode 100644 index 00000000000000..9fdaa6ce834ea5 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/logging.ts @@ -0,0 +1,146 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; +import fs from 'fs'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + + describe('plugin logging', function describeIndexTests() { + const LOG_FILE_DIRECTORY = resolve(__dirname, '..', '..', 'plugins', 'core_logging', 'server'); + const JSON_FILE_PATH = resolve(LOG_FILE_DIRECTORY, 'json_debug.log'); + const PATTERN_FILE_PATH = resolve(LOG_FILE_DIRECTORY, 'pattern_debug.log'); + + beforeEach(async () => { + // "touch" each file to ensure it exists and is empty before each test + await fs.promises.writeFile(JSON_FILE_PATH, ''); + await fs.promises.writeFile(PATTERN_FILE_PATH, ''); + }); + + async function readLines(path: string) { + const contents = await fs.promises.readFile(path, { encoding: 'utf8' }); + return contents.trim().split('\n'); + } + + async function readJsonLines() { + return (await readLines(JSON_FILE_PATH)) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .map(({ level, message, context }) => ({ level, message, context })); + } + + function writeLog(context: string[], level: string, message: string) { + return supertest + .post('/internal/core-logging/write-log') + .set('kbn-xsrf', 'anything') + .send({ context, level, message }) + .expect(200); + } + + function setContextConfig(enable: boolean) { + return supertest + .post('/internal/core-logging/update-config') + .set('kbn-xsrf', 'anything') + .send({ enableCustomConfig: enable }) + .expect(200); + } + + it('does not write to custom appenders when not configured', async () => { + await setContextConfig(false); + await writeLog(['debug_json'], 'info', 'i go to the default appender!'); + expect(await readJsonLines()).to.eql([]); + }); + + it('writes debug_json context to custom JSON appender', async () => { + await setContextConfig(true); + await writeLog(['debug_json'], 'debug', 'log1'); + await writeLog(['debug_json'], 'info', 'log2'); + expect(await readJsonLines()).to.eql([ + { + level: 'DEBUG', + context: 'plugins.core_logging.debug_json', + message: 'log1', + }, + { + level: 'INFO', + context: 'plugins.core_logging.debug_json', + message: 'log2', + }, + ]); + }); + + it('writes info_json context to custom JSON appender', async () => { + await setContextConfig(true); + await writeLog(['info_json'], 'debug', 'i should not be logged!'); + await writeLog(['info_json'], 'info', 'log2'); + expect(await readJsonLines()).to.eql([ + { + level: 'INFO', + context: 'plugins.core_logging.info_json', + message: 'log2', + }, + ]); + }); + + it('writes debug_pattern context to custom pattern appender', async () => { + await setContextConfig(true); + await writeLog(['debug_pattern'], 'debug', 'log1'); + await writeLog(['debug_pattern'], 'info', 'log2'); + expect(await readLines(PATTERN_FILE_PATH)).to.eql([ + 'CUSTOM - PATTERN [plugins.core_logging.debug_pattern][DEBUG] log1', + 'CUSTOM - PATTERN [plugins.core_logging.debug_pattern][INFO ] log2', + ]); + }); + + it('writes info_pattern context to custom pattern appender', async () => { + await setContextConfig(true); + await writeLog(['info_pattern'], 'debug', 'i should not be logged!'); + await writeLog(['info_pattern'], 'info', 'log2'); + expect(await readLines(PATTERN_FILE_PATH)).to.eql([ + 'CUSTOM - PATTERN [plugins.core_logging.info_pattern][INFO ] log2', + ]); + }); + + it('writes all context to both appenders', async () => { + await setContextConfig(true); + await writeLog(['all'], 'debug', 'log1'); + await writeLog(['all'], 'info', 'log2'); + expect(await readJsonLines()).to.eql([ + { + level: 'DEBUG', + context: 'plugins.core_logging.all', + message: 'log1', + }, + { + level: 'INFO', + context: 'plugins.core_logging.all', + message: 'log2', + }, + ]); + expect(await readLines(PATTERN_FILE_PATH)).to.eql([ + 'CUSTOM - PATTERN [plugins.core_logging.all][DEBUG] log1', + 'CUSTOM - PATTERN [plugins.core_logging.all][INFO ] log2', + ]); + }); + }); +} diff --git a/test/scripts/jenkins_security_solution_cypress.sh b/test/scripts/jenkins_security_solution_cypress.sh index 23b83cf946d491..8aa3425be0beb2 100644 --- a/test/scripts/jenkins_security_solution_cypress.sh +++ b/test/scripts/jenkins_security_solution_cypress.sh @@ -11,11 +11,16 @@ export KIBANA_INSTALL_DIR="$destDir" echo " -> Running security solution cypress tests" cd "$XPACK_DIR" -checks-reporter-with-killswitch "Security solution Cypress Tests" \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$KIBANA_INSTALL_DIR" \ - --config test/security_solution_cypress/config.ts +# Failures across multiple suites, skipping all +# https://github.com/elastic/kibana/issues/69847 +# https://github.com/elastic/kibana/issues/69848 +# https://github.com/elastic/kibana/issues/69849 + +# checks-reporter-with-killswitch "Security solution Cypress Tests" \ +# node scripts/functional_tests \ +# --debug --bail \ +# --kibana-install-dir "$KIBANA_INSTALL_DIR" \ +# --config test/security_solution_cypress/config.ts echo "" echo "" diff --git a/tsconfig.types.json b/tsconfig.types.json index fd3624dd8e31bf..2f5919e413e514 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -10,6 +10,8 @@ "include": [ "src/core/server/index.ts", "src/core/public/index.ts", + "src/plugins/data/server/index.ts", + "src/plugins/data/public/index.ts", "typings" ] } diff --git a/vars/buildState.groovy b/vars/buildState.groovy new file mode 100644 index 00000000000000..365705661350ce --- /dev/null +++ b/vars/buildState.groovy @@ -0,0 +1,30 @@ +import groovy.transform.Field + +public static @Field JENKINS_BUILD_STATE = [:] + +def add(key, value) { + if (!buildState.JENKINS_BUILD_STATE.containsKey(key)) { + buildState.JENKINS_BUILD_STATE[key] = value + return true + } + + return false +} + +def set(key, value) { + buildState.JENKINS_BUILD_STATE[key] = value +} + +def get(key) { + return buildState.JENKINS_BUILD_STATE[key] +} + +def has(key) { + return buildState.JENKINS_BUILD_STATE.containsKey(key) +} + +def get() { + return buildState.JENKINS_BUILD_STATE +} + +return this diff --git a/vars/githubCommitStatus.groovy b/vars/githubCommitStatus.groovy new file mode 100644 index 00000000000000..4cd4228d55f03c --- /dev/null +++ b/vars/githubCommitStatus.groovy @@ -0,0 +1,42 @@ +def shouldCreateStatuses() { + return !githubPr.isPr() && buildState.get('checkoutInfo') +} + +def onStart() { + catchError { + if (!shouldCreateStatuses()) { + return + } + + def checkoutInfo = buildState.get('checkoutInfo') + create(checkoutInfo.commit, 'pending', 'Build started.') + } +} + +def onFinish() { + catchError { + if (!shouldCreateStatuses()) { + return + } + + def checkoutInfo = buildState.get('checkoutInfo') + def status = buildUtils.getBuildStatus() + + if (status == 'SUCCESS' || status == 'UNSTABLE') { + create(checkoutInfo.commit, 'success', 'Build completed successfully.') + } else if(status == 'ABORTED') { + create(checkoutInfo.commit, 'error', 'Build aborted or timed out.') + } else { + create(checkoutInfo.commit, 'error', 'Build failed.') + } + } +} + +// state: error|failure|pending|success +def create(sha, state, description, context = 'kibana-ci') { + withGithubCredentials { + return githubApi.post("repos/elastic/kibana/statuses/${sha}", [ state: state, description: description, context: context, target_url: env.BUILD_URL ]) + } +} + +return this diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 4a5c8dff8a2301..46a76bbb8d523d 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -214,12 +214,15 @@ def runErrorReporter() { } def call(Map params = [:], Closure closure) { - def config = [timeoutMinutes: 135, checkPrChanges: false] + params + def config = [timeoutMinutes: 135, checkPrChanges: false, setCommitStatus: false] + params stage("Kibana Pipeline") { timeout(time: config.timeoutMinutes, unit: 'MINUTES') { timestamps { ansiColor('xterm') { + if (config.setCommitStatus) { + buildState.set('shouldSetCommitStatus', true) + } if (config.checkPrChanges && githubPr.isPr()) { pipelineLibraryTests() @@ -230,7 +233,13 @@ def call(Map params = [:], Closure closure) { return } } - closure() + try { + closure() + } finally { + if (config.setCommitStatus) { + githubCommitStatus.onFinish() + } + } } } } diff --git a/vars/workers.groovy b/vars/workers.groovy index e44df9ef75ad06..8b7e8525a7ce3b 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -65,6 +65,12 @@ def base(Map params, Closure closure) { dir("kibana") { checkoutInfo = getCheckoutInfo() + + // use `checkoutInfo` as a flag to indicate that we've already reported the pending commit status + if (buildState.get('shouldSetCommitStatus') && !buildState.has('checkoutInfo')) { + buildState.set('checkoutInfo', checkoutInfo) + githubCommitStatus.onStart() + } } ciStats.reportGitInfo( diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 278968cb472315..596ba17d343c0c 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -8,10 +8,11 @@ "xpack.apm": ["legacy/plugins/apm", "plugins/apm"], "xpack.beatsManagement": ["legacy/plugins/beats_management", "plugins/beats_management"], "xpack.canvas": "plugins/canvas", + "xpack.cloud": "plugins/cloud", "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.discover": "plugins/discover_enhanced", "xpack.crossClusterReplication": "plugins/cross_cluster_replication", - "xpack.dashboardMode": "legacy/plugins/dashboard_mode", + "xpack.dashboardMode": "plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index 5ecee2e3ad0d35..822802f3dacb79 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -17,5 +17,7 @@ const MutationObserver = require('mutation-observer'); Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); require('whatwg-fetch'); -const URL = { createObjectURL: () => '' }; -Object.defineProperty(window, 'URL', { value: URL }); + +if (!global.URL.hasOwnProperty('createObjectURL')) { + Object.defineProperty(global.URL, 'createObjectURL', { value: () => '' }); +} diff --git a/x-pack/index.js b/x-pack/index.js index e7dd4886e60526..2d2e42650cfa7d 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -7,7 +7,6 @@ import { xpackMain } from './legacy/plugins/xpack_main'; import { monitoring } from './legacy/plugins/monitoring'; import { security } from './legacy/plugins/security'; -import { dashboardMode } from './legacy/plugins/dashboard_mode'; import { beats } from './legacy/plugins/beats_management'; import { spaces } from './legacy/plugins/spaces'; import { ingestManager } from './legacy/plugins/ingest_manager'; @@ -18,8 +17,7 @@ module.exports = function (kibana) { monitoring(kibana), spaces(kibana), security(kibana), - dashboardMode(kibana), - beats(kibana), ingestManager(kibana), + beats(kibana), ]; }; diff --git a/x-pack/legacy/plugins/dashboard_mode/index.js b/x-pack/legacy/plugins/dashboard_mode/index.js deleted file mode 100644 index 1145fad2a8a282..00000000000000 --- a/x-pack/legacy/plugins/dashboard_mode/index.js +++ /dev/null @@ -1,64 +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 { resolve } from 'path'; -import { i18n } from '@kbn/i18n'; -import { CONFIG_DASHBOARD_ONLY_MODE_ROLES } from './common'; -import { createDashboardModeRequestInterceptor } from './server'; - -// Copied largely from plugins/kibana/index.js. The dashboard viewer includes just the dashboard section of -// the standard kibana plugin. We don't want to include code for the other links (visualize, dev tools, etc) -// since it's view only, but we want the urls to be the same, so we are using largely the same setup. -export function dashboardMode(kibana) { - return new kibana.Plugin({ - id: 'dashboard_mode', - publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], - uiExports: { - uiSettingDefaults: { - [CONFIG_DASHBOARD_ONLY_MODE_ROLES]: { - name: i18n.translate('xpack.dashboardMode.uiSettings.dashboardsOnlyRolesTitle', { - defaultMessage: 'Dashboards only roles', - }), - description: i18n.translate( - 'xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDescription', - { - defaultMessage: 'Roles that belong to View Dashboards Only mode', - } - ), - value: ['kibana_dashboard_only_user'], - category: ['dashboard'], - deprecation: { - message: i18n.translate( - 'xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDeprecation', - { - defaultMessage: 'This setting is deprecated and will be removed in Kibana 8.0.', - } - ), - docLinksKey: 'dashboardSettings', - }, - }, - }, - }, - - config(Joi) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - - init(server) { - server.injectUiAppVars( - 'dashboardViewer', - async () => await server.getInjectedUiAppVars('kibana') - ); - - if (server.plugins.security) { - server.ext(createDashboardModeRequestInterceptor()); - } - }, - }); -} diff --git a/x-pack/legacy/plugins/dashboard_mode/server/__tests__/dashboard_mode_request_interceptor.js b/x-pack/legacy/plugins/dashboard_mode/server/__tests__/dashboard_mode_request_interceptor.js deleted file mode 100644 index 3fd9bf5f59d525..00000000000000 --- a/x-pack/legacy/plugins/dashboard_mode/server/__tests__/dashboard_mode_request_interceptor.js +++ /dev/null @@ -1,163 +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 expect from '@kbn/expect'; -import Hapi from 'hapi'; - -import { createDashboardModeRequestInterceptor } from '../dashboard_mode_request_interceptor'; - -const DASHBOARD_ONLY_MODE_ROLE = 'test_dashboard_only_mode_role'; - -function setup() { - const server = new Hapi.Server(); - - server.decorate('request', 'getUiSettingsService', () => { - return { - get: () => Promise.resolve([DASHBOARD_ONLY_MODE_ROLE]), - }; - }); - - // attach the extension - server.ext(createDashboardModeRequestInterceptor()); - - // allow the extension to fake "render an app" - server.decorate('toolkit', 'renderApp', function (app) { - // `this` is the `h` response toolkit - return this.response({ renderApp: true, app }); - }); - - server.decorate('server', 'newPlatform', { - setup: { - core: { - http: { - basePath: { - get: () => '', - }, - }, - }, - }, - }); - - server.route({ - path: '/app/{appId}', - method: 'GET', - handler(req, h) { - return h.renderApp({ name: req.params.appId }); - }, - }); - - // catch all route for determining when we get through the extensions - server.route({ - path: '/{path*}', - method: 'GET', - handler(req) { - return { catchAll: true, path: `/${req.params.path}` }; - }, - }); - - return { server }; -} - -describe('DashboardOnlyModeRequestInterceptor', () => { - describe('request is not for dashboad-only user', () => { - describe('app route', () => { - it('lets the route render as normal', async () => { - const { server } = setup(); - const response = await server.inject({ - url: '/app/kibana', - credentials: { - roles: ['foo', 'bar'], - }, - }); - - expect(response) - .to.have.property('statusCode', 200) - .and.have.property('result') - .eql({ - renderApp: true, - app: { name: 'kibana' }, - }); - }); - }); - - describe('non-app route', () => { - it('lets the route render as normal', async () => { - const { server } = setup(); - const response = await server.inject({ - url: '/foo/bar', - credentials: { - roles: ['foo', 'bar'], - }, - }); - - expect(response).to.have.property('statusCode', 200).and.have.property('result').eql({ - catchAll: true, - path: '/foo/bar', - }); - }); - }); - }); - - describe('request for dashboard-only user', () => { - describe('non-kibana app route', () => { - it('responds with 404', async () => { - const { server } = setup(); - const response = await server.inject({ - url: '/app/foo', - credentials: { - roles: [DASHBOARD_ONLY_MODE_ROLE], - }, - }); - - expect(response).to.have.property('statusCode', 404); - }); - }); - - describe('requests to dashboard_mode app', () => { - it('lets the route render as normal', async () => { - const { server } = setup(); - const response = await server.inject({ - url: '/app/dashboard_mode', - credentials: { - roles: [DASHBOARD_ONLY_MODE_ROLE], - }, - }); - - expect(response) - .to.have.property('statusCode', 200) - .and.have.property('result') - .eql({ - renderApp: true, - app: { name: 'dashboard_mode' }, - }); - }); - }); - - function testRedirectToDashboardModeApp(url) { - describe(`requests to url:"${url}"`, () => { - it('redirects to the dashboard_mode app instead', async () => { - const { server } = setup(); - const response = await server.inject({ - url: url, - credentials: { - roles: [DASHBOARD_ONLY_MODE_ROLE], - }, - }); - - expect(response).to.have.property('statusCode', 301); - expect(response.headers).to.have.property('location', '/app/dashboard_mode'); - }); - }); - } - - testRedirectToDashboardModeApp('/app/kibana'); - testRedirectToDashboardModeApp('/app/kibana#/foo/bar'); - testRedirectToDashboardModeApp('/app/kibana/foo/bar'); - testRedirectToDashboardModeApp('/app/kibana?foo=bar'); - testRedirectToDashboardModeApp('/app/dashboards?foo=bar'); - testRedirectToDashboardModeApp('/app/home?foo=bar'); - }); -}); diff --git a/x-pack/legacy/plugins/dashboard_mode/server/dashboard_mode_request_interceptor.js b/x-pack/legacy/plugins/dashboard_mode/server/dashboard_mode_request_interceptor.js deleted file mode 100644 index 76a582d6cf2396..00000000000000 --- a/x-pack/legacy/plugins/dashboard_mode/server/dashboard_mode_request_interceptor.js +++ /dev/null @@ -1,73 +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 Boom from 'boom'; - -import { CONFIG_DASHBOARD_ONLY_MODE_ROLES } from '../common'; - -const superuserRole = 'superuser'; - -/** - * Intercept all requests after auth has completed and apply filtering - * logic to enforce dashboard only mode. - * - * @type {Hapi.RequestExtension} - */ -export function createDashboardModeRequestInterceptor() { - return { - type: 'onPostAuth', - async method(request, h) { - const { auth, url } = request; - const user = auth.credentials; - const roles = user ? user.roles : []; - - if (!user) { - return h.continue; - } - - const isAppRequest = url.path.startsWith('/app/'); - - // The act of retrieving this setting ends up creating the config document if it doesn't already exist. - // Various functional tests have come to indirectly rely on this behavior, so changing this is non-trivial. - // This will be addressed once dashboard-only-mode is removed altogether. - const uiSettings = request.getUiSettingsService(); - const dashboardOnlyModeRoles = await uiSettings.get(CONFIG_DASHBOARD_ONLY_MODE_ROLES); - - if (!isAppRequest || !dashboardOnlyModeRoles || !roles || roles.length === 0) { - return h.continue; - } - - const isDashboardOnlyModeUser = user.roles.find((role) => - dashboardOnlyModeRoles.includes(role) - ); - const isSuperUser = user.roles.find((role) => role === superuserRole); - - const enforceDashboardOnlyMode = isDashboardOnlyModeUser && !isSuperUser; - if (enforceDashboardOnlyMode) { - if ( - url.path.startsWith('/app/home') || - url.path.startsWith('/app/kibana') || - url.path.startsWith('/app/dashboards') - ) { - const basePath = request.server.newPlatform.setup.core.http.basePath.get(request); - const url = `${basePath}/app/dashboard_mode`; - // If the user is in "Dashboard only mode" they should only be allowed to see - // the dashboard app and none others. If the kibana app is requested, this might be a old - // url we will migrate on the fly. - return h.redirect(url).permanent().takeover(); - } - if (url.path.startsWith('/app/dashboard_mode')) { - // let through requests to the dashboard_mode app - return h.continue; - } - - throw Boom.notFound(); - } - - return h.continue; - }, - }; -} diff --git a/x-pack/legacy/plugins/ingest_manager/index.ts b/x-pack/legacy/plugins/ingest_manager/index.ts index df9923d9f11ecc..2b20bf16f2400e 100644 --- a/x-pack/legacy/plugins/ingest_manager/index.ts +++ b/x-pack/legacy/plugins/ingest_manager/index.ts @@ -8,6 +8,7 @@ import { resolve } from 'path'; export function ingestManager(kibana: any) { return new kibana.Plugin({ id: 'ingestManager', + require: ['kibana', 'elasticsearch', 'xpack_main'], publicDir: resolve(__dirname, '../../../plugins/ingest_manager/public'), }); } diff --git a/x-pack/mocks.ts b/x-pack/mocks.ts index 28c589bee4baa2..777c8d0a081318 100644 --- a/x-pack/mocks.ts +++ b/x-pack/mocks.ts @@ -9,7 +9,7 @@ import { licensingMock } from './plugins/licensing/server/mocks'; function createCoreRequestHandlerContextMock() { return { core: coreMock.createRequestHandlerContext(), - licensing: { license: licensingMock.createLicense() }, + licensing: licensingMock.createRequestHandlerContext(), }; } diff --git a/x-pack/package.json b/x-pack/package.json index ad8c12d41000c5..ac5b77c4f78dbb 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -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", diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index 459ffd7667f155..21efc05d49c38a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -9,7 +9,7 @@ import { ActionTypeRegistry } from '../action_type_registry'; import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; import { registerBuiltInActionTypes } from './index'; import { Logger } from '../../../../../src/core/server'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../actions_config.mock'; import { licenseStateMock } from '../lib/license_state.mock'; @@ -19,7 +19,7 @@ export function createActionTypeRegistry(): { logger: jest.Mocked; actionTypeRegistry: ActionTypeRegistry; } { - const logger = loggingServiceMock.create().get() as jest.Mocked; + const logger = loggingSystemMock.create().get() as jest.Mocked; const actionTypeRegistry = new ActionTypeRegistry({ taskManager: taskManagerMock.setup(), taskRunnerFactory: new TaskRunnerFactory( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index a02f79a49e8e24..3514bd4257b0f6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -10,14 +10,14 @@ jest.mock('nodemailer', () => ({ import { Logger } from '../../../../../../src/core/server'; import { sendEmail } from './send_email'; -import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; const sendMailMock = jest.fn(); -const mockLogger = loggingServiceMock.create().get() as jest.Mocked; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; describe('send_email module', () => { beforeEach(() => { diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 88216c4bf13adc..c8e6669275e117 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { loggingServiceMock, savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock'; import { ActionType } from '../types'; @@ -31,7 +31,7 @@ const executeParams = { const spacesMock = spacesServiceMock.createSetupContract(); actionExecutor.initialize({ - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), spaces: spacesMock, getServices: () => services, getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, @@ -266,7 +266,7 @@ test('should not throws an error if actionType is preconfigured', async () => { test('throws an error when passing isESOUsingEphemeralEncryptionKey with value of true', async () => { const customActionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: true }); customActionExecutor.initialize({ - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), spaces: spacesMock, getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, getServices: () => services, diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 3339416a1112d1..06cb84ad79a891 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -12,7 +12,7 @@ import { TaskRunnerFactory } from './task_runner_factory'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { actionExecutorMock } from './action_executor.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { savedObjectsClientMock, loggingServiceMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, loggingSystemMock } from 'src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { ActionTypeDisabledError } from './errors'; @@ -56,7 +56,7 @@ const services = { savedObjectsClient: savedObjectsClientMock.create(), }; const actionExecutorInitializerParams = { - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), getServices: jest.fn().mockReturnValue(services), actionTypeRegistry, getScopedSavedObjectsClient: () => savedObjectsClientMock.create(), @@ -67,7 +67,7 @@ const actionExecutorInitializerParams = { const taskRunnerFactoryInitializerParams = { spaceIdToNamespace, actionTypeRegistry, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient, getBasePath: jest.fn().mockReturnValue(undefined), getScopedSavedObjectsClient: jest.fn().mockReturnValue(services.savedObjectsClient), diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts index 315e4800d4c733..d3583fd4cdb0b5 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { getAlertType } from './alert_type'; import { Params } from './alert_type_params'; @@ -13,7 +13,7 @@ describe('alertType', () => { indexThreshold: { timeSeriesQuery: jest.fn(), }, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }; const alertType = getAlertType(service); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts index d40df4c91998ff..0565a8634fc71c 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts @@ -6,7 +6,7 @@ // test error conditions of calling timeSeriesQuery - postive results tested in FT -import { loggingServiceMock } from '../../../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; import { coreMock } from '../../../../../../../src/core/server/mocks'; import { AlertingBuiltinsPlugin } from '../../../plugin'; import { TimeSeriesQueryParameters, TimeSeriesResult, TimeSeriesQuery } from './time_series_query'; @@ -44,7 +44,7 @@ describe('timeSeriesQuery', () => { mockCallCluster.mockReset(); params = { - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), callCluster: mockCallCluster, query: DefaultQueryParams, }; diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index f494f1358980d1..d69d04f71ce9ec 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -6,7 +6,7 @@ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; import { AlertsClient, CreateOptions } from './alerts_client'; -import { savedObjectsClientMock, loggingServiceMock } from '../../../../src/core/server/mocks'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../src/core/server/mocks'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { TaskStatus } from '../../task_manager/server'; @@ -29,7 +29,7 @@ const alertsClientParams = { getUserName: jest.fn(), createAPIKey: jest.fn(), invalidateAPIKey: jest.fn(), - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), encryptedSavedObjectsClient: encryptedSavedObjects, getActionsClient: jest.fn(), }; diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index a2d64c94ce007c..128d54c10b66a4 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -9,7 +9,7 @@ import { AlertsClientFactory, AlertsClientFactoryOpts } from './alerts_client_fa import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { KibanaRequest } from '../../../../src/core/server'; -import { loggingServiceMock, savedObjectsClientMock } from '../../../../src/core/server/mocks'; +import { loggingSystemMock, savedObjectsClientMock } from '../../../../src/core/server/mocks'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { AuthenticatedUser } from '../../../plugins/security/common/model'; import { securityMock } from '../../security/server/mocks'; @@ -20,7 +20,7 @@ jest.mock('./alerts_client'); const savedObjectsClient = savedObjectsClientMock.create(); const securityPluginSetup = securityMock.createSetup(); const alertsClientFactoryParams: jest.Mocked = { - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), taskManager: taskManagerMock.start(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index dd5a9f531bd58b..3b1948c5e7ad7d 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -6,7 +6,7 @@ import { AlertType } from '../types'; import { createExecutionHandler } from './create_execution_handler'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { KibanaRequest } from 'kibana/server'; @@ -34,7 +34,7 @@ const createExecutionHandlerParams = { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), alertType, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), eventLogger: eventLoggerMock.create(), actions: [ { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 690971bc870062..7a031c6671fd07 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -11,7 +11,7 @@ import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; import { alertsMock } from '../mocks'; @@ -66,7 +66,7 @@ describe('Task Runner', () => { getServices: jest.fn().mockReturnValue(services), actionsPlugin: actionsMock.createStart(), encryptedSavedObjectsClient, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), eventLogger: eventLoggerMock.create(), diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts index 7d9710d8a3e082..8f3e44b1cf42df 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts @@ -8,7 +8,7 @@ import sinon from 'sinon'; import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; import { alertsMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; @@ -57,7 +57,7 @@ describe('Task Runner Factory', () => { getServices: jest.fn().mockReturnValue(services), actionsPlugin: actionsMock.createStart(), encryptedSavedObjectsClient: encryptedSavedObjectsPlugin.getClient(), - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), getBasePath: jest.fn().mockReturnValue(undefined), eventLogger: eventLoggerMock.create(), diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts index 45bb7133e852ef..96e3ba826d2010 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.test.ts @@ -4,45 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getMlJobId, - getMlPrefix, - getMlJobServiceName, - getSeverity, - severity, -} from './ml_job_constants'; +import { getSeverity, severity } from './ml_job_constants'; describe('ml_job_constants', () => { - it('getMlPrefix', () => { - expect(getMlPrefix('myServiceName')).toBe('myservicename-'); - expect(getMlPrefix('myServiceName', 'myTransactionType')).toBe( - 'myservicename-mytransactiontype-' - ); - }); - - it('getMlJobId', () => { - expect(getMlJobId('myServiceName')).toBe( - 'myservicename-high_mean_response_time' - ); - expect(getMlJobId('myServiceName', 'myTransactionType')).toBe( - 'myservicename-mytransactiontype-high_mean_response_time' - ); - expect(getMlJobId('my service name')).toBe( - 'my_service_name-high_mean_response_time' - ); - expect(getMlJobId('my service name', 'my transaction type')).toBe( - 'my_service_name-my_transaction_type-high_mean_response_time' - ); - }); - - describe('getMlJobServiceName', () => { - it('extracts the service name from a job id', () => { - expect( - getMlJobServiceName('opbeans-node-request-high_mean_response_time') - ).toEqual('opbeans-node'); - }); - }); - describe('getSeverity', () => { describe('when score is undefined', () => { it('returns undefined', () => { diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/common/ml_job_constants.ts index f9b0119d8a107e..b8c2546bd0c84a 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.ts @@ -11,25 +11,6 @@ export enum severity { warning = 'warning', } -export const APM_ML_JOB_GROUP_NAME = 'apm'; - -export function getMlPrefix(serviceName: string, transactionType?: string) { - const maybeTransactionType = transactionType ? `${transactionType}-` : ''; - return encodeForMlApi(`${serviceName}-${maybeTransactionType}`); -} - -export function getMlJobId(serviceName: string, transactionType?: string) { - return `${getMlPrefix(serviceName, transactionType)}high_mean_response_time`; -} - -export function getMlJobServiceName(jobId: string) { - return jobId.split('-').slice(0, -2).join('-'); -} - -export function encodeForMlApi(value: string) { - return value.replace(/\s+/g, '_').toLowerCase(); -} - export function getSeverity(score?: number) { if (typeof score !== 'number') { return undefined; diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 7d7a7811eeba2c..43f3585d0ebb2e 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -34,16 +34,6 @@ export interface Connection { destination: ConnectionNode; } -export interface ServiceAnomaly { - anomaly_score: number; - anomaly_severity: string; - actual_value: number; - typical_value: number; - ml_job_id: string; -} - -export type ServiceNode = ConnectionNode & Partial; - export interface ServiceNodeMetrics { avgMemoryUsage: number | null; avgCpuUsage: number | null; diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx deleted file mode 100644 index 42f7246b6ea359..00000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx +++ /dev/null @@ -1,56 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSuperSelect, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface TransactionSelectProps { - transactionTypes: string[]; - onChange: (value: string) => void; - selectedTransactionType: string; -} - -export function TransactionSelect({ - transactionTypes, - onChange, - selectedTransactionType, -}: TransactionSelectProps) { - return ( - - { - return { - value: transactionType, - inputDisplay: transactionType, - dropdownDisplay: ( - - - {transactionType} - - - ), - }; - })} - /> - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx deleted file mode 100644 index 91778b2940c6b5..00000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx +++ /dev/null @@ -1,167 +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 { i18n } from '@kbn/i18n'; -import React, { Component } from 'react'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { startMLJob, MLError } from '../../../../../services/rest/ml'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { MachineLearningFlyoutView } from './view'; -import { ApmPluginContext } from '../../../../../context/ApmPluginContext'; - -interface Props { - isOpen: boolean; - onClose: () => void; - urlParams: IUrlParams; -} - -interface State { - isCreatingJob: boolean; -} - -export class MachineLearningFlyout extends Component { - static contextType = ApmPluginContext; - - public state: State = { - isCreatingJob: false, - }; - - public onClickCreate = async ({ - transactionType, - }: { - transactionType: string; - }) => { - this.setState({ isCreatingJob: true }); - try { - const { http } = this.context.core; - const { serviceName } = this.props.urlParams; - if (!serviceName) { - throw new Error('Service name is required to create this ML job'); - } - const res = await startMLJob({ http, serviceName, transactionType }); - const didSucceed = res.datafeeds[0].success && res.jobs[0].success; - if (!didSucceed) { - throw new Error('Creating ML job failed'); - } - this.addSuccessToast({ transactionType }); - } catch (e) { - this.addErrorToast(e as MLError); - } - - this.setState({ isCreatingJob: false }); - this.props.onClose(); - }; - - public addErrorToast = (error: MLError) => { - const { core } = this.context; - - const { urlParams } = this.props; - const { serviceName } = urlParams; - - if (!serviceName) { - return; - } - - const errorDescription = error?.body?.message; - const errorText = errorDescription - ? `${error.message}: ${errorDescription}` - : error.message; - - core.notifications.toasts.addWarning({ - title: i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle', - { - defaultMessage: 'Job creation failed', - } - ), - text: toMountPoint( - <> -

{errorText}

-

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText', - { - defaultMessage: - 'Your current license may not allow for creating machine learning jobs, or this job may already exist.', - } - )} -

- - ), - }); - }; - - public addSuccessToast = ({ - transactionType, - }: { - transactionType: string; - }) => { - const { core } = this.context; - const { urlParams } = this.props; - const { serviceName } = urlParams; - - if (!serviceName) { - return; - } - - core.notifications.toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle', - { - defaultMessage: 'Job successfully created', - } - ), - text: toMountPoint( -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText', - { - defaultMessage: - 'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.', - values: { - serviceName, - transactionType, - }, - } - )}{' '} - - - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText', - { - defaultMessage: 'View job', - } - )} - - -

- ), - }); - }; - - public render() { - const { isOpen, onClose, urlParams } = this.props; - const { serviceName } = urlParams; - const { isCreatingJob } = this.state; - - if (!isOpen || !serviceName) { - return null; - } - - return ( - - ); - } -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx deleted file mode 100644 index 72e8193ba2de27..00000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx +++ /dev/null @@ -1,264 +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 { - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiFormRow, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState, useEffect } from 'react'; -import { isEmpty } from 'lodash'; -import { FETCH_STATUS, useFetcher } from '../../../../../hooks/useFetcher'; -import { getHasMLJob } from '../../../../../services/rest/ml'; -import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { MLLink } from '../../../../shared/Links/MachineLearningLinks/MLLink'; -import { TransactionSelect } from './TransactionSelect'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { useServiceTransactionTypes } from '../../../../../hooks/useServiceTransactionTypes'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; - -interface Props { - isCreatingJob: boolean; - onClickCreate: ({ transactionType }: { transactionType: string }) => void; - onClose: () => void; - urlParams: IUrlParams; -} - -export function MachineLearningFlyoutView({ - isCreatingJob, - onClickCreate, - onClose, - urlParams, -}: Props) { - const { serviceName } = urlParams; - const transactionTypes = useServiceTransactionTypes(urlParams); - - const [selectedTransactionType, setSelectedTransactionType] = useState< - string | undefined - >(undefined); - - const { http } = useApmPluginContext().core; - - const { data: hasMLJob, status } = useFetcher( - () => { - if (serviceName && selectedTransactionType) { - return getHasMLJob({ - serviceName, - transactionType: selectedTransactionType, - http, - }); - } - }, - [serviceName, selectedTransactionType, http], - { showToastOnError: false } - ); - - // update selectedTransactionType when list of transaction types has loaded - useEffect(() => { - setSelectedTransactionType(transactionTypes[0]); - }, [transactionTypes]); - - if (!serviceName || !selectedTransactionType || isEmpty(transactionTypes)) { - return null; - } - - const isLoadingMLJob = status === FETCH_STATUS.LOADING; - const isMlAvailable = status !== FETCH_STATUS.FAILURE; - - return ( - - - -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle', - { - defaultMessage: 'Enable anomaly detection', - } - )} -

-
- -
- - {!isMlAvailable && ( -
- -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.mlNotAvailableDescription', - { - defaultMessage: - 'Unable to connect to Machine learning. Make sure it is enabled in Kibana to use anomaly detection.', - } - )} -

-
- -
- )} - {hasMLJob && ( -
- -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription', - { - defaultMessage: - 'There is currently a job running for {serviceName} ({transactionType}).', - values: { - serviceName, - transactionType: selectedTransactionType, - }, - } - )}{' '} - - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText', - { - defaultMessage: 'View existing job', - } - )} - -

-
- -
- )} - -

- - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText', - { - defaultMessage: 'transaction duration', - } - )} - - ), - serviceMapAnnotationText: ( - - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.serviceMapAnnotationText', - { - defaultMessage: 'service maps', - } - )} - - ), - }} - /> -

-

- - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText', - { - defaultMessage: 'Machine Learning Job Management page', - } - )} - - ), - }} - />{' '} - - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText', - { - defaultMessage: - 'Note: It might take a few minutes for the job to begin calculating results.', - } - )} - -

-
- - -
- - - - {transactionTypes.length > 1 ? ( - { - setSelectedTransactionType(value); - }} - /> - ) : null} - - - - - onClickCreate({ transactionType: selectedTransactionType }) - } - fill - disabled={isCreatingJob || hasMLJob || isLoadingMLJob} - > - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel', - { - defaultMessage: 'Create job', - } - )} - - - - - -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx index 321617ed8496af..0a7dcbd0be3dfc 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx @@ -4,18 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiContextMenu, - EuiContextMenuPanelItemDescriptor, - EuiPopover, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { memoize } from 'lodash'; -import React, { Fragment } from 'react'; +import React from 'react'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { LicenseContext } from '../../../../context/LicenseContext'; -import { MachineLearningFlyout } from './MachineLearningFlyout'; import { WatcherFlyout } from './WatcherFlyout'; import { ApmPluginContext } from '../../../../context/ApmPluginContext'; @@ -26,7 +18,7 @@ interface State { isPopoverOpen: boolean; activeFlyout: FlyoutName; } -type FlyoutName = null | 'ML' | 'Watcher'; +type FlyoutName = null | 'Watcher'; export class ServiceIntegrations extends React.Component { static contextType = ApmPluginContext; @@ -34,38 +26,6 @@ export class ServiceIntegrations extends React.Component { public state: State = { isPopoverOpen: false, activeFlyout: null }; - public getPanelItems = memoize((mlAvailable: boolean | undefined) => { - let panelItems: EuiContextMenuPanelItemDescriptor[] = []; - if (mlAvailable) { - panelItems = panelItems.concat(this.getMLPanelItems()); - } - return panelItems.concat(this.getWatcherPanelItems()); - }); - - public getMLPanelItems = () => { - return [ - { - name: i18n.translate( - 'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel', - { - defaultMessage: 'Enable ML anomaly detection', - } - ), - icon: 'machineLearningApp', - toolTipContent: i18n.translate( - 'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip', - { - defaultMessage: 'Set up a machine learning job for this service', - } - ), - onClick: () => { - this.closePopover(); - this.openFlyout('ML'); - }, - }, - ]; - }; - public getWatcherPanelItems = () => { const { core } = this.context; @@ -132,42 +92,31 @@ export class ServiceIntegrations extends React.Component { ); return ( - - {(license) => ( - - - - - - - - )} - + <> + + + + + ); } } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index ff68288916af47..78779bdcc2052e 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -15,8 +15,6 @@ import React, { MouseEvent } from 'react'; import { Buttons } from './Buttons'; import { Info } from './Info'; import { ServiceMetricFetcher } from './ServiceMetricFetcher'; -import { AnomalyDetection } from './anomaly_detection'; -import { ServiceNode } from '../../../../../common/service_map'; import { popoverMinWidth } from '../cytoscapeOptions'; interface ContentsProps { @@ -70,12 +68,13 @@ export function Contents({ - {isService && ( + {/* //TODO [APM ML] add service health stats here: + isService && ( - + - )} + )*/} {isService ? ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx deleted file mode 100644 index 531bbb139d58b6..00000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx +++ /dev/null @@ -1,157 +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 { i18n } from '@kbn/i18n'; -import React from 'react'; -import styled from 'styled-components'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiIconTip, - EuiHealth, -} from '@elastic/eui'; -import { useTheme } from '../../../../hooks/useTheme'; -import { fontSize, px } from '../../../../style/variables'; -import { asInteger } from '../../../../utils/formatters'; -import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { getSeverityColor, popoverMinWidth } from '../cytoscapeOptions'; -import { getMetricChangeDescription } from '../../../../../../ml/public'; -import { ServiceNode } from '../../../../../common/service_map'; - -const HealthStatusTitle = styled(EuiTitle)` - display: inline; - text-transform: uppercase; -`; - -const VerticallyCentered = styled.div` - display: flex; - align-items: center; -`; - -const SubduedText = styled.span` - color: ${({ theme }) => theme.eui.euiTextSubduedColor}; -`; - -const EnableText = styled.section` - color: ${({ theme }) => theme.eui.euiTextSubduedColor}; - line-height: 1.4; - font-size: ${fontSize}; - width: ${px(popoverMinWidth)}; -`; - -export const ContentLine = styled.section` - line-height: 2; -`; - -interface AnomalyDetectionProps { - serviceNodeData: cytoscape.NodeDataDefinition & ServiceNode; -} - -export function AnomalyDetection({ serviceNodeData }: AnomalyDetectionProps) { - const theme = useTheme(); - const anomalySeverity = serviceNodeData.anomaly_severity; - const anomalyScore = serviceNodeData.anomaly_score; - const actualValue = serviceNodeData.actual_value; - const typicalValue = serviceNodeData.typical_value; - const mlJobId = serviceNodeData.ml_job_id; - const hasAnomalyDetectionScore = - anomalySeverity !== undefined && anomalyScore !== undefined; - const anomalyDescription = - hasAnomalyDetectionScore && - actualValue !== undefined && - typicalValue !== undefined - ? getMetricChangeDescription(actualValue, typicalValue).message - : null; - - return ( - <> -
- -

{ANOMALY_DETECTION_TITLE}

-
-   - - {!mlJobId && {ANOMALY_DETECTION_DISABLED_TEXT}} -
- {hasAnomalyDetectionScore && ( - - - - - - {ANOMALY_DETECTION_SCORE_METRIC} - - - -
- {getDisplayedAnomalyScore(anomalyScore as number)} - {anomalyDescription && ( -  ({anomalyDescription}) - )} -
-
-
-
- )} - {mlJobId && !hasAnomalyDetectionScore && ( - {ANOMALY_DETECTION_NO_DATA_TEXT} - )} - {mlJobId && ( - - - {ANOMALY_DETECTION_LINK} - - - )} - - ); -} - -function getDisplayedAnomalyScore(score: number) { - if (score > 0 && score < 1) { - return '< 1'; - } - return asInteger(score); -} - -const ANOMALY_DETECTION_TITLE = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverTitle', - { defaultMessage: 'Anomaly Detection' } -); - -const ANOMALY_DETECTION_TOOLTIP = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverTooltip', - { - defaultMessage: - 'Service health indicators are powered by the anomaly detection feature in machine learning', - } -); - -const ANOMALY_DETECTION_SCORE_METRIC = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric', - { defaultMessage: 'Score (max.)' } -); - -const ANOMALY_DETECTION_LINK = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverLink', - { defaultMessage: 'View anomalies' } -); - -const ANOMALY_DETECTION_DISABLED_TEXT = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverDisabled', - { - defaultMessage: - 'Display service health indicators by enabling anomaly detection from the Integrations menu in the Service details view.', - } -); - -const ANOMALY_DETECTION_NO_DATA_TEXT = i18n.translate( - 'xpack.apm.serviceMap.anomalyDetectionPopoverNoData', - { - defaultMessage: `We couldn't find an anomaly score within the selected time range. See details in the anomaly explorer.`, - } -); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx index 28cb7a6f9d291d..aee392b53298a5 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx @@ -10,60 +10,63 @@ import cytoscape from 'cytoscape'; import React from 'react'; import { Cytoscape } from '../Cytoscape'; import { iconForNode } from '../icons'; +import { EuiThemeProvider } from '../../../../../../observability/public'; -storiesOf('app/ServiceMap/Cytoscape', module).add( - 'example', - () => { - const elements: cytoscape.ElementDefinition[] = [ - { - data: { - id: 'opbeans-python', - 'service.name': 'opbeans-python', - 'agent.name': 'python', - }, - }, - { - data: { - id: 'opbeans-node', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - }, - { - data: { - id: 'opbeans-ruby', - 'service.name': 'opbeans-ruby', - 'agent.name': 'ruby', - }, - }, - { data: { source: 'opbeans-python', target: 'opbeans-node' } }, - { - data: { - bidirectional: true, - source: 'opbeans-python', - target: 'opbeans-ruby', - }, - }, - ]; - const height = 300; - const width = 1340; - const serviceName = 'opbeans-python'; - return ( - - ); - }, - { - info: { - propTables: false, - source: false, +storiesOf('app/ServiceMap/Cytoscape', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'example', + () => { + const elements: cytoscape.ElementDefinition[] = [ + { + data: { + id: 'opbeans-python', + 'service.name': 'opbeans-python', + 'agent.name': 'python', + }, + }, + { + data: { + id: 'opbeans-node', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + }, + { + data: { + id: 'opbeans-ruby', + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + }, + }, + { data: { source: 'opbeans-python', target: 'opbeans-node' } }, + { + data: { + bidirectional: true, + source: 'opbeans-python', + target: 'opbeans-ruby', + }, + }, + ]; + const height = 300; + const width = 1340; + const serviceName = 'opbeans-python'; + return ( + + ); }, - } -); + { + info: { + propTables: false, + source: false, + }, + } + ); storiesOf('app/ServiceMap/Cytoscape', module).add( 'node icons', diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx index 3aced1b33dcacb..44278b2846128a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx @@ -6,23 +6,25 @@ /* eslint-disable no-console */ import { + EuiButton, + EuiCodeEditor, + EuiFieldNumber, + EuiFilePicker, EuiFlexGroup, EuiFlexItem, - EuiButton, EuiForm, - EuiFieldNumber, - EuiToolTip, - EuiCodeEditor, EuiSpacer, - EuiFilePicker, + EuiToolTip, } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { EuiThemeProvider } from '../../../../../../observability/public'; import { Cytoscape } from '../Cytoscape'; -import { generateServiceMapElements } from './generate_service_map_elements'; -import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; import exampleResponseHipsterStore from './example_response_hipster_store.json'; +import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; import exampleResponseTodo from './example_response_todo.json'; +import exampleResponseOneDomainManyIPs from './example_response_one_domain_many_ips.json'; +import { generateServiceMapElements } from './generate_service_map_elements'; const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; @@ -34,151 +36,155 @@ function setSessionJson(json: string) { window.sessionStorage.setItem(SESSION_STORAGE_KEY, json); } -storiesOf(STORYBOOK_PATH, module).add( - 'Generate map', - () => { - const [size, setSize] = useState(10); - const [json, setJson] = useState(''); - const [elements, setElements] = useState( - generateServiceMapElements(size) - ); - - return ( -
- - - { - setElements(generateServiceMapElements(size)); - setJson(''); - }} - > - Generate service map - - - - - setSize(e.target.valueAsNumber)} - /> - - - - { - setJson(JSON.stringify({ elements }, null, 2)); - }} - > - Get JSON - - - - - - - {json && ( - - )} -
- ); - }, - { - info: { propTables: false, source: false }, - } -); - -storiesOf(STORYBOOK_PATH, module).add( - 'Map from JSON', - () => { - const [json, setJson] = useState( - getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2) - ); - const [error, setError] = useState(); - - const [elements, setElements] = useState([]); - useEffect(() => { - try { - setElements(JSON.parse(json).elements); - } catch (e) { - setError(e.message); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( -
- - +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Generate map', + () => { + const [size, setSize] = useState(10); + const [json, setJson] = useState(''); + const [elements, setElements] = useState( + generateServiceMapElements(size) + ); + + return ( +
- { - setJson(value); + { + setElements(generateServiceMapElements(size)); + setJson(''); }} - /> + > + Generate service map + - - { - const item = event?.item(0); - - if (item) { - const f = new FileReader(); - f.onload = (onloadEvent) => { - const result = onloadEvent?.target?.result; - if (typeof result === 'string') { - setJson(result); - } - }; - f.readAsText(item); - } - }} + + setSize(e.target.valueAsNumber)} /> - - { - try { - setElements(JSON.parse(json).elements); - setSessionJson(json); - setError(undefined); - } catch (e) { - setError(e.message); - } - }} - > - Render JSON - - + + + + { + setJson(JSON.stringify({ elements }, null, 2)); + }} + > + Get JSON + - -
- ); - }, - { - info: { - propTables: false, - source: false, - text: ` + + + + {json && ( + + )} +
+ ); + }, + { + info: { propTables: false, source: false }, + } + ); + +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Map from JSON', + () => { + const [json, setJson] = useState( + getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2) + ); + const [error, setError] = useState(); + + const [elements, setElements] = useState([]); + useEffect(() => { + try { + setElements(JSON.parse(json).elements); + } catch (e) { + setError(e.message); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ + + + + { + setJson(value); + }} + /> + + + + { + const item = event?.item(0); + + if (item) { + const f = new FileReader(); + f.onload = (onloadEvent) => { + const result = onloadEvent?.target?.result; + if (typeof result === 'string') { + setJson(result); + } + }; + f.readAsText(item); + } + }} + /> + + { + try { + setElements(JSON.parse(json).elements); + setSessionJson(json); + setError(undefined); + } catch (e) { + setError(e.message); + } + }} + > + Render JSON + + + + + +
+ ); + }, + { + info: { + propTables: false, + source: false, + text: ` Enter JSON map data into the text box or upload a file and click "Render JSON" to see the results. You can enable a download button on the service map by putting \`\`\` @@ -186,60 +192,86 @@ storiesOf(STORYBOOK_PATH, module).add( \`\`\` into the JavaScript console and reloading the page.`, + }, + } + ); + +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Todo app', + () => { + return ( +
+ +
+ ); + }, + { + info: { propTables: false, source: false }, + } + ); + +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Opbeans + beats', + () => { + return ( +
+ +
+ ); + }, + { + info: { propTables: false, source: false }, + } + ); + +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Hipster store', + () => { + return ( +
+ +
+ ); + }, + { + info: { propTables: false, source: false }, + } + ); + +storiesOf(STORYBOOK_PATH, module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'Node resolves one domain name to many IPs', + () => { + return ( +
+ +
+ ); }, - } -); - -storiesOf(STORYBOOK_PATH, module).add( - 'Todo app', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } -); - -storiesOf(STORYBOOK_PATH, module).add( - 'Opbeans + beats', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } -); - -storiesOf(STORYBOOK_PATH, module).add( - 'Hipster store', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } -); + { + info: { propTables: false, source: false }, + } + ); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json new file mode 100644 index 00000000000000..f9b8a273d8577c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json @@ -0,0 +1,2122 @@ +{ + "elements": [ + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.99:80", + "id": "artifact_api~>192.0.2.99:80", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "http", + "span.destination.service.resource": "192.0.2.99:80", + "span.type": "external", + "id": ">192.0.2.99:80", + "label": ">192.0.2.99:80" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.47:443", + "id": "artifact_api~>192.0.2.47:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.47:443", + "span.type": "external", + "id": ">192.0.2.47:443", + "label": ">192.0.2.47:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.13:443", + "id": "artifact_api~>192.0.2.13:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.13:443", + "span.type": "external", + "id": ">192.0.2.13:443", + "label": ">192.0.2.13:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.106:443", + "id": "artifact_api~>192.0.2.106:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.106:443", + "span.type": "external", + "id": ">192.0.2.106:443", + "label": ">192.0.2.106:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.83:443", + "id": "artifact_api~>192.0.2.83:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.83:443", + "span.type": "external", + "id": ">192.0.2.83:443", + "label": ">192.0.2.83:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.111:443", + "id": "artifact_api~>192.0.2.111:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.111:443", + "span.type": "external", + "id": ">192.0.2.111:443", + "label": ">192.0.2.111:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.189:443", + "id": "artifact_api~>192.0.2.189:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.189:443", + "span.type": "external", + "id": ">192.0.2.189:443", + "label": ">192.0.2.189:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.148:443", + "id": "artifact_api~>192.0.2.148:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.148:443", + "span.type": "external", + "id": ">192.0.2.148:443", + "label": ">192.0.2.148:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.39:443", + "id": "artifact_api~>192.0.2.39:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.39:443", + "span.type": "external", + "id": ">192.0.2.39:443", + "label": ">192.0.2.39:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.42:443", + "id": "artifact_api~>192.0.2.42:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.42:443", + "span.type": "external", + "id": ">192.0.2.42:443", + "label": ">192.0.2.42:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.240:443", + "id": "artifact_api~>192.0.2.240:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.240:443", + "span.type": "external", + "id": ">192.0.2.240:443", + "label": ">192.0.2.240:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.156:443", + "id": "artifact_api~>192.0.2.156:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.156:443", + "span.type": "external", + "id": ">192.0.2.156:443", + "label": ">192.0.2.156:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.245:443", + "id": "artifact_api~>192.0.2.245:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.245:443", + "span.type": "external", + "id": ">192.0.2.245:443", + "label": ">192.0.2.245:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.198:443", + "id": "artifact_api~>192.0.2.198:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.198:443", + "span.type": "external", + "id": ">192.0.2.198:443", + "label": ">192.0.2.198:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.77:443", + "id": "artifact_api~>192.0.2.77:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.77:443", + "span.type": "external", + "id": ">192.0.2.77:443", + "label": ">192.0.2.77:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.8:443", + "id": "artifact_api~>192.0.2.8:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.8:443", + "span.type": "external", + "id": ">192.0.2.8:443", + "label": ">192.0.2.8:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.69:443", + "id": "artifact_api~>192.0.2.69:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.69:443", + "span.type": "external", + "id": ">192.0.2.69:443", + "label": ">192.0.2.69:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.5:443", + "id": "artifact_api~>192.0.2.5:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.5:443", + "span.type": "external", + "id": ">192.0.2.5:443", + "label": ">192.0.2.5:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.139:443", + "id": "artifact_api~>192.0.2.139:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.139:443", + "span.type": "external", + "id": ">192.0.2.139:443", + "label": ">192.0.2.139:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.113:443", + "id": "artifact_api~>192.0.2.113:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.113:443", + "span.type": "external", + "id": ">192.0.2.113:443", + "label": ">192.0.2.113:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.2:443", + "id": "artifact_api~>192.0.2.2:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.2:443", + "span.type": "external", + "id": ">192.0.2.2:443", + "label": ">192.0.2.2:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.213:443", + "id": "artifact_api~>192.0.2.213:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.213:443", + "span.type": "external", + "id": ">192.0.2.213:443", + "label": ">192.0.2.213:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.153:443", + "id": "artifact_api~>192.0.2.153:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.153:443", + "span.type": "external", + "id": ">192.0.2.153:443", + "label": ">192.0.2.153:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.36:443", + "id": "artifact_api~>192.0.2.36:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.36:443", + "span.type": "external", + "id": ">192.0.2.36:443", + "label": ">192.0.2.36:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.164:443", + "id": "artifact_api~>192.0.2.164:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.164:443", + "span.type": "external", + "id": ">192.0.2.164:443", + "label": ">192.0.2.164:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.190:443", + "id": "artifact_api~>192.0.2.190:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.190:443", + "span.type": "external", + "id": ">192.0.2.190:443", + "label": ">192.0.2.190:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.9:443", + "id": "artifact_api~>192.0.2.9:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.9:443", + "span.type": "external", + "id": ">192.0.2.9:443", + "label": ">192.0.2.9:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.210:443", + "id": "artifact_api~>192.0.2.210:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.210:443", + "span.type": "external", + "id": ">192.0.2.210:443", + "label": ">192.0.2.210:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.21:443", + "id": "artifact_api~>192.0.2.21:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.21:443", + "span.type": "external", + "id": ">192.0.2.21:443", + "label": ">192.0.2.21:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.176:443", + "id": "artifact_api~>192.0.2.176:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.176:443", + "span.type": "external", + "id": ">192.0.2.176:443", + "label": ">192.0.2.176:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.81:443", + "id": "artifact_api~>192.0.2.81:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.81:443", + "span.type": "external", + "id": ">192.0.2.81:443", + "label": ">192.0.2.81:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.118:443", + "id": "artifact_api~>192.0.2.118:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.118:443", + "span.type": "external", + "id": ">192.0.2.118:443", + "label": ">192.0.2.118:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.103:443", + "id": "artifact_api~>192.0.2.103:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.103:443", + "span.type": "external", + "id": ">192.0.2.103:443", + "label": ">192.0.2.103:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.3:443", + "id": "artifact_api~>192.0.2.3:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.3:443", + "span.type": "external", + "id": ">192.0.2.3:443", + "label": ">192.0.2.3:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.135:443", + "id": "artifact_api~>192.0.2.135:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.135:443", + "span.type": "external", + "id": ">192.0.2.135:443", + "label": ">192.0.2.135:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.26:443", + "id": "artifact_api~>192.0.2.26:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.26:443", + "span.type": "external", + "id": ">192.0.2.26:443", + "label": ">192.0.2.26:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.185:443", + "id": "artifact_api~>192.0.2.185:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.185:443", + "span.type": "external", + "id": ">192.0.2.185:443", + "label": ">192.0.2.185:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.173:443", + "id": "artifact_api~>192.0.2.173:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.173:443", + "span.type": "external", + "id": ">192.0.2.173:443", + "label": ">192.0.2.173:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.45:443", + "id": "artifact_api~>192.0.2.45:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.45:443", + "span.type": "external", + "id": ">192.0.2.45:443", + "label": ">192.0.2.45:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.144:443", + "id": "artifact_api~>192.0.2.144:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.144:443", + "span.type": "external", + "id": ">192.0.2.144:443", + "label": ">192.0.2.144:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.165:443", + "id": "artifact_api~>192.0.2.165:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.165:443", + "span.type": "external", + "id": ">192.0.2.165:443", + "label": ">192.0.2.165:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.119:443", + "id": "artifact_api~>192.0.2.119:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.119:443", + "span.type": "external", + "id": ">192.0.2.119:443", + "label": ">192.0.2.119:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.186:443", + "id": "artifact_api~>192.0.2.186:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.186:443", + "span.type": "external", + "id": ">192.0.2.186:443", + "label": ">192.0.2.186:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.54:443", + "id": "artifact_api~>192.0.2.54:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.54:443", + "span.type": "external", + "id": ">192.0.2.54:443", + "label": ">192.0.2.54:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.23:443", + "id": "artifact_api~>192.0.2.23:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.23:443", + "span.type": "external", + "id": ">192.0.2.23:443", + "label": ">192.0.2.23:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.34:443", + "id": "artifact_api~>192.0.2.34:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.34:443", + "span.type": "external", + "id": ">192.0.2.34:443", + "label": ">192.0.2.34:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.169:443", + "id": "artifact_api~>192.0.2.169:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.169:443", + "span.type": "external", + "id": ">192.0.2.169:443", + "label": ">192.0.2.169:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.226:443", + "id": "artifact_api~>192.0.2.226:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.226:443", + "span.type": "external", + "id": ">192.0.2.226:443", + "label": ">192.0.2.226:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.82:443", + "id": "artifact_api~>192.0.2.82:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.82:443", + "span.type": "external", + "id": ">192.0.2.82:443", + "label": ">192.0.2.82:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.132:443", + "id": "artifact_api~>192.0.2.132:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.132:443", + "span.type": "external", + "id": ">192.0.2.132:443", + "label": ">192.0.2.132:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.78:443", + "id": "artifact_api~>192.0.2.78:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.78:443", + "span.type": "external", + "id": ">192.0.2.78:443", + "label": ">192.0.2.78:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.71:443", + "id": "artifact_api~>192.0.2.71:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.71:443", + "span.type": "external", + "id": ">192.0.2.71:443", + "label": ">192.0.2.71:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.48:443", + "id": "artifact_api~>192.0.2.48:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.48:443", + "span.type": "external", + "id": ">192.0.2.48:443", + "label": ">192.0.2.48:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.107:443", + "id": "artifact_api~>192.0.2.107:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.107:443", + "span.type": "external", + "id": ">192.0.2.107:443", + "label": ">192.0.2.107:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.239:443", + "id": "artifact_api~>192.0.2.239:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.239:443", + "span.type": "external", + "id": ">192.0.2.239:443", + "label": ">192.0.2.239:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.209:443", + "id": "artifact_api~>192.0.2.209:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.209:443", + "span.type": "external", + "id": ">192.0.2.209:443", + "label": ">192.0.2.209:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.248:443", + "id": "artifact_api~>192.0.2.248:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.248:443", + "span.type": "external", + "id": ">192.0.2.248:443", + "label": ">192.0.2.248:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.18:443", + "id": "artifact_api~>192.0.2.18:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.18:443", + "span.type": "external", + "id": ">192.0.2.18:443", + "label": ">192.0.2.18:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.228:443", + "id": "artifact_api~>192.0.2.228:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.228:443", + "span.type": "external", + "id": ">192.0.2.228:443", + "label": ">192.0.2.228:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.145:443", + "id": "artifact_api~>192.0.2.145:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.145:443", + "span.type": "external", + "id": ">192.0.2.145:443", + "label": ">192.0.2.145:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.25:443", + "id": "artifact_api~>192.0.2.25:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.25:443", + "span.type": "external", + "id": ">192.0.2.25:443", + "label": ">192.0.2.25:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.162:443", + "id": "artifact_api~>192.0.2.162:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.162:443", + "span.type": "external", + "id": ">192.0.2.162:443", + "label": ">192.0.2.162:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.202:443", + "id": "artifact_api~>192.0.2.202:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.202:443", + "span.type": "external", + "id": ">192.0.2.202:443", + "label": ">192.0.2.202:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.60:443", + "id": "artifact_api~>192.0.2.60:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.60:443", + "span.type": "external", + "id": ">192.0.2.60:443", + "label": ">192.0.2.60:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.59:443", + "id": "artifact_api~>192.0.2.59:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.59:443", + "span.type": "external", + "id": ">192.0.2.59:443", + "label": ">192.0.2.59:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.114:443", + "id": "artifact_api~>192.0.2.114:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.114:443", + "span.type": "external", + "id": ">192.0.2.114:443", + "label": ">192.0.2.114:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.215:443", + "id": "artifact_api~>192.0.2.215:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.215:443", + "span.type": "external", + "id": ">192.0.2.215:443", + "label": ">192.0.2.215:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.238:443", + "id": "artifact_api~>192.0.2.238:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.238:443", + "span.type": "external", + "id": ">192.0.2.238:443", + "label": ">192.0.2.238:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.160:443", + "id": "artifact_api~>192.0.2.160:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.160:443", + "span.type": "external", + "id": ">192.0.2.160:443", + "label": ">192.0.2.160:443" + } + } + }, + { + "data": { + "source": "artifact_api", + "target": ">192.0.2.70:443", + "id": "artifact_api~>192.0.2.70:443", + "sourceData": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + }, + "targetData": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.70:443", + "span.type": "external", + "id": ">192.0.2.70:443", + "label": ">192.0.2.70:443" + } + } + }, + { + "data": { + "id": "artifact_api", + "service.environment": "development", + "service.name": "artifact_api", + "agent.name": "nodejs", + "service.framework.name": "express" + } + }, + { + "data": { + "span.subtype": "http", + "span.destination.service.resource": "192.0.2.99:80", + "span.type": "external", + "id": ">192.0.2.99:80", + "label": ">192.0.2.99:80" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.186:443", + "span.type": "external", + "id": ">192.0.2.186:443", + "label": ">192.0.2.186:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.78:443", + "span.type": "external", + "id": ">192.0.2.78:443", + "label": ">192.0.2.78:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.226:443", + "span.type": "external", + "id": ">192.0.2.226:443", + "label": ">192.0.2.226:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.245:443", + "span.type": "external", + "id": ">192.0.2.245:443", + "label": ">192.0.2.245:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.77:443", + "span.type": "external", + "id": ">192.0.2.77:443", + "label": ">192.0.2.77:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.2:443", + "span.type": "external", + "id": ">192.0.2.2:443", + "label": ">192.0.2.2:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.198:443", + "span.type": "external", + "id": ">192.0.2.198:443", + "label": ">192.0.2.198:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.113:443", + "span.type": "external", + "id": ">192.0.2.113:443", + "label": ">192.0.2.113:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.39:443", + "span.type": "external", + "id": ">192.0.2.39:443", + "label": ">192.0.2.39:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.83:443", + "span.type": "external", + "id": ">192.0.2.83:443", + "label": ">192.0.2.83:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.5:443", + "span.type": "external", + "id": ">192.0.2.5:443", + "label": ">192.0.2.5:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.165:443", + "span.type": "external", + "id": ">192.0.2.165:443", + "label": ">192.0.2.165:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.156:443", + "span.type": "external", + "id": ">192.0.2.156:443", + "label": ">192.0.2.156:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.132:443", + "span.type": "external", + "id": ">192.0.2.132:443", + "label": ">192.0.2.132:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.240:443", + "span.type": "external", + "id": ">192.0.2.240:443", + "label": ">192.0.2.240:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.54:443", + "span.type": "external", + "id": ">192.0.2.54:443", + "label": ">192.0.2.54:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.213:443", + "span.type": "external", + "id": ">192.0.2.213:443", + "label": ">192.0.2.213:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.81:443", + "span.type": "external", + "id": ">192.0.2.81:443", + "label": ">192.0.2.81:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.176:443", + "span.type": "external", + "id": ">192.0.2.176:443", + "label": ">192.0.2.176:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.82:443", + "span.type": "external", + "id": ">192.0.2.82:443", + "label": ">192.0.2.82:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.23:443", + "span.type": "external", + "id": ">192.0.2.23:443", + "label": ">192.0.2.23:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.189:443", + "span.type": "external", + "id": ">192.0.2.189:443", + "label": ">192.0.2.189:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.190:443", + "span.type": "external", + "id": ">192.0.2.190:443", + "label": ">192.0.2.190:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.119:443", + "span.type": "external", + "id": ">192.0.2.119:443", + "label": ">192.0.2.119:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.169:443", + "span.type": "external", + "id": ">192.0.2.169:443", + "label": ">192.0.2.169:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.210:443", + "span.type": "external", + "id": ">192.0.2.210:443", + "label": ">192.0.2.210:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.148:443", + "span.type": "external", + "id": ">192.0.2.148:443", + "label": ">192.0.2.148:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.26:443", + "span.type": "external", + "id": ">192.0.2.26:443", + "label": ">192.0.2.26:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.139:443", + "span.type": "external", + "id": ">192.0.2.139:443", + "label": ">192.0.2.139:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.111:443", + "span.type": "external", + "id": ">192.0.2.111:443", + "label": ">192.0.2.111:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.13:443", + "span.type": "external", + "id": ">192.0.2.13:443", + "label": ">192.0.2.13:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.36:443", + "span.type": "external", + "id": ">192.0.2.36:443", + "label": ">192.0.2.36:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.69:443", + "span.type": "external", + "id": ">192.0.2.69:443", + "label": ">192.0.2.69:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.173:443", + "span.type": "external", + "id": ">192.0.2.173:443", + "label": ">192.0.2.173:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.144:443", + "span.type": "external", + "id": ">192.0.2.144:443", + "label": ">192.0.2.144:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.135:443", + "span.type": "external", + "id": ">192.0.2.135:443", + "label": ">192.0.2.135:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.21:443", + "span.type": "external", + "id": ">192.0.2.21:443", + "label": ">192.0.2.21:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.118:443", + "span.type": "external", + "id": ">192.0.2.118:443", + "label": ">192.0.2.118:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.42:443", + "span.type": "external", + "id": ">192.0.2.42:443", + "label": ">192.0.2.42:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.106:443", + "span.type": "external", + "id": ">192.0.2.106:443", + "label": ">192.0.2.106:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.3:443", + "span.type": "external", + "id": ">192.0.2.3:443", + "label": ">192.0.2.3:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.34:443", + "span.type": "external", + "id": ">192.0.2.34:443", + "label": ">192.0.2.34:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.185:443", + "span.type": "external", + "id": ">192.0.2.185:443", + "label": ">192.0.2.185:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.153:443", + "span.type": "external", + "id": ">192.0.2.153:443", + "label": ">192.0.2.153:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.9:443", + "span.type": "external", + "id": ">192.0.2.9:443", + "label": ">192.0.2.9:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.164:443", + "span.type": "external", + "id": ">192.0.2.164:443", + "label": ">192.0.2.164:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.47:443", + "span.type": "external", + "id": ">192.0.2.47:443", + "label": ">192.0.2.47:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.45:443", + "span.type": "external", + "id": ">192.0.2.45:443", + "label": ">192.0.2.45:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.8:443", + "span.type": "external", + "id": ">192.0.2.8:443", + "label": ">192.0.2.8:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.103:443", + "span.type": "external", + "id": ">192.0.2.103:443", + "label": ">192.0.2.103:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.60:443", + "span.type": "external", + "id": ">192.0.2.60:443", + "label": ">192.0.2.60:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.202:443", + "span.type": "external", + "id": ">192.0.2.202:443", + "label": ">192.0.2.202:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.70:443", + "span.type": "external", + "id": ">192.0.2.70:443", + "label": ">192.0.2.70:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.114:443", + "span.type": "external", + "id": ">192.0.2.114:443", + "label": ">192.0.2.114:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.25:443", + "span.type": "external", + "id": ">192.0.2.25:443", + "label": ">192.0.2.25:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.209:443", + "span.type": "external", + "id": ">192.0.2.209:443", + "label": ">192.0.2.209:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.248:443", + "span.type": "external", + "id": ">192.0.2.248:443", + "label": ">192.0.2.248:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.18:443", + "span.type": "external", + "id": ">192.0.2.18:443", + "label": ">192.0.2.18:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.107:443", + "span.type": "external", + "id": ">192.0.2.107:443", + "label": ">192.0.2.107:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.160:443", + "span.type": "external", + "id": ">192.0.2.160:443", + "label": ">192.0.2.160:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.228:443", + "span.type": "external", + "id": ">192.0.2.228:443", + "label": ">192.0.2.228:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.215:443", + "span.type": "external", + "id": ">192.0.2.215:443", + "label": ">192.0.2.215:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.162:443", + "span.type": "external", + "id": ">192.0.2.162:443", + "label": ">192.0.2.162:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.238:443", + "span.type": "external", + "id": ">192.0.2.238:443", + "label": ">192.0.2.238:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.145:443", + "span.type": "external", + "id": ">192.0.2.145:443", + "label": ">192.0.2.145:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.239:443", + "span.type": "external", + "id": ">192.0.2.239:443", + "label": ">192.0.2.239:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.59:443", + "span.type": "external", + "id": ">192.0.2.59:443", + "label": ">192.0.2.59:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.71:443", + "span.type": "external", + "id": ">192.0.2.71:443", + "label": ">192.0.2.71:443" + } + }, + { + "data": { + "span.subtype": "https", + "span.destination.service.resource": "192.0.2.48:443", + "span.type": "external", + "id": ">192.0.2.48:443", + "label": ">192.0.2.48:443" + } + }, + { + "data": { + "service.name": "graphics-worker", + "agent.name": "nodejs", + "service.environment": null, + "service.framework.name": null, + "id": "graphics-worker" + } + } + ] +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 7c69fb28d668fd..920ef39e84ca32 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -21,13 +21,13 @@ import { ApmPluginContext, ApmPluginContextValue, } from '../../../../../context/ApmPluginContext'; +import { EuiThemeProvider } from '../../../../../../../observability/public'; storiesOf( 'app/Settings/AgentConfigurations/AgentConfigurationCreateEdit', module -).add( - 'with config', - () => { +) + .addDecorator((storyFn) => { const httpMock = {}; // mock @@ -40,10 +40,21 @@ storiesOf( }, }, }; + return ( - + + + {storyFn()} + + + ); + }) + .add( + 'with config', + () => { + return ( - - ); - }, - { - info: { - source: false, + ); }, - } -); + { + info: { + source: false, + }, + } + ); diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx index cb6003c58e90d1..cdebb3aac129b6 100644 --- a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx @@ -12,11 +12,19 @@ import { useUrlParams } from '../../../hooks/useUrlParams'; import { useTrackPageview } from '../../../../../observability/public'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { PROJECTION } from '../../../../common/projections/typings'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; + +type TracesAPIResponse = APIReturnType<'/api/apm/traces'>; +const DEFAULT_RESPONSE: TracesAPIResponse = { + items: [], + isAggregationAccurate: true, + bucketSize: 0, +}; export function TraceOverview() { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; - const { status, data = [] } = useFetcher( + const { status, data = DEFAULT_RESPONSE } = useFetcher( (callApmApi) => { if (start && end) { return callApmApi({ @@ -56,7 +64,7 @@ export function TraceOverview() { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 9018fbb2bc410b..a1e01b61d5c1b5 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -11,19 +11,22 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, + EuiCallOut, + EuiCode, } from '@elastic/eui'; import { Location } from 'history'; +import { FormattedMessage } from '@kbn/i18n/react'; import { first } from 'lodash'; import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { TransactionCharts } from '../../shared/charts/TransactionCharts'; import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; import { TransactionList } from './List'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { useRedirect } from './useRedirect'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { getHasMLJob } from '../../../services/rest/ml'; import { history } from '../../../utils/history'; import { useLocation } from '../../../hooks/useLocation'; import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; @@ -34,7 +37,6 @@ import { PROJECTION } from '../../../../common/projections/typings'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; -import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; function getRedirectLocation({ urlParams, @@ -86,18 +88,6 @@ export function TransactionOverview() { status: transactionListStatus, } = useTransactionList(urlParams); - const { http } = useApmPluginContext().core; - - const { data: hasMLJob = false } = useFetcher( - () => { - if (serviceName && transactionType) { - return getHasMLJob({ serviceName, transactionType, http }); - } - }, - [http, serviceName, transactionType], - { showToastOnError: false } - ); - const localFiltersConfig: React.ComponentProps = useMemo( () => ({ filterNames: [ @@ -140,7 +130,8 @@ export function TransactionOverview() { Transactions + {!transactionListData.isAggregationAccurate && ( + +

+ + xpack.apm.ui.transactionGroupBucketSize + + ), + }} + /> + + + {i18n.translate( + 'xpack.apm.transactionCardinalityWarning.docsLink', + { defaultMessage: 'Learn more in the docs' } + )} + +

+
+ )} +
diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 9065f20ae600e6..b4cf3a65fea355 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -10,21 +10,6 @@ import { getRenderedHref } from '../../../../utils/testHelpers'; import { MLJobLink } from './MLJobLink'; describe('MLJobLink', () => { - it('should produce the correct URL with serviceName', async () => { - const href = await getRenderedHref( - () => ( - - ), - { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location - ); - - expect(href).toEqual( - `/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` - ); - }); it('should produce the correct URL with jobId', async () => { const href = await getRenderedHref( () => ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx index 346748964d529b..1e1f9ea5f23b72 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx @@ -5,28 +5,16 @@ */ import React from 'react'; -import { getMlJobId } from '../../../../../common/ml_job_constants'; import { MLLink } from './MLLink'; -interface PropsServiceName { - serviceName: string; - transactionType?: string; -} -interface PropsJobId { +interface Props { jobId: string; -} - -type Props = (PropsServiceName | PropsJobId) & { external?: boolean; -}; +} export const MLJobLink: React.FC = (props) => { - const jobId = - 'jobId' in props - ? props.jobId - : getMlJobId(props.serviceName, props.transactionType); const query = { - ml: { jobIds: [jobId] }, + ml: { jobIds: [props.jobId] }, }; return ( diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx index 0afed6c3c1fa8c..2cb3696f880027 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -5,7 +5,6 @@ */ import React, { useMemo } from 'react'; -import numeral from '@elastic/numeral'; import { throttle } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; @@ -21,7 +20,7 @@ interface Props { } const tickFormatY = (y: Maybe) => { - return numeral(y || 0).format('0 %'); + return asPercent(y ?? 0, 1); }; const formatTooltipValue = (coordinate: Coordinate) => { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx index eda8f19c949a3d..3898679f835371 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx @@ -13,7 +13,7 @@ import { EuiIcon, } from '@elastic/eui'; import styled from 'styled-components'; -import { FORMATTERS, InfraFormatterType } from '../../../../../infra/public'; +import { asPercent } from '../../../utils/formatters'; interface TransactionBreakdownKpi { name: string; @@ -65,9 +65,7 @@ const TransactionBreakdownKpiList: React.FC = ({ kpis }) => { - - {FORMATTERS[InfraFormatterType.percent](kpi.percentage)} - + {asPercent(kpi.percentage, 1)} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 4821e06419e341..00ff6f9969725a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -101,11 +101,13 @@ export class TransactionCharts extends Component { return null; } - const { serviceName, transactionType, kuery } = this.props.urlParams; + const { serviceName, kuery } = this.props.urlParams; if (!serviceName) { return null; } + const linkedJobId = ''; // TODO [APM ML] link to ML job id for the selected environment + const hasKuery = !isEmpty(kuery); const icon = hasKuery ? ( { } )}{' '} - - View Job - + View Job ); diff --git a/x-pack/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/plugins/apm/public/hooks/useTransactionList.ts index 202437ae722572..ed6bb9309a557c 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionList.ts @@ -8,8 +8,7 @@ import { useMemo } from 'react'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionGroupListAPIResponse } from '../../server/lib/transaction_groups'; +import { APIReturnType } from '../services/rest/createCallApmApi'; const getRelativeImpact = ( impact: number, @@ -21,7 +20,11 @@ const getRelativeImpact = ( 1 ); -function getWithRelativeImpact(items: TransactionGroupListAPIResponse) { +type TransactionsAPIResponse = APIReturnType< + '/api/apm/services/{serviceName}/transaction_groups' +>; + +function getWithRelativeImpact(items: TransactionsAPIResponse['items']) { const impacts = items .map(({ impact }) => impact) .filter((impact) => impact !== null) as number[]; @@ -40,10 +43,16 @@ function getWithRelativeImpact(items: TransactionGroupListAPIResponse) { }); } +const DEFAULT_RESPONSE: TransactionsAPIResponse = { + items: [], + isAggregationAccurate: true, + bucketSize: 0, +}; + export function useTransactionList(urlParams: IUrlParams) { const { serviceName, transactionType, start, end } = urlParams; const uiFilters = useUiFilters(urlParams); - const { data = [], error, status } = useFetcher( + const { data = DEFAULT_RESPONSE, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType) { return callApmApi({ @@ -63,7 +72,14 @@ export function useTransactionList(urlParams: IUrlParams) { [serviceName, start, end, transactionType, uiFilters] ); - const memoizedData = useMemo(() => getWithRelativeImpact(data), [data]); + const memoizedData = useMemo( + () => ({ + items: getWithRelativeImpact(data.items), + isAggregationAccurate: data.isAggregationAccurate, + bucketSize: data.bucketSize, + }), + [data] + ); return { data: memoizedData, status, diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index 44768c94f3b1df..8babc72ef129ce 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -8,7 +8,7 @@ import { callApi, FetchOptions } from './callApi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { APMAPI } from '../../../server/routes/create_apm_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Client } from '../../../server/routes/typings'; +import { Client, HttpMethod } from '../../../server/routes/typings'; export type APMClient = Client; export type APMClientOptions = Omit & { @@ -43,3 +43,11 @@ export function createCallApmApi(http: HttpSetup) { }); }) as APMClient; } + +// infer return type from API +export type APIReturnType< + TPath extends keyof APMAPI['_S'], + TMethod extends HttpMethod = 'GET' +> = APMAPI['_S'][TPath] extends { [key in TMethod]: { ret: any } } + ? APMAPI['_S'][TPath][TMethod]['ret'] + : unknown; diff --git a/x-pack/plugins/apm/public/services/rest/ml.ts b/x-pack/plugins/apm/public/services/rest/ml.ts deleted file mode 100644 index 47032501d9fbe1..00000000000000 --- a/x-pack/plugins/apm/public/services/rest/ml.ts +++ /dev/null @@ -1,123 +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 { HttpSetup } from 'kibana/public'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_TYPE, -} from '../../../common/elasticsearch_fieldnames'; -import { - APM_ML_JOB_GROUP_NAME, - getMlJobId, - getMlPrefix, - encodeForMlApi, -} from '../../../common/ml_job_constants'; -import { callApi } from './callApi'; -import { ESFilter } from '../../../typings/elasticsearch'; -import { callApmApi } from './createCallApmApi'; - -interface MlResponseItem { - id: string; - success: boolean; - error?: { - msg: string; - body: string; - path: string; - response: string; - statusCode: number; - }; -} - -interface StartedMLJobApiResponse { - datafeeds: MlResponseItem[]; - jobs: MlResponseItem[]; -} - -async function getTransactionIndices() { - const indices = await callApmApi({ - method: 'GET', - pathname: `/api/apm/settings/apm-indices`, - }); - return indices['apm_oss.transactionIndices']; -} - -export async function startMLJob({ - serviceName, - transactionType, - http, -}: { - serviceName: string; - transactionType: string; - http: HttpSetup; -}) { - const transactionIndices = await getTransactionIndices(); - const groups = [ - APM_ML_JOB_GROUP_NAME, - encodeForMlApi(serviceName), - encodeForMlApi(transactionType), - ]; - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - ]; - return callApi(http, { - method: 'POST', - pathname: `/api/ml/modules/setup/apm_transaction`, - body: { - prefix: getMlPrefix(serviceName, transactionType), - groups, - indexPatternName: transactionIndices, - startDatafeed: true, - query: { - bool: { - filter, - }, - }, - }, - }); -} - -// https://www.elastic.co/guide/en/elasticsearch/reference/6.5/ml-get-job.html -export interface MLJobApiResponse { - count: number; - jobs: Array<{ - job_id: string; - }>; -} - -export type MLError = Error & { body?: { message?: string } }; - -export async function getHasMLJob({ - serviceName, - transactionType, - http, -}: { - serviceName: string; - transactionType: string; - http: HttpSetup; -}) { - try { - await callApi(http, { - method: 'GET', - pathname: `/api/ml/anomaly_detectors/${getMlJobId( - serviceName, - transactionType - )}`, - }); - return true; - } catch (error) { - if ( - error?.body?.statusCode === 404 && - error?.body?.attributes?.body?.error?.type === - 'resource_not_found_exception' - ) { - return false; // false only if ML api responds with resource_not_found_exception - } - throw error; - } -} diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts index f6ed88a850a5b2..66101baf3a7461 100644 --- a/x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts +++ b/x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts @@ -7,12 +7,16 @@ import { asPercent } from '../formatters'; describe('formatters', () => { describe('asPercent', () => { - it('should divide and format item as percent', () => { - expect(asPercent(3725, 10000, 'n/a')).toEqual('37.3%'); + it('should format as integer when number is above 10', () => { + expect(asPercent(3725, 10000, 'n/a')).toEqual('37%'); + }); + + it('should add a decimal when value is below 10', () => { + expect(asPercent(0.092, 1)).toEqual('9.2%'); }); it('should format when numerator is 0', () => { - expect(asPercent(0, 1, 'n/a')).toEqual('0.0%'); + expect(asPercent(0, 1, 'n/a')).toEqual('0%'); }); it('should return fallback when denominator is undefined', () => { diff --git a/x-pack/plugins/apm/public/utils/formatters/formatters.ts b/x-pack/plugins/apm/public/utils/formatters/formatters.ts index 9fdac85c7154ff..649f11063b149a 100644 --- a/x-pack/plugins/apm/public/utils/formatters/formatters.ts +++ b/x-pack/plugins/apm/public/utils/formatters/formatters.ts @@ -34,5 +34,13 @@ export function asPercent( } const decimal = numerator / denominator; + + // 33.2 => 33% + // 3.32 => 3.3% + // 0 => 0% + if (Math.abs(decimal) >= 0.1 || decimal === 0) { + return numeral(decimal).format('0%'); + } + return numeral(decimal).format('0.0%'); } diff --git a/x-pack/plugins/apm/scripts/aggregate-latency-metrics.js b/x-pack/plugins/apm/scripts/aggregate-latency-metrics.js new file mode 100644 index 00000000000000..287f267343b115 --- /dev/null +++ b/x-pack/plugins/apm/scripts/aggregate-latency-metrics.js @@ -0,0 +1,31 @@ +/* + * 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. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator', + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }], + ], +}); + +const { + aggregateLatencyMetrics, +} = require('./aggregate-latency-metrics/index.ts'); + +aggregateLatencyMetrics().catch((err) => { + if (err.meta && err.meta.body) { + // error from elasticsearch client + console.error(err.meta.body); + } else { + console.error(err); + } + process.exit(1); +}); diff --git a/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts new file mode 100644 index 00000000000000..6bc370be903df9 --- /dev/null +++ b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts @@ -0,0 +1,444 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { argv } from 'yargs'; +import pLimit from 'p-limit'; +import pRetry from 'p-retry'; +import { parse, format } from 'url'; +import { unique, without, set, merge, flatten } from 'lodash'; +import * as histogram from 'hdr-histogram-js'; +import { ESSearchResponse } from '../../typings/elasticsearch'; +import { + HOST_NAME, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, + AGENT_NAME, + SERVICE_ENVIRONMENT, + POD_NAME, + CONTAINER_ID, + SERVICE_VERSION, + TRANSACTION_RESULT, + PROCESSOR_EVENT, +} from '../../common/elasticsearch_fieldnames'; +import { stampLogger } from '../shared/stamp-logger'; +import { createOrUpdateIndex } from '../shared/create-or-update-index'; + +// This script will try to estimate how many latency metric documents +// will be created based on the available transaction documents. +// It can also generate metric documents based on a painless script +// and hdr histograms. +// +// Options: +// - interval: the interval (in minutes) for which latency metrics will be aggregated. +// Defaults to 1. +// - concurrency: number of maximum concurrent requests to ES. Defaults to 3. +// - from: start of the date range that should be processed. Should be a valid ISO timestamp. +// - to: end of the date range that should be processed. Should be a valid ISO timestamp. +// - source: from which transaction documents should be read. Should be location of ES (basic auth +// is supported) plus the index name (or an index pattern). Example: +// https://foo:bar@apm.elstc.co:9999/apm-8.0.0-transaction +// - dest: to which metric documents should be written. If this is not set, no metric documents +// will be created.Should be location of ES (basic auth is supported) plus the index name. +// Example: https://foo:bar@apm.elstc.co:9999/apm-8.0.0-metric +// - include: comma-separated list of fields that should be aggregated on, in addition to the +// default ones. +// - exclude: comma-separated list of fields that should be not be aggregated on. + +stampLogger(); + +export async function aggregateLatencyMetrics() { + const interval = parseInt(String(argv.interval), 10) || 1; + const concurrency = parseInt(String(argv.concurrency), 10) || 3; + const numSigFigures = (parseInt(String(argv.sigfig), 10) || 2) as + | 1 + | 2 + | 3 + | 4 + | 5; + + const from = new Date(String(argv.from)).getTime(); + const to = new Date(String(argv.to)).getTime(); + + if (isNaN(from) || isNaN(to)) { + throw new Error( + `from and to are not valid dates - please supply valid ISO timestamps` + ); + } + + if (to <= from) { + throw new Error('to cannot be earlier than from'); + } + + const limit = pLimit(concurrency); + // retry function to handle ES timeouts + const retry = (fn: (...args: any[]) => any) => { + return () => + pRetry(fn, { + factor: 1, + retries: 3, + minTimeout: 2500, + }); + }; + + const tasks: Array> = []; + + const defaultFields = [ + SERVICE_NAME, + SERVICE_VERSION, + SERVICE_ENVIRONMENT, + AGENT_NAME, + HOST_NAME, + POD_NAME, + CONTAINER_ID, + TRANSACTION_NAME, + TRANSACTION_RESULT, + TRANSACTION_TYPE, + ]; + + const include = String(argv.include ?? '') + .split(',') + .filter(Boolean) as string[]; + + const exclude = String(argv.exclude ?? '') + .split(',') + .filter(Boolean) as string[]; + + const only = String(argv.only ?? '') + .split(',') + .filter(Boolean) as string[]; + + const fields = only.length + ? unique(only) + : without(unique([...include, ...defaultFields]), ...exclude); + + const globalFilter = argv.filter ? JSON.parse(String(argv.filter)) : {}; + + // eslint-disable-next-line no-console + console.log('Aggregating on', fields.join(',')); + + const source = String(argv.source ?? ''); + const dest = String(argv.dest ?? ''); + + function getClientOptionsFromIndexUrl( + url: string + ): { node: string; index: string } { + const parsed = parse(url); + const { pathname, ...rest } = parsed; + + return { + node: format(rest), + index: pathname!.replace('/', ''), + }; + } + + const sourceOptions = getClientOptionsFromIndexUrl(source); + + const sourceClient = new Client({ + node: sourceOptions.node, + ssl: { + rejectUnauthorized: false, + }, + requestTimeout: 120000, + }); + + let destClient: Client | undefined; + let destOptions: { node: string; index: string } | undefined; + + const uploadMetrics = !!dest; + + if (uploadMetrics) { + destOptions = getClientOptionsFromIndexUrl(dest); + destClient = new Client({ + node: destOptions.node, + ssl: { + rejectUnauthorized: false, + }, + }); + + const mappings = ( + await sourceClient.indices.getMapping({ + index: sourceOptions.index, + }) + ).body; + + const lastMapping = mappings[Object.keys(mappings)[0]]; + + const newMapping = merge({}, lastMapping, { + mappings: { + properties: { + transaction: { + properties: { + duration: { + properties: { + histogram: { + type: 'histogram', + }, + }, + }, + }, + }, + }, + }, + }); + + await createOrUpdateIndex({ + client: destClient, + indexName: destOptions.index, + clear: false, + template: newMapping, + }); + } else { + // eslint-disable-next-line no-console + console.log( + 'No destination was defined, not uploading aggregated documents' + ); + } + + let at = to; + while (at > from) { + const end = at; + const start = Math.max(from, at - interval * 60 * 1000); + + tasks.push( + limit( + retry(async () => { + const filter = [ + { + term: { + [PROCESSOR_EVENT]: 'transaction', + }, + }, + { + range: { + '@timestamp': { + gte: start, + lt: end, + }, + }, + }, + ]; + + const query: { + query: Record; + } = { + ...globalFilter, + query: { + ...(globalFilter?.query ?? {}), + bool: { + ...(globalFilter?.query?.bool ?? {}), + filter: [ + ...Object.values(globalFilter?.query?.bool?.filter ?? {}), + ...filter, + ], + }, + }, + }; + + async function paginateThroughBuckets( + buckets: Array<{ + doc_count: number; + key: any; + recorded_values?: { value: unknown }; + }>, + after?: any + ): Promise< + Array<{ + doc_count: number; + key: any; + recorded_values?: { value: unknown }; + }> + > { + const params = { + index: sourceOptions.index, + body: { + ...query, + aggs: { + transactionGroups: { + composite: { + ...(after ? { after } : {}), + size: 10000, + sources: fields.map((field) => ({ + [field]: { + terms: { + field, + missing_bucket: true, + }, + }, + })), + }, + ...(dest + ? { + // scripted metric agg to get all the values (rather than downloading all the documents) + aggs: { + recorded_values: { + scripted_metric: { + init_script: 'state.values = new ArrayList()', + map_script: ` + if (!doc['transaction.duration.us'].empty) { + state.values.add(doc['transaction.duration.us'].value); + } + `, + combine_script: 'return state.values', + reduce_script: ` + return states.stream().flatMap(l -> l.stream()).collect(Collectors.toList()) + `, + }, + }, + }, + } + : {}), + }, + }, + }, + }; + + const response = (await sourceClient.search(params)) + .body as ESSearchResponse; + + const { aggregations } = response; + + if (!aggregations) { + return buckets; + } + + const { transactionGroups } = aggregations; + + const nextBuckets = buckets.concat(transactionGroups.buckets); + + if (!transactionGroups.after_key) { + return nextBuckets; + } + + return nextBuckets.concat( + await paginateThroughBuckets(buckets, transactionGroups.after_key) + ); + } + + async function getNumberOfTransactionDocuments() { + const params = { + index: sourceOptions.index, + body: { + query: { + bool: { + filter, + }, + }, + track_total_hits: true, + }, + }; + + const response = (await sourceClient.search(params)) + .body as ESSearchResponse; + + return response.hits.total.value; + } + + const [buckets, numberOfTransactionDocuments] = await Promise.all([ + paginateThroughBuckets([]), + getNumberOfTransactionDocuments(), + ]); + + const rangeLabel = `${new Date(start).toISOString()}-${new Date( + end + ).toISOString()}`; + + // eslint-disable-next-line no-console + console.log( + `${rangeLabel}: Compression: ${ + buckets.length + }/${numberOfTransactionDocuments} (${( + (buckets.length / numberOfTransactionDocuments) * + 100 + ).toPrecision(2)}%)` + ); + + const docs: Array> = []; + + if (uploadMetrics) { + buckets.forEach((bucket) => { + const values = (bucket.recorded_values?.value ?? []) as number[]; + const h = histogram.build({ + numberOfSignificantValueDigits: numSigFigures, + }); + values.forEach((value) => { + h.recordValue(value); + }); + + const iterator = h.recordedValuesIterator; + + const distribution = { + values: [] as number[], + counts: [] as number[], + }; + + iterator.reset(); + + while (iterator.hasNext()) { + const value = iterator.next(); + distribution.values.push(value.valueIteratedTo); + distribution.counts.push(value.countAtValueIteratedTo); + } + + const structured = Object.keys(bucket.key).reduce((prev, key) => { + set(prev, key, bucket.key[key]); + return prev; + }, {}); + + const doc = merge({}, structured, { + '@timestamp': new Date(start).toISOString(), + timestamp: { + us: start * 1000, + }, + processor: { + name: 'metric', + event: 'metric', + }, + transaction: { + duration: { + histogram: distribution, + }, + }, + }); + + docs.push(doc); + }); + + if (!docs.length) { + // eslint-disable-next-line no-console + console.log(`${rangeLabel}: No docs to upload`); + return; + } + + const response = await destClient?.bulk({ + refresh: 'wait_for', + body: flatten( + docs.map((doc) => [ + { index: { _index: destOptions?.index } }, + doc, + ]) + ), + }); + + if (response?.body.errors) { + throw new Error( + `${rangeLabel}: Could not upload all metric documents` + ); + } + // eslint-disable-next-line no-console + console.log( + `${rangeLabel}: Uploaded ${docs.length} metric documents` + ); + } + }) + ) + ); + at = start; + } + + await Promise.all(tasks); +} diff --git a/x-pack/plugins/apm/scripts/package.json b/x-pack/plugins/apm/scripts/package.json index 9121449c536199..c5a9df792f8562 100644 --- a/x-pack/plugins/apm/scripts/package.json +++ b/x-pack/plugins/apm/scripts/package.json @@ -4,7 +4,10 @@ "main": "index.js", "license": "MIT", "dependencies": { + "@elastic/elasticsearch": "^7.6.1", "@octokit/rest": "^16.35.0", - "console-stamp": "^0.2.9" + "@types/console-stamp": "^0.2.32", + "console-stamp": "^0.2.9", + "hdr-histogram-js": "^1.2.0" } } diff --git a/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts b/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts new file mode 100644 index 00000000000000..3f88b73f559843 --- /dev/null +++ b/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts @@ -0,0 +1,60 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; + +export async function createOrUpdateIndex({ + client, + clear, + indexName, + template, +}: { + client: Client; + clear: boolean; + indexName: string; + template: any; +}) { + if (clear) { + try { + await client.indices.delete({ + index: indexName, + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.body.status !== 404) { + throw err; + } + } + } + + const indexExists = ( + await client.indices.exists({ + index: indexName, + }) + ).body as boolean; + + if (!indexExists) { + await client.indices.create({ + index: indexName, + body: template, + }); + } else { + await Promise.all([ + template.mappings + ? client.indices.putMapping({ + index: indexName, + body: template.mappings, + }) + : Promise.resolve(undefined as any), + template.settings + ? client.indices.putSettings({ + index: indexName, + body: template.settings, + }) + : Promise.resolve(undefined as any), + ]); + } +} diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/plugins/apm/scripts/shared/download-telemetry-template.ts similarity index 68% rename from x-pack/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts rename to x-pack/plugins/apm/scripts/shared/download-telemetry-template.ts index 31559f1ab3c78f..f20c6328281f49 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts +++ b/x-pack/plugins/apm/scripts/shared/download-telemetry-template.ts @@ -4,15 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore import { Octokit } from '@octokit/rest'; -export async function downloadTelemetryTemplate(octokit: Octokit) { +export async function downloadTelemetryTemplate({ + githubToken, +}: { + githubToken: string; +}) { + const octokit = new Octokit({ + auth: githubToken, + }); const file = await octokit.repos.getContents({ owner: 'elastic', repo: 'telemetry', path: 'config/templates/xpack-phone-home.json', - // @ts-ignore mediaType: { format: 'application/vnd.github.VERSION.raw', }, @@ -22,5 +27,11 @@ export async function downloadTelemetryTemplate(octokit: Octokit) { throw new Error('Expected single response, got array'); } - return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()) as { + index_patterns: string[]; + mappings: { + properties: Record; + }; + settings: Record; + }; } diff --git a/x-pack/plugins/apm/scripts/shared/get-http-auth.ts b/x-pack/plugins/apm/scripts/shared/get-http-auth.ts new file mode 100644 index 00000000000000..b662deb863a35f --- /dev/null +++ b/x-pack/plugins/apm/scripts/shared/get-http-auth.ts @@ -0,0 +1,19 @@ +/* + * 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 { KibanaConfig } from './read-kibana-config'; + +export const getHttpAuth = (config: KibanaConfig) => { + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'], + } + : null; + + return httpAuth; +}; diff --git a/x-pack/plugins/apm/scripts/shared/read-kibana-config.ts b/x-pack/plugins/apm/scripts/shared/read-kibana-config.ts new file mode 100644 index 00000000000000..bc5f1afc63cacb --- /dev/null +++ b/x-pack/plugins/apm/scripts/shared/read-kibana-config.ts @@ -0,0 +1,49 @@ +/* + * 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 path from 'path'; +import fs from 'fs'; +import yaml from 'js-yaml'; +import { identity, pick } from 'lodash'; + +export type KibanaConfig = ReturnType; + +export const readKibanaConfig = () => { + const kibanaConfigDir = path.join(__filename, '../../../../../../config'); + const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); + const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + + const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) + ) || {}) as {}; + + const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST, + }, + identity + ) as { + 'elasticsearch.username'?: string; + 'elasticsearch.password'?: string; + 'elasticsearch.hosts'?: string; + }; + + return { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials, + }; +}; diff --git a/x-pack/plugins/apm/scripts/shared/stamp-logger.ts b/x-pack/plugins/apm/scripts/shared/stamp-logger.ts new file mode 100644 index 00000000000000..65d24bbae7008b --- /dev/null +++ b/x-pack/plugins/apm/scripts/shared/stamp-logger.ts @@ -0,0 +1,11 @@ +/* + * 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 consoleStamp from 'console-stamp'; + +export function stampLogger() { + consoleStamp(console, { pattern: '[HH:MM:ss.l]' }); +} diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json new file mode 100644 index 00000000000000..350db55e72446a --- /dev/null +++ b/x-pack/plugins/apm/scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "./**/*" + ], + "exclude": [], + "compilerOptions": { + "types": [ + "node" + ] + } +} diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index a3c97cd8828d81..5f9c72810fc91c 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -11,115 +11,50 @@ // - Easier testing of the telemetry tasks // - Validate whether we can run the queries we want to on the telemetry data -import fs from 'fs'; -import path from 'path'; -// @ts-ignore -import { Octokit } from '@octokit/rest'; -import { merge, chunk, flatten, pick, identity } from 'lodash'; -import axios from 'axios'; -import yaml from 'js-yaml'; -import { Client } from 'elasticsearch'; +import { merge, chunk, flatten } from 'lodash'; +import { Client } from '@elastic/elasticsearch'; import { argv } from 'yargs'; -import { promisify } from 'util'; import { Logger } from 'kibana/server'; -// @ts-ignore -import consoleStamp from 'console-stamp'; +import { stampLogger } from '../shared/stamp-logger'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { CollectTelemetryParams } from '../../server/lib/apm_telemetry/collect_data_telemetry'; -import { downloadTelemetryTemplate } from './download-telemetry-template'; -import mapping from '../../mappings.json'; +import { downloadTelemetryTemplate } from '../shared/download-telemetry-template'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { apmTelemetry } from '../../server/saved_objects/apm_telemetry'; import { generateSampleDocuments } from './generate-sample-documents'; +import { readKibanaConfig } from '../shared/read-kibana-config'; +import { getHttpAuth } from '../shared/get-http-auth'; +import { createOrUpdateIndex } from '../shared/create-or-update-index'; -consoleStamp(console, '[HH:MM:ss.l]'); - -const githubToken = process.env.GITHUB_TOKEN; +stampLogger(); -if (!githubToken) { - throw new Error('GITHUB_TOKEN was not provided.'); -} +async function uploadData() { + const githubToken = process.env.GITHUB_TOKEN; -const kibanaConfigDir = path.join(__filename, '../../../../../../config'); -const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); -const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); - -const xpackTelemetryIndexName = 'xpack-phone-home'; - -const loadedKibanaConfig = (yaml.safeLoad( - fs.readFileSync( - fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, - 'utf8' - ) -) || {}) as {}; - -const cliEsCredentials = pick( - { - 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, - 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, - 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST, - }, - identity -) as { - 'elasticsearch.username'?: string; - 'elasticsearch.password'?: string; - 'elasticsearch.hosts'?: string; -}; - -const config = { - 'apm_oss.transactionIndices': 'apm-*', - 'apm_oss.metricsIndices': 'apm-*', - 'apm_oss.errorIndices': 'apm-*', - 'apm_oss.spanIndices': 'apm-*', - 'apm_oss.onboardingIndices': 'apm-*', - 'apm_oss.sourcemapIndices': 'apm-*', - 'elasticsearch.hosts': 'http://localhost:9200', - ...loadedKibanaConfig, - ...cliEsCredentials, -}; + if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); + } -async function uploadData() { - const octokit = new Octokit({ - auth: githubToken, + const xpackTelemetryIndexName = 'xpack-phone-home'; + const telemetryTemplate = await downloadTelemetryTemplate({ + githubToken, }); - const telemetryTemplate = await downloadTelemetryTemplate(octokit); + const kibanaMapping = apmTelemetry.mappings; - const kibanaMapping = mapping['apm-telemetry']; + const config = readKibanaConfig(); - const httpAuth = - config['elasticsearch.username'] && config['elasticsearch.password'] - ? { - username: config['elasticsearch.username'], - password: config['elasticsearch.password'], - } - : null; + const httpAuth = getHttpAuth(config); const client = new Client({ - host: config['elasticsearch.hosts'], + nodes: [config['elasticsearch.hosts']], ...(httpAuth ? { - httpAuth: `${httpAuth.username}:${httpAuth.password}`, + auth: httpAuth, } : {}), }); - if (argv.clear) { - try { - await promisify(client.indices.delete.bind(client))({ - index: xpackTelemetryIndexName, - }); - } catch (err) { - // 404 = index not found, totally okay - if (err.status !== 404) { - throw err; - } - } - } - - const axiosInstance = axios.create({ - baseURL: config['elasticsearch.hosts'], - ...(httpAuth ? { auth: httpAuth } : {}), - }); - const newTemplate = merge(telemetryTemplate, { settings: { index: { mapping: { total_fields: { limit: 10000 } } }, @@ -129,7 +64,12 @@ async function uploadData() { // override apm mapping instead of merging newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; - await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + await createOrUpdateIndex({ + indexName: xpackTelemetryIndexName, + client, + template: newTemplate, + clear: !!argv.clear, + }); const sampleDocuments = await generateSampleDocuments({ collectTelemetryParams: { @@ -140,19 +80,16 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return promisify(client.search.bind(client))({ - ...body, - requestTimeout: 120000, - }) as any; + return client.search(body as any).then((res) => res.body); }, indicesStats: (body) => { - return promisify(client.indices.stats.bind(client))({ - ...body, - requestTimeout: 120000, - }) as any; + return client.indices.stats(body as any); }, transportRequest: ((params) => { - return axiosInstance[params.method](params.path); + return client.transport.request({ + method: params.method, + path: params.path, + }); }) as CollectTelemetryParams['transportRequest'], }, }); @@ -162,20 +99,27 @@ async function uploadData() { await chunks.reduce>((prev, documents) => { return prev.then(async () => { const body = flatten( - documents.map((doc) => [{ index: { _index: 'xpack-phone-home' } }, doc]) + documents.map((doc) => [ + { index: { _index: xpackTelemetryIndexName } }, + doc, + ]) ); - return promisify(client.bulk.bind(client))({ - body, - refresh: true, - }).then((response: any) => { - if (response.errors) { - const firstError = response.items.filter( - (item: any) => item.index.status >= 400 - )[0].index.error; - throw new Error(`Failed to upload documents: ${firstError.reason} `); - } - }); + return client + .bulk({ + body, + refresh: 'wait_for', + }) + .then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error( + `Failed to upload documents: ${firstError.reason} ` + ); + } + }); }); }, Promise.resolve()); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.test.ts deleted file mode 100644 index aefd074c373f95..00000000000000 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.test.ts +++ /dev/null @@ -1,40 +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 { getApmMlJobCategory } from './get_service_anomalies'; -import { Job as AnomalyDetectionJob } from '../../../../ml/server'; - -describe('getApmMlJobCategory', () => { - it('should match service names with different casings', () => { - const mlJob = { - job_id: 'testservice-request-high_mean_response_time', - groups: ['apm', 'testservice', 'request'], - } as AnomalyDetectionJob; - const serviceNames = ['testService']; - const apmMlJobCategory = getApmMlJobCategory(mlJob, serviceNames); - - expect(apmMlJobCategory).toEqual({ - jobId: 'testservice-request-high_mean_response_time', - serviceName: 'testService', - transactionType: 'request', - }); - }); - - it('should match service names with spaces', () => { - const mlJob = { - job_id: 'test_service-request-high_mean_response_time', - groups: ['apm', 'test_service', 'request'], - } as AnomalyDetectionJob; - const serviceNames = ['Test Service']; - const apmMlJobCategory = getApmMlJobCategory(mlJob, serviceNames); - - expect(apmMlJobCategory).toEqual({ - jobId: 'test_service-request-high_mean_response_time', - serviceName: 'Test Service', - transactionType: 'request', - }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts deleted file mode 100644 index 900141e9040ae1..00000000000000 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ /dev/null @@ -1,166 +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 { intersection } from 'lodash'; -import { leftJoin } from '../../../common/utils/left_join'; -import { Job as AnomalyDetectionJob } from '../../../../ml/server'; -import { PromiseReturnType } from '../../../typings/common'; -import { IEnvOptions } from './get_service_map'; -import { Setup } from '../helpers/setup_request'; -import { - APM_ML_JOB_GROUP_NAME, - encodeForMlApi, -} from '../../../common/ml_job_constants'; - -async function getApmAnomalyDetectionJobs( - setup: Setup -): Promise { - const { ml } = setup; - - if (!ml) { - return []; - } - try { - const { jobs } = await ml.anomalyDetectors.jobs(APM_ML_JOB_GROUP_NAME); - return jobs; - } catch (error) { - if (error.statusCode === 404) { - return []; - } - throw error; - } -} - -type ApmMlJobCategory = NonNullable>; - -export const getApmMlJobCategory = ( - mlJob: AnomalyDetectionJob, - serviceNames: string[] -) => { - const serviceByGroupNameMap = new Map( - serviceNames.map((serviceName) => [ - encodeForMlApi(serviceName), - serviceName, - ]) - ); - if (!mlJob.groups.includes(APM_ML_JOB_GROUP_NAME)) { - // ML job missing "apm" group name - return; - } - const apmJobGroups = mlJob.groups.filter( - (groupName) => groupName !== APM_ML_JOB_GROUP_NAME - ); - const apmJobServiceNames = apmJobGroups.map( - (groupName) => serviceByGroupNameMap.get(groupName) || groupName - ); - const [serviceName] = intersection(apmJobServiceNames, serviceNames); - if (!serviceName) { - // APM ML job service was not found - return; - } - const serviceGroupName = encodeForMlApi(serviceName); - const [transactionType] = apmJobGroups.filter( - (groupName) => groupName !== serviceGroupName - ); - if (!transactionType) { - // APM ML job transaction type was not found. - return; - } - return { jobId: mlJob.job_id, serviceName, transactionType }; -}; - -export type ServiceAnomalies = PromiseReturnType; - -export async function getServiceAnomalies( - options: IEnvOptions, - serviceNames: string[] -) { - const { start, end, ml } = options.setup; - - if (!ml || serviceNames.length === 0) { - return []; - } - - const apmMlJobs = await getApmAnomalyDetectionJobs(options.setup); - if (apmMlJobs.length === 0) { - return []; - } - const apmMlJobCategories = apmMlJobs - .map((job) => getApmMlJobCategory(job, serviceNames)) - .filter( - (apmJobCategory) => apmJobCategory !== undefined - ) as ApmMlJobCategory[]; - const apmJobIds = apmMlJobs.map((job) => job.job_id); - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { result_type: 'record' } }, - { - terms: { - job_id: apmJobIds, - }, - }, - { - range: { - timestamp: { gte: start, lte: end, format: 'epoch_millis' }, - }, - }, - ], - }, - }, - aggs: { - jobs: { - terms: { field: 'job_id', size: apmJobIds.length }, - aggs: { - top_score_hits: { - top_hits: { - sort: [{ record_score: { order: 'desc' as const } }], - _source: ['record_score', 'timestamp', 'typical', 'actual'], - size: 1, - }, - }, - }, - }, - }, - }, - }; - - const response = (await ml.mlSystem.mlAnomalySearch(params)) as { - aggregations: { - jobs: { - buckets: Array<{ - key: string; - top_score_hits: { - hits: { - hits: Array<{ - _source: { - record_score: number; - timestamp: number; - typical: number[]; - actual: number[]; - }; - }>; - }; - }; - }>; - }; - }; - }; - const anomalyScores = response.aggregations.jobs.buckets.map((jobBucket) => { - const jobId = jobBucket.key; - const bucketSource = jobBucket.top_score_hits.hits.hits?.[0]?._source; - return { - jobId, - anomalyScore: bucketSource.record_score, - timestamp: bucketSource.timestamp, - typical: bucketSource.typical[0], - actual: bucketSource.actual[0], - }; - }); - return leftJoin(apmMlJobCategories, 'jobId', anomalyScores); -} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 9f3ded82d7cbd8..4d488cd1a55096 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -13,14 +13,9 @@ import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { PromiseReturnType } from '../../../typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { - transformServiceMapResponses, - getAllNodes, - getServiceNodes, -} from './transform_service_map_responses'; +import { transformServiceMapResponses } from './transform_service_map_responses'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; -import { getServiceAnomalies, ServiceAnomalies } from './get_service_anomalies'; export interface IEnvOptions { setup: Setup & SetupTimeRange; @@ -132,7 +127,6 @@ async function getServicesData(options: IEnvOptions) { ); } -export { ServiceAnomalies }; export type ConnectionsResponse = PromiseReturnType; export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; @@ -143,19 +137,8 @@ export async function getServiceMap(options: IEnvOptions) { getServicesData(options), ]); - // Derive all related service names from connection and service data - const allNodes = getAllNodes(servicesData, connectionData.connections); - const serviceNodes = getServiceNodes(allNodes); - const serviceNames = serviceNodes.map( - (serviceData) => serviceData[SERVICE_NAME] - ); - - // Get related service anomalies - const serviceAnomalies = await getServiceAnomalies(options, serviceNames); - return transformServiceMapResponses({ ...connectionData, - anomalies: serviceAnomalies, services: servicesData, }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts deleted file mode 100644 index f07b575cc0a35a..00000000000000 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts +++ /dev/null @@ -1,76 +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 { ServiceAnomalies } from './get_service_map'; -import { addAnomaliesDataToNodes } from './ml_helpers'; - -describe('addAnomaliesDataToNodes', () => { - it('adds anomalies to nodes', () => { - const nodes = [ - { - 'service.name': 'opbeans-ruby', - 'agent.name': 'ruby', - 'service.environment': null, - }, - { - 'service.name': 'opbeans-java', - 'agent.name': 'java', - 'service.environment': null, - }, - ]; - - const serviceAnomalies: ServiceAnomalies = [ - { - jobId: 'opbeans-ruby-request-high_mean_response_time', - serviceName: 'opbeans-ruby', - transactionType: 'request', - anomalyScore: 50, - timestamp: 1591351200000, - actual: 2000, - typical: 1000, - }, - { - jobId: 'opbeans-java-request-high_mean_response_time', - serviceName: 'opbeans-java', - transactionType: 'request', - anomalyScore: 100, - timestamp: 1591351200000, - actual: 9000, - typical: 3000, - }, - ]; - - const result = [ - { - 'service.name': 'opbeans-ruby', - 'agent.name': 'ruby', - 'service.environment': null, - anomaly_score: 50, - anomaly_severity: 'major', - actual_value: 2000, - typical_value: 1000, - ml_job_id: 'opbeans-ruby-request-high_mean_response_time', - }, - { - 'service.name': 'opbeans-java', - 'agent.name': 'java', - 'service.environment': null, - anomaly_score: 100, - anomaly_severity: 'critical', - actual_value: 9000, - typical_value: 3000, - ml_job_id: 'opbeans-java-request-high_mean_response_time', - }, - ]; - - expect( - addAnomaliesDataToNodes( - nodes, - (serviceAnomalies as unknown) as ServiceAnomalies - ) - ).toEqual(result); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts deleted file mode 100644 index 8162417616b6cc..00000000000000 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts +++ /dev/null @@ -1,67 +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 { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; -import { getSeverity } from '../../../common/ml_job_constants'; -import { ConnectionNode, ServiceNode } from '../../../common/service_map'; -import { ServiceAnomalies } from './get_service_map'; - -export function addAnomaliesDataToNodes( - nodes: ConnectionNode[], - serviceAnomalies: ServiceAnomalies -) { - const anomaliesMap = serviceAnomalies.reduce( - (acc, anomalyJob) => { - const serviceAnomaly: typeof acc[string] | undefined = - acc[anomalyJob.serviceName]; - const hasAnomalyJob = serviceAnomaly !== undefined; - const hasAnomalyScore = serviceAnomaly?.anomaly_score !== undefined; - const hasNewAnomalyScore = anomalyJob.anomalyScore !== undefined; - const hasNewMaxAnomalyScore = - hasNewAnomalyScore && - (!hasAnomalyScore || - (anomalyJob?.anomalyScore ?? 0) > - (serviceAnomaly?.anomaly_score ?? 0)); - - if (!hasAnomalyJob || hasNewMaxAnomalyScore) { - acc[anomalyJob.serviceName] = { - anomaly_score: anomalyJob.anomalyScore, - actual_value: anomalyJob.actual, - typical_value: anomalyJob.typical, - ml_job_id: anomalyJob.jobId, - }; - } - - return acc; - }, - {} as { - [serviceName: string]: { - anomaly_score?: number; - actual_value?: number; - typical_value?: number; - ml_job_id: string; - }; - } - ); - - const servicesDataWithAnomalies: ServiceNode[] = nodes.map((service) => { - const serviceAnomaly = anomaliesMap[service[SERVICE_NAME]]; - if (serviceAnomaly) { - const anomalyScore = serviceAnomaly.anomaly_score; - return { - ...service, - anomaly_score: anomalyScore, - anomaly_severity: getSeverity(anomalyScore), - actual_value: serviceAnomaly.actual_value, - typical_value: serviceAnomaly.typical_value, - ml_job_id: serviceAnomaly.ml_job_id, - }; - } - return service; - }); - - return servicesDataWithAnomalies; -} diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts index 6c9880c2dc4dfb..1e26634bdf0f12 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts @@ -12,7 +12,6 @@ import { SPAN_SUBTYPE, SPAN_TYPE, } from '../../../common/elasticsearch_fieldnames'; -import { ServiceAnomalies } from './get_service_map'; import { transformServiceMapResponses, ServiceMapResponse, @@ -36,12 +35,9 @@ const javaService = { [AGENT_NAME]: 'java', }; -const serviceAnomalies: ServiceAnomalies = []; - describe('transformServiceMapResponses', () => { it('maps external destinations to internal services', () => { const response: ServiceMapResponse = { - anomalies: serviceAnomalies, services: [nodejsService, javaService], discoveredServices: [ { @@ -73,7 +69,6 @@ describe('transformServiceMapResponses', () => { it('collapses external destinations based on span.destination.resource.name', () => { const response: ServiceMapResponse = { - anomalies: serviceAnomalies, services: [nodejsService, javaService], discoveredServices: [ { @@ -109,7 +104,6 @@ describe('transformServiceMapResponses', () => { it('picks the first span.type/subtype in an alphabetically sorted list', () => { const response: ServiceMapResponse = { - anomalies: serviceAnomalies, services: [javaService], discoveredServices: [], connections: [ @@ -148,7 +142,6 @@ describe('transformServiceMapResponses', () => { it('processes connections without a matching "service" aggregation', () => { const response: ServiceMapResponse = { - anomalies: serviceAnomalies, services: [javaService], discoveredServices: [], connections: [ diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts index 53abf54cbcf313..835c00b8df239e 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts @@ -17,12 +17,7 @@ import { ServiceConnectionNode, ExternalConnectionNode, } from '../../../common/service_map'; -import { - ConnectionsResponse, - ServicesResponse, - ServiceAnomalies, -} from './get_service_map'; -import { addAnomaliesDataToNodes } from './ml_helpers'; +import { ConnectionsResponse, ServicesResponse } from './get_service_map'; function getConnectionNodeId(node: ConnectionNode): string { if ('span.destination.service.resource' in node) { @@ -67,12 +62,11 @@ export function getServiceNodes(allNodes: ConnectionNode[]) { } export type ServiceMapResponse = ConnectionsResponse & { - anomalies: ServiceAnomalies; services: ServicesResponse; }; export function transformServiceMapResponses(response: ServiceMapResponse) { - const { anomalies, discoveredServices, services, connections } = response; + const { discoveredServices, services, connections } = response; const allNodes = getAllNodes(services, connections); const serviceNodes = getServiceNodes(allNodes); @@ -214,18 +208,10 @@ export function transformServiceMapResponses(response: ServiceMapResponse) { return prev.concat(connection); }, []); - // Add anomlies data - const dedupedNodesWithAnomliesData = addAnomaliesDataToNodes( - dedupedNodes, - anomalies - ); - // Put everything together in elements, with everything in the "data" property - const elements = [...dedupedConnections, ...dedupedNodesWithAnomliesData].map( - (element) => ({ - data: element, - }) - ); + const elements = [...dedupedConnections, ...dedupedNodes].map((element) => ({ + data: element, + })); return { elements }; } diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index 2409da59d66ae2..e77307a3f9db13 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -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 { 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; } } diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.ts index 9365213a87f6ef..e2b6e74d4d65a5 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.ts @@ -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, }) : []; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap index 64f06ad0a81cd0..087dc6afc9a587 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap @@ -46,7 +46,7 @@ Array [ }, }, "composite": Object { - "size": 10000, + "size": 101, "sources": Array [ Object { "service": Object { @@ -159,7 +159,7 @@ Array [ }, }, "composite": Object { - "size": 10000, + "size": 101, "sources": Array [ Object { "transaction": Object { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index b93f842b878cb0..496533cf97e65d 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -44,7 +44,7 @@ Object { }, }, "composite": Object { - "size": 10000, + "size": 101, "sources": Array [ Object { "service": Object { @@ -153,7 +153,7 @@ Object { }, }, "composite": Object { - "size": 10000, + "size": 101, "sources": Array [ Object { "transaction": Object { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts index 00702be6744ec8..a26c3d85a3fc47 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.test.ts @@ -39,7 +39,8 @@ describe('transactionGroupsFetcher', () => { describe('type: top_traces', () => { it('should call client.search with correct query', async () => { const setup = getSetup(); - await transactionGroupsFetcher({ type: 'top_traces' }, setup); + const bucketSize = 100; + await transactionGroupsFetcher({ type: 'top_traces' }, setup, bucketSize); expect(setup.client.search.mock.calls).toMatchSnapshot(); }); }); @@ -47,13 +48,15 @@ describe('transactionGroupsFetcher', () => { describe('type: top_transactions', () => { it('should call client.search with correct query', async () => { const setup = getSetup(); + const bucketSize = 100; await transactionGroupsFetcher( { type: 'top_transactions', serviceName: 'opbeans-node', transactionType: 'request', }, - setup + setup, + bucketSize ); expect(setup.client.search.mock.calls).toMatchSnapshot(); }); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index d10c45ecbdbfb1..595ee9d8da2dcf 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -36,9 +36,10 @@ interface TopTraceOptions { export type Options = TopTransactionOptions | TopTraceOptions; export type ESResponse = PromiseReturnType; -export function transactionGroupsFetcher( +export async function transactionGroupsFetcher( options: Options, - setup: Setup & SetupTimeRange & SetupUIFilters + setup: Setup & SetupTimeRange & SetupUIFilters, + bucketSize: number ) { const { client } = setup; @@ -71,7 +72,7 @@ export function transactionGroupsFetcher( aggs: { transaction_groups: { composite: { - size: 10000, + size: bucketSize + 1, // 1 extra bucket is added to check whether the total number of buckets exceed the specified bucket size. sources: [ ...(isTopTraces ? [{ service: { terms: { field: SERVICE_NAME } } }] diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts index 30c4975120483e..893e586b351a80 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/index.ts @@ -11,20 +11,18 @@ import { } from '../helpers/setup_request'; import { transactionGroupsFetcher, Options } from './fetcher'; import { transactionGroupsTransformer } from './transform'; -import { PromiseReturnType } from '../../../../observability/typings/common'; -export type TransactionGroupListAPIResponse = PromiseReturnType< - typeof getTransactionGroupList ->; export async function getTransactionGroupList( options: Options, setup: Setup & SetupTimeRange & SetupUIFilters ) { const { start, end } = setup; - const response = await transactionGroupsFetcher(options, setup); + const bucketSize = setup.config['xpack.apm.ui.transactionGroupBucketSize']; + const response = await transactionGroupsFetcher(options, setup, bucketSize); return transactionGroupsTransformer({ response, start, end, + bucketSize, }); } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts index 58d770bebce979..2c5aa79bb3483c 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts @@ -18,6 +18,7 @@ describe('transaction group queries', () => { }); it('fetches top transactions', async () => { + const bucketSize = 100; mock = await inspectSearchParams((setup) => transactionGroupsFetcher( { @@ -25,7 +26,8 @@ describe('transaction group queries', () => { serviceName: 'foo', transactionType: 'bar', }, - setup + setup, + bucketSize ) ); @@ -33,12 +35,14 @@ describe('transaction group queries', () => { }); it('fetches top traces', async () => { + const bucketSize = 100; mock = await inspectSearchParams((setup) => transactionGroupsFetcher( { type: 'top_traces', }, - setup + setup, + bucketSize ) ); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/transform.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/transform.test.ts index e5ec9a8eae782b..0bb29e27f0219e 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/transform.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/transform.test.ts @@ -10,13 +10,20 @@ import { transactionGroupsTransformer } from './transform'; describe('transactionGroupsTransformer', () => { it('should match snapshot', () => { - expect( - transactionGroupsTransformer({ - response: transactionGroupsResponse, - start: 100, - end: 2000, - }) - ).toMatchSnapshot(); + const { + bucketSize, + isAggregationAccurate, + items, + } = transactionGroupsTransformer({ + response: transactionGroupsResponse, + start: 100, + end: 2000, + bucketSize: 100, + }); + + expect(bucketSize).toBe(100); + expect(isAggregationAccurate).toBe(true); + expect(items).toMatchSnapshot(); }); it('should transform response correctly', () => { @@ -43,17 +50,59 @@ describe('transactionGroupsTransformer', () => { } as unknown) as ESResponse; expect( - transactionGroupsTransformer({ response, start: 100, end: 20000 }) - ).toEqual([ - { - averageResponseTime: 255966.30555555556, - impact: 0, - name: 'POST /api/orders', - p95: 320238.5, - sample: 'sample source', - transactionsPerMinute: 542.713567839196, + transactionGroupsTransformer({ + response, + start: 100, + end: 20000, + bucketSize: 100, + }) + ).toEqual({ + bucketSize: 100, + isAggregationAccurate: true, + items: [ + { + averageResponseTime: 255966.30555555556, + impact: 0, + name: 'POST /api/orders', + p95: 320238.5, + sample: 'sample source', + transactionsPerMinute: 542.713567839196, + }, + ], + }); + }); + + it('`isAggregationAccurate` should be false if number of bucket is higher than `bucketSize`', () => { + const bucket = { + key: { transaction: 'POST /api/orders' }, + doc_count: 180, + avg: { value: 255966.30555555556 }, + p95: { values: { '95.0': 320238.5 } }, + sum: { value: 3000000000 }, + sample: { + hits: { + total: 180, + hits: [{ _source: 'sample source' }], + }, }, - ]); + }; + + const response = ({ + aggregations: { + transaction_groups: { + buckets: [bucket, bucket, bucket, bucket], // four buckets returned + }, + }, + } as unknown) as ESResponse; + + const { isAggregationAccurate } = transactionGroupsTransformer({ + response, + start: 100, + end: 20000, + bucketSize: 3, // bucket size of three + }); + + expect(isAggregationAccurate).toEqual(false); }); it('should calculate impact from sum', () => { @@ -74,10 +123,13 @@ describe('transactionGroupsTransformer', () => { }, } as unknown) as ESResponse; - expect( - transactionGroupsTransformer({ response, start: 100, end: 20000 }).map( - (bucket) => bucket.impact - ) - ).toEqual([100, 25, 0]); + const { items } = transactionGroupsTransformer({ + response, + start: 100, + end: 20000, + bucketSize: 100, + }); + + expect(items.map((bucket) => bucket.impact)).toEqual([100, 25, 0]); }); }); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/transform.ts b/x-pack/plugins/apm/server/lib/transaction_groups/transform.ts index 2f34d365e5be9a..81dba39e9d7126 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/transform.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/transform.ts @@ -8,15 +8,15 @@ import moment from 'moment'; import { sortByOrder } from 'lodash'; import { ESResponse } from './fetcher'; -function calculateRelativeImpacts(transactionGroups: ITransactionGroup[]) { - const values = transactionGroups +function calculateRelativeImpacts(items: ITransactionGroup[]) { + const values = items .map(({ impact }) => impact) .filter((value) => value !== null) as number[]; const max = Math.max(...values); const min = Math.min(...values); - return transactionGroups.map((bucket) => ({ + return items.map((bucket) => ({ ...bucket, impact: bucket.impact !== null @@ -60,17 +60,30 @@ export function transactionGroupsTransformer({ response, start, end, + bucketSize, }: { response: ESResponse; start: number; end: number; -}): ITransactionGroup[] { + bucketSize: number; +}): { + items: ITransactionGroup[]; + isAggregationAccurate: boolean; + bucketSize: number; +} { const buckets = getBuckets(response); const duration = moment.duration(end - start); const minutes = duration.asMinutes(); - const transactionGroups = buckets.map((bucket) => - getTransactionGroup(bucket, minutes) - ); + const items = buckets.map((bucket) => getTransactionGroup(bucket, minutes)); - return calculateRelativeImpacts(transactionGroups); + const itemsWithRelativeImpact = calculateRelativeImpacts(items); + + return { + items: itemsWithRelativeImpact, + + // The aggregation is considered accurate if the configured bucket size is larger or equal to the number of buckets returned + // the actual number of buckets retrieved are `bucketsize + 1` to detect whether it's above the limit + isAggregationAccurate: bucketSize >= buckets.length, + bucketSize, + }; } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/fetcher.test.ts.snap deleted file mode 100644 index cf3fdac221b597..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/fetcher.test.ts.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`anomalyAggsFetcher when ES returns valid response should call client with correct query 1`] = ` -Array [ - Array [ - Object { - "body": Object { - "aggs": Object { - "ml_avg_response_times": Object { - "aggs": Object { - "anomaly_score": Object { - "max": Object { - "field": "anomaly_score", - }, - }, - "lower": Object { - "min": Object { - "field": "model_lower", - }, - }, - "upper": Object { - "max": Object { - "field": "model_upper", - }, - }, - }, - "date_histogram": Object { - "extended_bounds": Object { - "max": 200000, - "min": 90000, - }, - "field": "timestamp", - "fixed_interval": "myInterval", - "min_doc_count": 0, - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "job_id": "myservicename-mytransactiontype-high_mean_response_time", - }, - }, - Object { - "exists": Object { - "field": "bucket_span", - }, - }, - Object { - "range": Object { - "timestamp": Object { - "format": "epoch_millis", - "gte": 90000, - "lte": 200000, - }, - }, - }, - ], - }, - }, - "size": 0, - }, - }, - ], -] -`; diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/index.test.ts.snap deleted file mode 100644 index 971fa3b92cc83f..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,38 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`getAnomalySeries should match snapshot 1`] = ` -Object { - "anomalyBoundaries": Array [ - Object { - "x": 5000, - "y": 200, - "y0": 20, - }, - Object { - "x": 15000, - "y": 100, - "y0": 20, - }, - Object { - "x": 25000, - "y": 50, - "y0": 10, - }, - Object { - "x": 30000, - "y": 50, - "y0": 10, - }, - ], - "anomalyScore": Array [ - Object { - "x": 25000, - "x0": 15000, - }, - Object { - "x": 35000, - "x0": 25000, - }, - ], -} -`; diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/transform.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/transform.test.ts.snap deleted file mode 100644 index 8cf471cb34ed26..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/__snapshots__/transform.test.ts.snap +++ /dev/null @@ -1,33 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`anomalySeriesTransform should match snapshot 1`] = ` -Object { - "anomalyBoundaries": Array [ - Object { - "x": 10000, - "y": 200, - "y0": 20, - }, - Object { - "x": 15000, - "y": 100, - "y0": 20, - }, - Object { - "x": 25000, - "y": 50, - "y0": 10, - }, - ], - "anomalyScore": Array [ - Object { - "x": 25000, - "x0": 15000, - }, - Object { - "x": 25000, - "x0": 25000, - }, - ], -} -`; diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.test.ts deleted file mode 100644 index 313cf818a322da..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.test.ts +++ /dev/null @@ -1,76 +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 { anomalySeriesFetcher, ESResponse } from './fetcher'; - -describe('anomalyAggsFetcher', () => { - describe('when ES returns valid response', () => { - let response: ESResponse | undefined; - let clientSpy: jest.Mock; - - beforeEach(async () => { - clientSpy = jest.fn().mockReturnValue('ES Response'); - response = await anomalySeriesFetcher({ - serviceName: 'myServiceName', - transactionType: 'myTransactionType', - intervalString: 'myInterval', - mlBucketSize: 10, - setup: { - ml: { - mlSystem: { - mlAnomalySearch: clientSpy, - }, - } as any, - start: 100000, - end: 200000, - } as any, - }); - }); - - it('should call client with correct query', () => { - expect(clientSpy.mock.calls).toMatchSnapshot(); - }); - - it('should return correct response', () => { - expect(response).toBe('ES Response'); - }); - }); - - it('should swallow HTTP errors', () => { - const httpError = new Error('anomaly lookup failed') as any; - httpError.statusCode = 418; - const failedRequestSpy = jest.fn(() => Promise.reject(httpError)); - - return expect( - anomalySeriesFetcher({ - setup: { - ml: { - mlSystem: { - mlAnomalySearch: failedRequestSpy, - }, - } as any, - }, - } as any) - ).resolves.toEqual(undefined); - }); - - it('should throw other errors', () => { - const otherError = new Error('anomaly lookup ASPLODED') as any; - const failedRequestSpy = jest.fn(() => Promise.reject(otherError)); - - return expect( - anomalySeriesFetcher({ - setup: { - ml: { - mlSystem: { - mlAnomalySearch: failedRequestSpy, - }, - } as any, - }, - } as any) - ).rejects.toThrow(otherError); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts deleted file mode 100644 index 8ee078de7f3ce1..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts +++ /dev/null @@ -1,90 +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 { getMlJobId } from '../../../../../common/ml_job_constants'; -import { PromiseReturnType } from '../../../../../../observability/typings/common'; -import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; - -export type ESResponse = Exclude< - PromiseReturnType, - undefined ->; - -export async function anomalySeriesFetcher({ - serviceName, - transactionType, - intervalString, - mlBucketSize, - setup, -}: { - serviceName: string; - transactionType: string; - intervalString: string; - mlBucketSize: number; - setup: Setup & SetupTimeRange; -}) { - const { ml, start, end } = setup; - if (!ml) { - return; - } - - // move the start back with one bucket size, to ensure to get anomaly data in the beginning - // this is required because ML has a minimum bucket size (default is 900s) so if our buckets are smaller, we might have several null buckets in the beginning - const newStart = start - mlBucketSize * 1000; - const jobId = getMlJobId(serviceName, transactionType); - - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { job_id: jobId } }, - { exists: { field: 'bucket_span' } }, - { - range: { - timestamp: { - gte: newStart, - lte: end, - format: 'epoch_millis', - }, - }, - }, - ], - }, - }, - aggs: { - ml_avg_response_times: { - date_histogram: { - field: 'timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: newStart, - max: end, - }, - }, - aggs: { - anomaly_score: { max: { field: 'anomaly_score' } }, - lower: { min: { field: 'model_lower' } }, - upper: { max: { field: 'model_upper' } }, - }, - }, - }, - }, - }; - - try { - const response = await ml.mlSystem.mlAnomalySearch(params); - return response; - } catch (err) { - const isHttpError = 'statusCode' in err; - if (isHttpError) { - return; - } - throw err; - } -} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts deleted file mode 100644 index d649bfb1927390..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts +++ /dev/null @@ -1,65 +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 { getMlJobId } from '../../../../../common/ml_job_constants'; -import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; - -interface IOptions { - serviceName: string; - transactionType: string; - setup: Setup & SetupTimeRange; -} - -interface ESResponse { - bucket_span: number; -} - -export async function getMlBucketSize({ - serviceName, - transactionType, - setup, -}: IOptions): Promise { - const { ml, start, end } = setup; - if (!ml) { - return 0; - } - const jobId = getMlJobId(serviceName, transactionType); - - const params = { - body: { - _source: 'bucket_span', - size: 1, - query: { - bool: { - filter: [ - { term: { job_id: jobId } }, - { exists: { field: 'bucket_span' } }, - { - range: { - timestamp: { - gte: start, - lte: end, - format: 'epoch_millis', - }, - }, - }, - ], - }, - }, - }, - }; - - try { - const resp = await ml.mlSystem.mlAnomalySearch(params); - return resp.hits.hits[0]?._source.bucket_span || 0; - } catch (err) { - const isHttpError = 'statusCode' in err; - if (isHttpError) { - return 0; - } - throw err; - } -} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts deleted file mode 100644 index fb87f1b5707d14..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ /dev/null @@ -1,83 +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 { getAnomalySeries } from '.'; -import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response'; -import { mlBucketSpanResponse } from './mock_responses/ml_bucket_span_response'; -import { PromiseReturnType } from '../../../../../../observability/typings/common'; -import { APMConfig } from '../../../..'; - -describe('getAnomalySeries', () => { - let avgAnomalies: PromiseReturnType; - beforeEach(async () => { - const clientSpy = jest - .fn() - .mockResolvedValueOnce(mlBucketSpanResponse) - .mockResolvedValueOnce(mlAnomalyResponse); - - avgAnomalies = await getAnomalySeries({ - serviceName: 'myServiceName', - transactionType: 'myTransactionType', - transactionName: undefined, - timeSeriesDates: [100, 100000], - setup: { - start: 0, - end: 500000, - client: { search: () => {} } as any, - internalClient: { search: () => {} } as any, - config: new Proxy( - {}, - { - get: () => 'myIndex', - } - ) as APMConfig, - uiFiltersES: [], - indices: { - 'apm_oss.sourcemapIndices': 'myIndex', - 'apm_oss.errorIndices': 'myIndex', - 'apm_oss.onboardingIndices': 'myIndex', - 'apm_oss.spanIndices': 'myIndex', - 'apm_oss.transactionIndices': 'myIndex', - 'apm_oss.metricsIndices': 'myIndex', - apmAgentConfigurationIndex: 'myIndex', - apmCustomLinkIndex: 'myIndex', - }, - dynamicIndexPattern: null as any, - ml: { - mlSystem: { - mlAnomalySearch: clientSpy, - mlCapabilities: async () => ({ isPlatinumOrTrialLicense: true }), - }, - } as any, - }, - }); - }); - - it('should remove buckets lower than threshold and outside date range from anomalyScore', () => { - expect(avgAnomalies!.anomalyScore).toEqual([ - { x0: 15000, x: 25000 }, - { x0: 25000, x: 35000 }, - ]); - }); - - it('should remove buckets outside date range from anomalyBoundaries', () => { - expect( - avgAnomalies!.anomalyBoundaries!.filter( - (bucket) => bucket.x < 100 || bucket.x > 100000 - ).length - ).toBe(0); - }); - - it('should remove buckets with null from anomalyBoundaries', () => { - expect( - avgAnomalies!.anomalyBoundaries!.filter((p) => p.y === null).length - ).toBe(0); - }); - - it('should match snapshot', async () => { - expect(avgAnomalies).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts index 6f44cfa1df9f06..b2d11f2ffe19a6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getBucketSize } from '../../../helpers/get_bucket_size'; import { Setup, SetupTimeRange, SetupUIFilters, } from '../../../helpers/setup_request'; -import { anomalySeriesFetcher } from './fetcher'; -import { getMlBucketSize } from './get_ml_bucket_size'; -import { anomalySeriesTransform } from './transform'; +import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries'; + +interface AnomalyTimeseries { + anomalyBoundaries: Coordinate[]; + anomalyScore: RectCoordinate[]; +} export async function getAnomalySeries({ serviceName, @@ -26,7 +28,7 @@ export async function getAnomalySeries({ transactionName: string | undefined; timeSeriesDates: number[]; setup: Setup & SetupTimeRange & SetupUIFilters; -}) { +}): Promise { // don't fetch anomalies for transaction details page if (transactionName) { return; @@ -53,29 +55,6 @@ export async function getAnomalySeries({ return; } - const mlBucketSize = await getMlBucketSize({ - serviceName, - transactionType, - setup, - }); - - const { start, end } = setup; - const { intervalString, bucketSize } = getBucketSize(start, end, 'auto'); - - const esResponse = await anomalySeriesFetcher({ - serviceName, - transactionType, - intervalString, - mlBucketSize, - setup, - }); - - return esResponse - ? anomalySeriesTransform( - esResponse, - mlBucketSize, - bucketSize, - timeSeriesDates - ) - : undefined; + // TODO [APM ML] return a series of anomaly scores, upper & lower bounds for the given timeSeriesDates + return; } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_anomaly_response.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_anomaly_response.ts deleted file mode 100644 index 523161ec102759..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_anomaly_response.ts +++ /dev/null @@ -1,127 +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 { ESResponse } from '../fetcher'; - -export const mlAnomalyResponse: ESResponse = ({ - took: 3, - timed_out: false, - _shards: { - total: 5, - successful: 5, - skipped: 0, - failed: 0, - }, - hits: { - total: 10, - max_score: 0, - hits: [], - }, - aggregations: { - ml_avg_response_times: { - buckets: [ - { - key_as_string: '2018-07-02T09:16:40.000Z', - key: 0, - doc_count: 0, - anomaly_score: { - value: null, - }, - upper: { - value: 200, - }, - lower: { - value: 20, - }, - }, - { - key_as_string: '2018-07-02T09:25:00.000Z', - key: 5000, - doc_count: 4, - anomaly_score: { - value: null, - }, - upper: { - value: null, - }, - lower: { - value: null, - }, - }, - { - key_as_string: '2018-07-02T09:33:20.000Z', - key: 10000, - doc_count: 0, - anomaly_score: { - value: null, - }, - upper: { - value: null, - }, - lower: { - value: null, - }, - }, - { - key_as_string: '2018-07-02T09:41:40.000Z', - key: 15000, - doc_count: 2, - anomaly_score: { - value: 90, - }, - upper: { - value: 100, - }, - lower: { - value: 20, - }, - }, - { - key_as_string: '2018-07-02T09:50:00.000Z', - key: 20000, - doc_count: 0, - anomaly_score: { - value: null, - }, - upper: { - value: null, - }, - lower: { - value: null, - }, - }, - { - key_as_string: '2018-07-02T09:58:20.000Z', - key: 25000, - doc_count: 2, - anomaly_score: { - value: 100, - }, - upper: { - value: 50, - }, - lower: { - value: 10, - }, - }, - { - key_as_string: '2018-07-02T10:15:00.000Z', - key: 30000, - doc_count: 2, - anomaly_score: { - value: 0, - }, - upper: { - value: null, - }, - lower: { - value: null, - }, - }, - ], - }, - }, -} as unknown) as ESResponse; diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_bucket_span_response.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_bucket_span_response.ts deleted file mode 100644 index 3689529a07c4a9..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/mock_responses/ml_bucket_span_response.ts +++ /dev/null @@ -1,31 +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. - */ - -export const mlBucketSpanResponse = { - took: 1, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: 192, - max_score: 1.0, - hits: [ - { - _index: '.ml-anomalies-shared', - _id: - 'opbeans-go-request-high_mean_response_time_model_plot_1542636000000_900_0_29791_0', - _score: 1.0, - _source: { - bucket_span: 10, - }, - }, - ], - }, -}; diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.test.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.test.ts deleted file mode 100644 index eb94c83e92576d..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.test.ts +++ /dev/null @@ -1,303 +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 { ESResponse } from './fetcher'; -import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response'; -import { anomalySeriesTransform, replaceFirstAndLastBucket } from './transform'; - -describe('anomalySeriesTransform', () => { - it('should match snapshot', () => { - const getMlBucketSize = 10; - const bucketSize = 5; - const timeSeriesDates = [10000, 25000]; - const anomalySeries = anomalySeriesTransform( - mlAnomalyResponse, - getMlBucketSize, - bucketSize, - timeSeriesDates - ); - expect(anomalySeries).toMatchSnapshot(); - }); - - describe('anomalyScoreSeries', () => { - it('should only returns bucket within range and above threshold', () => { - const esResponse = getESResponse([ - { - key: 0, - anomaly_score: { value: 90 }, - }, - { - key: 5000, - anomaly_score: { value: 0 }, - }, - { - key: 10000, - anomaly_score: { value: 90 }, - }, - { - key: 15000, - anomaly_score: { value: 0 }, - }, - { - key: 20000, - anomaly_score: { value: 90 }, - }, - ]); - - const getMlBucketSize = 5; - const bucketSize = 5; - const timeSeriesDates = [5000, 15000]; - const anomalySeries = anomalySeriesTransform( - esResponse, - getMlBucketSize, - bucketSize, - timeSeriesDates - ); - - const buckets = anomalySeries!.anomalyScore; - expect(buckets).toEqual([{ x0: 10000, x: 15000 }]); - }); - - it('should decrease the x-value to avoid going beyond last date', () => { - const esResponse = getESResponse([ - { - key: 0, - anomaly_score: { value: 0 }, - }, - { - key: 5000, - anomaly_score: { value: 90 }, - }, - ]); - - const getMlBucketSize = 10; - const bucketSize = 5; - const timeSeriesDates = [0, 10000]; - const anomalySeries = anomalySeriesTransform( - esResponse, - getMlBucketSize, - bucketSize, - timeSeriesDates - ); - - const buckets = anomalySeries!.anomalyScore; - expect(buckets).toEqual([{ x0: 5000, x: 10000 }]); - }); - }); - - describe('anomalyBoundariesSeries', () => { - it('should trim buckets to time range', () => { - const esResponse = getESResponse([ - { - key: 0, - upper: { value: 15 }, - lower: { value: 10 }, - }, - { - key: 5000, - upper: { value: 25 }, - lower: { value: 20 }, - }, - { - key: 10000, - upper: { value: 35 }, - lower: { value: 30 }, - }, - { - key: 15000, - upper: { value: 45 }, - lower: { value: 40 }, - }, - ]); - - const mlBucketSize = 10; - const bucketSize = 5; - const timeSeriesDates = [5000, 10000]; - const anomalySeries = anomalySeriesTransform( - esResponse, - mlBucketSize, - bucketSize, - timeSeriesDates - ); - - const buckets = anomalySeries!.anomalyBoundaries; - expect(buckets).toEqual([ - { x: 5000, y: 25, y0: 20 }, - { x: 10000, y: 35, y0: 30 }, - ]); - }); - - it('should replace first bucket in range', () => { - const esResponse = getESResponse([ - { - key: 0, - anomaly_score: { value: 0 }, - upper: { value: 15 }, - lower: { value: 10 }, - }, - { - key: 5000, - anomaly_score: { value: 0 }, - upper: { value: null }, - lower: { value: null }, - }, - { - key: 10000, - anomaly_score: { value: 0 }, - upper: { value: 25 }, - lower: { value: 20 }, - }, - ]); - - const getMlBucketSize = 10; - const bucketSize = 5; - const timeSeriesDates = [5000, 10000]; - const anomalySeries = anomalySeriesTransform( - esResponse, - getMlBucketSize, - bucketSize, - timeSeriesDates - ); - - const buckets = anomalySeries!.anomalyBoundaries; - expect(buckets).toEqual([ - { x: 5000, y: 15, y0: 10 }, - { x: 10000, y: 25, y0: 20 }, - ]); - }); - - it('should replace last bucket in range', () => { - const esResponse = getESResponse([ - { - key: 0, - anomaly_score: { value: 0 }, - upper: { value: 15 }, - lower: { value: 10 }, - }, - { - key: 5000, - anomaly_score: { value: 0 }, - upper: { value: null }, - lower: { value: null }, - }, - { - key: 10000, - anomaly_score: { value: 0 }, - upper: { value: null }, - lower: { value: null }, - }, - ]); - - const getMlBucketSize = 10; - const bucketSize = 5; - const timeSeriesDates = [5000, 10000]; - const anomalySeries = anomalySeriesTransform( - esResponse, - getMlBucketSize, - bucketSize, - timeSeriesDates - ); - - const buckets = anomalySeries!.anomalyBoundaries; - expect(buckets).toEqual([ - { x: 5000, y: 15, y0: 10 }, - { x: 10000, y: 15, y0: 10 }, - ]); - }); - }); -}); - -describe('replaceFirstAndLastBucket', () => { - it('should extend the first bucket', () => { - const buckets = [ - { - x: 0, - lower: 10, - upper: 20, - }, - { - x: 5, - lower: null, - upper: null, - }, - { - x: 10, - lower: null, - upper: null, - }, - { - x: 15, - lower: 30, - upper: 40, - }, - ]; - - const timeSeriesDates = [10, 15]; - expect(replaceFirstAndLastBucket(buckets as any, timeSeriesDates)).toEqual([ - { x: 10, lower: 10, upper: 20 }, - { x: 15, lower: 30, upper: 40 }, - ]); - }); - - it('should extend the last bucket', () => { - const buckets = [ - { - x: 10, - lower: 30, - upper: 40, - }, - { - x: 15, - lower: null, - upper: null, - }, - { - x: 20, - lower: null, - upper: null, - }, - ] as any; - - const timeSeriesDates = [10, 15, 20]; - expect(replaceFirstAndLastBucket(buckets, timeSeriesDates)).toEqual([ - { x: 10, lower: 30, upper: 40 }, - { x: 15, lower: null, upper: null }, - { x: 20, lower: 30, upper: 40 }, - ]); - }); -}); - -function getESResponse(buckets: any): ESResponse { - return ({ - took: 3, - timed_out: false, - _shards: { - total: 5, - successful: 5, - skipped: 0, - failed: 0, - }, - hits: { - total: 10, - max_score: 0, - hits: [], - }, - aggregations: { - ml_avg_response_times: { - buckets: buckets.map((bucket: any) => { - return { - ...bucket, - lower: { value: bucket?.lower?.value || null }, - upper: { value: bucket?.upper?.value || null }, - anomaly_score: { - value: bucket?.anomaly_score?.value || null, - }, - }; - }), - }, - }, - } as unknown) as ESResponse; -} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts deleted file mode 100644 index 454a6add3e2562..00000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts +++ /dev/null @@ -1,126 +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 { first, last } from 'lodash'; -import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries'; -import { ESResponse } from './fetcher'; - -type IBucket = ReturnType; -function getBucket( - bucket: Required< - ESResponse - >['aggregations']['ml_avg_response_times']['buckets'][0] -) { - return { - x: bucket.key, - anomalyScore: bucket.anomaly_score.value, - lower: bucket.lower.value, - upper: bucket.upper.value, - }; -} - -export type AnomalyTimeSeriesResponse = ReturnType< - typeof anomalySeriesTransform ->; -export function anomalySeriesTransform( - response: ESResponse, - mlBucketSize: number, - bucketSize: number, - timeSeriesDates: number[] -) { - const buckets = - response.aggregations?.ml_avg_response_times.buckets.map(getBucket) || []; - - const bucketSizeInMillis = Math.max(bucketSize, mlBucketSize) * 1000; - - return { - anomalyScore: getAnomalyScoreDataPoints( - buckets, - timeSeriesDates, - bucketSizeInMillis - ), - anomalyBoundaries: getAnomalyBoundaryDataPoints(buckets, timeSeriesDates), - }; -} - -export function getAnomalyScoreDataPoints( - buckets: IBucket[], - timeSeriesDates: number[], - bucketSizeInMillis: number -): RectCoordinate[] { - const ANOMALY_THRESHOLD = 75; - const firstDate = first(timeSeriesDates); - const lastDate = last(timeSeriesDates); - - return buckets - .filter( - (bucket) => - bucket.anomalyScore !== null && bucket.anomalyScore > ANOMALY_THRESHOLD - ) - .filter(isInDateRange(firstDate, lastDate)) - .map((bucket) => { - return { - x0: bucket.x, - x: Math.min(bucket.x + bucketSizeInMillis, lastDate), // don't go beyond last date - }; - }); -} - -export function getAnomalyBoundaryDataPoints( - buckets: IBucket[], - timeSeriesDates: number[] -): Coordinate[] { - return replaceFirstAndLastBucket(buckets, timeSeriesDates) - .filter((bucket) => bucket.lower !== null) - .map((bucket) => { - return { - x: bucket.x, - y0: bucket.lower, - y: bucket.upper, - }; - }); -} - -export function replaceFirstAndLastBucket( - buckets: IBucket[], - timeSeriesDates: number[] -) { - const firstDate = first(timeSeriesDates); - const lastDate = last(timeSeriesDates); - - const preBucketWithValue = buckets - .filter((p) => p.x <= firstDate) - .reverse() - .find((p) => p.lower !== null); - - const bucketsInRange = buckets.filter(isInDateRange(firstDate, lastDate)); - - // replace first bucket if it is null - const firstBucket = first(bucketsInRange); - if (preBucketWithValue && firstBucket && firstBucket.lower === null) { - firstBucket.lower = preBucketWithValue.lower; - firstBucket.upper = preBucketWithValue.upper; - } - - const lastBucketWithValue = [...buckets] - .reverse() - .find((p) => p.lower !== null); - - // replace last bucket if it is null - const lastBucket = last(bucketsInRange); - if (lastBucketWithValue && lastBucket && lastBucket.lower === null) { - lastBucket.lower = lastBucketWithValue.lower; - lastBucket.upper = lastBucketWithValue.upper; - } - - return bucketsInRange; -} - -// anomaly time series contain one or more buckets extra in the beginning -// these extra buckets should be removed -function isInDateRange(firstDate: number, lastDate: number) { - return (p: IBucket) => p.x >= firstDate && p.x <= lastDate; -} diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 8672c6c108c4cf..08eba00251e264 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -105,6 +105,7 @@ export const serviceAnnotationsRoute = createRoute(() => ({ environment, annotationsClient, apiCaller: context.core.elasticsearch.legacy.client.callAsCurrentUser, + logger: context.logger, }); }, })); diff --git a/x-pack/plugins/canvas/.storybook/config.js b/x-pack/plugins/canvas/.storybook/config.js index c808a672711aba..04b4e2a8e7b4b0 100644 --- a/x-pack/plugins/canvas/.storybook/config.js +++ b/x-pack/plugins/canvas/.storybook/config.js @@ -59,6 +59,9 @@ function loadStories() { // Find all files ending in *.examples.ts const req = require.context('./..', true, /.(stories|examples).tsx$/); req.keys().forEach(filename => req(filename)); + + // Import Canvas CSS + require('../public/style/index.scss') } // Set up the Storybook environment with custom settings. diff --git a/x-pack/plugins/canvas/.storybook/webpack.config.js b/x-pack/plugins/canvas/.storybook/webpack.config.js index 4d83a3d4fa70f9..45a5303d8b0db1 100644 --- a/x-pack/plugins/canvas/.storybook/webpack.config.js +++ b/x-pack/plugins/canvas/.storybook/webpack.config.js @@ -199,6 +199,7 @@ module.exports = async ({ config }) => { config.resolve.alias['ui/url/absolute_to_parsed_url'] = path.resolve(__dirname, '../tasks/mocks/uiAbsoluteToParsedUrl'); config.resolve.alias['ui/chrome'] = path.resolve(__dirname, '../tasks/mocks/uiChrome'); config.resolve.alias.ui = path.resolve(KIBANA_ROOT, 'src/legacy/ui/public'); + config.resolve.alias['src/legacy/ui/public/styles/styling_constants'] = path.resolve(KIBANA_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss'); config.resolve.alias.ng_mock$ = path.resolve(KIBANA_ROOT, 'src/test_utils/public/ng_mock'); return config; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.ts index 41323a82f4ee00..e44fb903ef042a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/markdown.ts @@ -10,7 +10,7 @@ import { Style, ExpressionFunctionDefinition, } from 'src/plugins/expressions/common'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Handlebars } from '../../../common/lib/handlebars'; import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.ts index e952faca1d5eb3..8a28f71ee1b47d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/compare.ts @@ -59,25 +59,25 @@ export function compare(): ExpressionFunctionDefinition<'compare', Context, Argu return a !== b; case Operation.LT: if (typesMatch) { - // @ts-ignore #35433 This is a wonky comparison for nulls + // @ts-expect-error #35433 This is a wonky comparison for nulls return a < b; } return false; case Operation.LTE: if (typesMatch) { - // @ts-ignore #35433 This is a wonky comparison for nulls + // @ts-expect-error #35433 This is a wonky comparison for nulls return a <= b; } return false; case Operation.GT: if (typesMatch) { - // @ts-ignore #35433 This is a wonky comparison for nulls + // @ts-expect-error #35433 This is a wonky comparison for nulls return a > b; } return false; case Operation.GTE: if (typesMatch) { - // @ts-ignore #35433 This is a wonky comparison for nulls + // @ts-expect-error #35433 This is a wonky comparison for nulls return a >= b; } return false; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts index b841fde284ab6f..09ce2b2bf17558 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/containerStyle.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ContainerStyle, Overflow, BackgroundRepeat, BackgroundSize } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { isValidUrl } from '../../../common/lib/url'; interface Output extends ContainerStyle { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts index c43ff6373ea0f1..3ef956b41ce20b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts @@ -6,9 +6,9 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -// @ts-ignore .png file +// @ts-expect-error .png file import { elasticLogo } from '../../lib/elastic_logo'; export enum ImageMode { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts index 7f84dc54d8092e..e36644530eae86 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore no @typed def; Elastic library +// @ts-expect-error no @typed def; Elastic library import { evaluate } from 'tinymath'; -// @ts-ignore untyped local import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js index af03297ad666ba..01cabd171c2fed 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js @@ -5,7 +5,7 @@ */ import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; -import { palettes } from '../../../common/lib/palettes'; +import { paulTor14 } from '../../../common/lib/palettes'; import { palette } from './palette'; describe('palette', () => { @@ -25,7 +25,7 @@ describe('palette', () => { it('defaults to pault_tor_14 colors', () => { const result = fn(null); - expect(result.colors).toEqual(palettes.paul_tor_14.colors); + expect(result.colors).toEqual(paulTor14.colors); }); }); @@ -47,17 +47,17 @@ describe('palette', () => { describe('reverse', () => { it('reverses order of the colors', () => { const result = fn(null, { reverse: true }); - expect(result.colors).toEqual(palettes.paul_tor_14.colors.reverse()); + expect(result.colors).toEqual(paulTor14.colors.reverse()); }); it('keeps the original order of the colors', () => { const result = fn(null, { reverse: false }); - expect(result.colors).toEqual(palettes.paul_tor_14.colors); + expect(result.colors).toEqual(paulTor14.colors); }); it(`defaults to 'false`, () => { const result = fn(null); - expect(result.colors).toEqual(palettes.paul_tor_14.colors); + expect(result.colors).toEqual(paulTor14.colors); }); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts index 63cd663d2ac4c5..50d62a19b23612 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts @@ -5,8 +5,7 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -// @ts-ignore untyped local -import { palettes } from '../../../common/lib/palettes'; +import { paulTor14 } from '../../../common/lib/palettes'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -52,7 +51,7 @@ export function palette(): ExpressionFunctionDefinition<'palette', null, Argumen }, fn: (input, args) => { const { color, reverse, gradient } = args; - const colors = ([] as string[]).concat(color || palettes.paul_tor_14.colors); + const colors = ([] as string[]).concat(color || paulTor14.colors); return { type: 'palette', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts index 6cb64a43ea5827..b568f18924869f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts @@ -5,11 +5,11 @@ */ import { get, map, groupBy } from 'lodash'; -// @ts-ignore lodash.keyby imports invalid member from @types/lodash +// @ts-expect-error lodash.keyby imports invalid member from @types/lodash import keyBy from 'lodash.keyby'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getColorsFromPalette } from '../../../common/lib/get_colors_from_palette'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getLegendConfig } from '../../../common/lib/get_legend_config'; import { getFunctionHelp } from '../../../i18n'; import { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts index e8214ca8eaf9f0..0b4583f4581aea 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore no @typed def +// @ts-expect-error no @typed def import keyBy from 'lodash.keyby'; import { groupBy, get, set, map, sortBy } from 'lodash'; import { ExpressionFunctionDefinition, Style } from 'src/plugins/expressions'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getColorsFromPalette } from '../../../../common/lib/get_colors_from_palette'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getLegendConfig } from '../../../../common/lib/get_legend_config'; import { getFlotAxisConfig } from './get_flot_axis_config'; import { getFontSpec } from './get_font_spec'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts index da50195480c687..f8eeabfccde6d4 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/render.ts @@ -7,7 +7,6 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { Render, ContainerStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; -// @ts-ignore unconverted local file import { DEFAULT_ELEMENT_CSS } from '../../../common/lib/constants'; interface ContainerStyleArgument extends ContainerStyle { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.ts index f91fd3dfc55229..9e296f2b9a92a8 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeatImage.ts @@ -5,9 +5,9 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -// @ts-ignore .png file +// @ts-expect-error .png file import { elasticOutline } from '../../lib/elastic_outline'; import { Render } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts index d961227a302b8d..3e721cc49b4111 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/revealImage.ts @@ -5,9 +5,9 @@ */ import { ExpressionFunctionDefinition, ExpressionValueRender } from 'src/plugins/expressions'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { resolveWithMissingImage } from '../../../common/lib/resolve_dataurl'; -// @ts-ignore .png file +// @ts-expect-error .png file import { elasticOutline } from '../../lib/elastic_outline'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 83663dd2a00ad8..2782ca039d7ed1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -78,7 +78,7 @@ export function savedVisualization(): ExpressionFunctionDefinition< } if (hideLegend === true) { - // @ts-ignore LegendOpen missing on VisualizeInput + // @ts-expect-error LegendOpen missing on VisualizeInput visOptions.legendOpen = false; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts index 9dd38dd57c677a..4fa4be0a2f09ff 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore untyped Elastic library import { getType } from '@kbn/interpreter/common'; import { ExpressionFunctionDefinition, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts index 843e2bda47e125..60d5edeb10483d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts @@ -6,7 +6,7 @@ import { sortBy } from 'lodash'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; -// @ts-ignore unconverted lib file +// @ts-expect-error unconverted lib file import { queryDatatable } from '../../../../common/lib/datatable/query'; import { DemoRows } from './demo_rows_types'; import { getDemoRows } from './get_demo_rows'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.ts index 142331aabf3514..26f651e770363e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/escount.ts @@ -9,7 +9,7 @@ import { ExpressionValueFilter, } from 'src/plugins/expressions/common'; /* eslint-disable */ -// @ts-ignore untyped local +// @ts-expect-error untyped local import { buildESRequest } from '../../../server/lib/build_es_request'; /* eslint-enable */ import { getFunctionHelp } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 2b229b8957ec1d..a090f09a76ea2a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -7,7 +7,7 @@ import squel from 'squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; /* eslint-disable */ -// @ts-ignore untyped local +// @ts-expect-error untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; /* eslint-enable */ import { ExpressionValueFilter } from '../../../types'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql.ts index c64398d4b3a183..5ac91bec849c2a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/essql.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; /* eslint-disable */ -// @ts-ignore untyped local +// @ts-expect-error untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; /* eslint-enable */ import { ExpressionValueFilter } from '../../../types'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts index c25628e5cf2b90..7dee587895485a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore untyped library +// @ts-expect-error untyped library import { parse } from 'tinymath'; import { getFieldNames } from './pointseries/lib/get_field_names'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts index 54e48c8abf04b0..bae80d3c335104 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore Untyped library +// @ts-expect-error untyped library import uniqBy from 'lodash.uniqby'; -// @ts-ignore Untyped Elastic library +// @ts-expect-error untyped Elastic library import { evaluate } from 'tinymath'; import { groupBy, zipObject, omit } from 'lodash'; import moment from 'moment'; @@ -18,13 +18,10 @@ import { PointSeriesColumnName, PointSeriesColumns, } from 'src/plugins/expressions/common'; -// @ts-ignore Untyped local import { pivotObjectArray } from '../../../../common/lib/pivot_object_array'; -// @ts-ignore Untyped local import { unquoteString } from '../../../../common/lib/unquote_string'; -// @ts-ignore Untyped local import { isColumnReference } from './lib/is_column_reference'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { getExpressionType } from './lib/get_expression_type'; import { getFunctionHelp, getFunctionErrors } from '../../../../i18n'; @@ -125,7 +122,7 @@ export function pointseries(): ExpressionFunctionDefinition< col.role = 'measure'; } - // @ts-ignore untyped local: get_expression_type + // @ts-expect-error untyped local: get_expression_type columns[argName] = col; } }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts index 0ecc135ba90423..aed9861e1250cf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore Untyped Library +// @ts-expect-error untyped library import { parse } from 'tinymath'; export function isColumnReference(mathExpression: string | null): boolean { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts b/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts index c9ce4d065968a4..4fbb5d0069e51d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts @@ -12,17 +12,16 @@ import { Start as InspectorStart } from '../../../../src/plugins/inspector/publi import { functions } from './functions/browser'; import { typeFunctions } from './expression_types'; -// @ts-ignore: untyped local +// @ts-expect-error: untyped local import { renderFunctions, renderFunctionFactories } from './renderers'; import { initializeElements } from './elements'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { transformSpecs } from './uis/transforms'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { datasourceSpecs } from './uis/datasources'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { modelSpecs } from './uis/models'; import { initializeViews } from './uis/views'; -// @ts-ignore Untyped Local import { initializeArgs } from './uis/arguments'; import { tagSpecs } from './uis/tags'; import { templateSpecs } from './templates'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts index 4c8de2afd81ada..f03c10e2d424eb 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts @@ -26,7 +26,7 @@ export function toExpression(input: VisualizeInput): string { .reduce((_, part) => expressionParts.push(part), 0); } - // @ts-ignore LegendOpen missing on VisualizeInput type + // @ts-expect-error LegendOpen missing on VisualizeInput type if (input.vis?.legendOpen !== undefined && input.vis.legendOpen === false) { expressionParts.push(`hideLegend=true`); } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/__snapshots__/palette.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/__snapshots__/palette.stories.storyshot new file mode 100644 index 00000000000000..385b16d3d8e8e1 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/__snapshots__/palette.stories.storyshot @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/Palette default 1`] = ` +
+
+
+ +
+
+ + Select an option: +
+ , is selected + + +
+ + +
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/palette.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/palette.stories.tsx new file mode 100644 index 00000000000000..6bc285a3d66d29 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__examples__/palette.stories.tsx @@ -0,0 +1,32 @@ +/* + * 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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { PaletteArgInput } from '../palette'; +import { paulTor14 } from '../../../../common/lib/palettes'; + +storiesOf('arguments/Palette', module).add('default', () => ( +
+ +
+)); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/index.ts index fce9b21fa03873..e972928fe20b04 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/index.ts @@ -7,7 +7,6 @@ import { compose, withProps } from 'recompose'; import moment from 'moment'; import { DateFormatArgInput as Component, Props as ComponentProps } from './date_format'; -// @ts-ignore untyped local lib import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; import { ArgumentFactory } from '../../../../types/arguments'; import { ArgumentStrings } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.ts index 2f9a21d8a009f7..ddf428d884917c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/index.ts @@ -5,31 +5,30 @@ */ import { axisConfig } from './axis_config'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { datacolumn } from './datacolumn'; import { dateFormatInitializer } from './date_format'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { filterGroup } from './filter_group'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { imageUpload } from './image_upload'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { number } from './number'; import { numberFormatInitializer } from './number_format'; -// @ts-ignore untyped local import { palette } from './palette'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { percentage } from './percentage'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { range } from './range'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { select } from './select'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { shape } from './shape'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { string } from './string'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { textarea } from './textarea'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { toggle } from './toggle'; import { SetupInitializer } from '../../plugin'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number_format/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number_format/index.ts index 5a3e3904f4f234..17d630f0ab9e29 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number_format/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number_format/index.ts @@ -6,7 +6,6 @@ import { compose, withProps } from 'recompose'; import { NumberFormatArgInput as Component, Props as ComponentProps } from './number_format'; -// @ts-ignore untyped local lib import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component'; import { ArgumentFactory } from '../../../../types/arguments'; import { ArgumentStrings } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.tsx similarity index 58% rename from x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js rename to x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.tsx index eddaa20a4800ee..a33d000a1f6563 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/palette.tsx @@ -4,45 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; import { get } from 'lodash'; import { getType } from '@kbn/interpreter/common'; +import { ExpressionAstFunction, ExpressionAstExpression } from 'src/plugins/expressions'; import { PalettePicker } from '../../../public/components/palette_picker'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { ArgumentStrings } from '../../../i18n'; +import { identifyPalette, ColorPalette } from '../../../common/lib'; const { Palette: strings } = ArgumentStrings; -const PaletteArgInput = ({ onValueChange, argValue, renderError }) => { - // Why is this neccesary? Does the dialog really need to know what parameter it is setting? - - const throwNotParsed = () => renderError(); +interface Props { + onValueChange: (value: ExpressionAstExpression) => void; + argValue: ExpressionAstExpression; + renderError: () => void; + argId?: string; +} +export const PaletteArgInput: FC = ({ onValueChange, argId, argValue, renderError }) => { // TODO: This is weird, its basically a reimplementation of what the interpretter would return. - // Probably a better way todo this, and maybe a better way to handle template stype objects in general? - function astToPalette({ chain }) { + // Probably a better way todo this, and maybe a better way to handle template type objects in general? + const astToPalette = ({ chain }: { chain: ExpressionAstFunction[] }): ColorPalette | null => { if (chain.length !== 1 || chain[0].function !== 'palette') { - throwNotParsed(); + renderError(); + return null; } + try { const colors = chain[0].arguments._.map((astObj) => { if (getType(astObj) !== 'string') { - throwNotParsed(); + renderError(); } return astObj; - }); + }) as string[]; - const gradient = get(chain[0].arguments.gradient, '[0]'); + const gradient = get(chain[0].arguments.gradient, '[0]'); + const palette = identifyPalette({ colors, gradient }); - return { colors, gradient }; + if (palette) { + return palette; + } + + return ({ + id: 'custom', + label: strings.getCustomPaletteLabel(), + colors, + gradient, + } as any) as ColorPalette; } catch (e) { - throwNotParsed(); + renderError(); } - } + return null; + }; - function handleChange(palette) { - const astObj = { + const handleChange = (palette: ColorPalette): void => { + const astObj: ExpressionAstExpression = { type: 'expression', chain: [ { @@ -57,16 +75,20 @@ const PaletteArgInput = ({ onValueChange, argValue, renderError }) => { }; onValueChange(astObj); - } + }; const palette = astToPalette(argValue); - return ( - - ); + if (!palette) { + renderError(); + return null; + } + + return ; }; PaletteArgInput.propTypes = { + argId: PropTypes.string, onValueChange: PropTypes.func.isRequired, argValue: PropTypes.any.isRequired, renderError: PropTypes.func, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.ts index 34877f2fd551bf..19f10628a90cb2 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/index.ts @@ -4,33 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore untyped local +// @ts-expect-error untyped local import { dropdownControl } from './dropdownControl'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getCell } from './getCell'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { image } from './image'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { markdown } from './markdown'; -// @ts-ignore untyped local import { metricInitializer } from './metric'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { pie } from './pie'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { plot } from './plot'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { progress } from './progress'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { repeatImage } from './repeatImage'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { revealImage } from './revealImage'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { render } from './render'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { shape } from './shape'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { table } from './table'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { timefilterControl } from './timefilterControl'; import { SetupInitializer } from '../../plugin'; diff --git a/x-pack/plugins/canvas/common/lib/autocomplete.ts b/x-pack/plugins/canvas/common/lib/autocomplete.ts index c97879de2137e6..0a30b2e2f598eb 100644 --- a/x-pack/plugins/canvas/common/lib/autocomplete.ts +++ b/x-pack/plugins/canvas/common/lib/autocomplete.ts @@ -5,7 +5,7 @@ */ import { uniq } from 'lodash'; -// @ts-ignore Untyped Library +// @ts-expect-error untyped library import { parse } from '@kbn/interpreter/common'; import { ExpressionAstExpression, diff --git a/x-pack/plugins/canvas/common/lib/dataurl.ts b/x-pack/plugins/canvas/common/lib/dataurl.ts index ea5a26b27e4232..60e65a6d3ca1ca 100644 --- a/x-pack/plugins/canvas/common/lib/dataurl.ts +++ b/x-pack/plugins/canvas/common/lib/dataurl.ts @@ -6,7 +6,7 @@ import { fromByteArray } from 'base64-js'; -// @ts-ignore @types/mime doesn't resolve mime/lite for some reason. +// @ts-expect-error @types/mime doesn't resolve mime/lite for some reason. import mime from 'mime/lite'; const dataurlRegex = /^data:([a-z]+\/[a-z0-9-+.]+)(;[a-z-]+=[a-z0-9-]+)?(;([a-z0-9]+))?,/; diff --git a/x-pack/plugins/canvas/common/lib/index.ts b/x-pack/plugins/canvas/common/lib/index.ts index 5ab29c290c3da2..6bd7e0bc9948fd 100644 --- a/x-pack/plugins/canvas/common/lib/index.ts +++ b/x-pack/plugins/canvas/common/lib/index.ts @@ -4,39 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './datatable'; -// @ts-ignore missing local definition export * from './autocomplete'; export * from './constants'; export * from './dataurl'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './errors'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './expression_form_handlers'; -// @ts-ignore missing local definition export * from './fetch'; export * from './fonts'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './get_colors_from_palette'; -// @ts-ignore missing local definition export * from './get_field_type'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './get_legend_config'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './handlebars'; export * from './hex_to_rgb'; -// @ts-ignore missing local definition export * from './httpurl'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './missing_asset'; -// @ts-ignore missing local definition export * from './palettes'; -// @ts-ignore missing local definition export * from './pivot_object_array'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './resolve_dataurl'; -// @ts-ignore missing local definition export * from './unquote_string'; -// @ts-ignore missing local definition +// @ts-expect-error missing local definition export * from './url'; diff --git a/x-pack/plugins/canvas/common/lib/palettes.js b/x-pack/plugins/canvas/common/lib/palettes.js deleted file mode 100644 index 3fe977ec3862c4..00000000000000 --- a/x-pack/plugins/canvas/common/lib/palettes.js +++ /dev/null @@ -1,152 +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. - */ - -/* - This should be pluggable -*/ - -export const palettes = { - paul_tor_14: { - colors: [ - '#882E72', - '#B178A6', - '#D6C1DE', - '#1965B0', - '#5289C7', - '#7BAFDE', - '#4EB265', - '#90C987', - '#CAE0AB', - '#F7EE55', - '#F6C141', - '#F1932D', - '#E8601C', - '#DC050C', - ], - gradient: false, - }, - paul_tor_21: { - colors: [ - '#771155', - '#AA4488', - '#CC99BB', - '#114477', - '#4477AA', - '#77AADD', - '#117777', - '#44AAAA', - '#77CCCC', - '#117744', - '#44AA77', - '#88CCAA', - '#777711', - '#AAAA44', - '#DDDD77', - '#774411', - '#AA7744', - '#DDAA77', - '#771122', - '#AA4455', - '#DD7788', - ], - gradient: false, - }, - earth_tones: { - colors: [ - '#842113', - '#984d23', - '#32221c', - '#739379', - '#dab150', - '#4d2521', - '#716c49', - '#bb3918', - '#7e5436', - '#c27c34', - '#72392e', - '#8f8b7e', - ], - gradient: false, - }, - canvas: { - colors: [ - '#01A4A4', - '#CC6666', - '#D0D102', - '#616161', - '#00A1CB', - '#32742C', - '#F18D05', - '#113F8C', - '#61AE24', - '#D70060', - ], - gradient: false, - }, - color_blind: { - colors: [ - '#1ea593', - '#2b70f7', - '#ce0060', - '#38007e', - '#fca5d3', - '#f37020', - '#e49e29', - '#b0916f', - '#7b000b', - '#34130c', - ], - gradient: false, - }, - elastic_teal: { - colors: ['#C5FAF4', '#0F6259'], - gradient: true, - }, - elastic_blue: { - colors: ['#7ECAE3', '#003A4D'], - gradient: true, - }, - elastic_yellow: { - colors: ['#FFE674', '#4D3F00'], - gradient: true, - }, - elastic_pink: { - colors: ['#FEA8D5', '#531E3A'], - gradient: true, - }, - elastic_green: { - colors: ['#D3FB71', '#131A00'], - gradient: true, - }, - elastic_orange: { - colors: ['#FFC68A', '#7B3F00'], - gradient: true, - }, - elastic_purple: { - colors: ['#CCC7DF', '#130351'], - gradient: true, - }, - green_blue_red: { - colors: ['#D3FB71', '#7ECAE3', '#f03b20'], - gradient: true, - }, - yellow_green: { - colors: ['#f7fcb9', '#addd8e', '#31a354'], - gradient: true, - }, - yellow_blue: { - colors: ['#edf8b1', '#7fcdbb', '#2c7fb8'], - gradient: true, - }, - yellow_red: { - colors: ['#ffeda0', '#feb24c', '#f03b20'], - gradient: true, - }, - instagram: { - colors: ['#833ab4', '#fd1d1d', '#fcb045'], - gradient: true, - }, -}; diff --git a/x-pack/plugins/canvas/common/lib/palettes.ts b/x-pack/plugins/canvas/common/lib/palettes.ts new file mode 100644 index 00000000000000..1469ba63967c0e --- /dev/null +++ b/x-pack/plugins/canvas/common/lib/palettes.ts @@ -0,0 +1,263 @@ +/* + * 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 { LibStrings } from '../../i18n'; + +const { Palettes: strings } = LibStrings; + +/** + * This type contains a unions of all supported palette ids. + */ +export type PaletteID = typeof palettes[number]['id']; + +/** + * An interface representing a color palette in Canvas, with a textual label and a set of + * hex values. + */ +export interface ColorPalette { + id: PaletteID; + label: string; + colors: string[]; + gradient: boolean; +} + +// This function allows one to create a strongly-typed palette for inclusion in +// the palette collection. As a result, the values and labels are known to the +// type system, preventing one from specifying a non-existent palette at build +// time. +function createPalette< + RawPalette extends { + id: RawPaletteID; + }, + RawPaletteID extends string +>(palette: RawPalette) { + return palette; +} + +/** + * Return a palette given a set of colors and gradient. Returns undefined if the + * palette doesn't match. + */ +export const identifyPalette = ( + input: Pick +): ColorPalette | undefined => { + return palettes.find((palette) => { + const { colors, gradient } = palette; + return gradient === input.gradient && isEqual(colors, input.colors); + }); +}; + +export const paulTor14 = createPalette({ + id: 'paul_tor_14', + label: 'Paul Tor 14', + colors: [ + '#882E72', + '#B178A6', + '#D6C1DE', + '#1965B0', + '#5289C7', + '#7BAFDE', + '#4EB265', + '#90C987', + '#CAE0AB', + '#F7EE55', + '#F6C141', + '#F1932D', + '#E8601C', + '#DC050C', + ], + gradient: false, +}); + +export const paulTor21 = createPalette({ + id: 'paul_tor_21', + label: 'Paul Tor 21', + colors: [ + '#771155', + '#AA4488', + '#CC99BB', + '#114477', + '#4477AA', + '#77AADD', + '#117777', + '#44AAAA', + '#77CCCC', + '#117744', + '#44AA77', + '#88CCAA', + '#777711', + '#AAAA44', + '#DDDD77', + '#774411', + '#AA7744', + '#DDAA77', + '#771122', + '#AA4455', + '#DD7788', + ], + gradient: false, +}); + +export const earthTones = createPalette({ + id: 'earth_tones', + label: strings.getEarthTones(), + colors: [ + '#842113', + '#984d23', + '#32221c', + '#739379', + '#dab150', + '#4d2521', + '#716c49', + '#bb3918', + '#7e5436', + '#c27c34', + '#72392e', + '#8f8b7e', + ], + gradient: false, +}); + +export const canvas = createPalette({ + id: 'canvas', + label: strings.getCanvas(), + colors: [ + '#01A4A4', + '#CC6666', + '#D0D102', + '#616161', + '#00A1CB', + '#32742C', + '#F18D05', + '#113F8C', + '#61AE24', + '#D70060', + ], + gradient: false, +}); + +export const colorBlind = createPalette({ + id: 'color_blind', + label: strings.getColorBlind(), + colors: [ + '#1ea593', + '#2b70f7', + '#ce0060', + '#38007e', + '#fca5d3', + '#f37020', + '#e49e29', + '#b0916f', + '#7b000b', + '#34130c', + ], + gradient: false, +}); + +export const elasticTeal = createPalette({ + id: 'elastic_teal', + label: strings.getElasticTeal(), + colors: ['#7ECAE3', '#003A4D'], + gradient: true, +}); + +export const elasticBlue = createPalette({ + id: 'elastic_blue', + label: strings.getElasticBlue(), + colors: ['#C5FAF4', '#0F6259'], + gradient: true, +}); + +export const elasticYellow = createPalette({ + id: 'elastic_yellow', + label: strings.getElasticYellow(), + colors: ['#FFE674', '#4D3F00'], + gradient: true, +}); + +export const elasticPink = createPalette({ + id: 'elastic_pink', + label: strings.getElasticPink(), + colors: ['#FEA8D5', '#531E3A'], + gradient: true, +}); + +export const elasticGreen = createPalette({ + id: 'elastic_green', + label: strings.getElasticGreen(), + colors: ['#D3FB71', '#131A00'], + gradient: true, +}); + +export const elasticOrange = createPalette({ + id: 'elastic_orange', + label: strings.getElasticOrange(), + colors: ['#FFC68A', '#7B3F00'], + gradient: true, +}); + +export const elasticPurple = createPalette({ + id: 'elastic_purple', + label: strings.getElasticPurple(), + colors: ['#CCC7DF', '#130351'], + gradient: true, +}); + +export const greenBlueRed = createPalette({ + id: 'green_blue_red', + label: strings.getGreenBlueRed(), + colors: ['#D3FB71', '#7ECAE3', '#f03b20'], + gradient: true, +}); + +export const yellowGreen = createPalette({ + id: 'yellow_green', + label: strings.getYellowGreen(), + colors: ['#f7fcb9', '#addd8e', '#31a354'], + gradient: true, +}); + +export const yellowBlue = createPalette({ + id: 'yellow_blue', + label: strings.getYellowBlue(), + colors: ['#edf8b1', '#7fcdbb', '#2c7fb8'], + gradient: true, +}); + +export const yellowRed = createPalette({ + id: 'yellow_red', + label: strings.getYellowRed(), + colors: ['#ffeda0', '#feb24c', '#f03b20'], + gradient: true, +}); + +export const instagram = createPalette({ + id: 'instagram', + label: strings.getInstagram(), + colors: ['#833ab4', '#fd1d1d', '#fcb045'], + gradient: true, +}); + +export const palettes = [ + paulTor14, + paulTor21, + earthTones, + canvas, + colorBlind, + elasticTeal, + elasticBlue, + elasticYellow, + elasticPink, + elasticGreen, + elasticOrange, + elasticPurple, + greenBlueRed, + yellowGreen, + yellowBlue, + yellowRed, + instagram, +]; diff --git a/x-pack/plugins/canvas/common/lib/pivot_object_array.test.ts b/x-pack/plugins/canvas/common/lib/pivot_object_array.test.ts index faf319769cab03..0fbc2fa6b0f387 100644 --- a/x-pack/plugins/canvas/common/lib/pivot_object_array.test.ts +++ b/x-pack/plugins/canvas/common/lib/pivot_object_array.test.ts @@ -55,7 +55,7 @@ describe('pivotObjectArray', () => { }); it('throws when given an invalid column list', () => { - // @ts-ignore testing potential calls from legacy code that should throw + // @ts-expect-error testing potential calls from legacy code that should throw const check = () => pivotObjectArray(rows, [{ name: 'price' }, { name: 'missing' }]); expect(check).toThrowError('Columns should be an array of strings'); }); diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index de16bc2101e8cf..0b512c80b209ba 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -586,6 +586,16 @@ export const ComponentStrings = { defaultMessage: 'Delete', }), }, + PalettePicker: { + getEmptyPaletteLabel: () => + i18n.translate('xpack.canvas.palettePicker.emptyPaletteLabel', { + defaultMessage: 'None', + }), + getNoPaletteFoundErrorTitle: () => + i18n.translate('xpack.canvas.palettePicker.noPaletteFoundErrorTitle', { + defaultMessage: 'Color palette not found', + }), + }, SavedElementsModal: { getAddNewElementDescription: () => i18n.translate('xpack.canvas.savedElementsModal.addNewElementDescription', { diff --git a/x-pack/plugins/canvas/i18n/constants.ts b/x-pack/plugins/canvas/i18n/constants.ts index 099effc697fc56..af82d0afc7e9ff 100644 --- a/x-pack/plugins/canvas/i18n/constants.ts +++ b/x-pack/plugins/canvas/i18n/constants.ts @@ -20,6 +20,7 @@ export const FONT_FAMILY = '`font-family`'; export const FONT_WEIGHT = '`font-weight`'; export const HEX = 'HEX'; export const HTML = 'HTML'; +export const INSTAGRAM = 'Instagram'; export const ISO8601 = 'ISO8601'; export const JS = 'JavaScript'; export const JSON = 'JSON'; diff --git a/x-pack/plugins/canvas/i18n/index.ts b/x-pack/plugins/canvas/i18n/index.ts index 864311d34aca08..3bf1fa077130cd 100644 --- a/x-pack/plugins/canvas/i18n/index.ts +++ b/x-pack/plugins/canvas/i18n/index.ts @@ -11,6 +11,7 @@ export * from './errors'; export * from './expression_types'; export * from './elements'; export * from './functions'; +export * from './lib'; export * from './renderers'; export * from './shortcuts'; export * from './tags'; diff --git a/x-pack/plugins/canvas/i18n/lib.ts b/x-pack/plugins/canvas/i18n/lib.ts new file mode 100644 index 00000000000000..eca6dc44354a27 --- /dev/null +++ b/x-pack/plugins/canvas/i18n/lib.ts @@ -0,0 +1,92 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { CANVAS, INSTAGRAM } from './constants'; + +export const LibStrings = { + Palettes: { + getEarthTones: () => + i18n.translate('xpack.canvas.lib.palettes.earthTonesLabel', { + defaultMessage: 'Earth Tones', + }), + getCanvas: () => + i18n.translate('xpack.canvas.lib.palettes.canvasLabel', { + defaultMessage: '{CANVAS}', + values: { + CANVAS, + }, + }), + + getColorBlind: () => + i18n.translate('xpack.canvas.lib.palettes.colorBlindLabel', { + defaultMessage: 'Color Blind', + }), + + getElasticTeal: () => + i18n.translate('xpack.canvas.lib.palettes.elasticTealLabel', { + defaultMessage: 'Elastic Teal', + }), + + getElasticBlue: () => + i18n.translate('xpack.canvas.lib.palettes.elasticBlueLabel', { + defaultMessage: 'Elastic Blue', + }), + + getElasticYellow: () => + i18n.translate('xpack.canvas.lib.palettes.elasticYellowLabel', { + defaultMessage: 'Elastic Yellow', + }), + + getElasticPink: () => + i18n.translate('xpack.canvas.lib.palettes.elasticPinkLabel', { + defaultMessage: 'Elastic Pink', + }), + + getElasticGreen: () => + i18n.translate('xpack.canvas.lib.palettes.elasticGreenLabel', { + defaultMessage: 'Elastic Green', + }), + + getElasticOrange: () => + i18n.translate('xpack.canvas.lib.palettes.elasticOrangeLabel', { + defaultMessage: 'Elastic Orange', + }), + + getElasticPurple: () => + i18n.translate('xpack.canvas.lib.palettes.elasticPurpleLabel', { + defaultMessage: 'Elastic Purple', + }), + + getGreenBlueRed: () => + i18n.translate('xpack.canvas.lib.palettes.greenBlueRedLabel', { + defaultMessage: 'Green, Blue, Red', + }), + + getYellowGreen: () => + i18n.translate('xpack.canvas.lib.palettes.yellowGreenLabel', { + defaultMessage: 'Yellow, Green', + }), + + getYellowBlue: () => + i18n.translate('xpack.canvas.lib.palettes.yellowBlueLabel', { + defaultMessage: 'Yellow, Blue', + }), + + getYellowRed: () => + i18n.translate('xpack.canvas.lib.palettes.yellowRedLabel', { + defaultMessage: 'Yellow, Red', + }), + + getInstagram: () => + i18n.translate('xpack.canvas.lib.palettes.instagramLabel', { + defaultMessage: '{INSTAGRAM}', + values: { + INSTAGRAM, + }, + }), + }, +}; diff --git a/x-pack/plugins/canvas/i18n/ui.ts b/x-pack/plugins/canvas/i18n/ui.ts index f69f9e747ab902..bc282db203be2e 100644 --- a/x-pack/plugins/canvas/i18n/ui.ts +++ b/x-pack/plugins/canvas/i18n/ui.ts @@ -232,7 +232,11 @@ export const ArgumentStrings = { }), getHelp: () => i18n.translate('xpack.canvas.uis.arguments.paletteLabel', { - defaultMessage: 'Choose a color palette', + defaultMessage: 'The collection of colors used to render the element', + }), + getCustomPaletteLabel: () => + i18n.translate('xpack.canvas.uis.arguments.customPaletteLabel', { + defaultMessage: 'Custom', }), }, Percentage: { diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index c799f36a283c15..b2c836fe4805f0 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -15,14 +15,14 @@ import { BehaviorSubject } from 'rxjs'; import { AppMountParameters, CoreStart, CoreSetup, AppUpdater } from 'kibana/public'; import { CanvasStartDeps, CanvasSetupDeps } from './plugin'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { App } from './components/app'; import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; import { registerLanguage } from './lib/monaco_language_def'; import { SetupRegistries } from './plugin_api'; import { initRegistries, populateRegistries, destroyRegistries } from './registries'; import { getDocumentationLinks } from './lib/documentation_links'; -// @ts-ignore untyped component +// @ts-expect-error untyped component import { HelpMenu } from './components/help_menu/help_menu'; import { createStore } from './store'; @@ -32,12 +32,12 @@ import { init as initStatsReporter } from './lib/ui_metric'; import { CapabilitiesStrings } from '../i18n'; import { startServices, services } from './services'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { createHistory, destroyHistory } from './lib/history_provider'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { stopRouter } from './lib/router_provider'; import { initFunctions } from './functions'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { appUnload } from './state/actions/app'; import './style/index.scss'; diff --git a/x-pack/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx b/x-pack/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx index 7f5b53df4ba523..b0a8d1e990e758 100644 --- a/x-pack/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx +++ b/x-pack/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { mount } from 'enzyme'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { ExportApp } from '../export_app'; jest.mock('style-it', () => ({ diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx index 3014369d948579..981334ff8d9f25 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx @@ -6,11 +6,8 @@ import React, { useState, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -// @ts-ignore: Local Untyped import { trackCanvasUiMetric, METRIC_TYPE } from '../../../lib/ui_metric'; -// @ts-ignore: Local Untyped import { getElementCounts } from '../../../state/selectors/workpad'; -// @ts-ignore: Local Untyped import { getArgs } from '../../../state/selectors/resolved_args'; const WorkpadLoadedMetric = 'workpad-loaded'; diff --git a/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.tsx b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.tsx index c26fdb8c46d0f3..26295acecd9203 100644 --- a/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.tsx +++ b/x-pack/plugins/canvas/public/components/arg_add_popover/arg_add_popover.tsx @@ -7,11 +7,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { EuiButtonIcon } from '@elastic/eui'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Popover, PopoverChildrenProps } from '../popover'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { ArgAdd } from '../arg_add'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Arg } from '../../expression_types/arg'; import { ComponentStrings } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx index cb7ec1aba8f59b..b0eaecc7b5203c 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx @@ -7,7 +7,6 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - // @ts-ignore (elastic/eui#1262) EuiImage is not exported yet EuiImage, EuiPanel, EuiSpacer, diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx index c02fc440abb0ba..cb61bf1dc26c40 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx @@ -6,7 +6,6 @@ import { EuiButton, EuiEmptyPrompt, - // @ts-ignore (elastic/eui#1557) EuiFilePicker is not exported yet EuiFilePicker, EuiFlexGrid, EuiFlexGroup, @@ -27,7 +26,6 @@ import React, { FunctionComponent } from 'react'; import { ComponentStrings } from '../../../i18n'; -// @ts-ignore import { ASSET_MAX_SIZE } from '../../../common/lib/constants'; import { Loading } from '../loading'; import { Asset } from './asset'; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/index.ts b/x-pack/plugins/canvas/public/components/asset_manager/index.ts index 23dbe3df085d49..b07857f13f6c6e 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/index.ts +++ b/x-pack/plugins/canvas/public/components/asset_manager/index.ts @@ -9,16 +9,16 @@ import { compose, withProps } from 'recompose'; import { set, get } from 'lodash'; import { fromExpression, toExpression } from '@kbn/interpreter/common'; import { getAssets } from '../../state/selectors/assets'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { removeAsset, createAsset } from '../../state/actions/assets'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { elementsRegistry } from '../../lib/elements_registry'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { addElement } from '../../state/actions/elements'; import { getSelectedPage } from '../../state/selectors/workpad'; import { encode } from '../../../common/lib/dataurl'; import { getId } from '../../lib/get_id'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { findExistingAsset } from '../../lib/find_existing_asset'; import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/canvas/public/components/asset_picker/asset_picker.tsx b/x-pack/plugins/canvas/public/components/asset_picker/asset_picker.tsx index 4489e877abf88d..1f49e9ae14f5c4 100644 --- a/x-pack/plugins/canvas/public/components/asset_picker/asset_picker.tsx +++ b/x-pack/plugins/canvas/public/components/asset_picker/asset_picker.tsx @@ -6,14 +6,7 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFlexGrid, - EuiFlexItem, - EuiLink, - // @ts-ignore (elastic/eui#1557) EuiImage is not exported yet - EuiImage, - EuiIcon, -} from '@elastic/eui'; +import { EuiFlexGrid, EuiFlexItem, EuiLink, EuiImage, EuiIcon } from '@elastic/eui'; import { CanvasAsset } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx b/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx index 8f73939de69a62..ceb7c83f3cab5f 100644 --- a/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx +++ b/x-pack/plugins/canvas/public/components/custom_element_modal/custom_element_modal.tsx @@ -12,7 +12,6 @@ import { EuiButton, EuiButtonEmpty, EuiFieldText, - // @ts-ignore hasn't been converted to TypeScript yet EuiFilePicker, EuiFlexGroup, EuiFlexItem, @@ -27,7 +26,6 @@ import { EuiTextArea, EuiTitle, } from '@elastic/eui'; -// @ts-ignore converting /libs/constants to TS breaks CI import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; import { encode } from '../../../common/lib/dataurl'; import { ElementCard } from '../element_card'; diff --git a/x-pack/plugins/canvas/public/components/element_content/element_content.js b/x-pack/plugins/canvas/public/components/element_content/element_content.js index 114a457d167e77..e2c1a61c348d13 100644 --- a/x-pack/plugins/canvas/public/components/element_content/element_content.js +++ b/x-pack/plugins/canvas/public/components/element_content/element_content.js @@ -12,6 +12,7 @@ import { getType } from '@kbn/interpreter/common'; import { Loading } from '../loading'; import { RenderWithFn } from '../render_with_fn'; import { ElementShareContainer } from '../element_share_container'; +import { assignHandlers } from '../../lib/create_handlers'; import { InvalidExpression } from './invalid_expression'; import { InvalidElementType } from './invalid_element_type'; @@ -46,7 +47,7 @@ const branches = [ export const ElementContent = compose( pure, ...branches -)(({ renderable, renderFunction, size, handlers }) => { +)(({ renderable, renderFunction, width, height, handlers }) => { const { getFilter, setFilter, @@ -62,7 +63,7 @@ export const ElementContent = compose(
diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js b/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js index 845fc5927d8397..de7748413b718a 100644 --- a/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js +++ b/x-pack/plugins/canvas/public/components/element_wrapper/element_wrapper.js @@ -14,7 +14,13 @@ export const ElementWrapper = (props) => { return ( - + ); }; diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/index.js b/x-pack/plugins/canvas/public/components/element_wrapper/index.js index 390c349ab2ee64..6fc582bfee4446 100644 --- a/x-pack/plugins/canvas/public/components/element_wrapper/index.js +++ b/x-pack/plugins/canvas/public/components/element_wrapper/index.js @@ -10,12 +10,12 @@ import { compose, withPropsOnChange, mapProps } from 'recompose'; import isEqual from 'react-fast-compare'; import { getResolvedArgs, getSelectedPage } from '../../state/selectors/workpad'; import { getState, getValue } from '../../lib/resolved_arg'; +import { createDispatchedHandlerFactory } from '../../lib/create_handlers'; import { ElementWrapper as Component } from './element_wrapper'; -import { createHandlers as createHandlersWithDispatch } from './lib/handlers'; function selectorFactory(dispatch) { let result = {}; - const createHandlers = createHandlersWithDispatch(dispatch); + const createHandlers = createDispatchedHandlerFactory(dispatch); return (nextState, nextOwnProps) => { const { element, ...restOwnProps } = nextOwnProps; diff --git a/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js b/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js deleted file mode 100644 index 33e8eacd902dda..00000000000000 --- a/x-pack/plugins/canvas/public/components/element_wrapper/lib/handlers.js +++ /dev/null @@ -1,60 +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 { isEqual } from 'lodash'; -import { setFilter } from '../../../state/actions/elements'; -import { - updateEmbeddableExpression, - fetchEmbeddableRenderable, -} from '../../../state/actions/embeddable'; - -export const createHandlers = (dispatch) => { - let isComplete = false; - let oldElement; - let completeFn = () => {}; - - return (element) => { - // reset isComplete when element changes - if (!isEqual(oldElement, element)) { - isComplete = false; - oldElement = element; - } - - return { - setFilter(text) { - dispatch(setFilter(text, element.id, true)); - }, - - getFilter() { - return element.filter; - }, - - onComplete(fn) { - completeFn = fn; - }, - - getElementId: () => element.id, - - onEmbeddableInputChange(embeddableExpression) { - dispatch(updateEmbeddableExpression({ elementId: element.id, embeddableExpression })); - }, - - onEmbeddableDestroyed() { - dispatch(fetchEmbeddableRenderable(element.id)); - }, - - done() { - // don't emit if the element is already done - if (isComplete) { - return; - } - - isComplete = true; - completeFn(); - }, - }; - }; -}; diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx index 8e69396f67c2e2..9462ba0411de47 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -10,7 +10,7 @@ import { compose } from 'recompose'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { AddEmbeddableFlyout, Props } from './flyout'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { addElement } from '../../state/actions/elements'; import { getSelectedPage } from '../../state/selectors/workpad'; import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable'; diff --git a/x-pack/plugins/canvas/public/components/file_upload/file_upload.tsx b/x-pack/plugins/canvas/public/components/file_upload/file_upload.tsx index 993ee8bde2653d..22fa32606407bb 100644 --- a/x-pack/plugins/canvas/public/components/file_upload/file_upload.tsx +++ b/x-pack/plugins/canvas/public/components/file_upload/file_upload.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore (elastic/eui#1262) EuiFilePicker is not exported yet import { EuiFilePicker } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; diff --git a/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx b/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx index 4340430829342b..556a3c54521607 100644 --- a/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx +++ b/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore (elastic/eui#1262) EuiSuperSelect is not exported yet import { EuiSuperSelect } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/__examples__/__snapshots__/palette_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/palette_picker/__examples__/__snapshots__/palette_picker.stories.storyshot new file mode 100644 index 00000000000000..d3809b4c3979f1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/__examples__/__snapshots__/palette_picker.stories.storyshot @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Color/PalettePicker clearable 1`] = ` +
+
+
+ +
+
+ + Select an option: None, is selected + + +
+ + +
+
+
+
+
+`; + +exports[`Storyshots components/Color/PalettePicker default 1`] = ` +
+
+
+ +
+
+ + Select an option: +
+ , is selected + + +
+ + +
+
+
+
+
+`; + +exports[`Storyshots components/Color/PalettePicker interactive 1`] = ` +
+
+
+ +
+
+ + Select an option: +
+ , is selected + + +
+ + +
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/__examples__/palette_picker.stories.tsx b/x-pack/plugins/canvas/public/components/palette_picker/__examples__/palette_picker.stories.tsx new file mode 100644 index 00000000000000..b1ae860e80efb7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/__examples__/palette_picker.stories.tsx @@ -0,0 +1,25 @@ +/* + * 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, { FC, useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import { PalettePicker } from '../palette_picker'; + +import { paulTor14, ColorPalette } from '../../../../common/lib/palettes'; + +const Interactive: FC = () => { + const [palette, setPalette] = useState(paulTor14); + return ; +}; + +storiesOf('components/Color/PalettePicker', module) + .addDecorator((fn) =>
{fn()}
) + .add('default', () => ) + .add('clearable', () => ( + + )) + .add('interactive', () => ); diff --git a/x-pack/plugins/canvas/public/components/palette_picker/index.js b/x-pack/plugins/canvas/public/components/palette_picker/index.js deleted file mode 100644 index 33d1d227771839..00000000000000 --- a/x-pack/plugins/canvas/public/components/palette_picker/index.js +++ /dev/null @@ -1,11 +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 { pure } from 'recompose'; - -import { PalettePicker as Component } from './palette_picker'; - -export const PalettePicker = pure(Component); diff --git a/x-pack/legacy/plugins/dashboard_mode/common/constants.js b/x-pack/plugins/canvas/public/components/palette_picker/index.ts similarity index 76% rename from x-pack/legacy/plugins/dashboard_mode/common/constants.js rename to x-pack/plugins/canvas/public/components/palette_picker/index.ts index c9a2378ac5d823..840600698c5a42 100644 --- a/x-pack/legacy/plugins/dashboard_mode/common/constants.js +++ b/x-pack/plugins/canvas/public/components/palette_picker/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const CONFIG_DASHBOARD_ONLY_MODE_ROLES = 'xpackDashboardMode:roles'; +export { PalettePicker } from './palette_picker'; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js deleted file mode 100644 index ca2a499feb84cb..00000000000000 --- a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.js +++ /dev/null @@ -1,60 +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 from 'react'; -import PropTypes from 'prop-types'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { map } from 'lodash'; -import { Popover } from '../popover'; -import { PaletteSwatch } from '../palette_swatch'; -import { palettes } from '../../../common/lib/palettes'; - -export const PalettePicker = ({ onChange, value, anchorPosition, ariaLabel }) => { - const button = (handleClick) => ( - - ); - - return ( - - {() => ( -
- {map(palettes, (palette, name) => ( - - ))} -
- )} -
- ); -}; - -PalettePicker.propTypes = { - value: PropTypes.object, - onChange: PropTypes.func, - anchorPosition: PropTypes.string, -}; diff --git a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss deleted file mode 100644 index f837d47682f61f..00000000000000 --- a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.scss +++ /dev/null @@ -1,42 +0,0 @@ -.canvasPalettePicker { - display: inline-block; - width: 100%; -} - -.canvasPalettePicker__swatches { - @include euiScrollBar; - - width: 280px; - height: 250px; - overflow-y: scroll; -} - -.canvasPalettePicker__swatchesPanel { - padding: $euiSizeS 0 !important; // sass-lint:disable-line no-important -} - -.canvasPalettePicker__swatch { - padding: $euiSizeS $euiSize; - - &:hover, - &:focus { - text-decoration: underline; - background-color: $euiColorLightestShade; - - .canvasPaletteSwatch, - .canvasPaletteSwatch__background { - transform: scaleY(2); - } - - .canvasPalettePicker__label { - color: $euiTextColor; - } - } -} - -.canvasPalettePicker__label { - font-size: $euiFontSizeXS; - text-transform: capitalize; - text-align: left; - color: $euiColorDarkShade; -} diff --git a/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.tsx b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.tsx new file mode 100644 index 00000000000000..dec09a5335d950 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/palette_picker/palette_picker.tsx @@ -0,0 +1,92 @@ +/* + * 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, { FC } from 'react'; +import PropTypes from 'prop-types'; +import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui'; +import { palettes, ColorPalette } from '../../../common/lib/palettes'; +import { ComponentStrings } from '../../../i18n'; + +const { PalettePicker: strings } = ComponentStrings; + +interface RequiredProps { + id?: string; + onChange?: (palette: ColorPalette) => void; + palette: ColorPalette; + clearable?: false; +} + +interface ClearableProps { + id?: string; + onChange?: (palette: ColorPalette | null) => void; + palette: ColorPalette | null; + clearable: true; +} + +type Props = RequiredProps | ClearableProps; + +export const PalettePicker: FC = (props) => { + const colorPalettes: EuiColorPalettePickerPaletteProps[] = palettes.map((item) => ({ + value: item.id, + title: item.label, + type: item.gradient ? 'gradient' : 'fixed', + palette: item.colors, + })); + + if (props.clearable) { + const { palette, onChange = () => {} } = props; + + colorPalettes.unshift({ + value: 'clear', + title: strings.getEmptyPaletteLabel(), + type: 'text', + }); + + const onPickerChange = (value: string) => { + const canvasPalette = palettes.find((item) => item.id === value); + onChange(canvasPalette || null); + }; + + return ( + + ); + } + + const { palette, onChange = () => {} } = props; + + const onPickerChange = (value: string) => { + const canvasPalette = palettes.find((item) => item.id === value); + + if (!canvasPalette) { + throw new Error(strings.getNoPaletteFoundErrorTitle()); + } + + onChange(canvasPalette); + }; + + return ( + + ); +}; + +PalettePicker.propTypes = { + id: PropTypes.string, + palette: PropTypes.object, + onChange: PropTypes.func, + clearable: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js deleted file mode 100644 index 71d16260e00c73..00000000000000 --- a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.js +++ /dev/null @@ -1,46 +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 from 'react'; -import PropTypes from 'prop-types'; - -export const PaletteSwatch = ({ colors, gradient }) => { - let colorBoxes; - - if (!gradient) { - colorBoxes = colors.map((color) => ( -
- )); - } else { - colorBoxes = [ -
, - ]; - } - - return ( -
-
-
{colorBoxes}
-
- ); -}; - -PaletteSwatch.propTypes = { - colors: PropTypes.array, - gradient: PropTypes.bool, -}; diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss b/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss deleted file mode 100644 index b57c520a5b07f2..00000000000000 --- a/x-pack/plugins/canvas/public/components/palette_swatch/palette_swatch.scss +++ /dev/null @@ -1,35 +0,0 @@ -.canvasPaletteSwatch { - display: inline-block; - position: relative; - height: $euiSizeXS; - width: 100%; - overflow: hidden; - text-align: left; - transform: scaleY(1); - transition: transform $euiAnimSlightResistance $euiAnimSpeedExtraFast; - - .canvasPaletteSwatch__background { - position: absolute; - height: $euiSizeXS; - top: 0; - left: 0; - width: 100%; - transform: scaleY(1); - transition: transform $euiAnimSlightResistance $euiAnimSpeedExtraFast; - } - - .canvasPaletteSwatch__foreground { - position: absolute; - height: 100%; // TODO: No idea why this can't be 25, but it leaves a 1px white spot in the palettePicker if its 25 - top: 0; - left: 0; - white-space: nowrap; - width: 100%; - display: flex; - } - - .canvasPaletteSwatch__box { - display: inline-block; - width: 100%; - } -} diff --git a/x-pack/plugins/canvas/public/components/positionable/index.js b/x-pack/plugins/canvas/public/components/positionable/index.js deleted file mode 100644 index e5c3c32acb0241..00000000000000 --- a/x-pack/plugins/canvas/public/components/positionable/index.js +++ /dev/null @@ -1,10 +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 { pure } from 'recompose'; -import { Positionable as Component } from './positionable'; - -export const Positionable = pure(Component); diff --git a/x-pack/legacy/plugins/dashboard_mode/server/index.js b/x-pack/plugins/canvas/public/components/positionable/index.ts similarity index 72% rename from x-pack/legacy/plugins/dashboard_mode/server/index.js rename to x-pack/plugins/canvas/public/components/positionable/index.ts index a7193bb560ca18..964e2ee41df75b 100644 --- a/x-pack/legacy/plugins/dashboard_mode/server/index.js +++ b/x-pack/plugins/canvas/public/components/positionable/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createDashboardModeRequestInterceptor } from './dashboard_mode_request_interceptor'; +export { Positionable } from './positionable'; diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.js b/x-pack/plugins/canvas/public/components/positionable/positionable.js deleted file mode 100644 index 9898f50cbb0f0c..00000000000000 --- a/x-pack/plugins/canvas/public/components/positionable/positionable.js +++ /dev/null @@ -1,42 +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 from 'react'; -import PropTypes from 'prop-types'; -import { matrixToCSS } from '../../lib/dom'; - -export const Positionable = ({ children, transformMatrix, width, height }) => { - // Throw if there is more than one child - React.Children.only(children); - // This could probably be made nicer by having just one child - const wrappedChildren = React.Children.map(children, (child) => { - const newStyle = { - width, - height, - marginLeft: -width / 2, - marginTop: -height / 2, - position: 'absolute', - transform: matrixToCSS(transformMatrix.map((n, i) => (i < 12 ? n : Math.round(n)))), - }; - - const stepChild = React.cloneElement(child, { size: { width, height } }); - return ( -
- {stepChild} -
- ); - }); - - return wrappedChildren; -}; - -Positionable.propTypes = { - onChange: PropTypes.func, - children: PropTypes.element.isRequired, - transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.tsx b/x-pack/plugins/canvas/public/components/positionable/positionable.tsx new file mode 100644 index 00000000000000..3344398b001988 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/positionable/positionable.tsx @@ -0,0 +1,48 @@ +/* + * 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, { FC, ReactElement, CSSProperties } from 'react'; +import PropTypes from 'prop-types'; +import { matrixToCSS } from '../../lib/dom'; +import { TransformMatrix3d } from '../../lib/aeroelastic'; + +interface Props { + children: ReactElement; + transformMatrix: TransformMatrix3d; + height: number; + width: number; +} + +export const Positionable: FC = ({ children, transformMatrix, width, height }) => { + // Throw if there is more than one child + const childNode = React.Children.only(children); + + const matrix = (transformMatrix.map((n, i) => + i < 12 ? n : Math.round(n) + ) as any) as TransformMatrix3d; + + const newStyle: CSSProperties = { + width, + height, + marginLeft: -width / 2, + marginTop: -height / 2, + position: 'absolute', + transform: matrixToCSS(matrix), + }; + + return ( +
+ {childNode} +
+ ); +}; + +Positionable.propTypes = { + children: PropTypes.element.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/index.js b/x-pack/plugins/canvas/public/components/render_to_dom/index.js deleted file mode 100644 index e8a3f8cd8c93b7..00000000000000 --- a/x-pack/plugins/canvas/public/components/render_to_dom/index.js +++ /dev/null @@ -1,12 +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 { compose, withState } from 'recompose'; -import { RenderToDom as Component } from './render_to_dom'; - -export const RenderToDom = compose( - withState('domNode', 'setDomNode') // Still don't like this, seems to be the only way todo it. -)(Component); diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/index.ts b/x-pack/plugins/canvas/public/components/render_to_dom/index.ts new file mode 100644 index 00000000000000..43a5dad059c955 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_to_dom/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RenderToDom } from './render_to_dom'; diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js b/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js deleted file mode 100644 index db393a8dde4f9a..00000000000000 --- a/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.js +++ /dev/null @@ -1,40 +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 from 'react'; -import PropTypes from 'prop-types'; - -export class RenderToDom extends React.Component { - static propTypes = { - domNode: PropTypes.object, - setDomNode: PropTypes.func.isRequired, - render: PropTypes.func.isRequired, - style: PropTypes.object, - }; - - shouldComponentUpdate(nextProps) { - return this.props.domNode !== nextProps.domNode; - } - - componentDidUpdate() { - // Calls render function once we have the reference to the DOM element to render into - if (this.props.domNode) { - this.props.render(this.props.domNode); - } - } - - render() { - const { domNode, setDomNode, style } = this.props; - const linkRef = (refNode) => { - if (!domNode && refNode) { - // Initialize the domNode property. This should only happen once, even if config changes. - setDomNode(refNode); - } - }; - - return
; - } -} diff --git a/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.tsx b/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.tsx new file mode 100644 index 00000000000000..a37c0fc096e574 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_to_dom/render_to_dom.tsx @@ -0,0 +1,27 @@ +/* + * 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, FC } from 'react'; +import CSS from 'csstype'; + +interface Props { + render: (element: HTMLElement) => void; + style?: CSS.Properties; +} + +export const RenderToDom: FC = ({ render, style }) => { + // https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node + const ref = useCallback( + (node: HTMLDivElement) => { + if (node !== null) { + render(node); + } + }, + [render] + ); + + return
; +}; diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/index.js b/x-pack/plugins/canvas/public/components/render_with_fn/index.js deleted file mode 100644 index 37c49624a39407..00000000000000 --- a/x-pack/plugins/canvas/public/components/render_with_fn/index.js +++ /dev/null @@ -1,30 +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 { compose, withProps, withPropsOnChange } from 'recompose'; -import PropTypes from 'prop-types'; -import isEqual from 'react-fast-compare'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { RenderWithFn as Component } from './render_with_fn'; -import { ElementHandlers } from './lib/handlers'; - -export const RenderWithFn = compose( - withPropsOnChange( - // rebuild elementHandlers when handlers object changes - (props, nextProps) => !isEqual(props.handlers, nextProps.handlers), - ({ handlers }) => ({ - handlers: Object.assign(new ElementHandlers(), handlers), - }) - ), - withKibana, - withProps((props) => ({ - onError: props.kibana.services.canvas.notify.error, - })) -)(Component); - -RenderWithFn.propTypes = { - handlers: PropTypes.object, -}; diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/index.ts b/x-pack/plugins/canvas/public/components/render_with_fn/index.ts new file mode 100644 index 00000000000000..4bfef734d34f4c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_with_fn/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RenderWithFn } from './render_with_fn'; diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js deleted file mode 100644 index 763cbd5e53eb18..00000000000000 --- a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.js +++ /dev/null @@ -1,157 +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 from 'react'; -import PropTypes from 'prop-types'; -import { isEqual, cloneDeep } from 'lodash'; -import { RenderToDom } from '../render_to_dom'; -import { ErrorStrings } from '../../../i18n'; - -const { RenderWithFn: strings } = ErrorStrings; - -export class RenderWithFn extends React.Component { - static propTypes = { - name: PropTypes.string.isRequired, - renderFn: PropTypes.func.isRequired, - reuseNode: PropTypes.bool, - handlers: PropTypes.shape({ - // element handlers, see components/element_wrapper/lib/handlers.js - setFilter: PropTypes.func.isRequired, - getFilter: PropTypes.func.isRequired, - done: PropTypes.func.isRequired, - // render handlers, see lib/handlers.js - resize: PropTypes.func.isRequired, - onResize: PropTypes.func.isRequired, - destroy: PropTypes.func.isRequired, - onDestroy: PropTypes.func.isRequired, - }), - config: PropTypes.object, - size: PropTypes.object.isRequired, - onError: PropTypes.func.isRequired, - }; - - static defaultProps = { - reuseNode: false, - }; - - componentDidMount() { - this.firstRender = true; - this.renderTarget = null; - } - - UNSAFE_componentWillReceiveProps({ renderFn }) { - const newRenderFunction = renderFn !== this.props.renderFn; - - if (newRenderFunction) { - this._resetRenderTarget(this._domNode); - } - } - - shouldComponentUpdate(prevProps) { - return !isEqual(this.props.size, prevProps.size) || this._shouldFullRerender(prevProps); - } - - componentDidUpdate(prevProps) { - const { handlers, size } = this.props; - // Config changes - if (this._shouldFullRerender(prevProps)) { - // This should be the only place you call renderFn besides the first time - this._callRenderFn(); - } - - // Size changes - if (!isEqual(size, prevProps.size)) { - return handlers.resize(size); - } - } - - componentWillUnmount() { - this.props.handlers.destroy(); - } - - _domNode = null; - - _callRenderFn = () => { - const { handlers, config, renderFn, reuseNode, name: functionName } = this.props; - // TODO: We should wait until handlers.done() is called before replacing the element content? - if (!reuseNode || !this.renderTarget) { - this._resetRenderTarget(this._domNode); - } - // else if (!firstRender) handlers.destroy(); - - const renderConfig = cloneDeep(config); - - // TODO: this is hacky, but it works. it stops Kibana from blowing up when a render throws - try { - renderFn(this.renderTarget, renderConfig, handlers); - this.firstRender = false; - } catch (err) { - console.error('renderFn threw', err); - this.props.onError(err, { title: strings.getRenderErrorMessage(functionName) }); - } - }; - - _resetRenderTarget = (domNode) => { - const { handlers } = this.props; - - if (!domNode) { - throw new Error('RenderWithFn can not reset undefined target node'); - } - - // call destroy on existing element - if (!this.firstRender) { - handlers.destroy(); - } - - while (domNode.firstChild) { - domNode.removeChild(domNode.firstChild); - } - - this.firstRender = true; - this.renderTarget = this._createRenderTarget(); - domNode.appendChild(this.renderTarget); - }; - - _createRenderTarget = () => { - const div = document.createElement('div'); - div.style.width = '100%'; - div.style.height = '100%'; - return div; - }; - - _shouldFullRerender = (prevProps) => { - // required to stop re-renders on element move, anything that should - // cause a re-render needs to be checked here - // TODO: fix props passed in to remove this check - return ( - this.props.handlers !== prevProps.handlers || - !isEqual(this.props.config, prevProps.config) || - !isEqual(this.props.renderFn.toString(), prevProps.renderFn.toString()) - ); - }; - - destroy = () => { - this.props.handlers.destroy(); - }; - - render() { - // NOTE: the data-shared-* attributes here are used for reporting - return ( -
- { - this._domNode = domNode; - this._callRenderFn(); - }} - /> -
- ); - } -} diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx new file mode 100644 index 00000000000000..bc51128cf0c876 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx @@ -0,0 +1,117 @@ +/* + * 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, { useState, useEffect, useRef, FC, useCallback } from 'react'; +import { useDebounce } from 'react-use'; + +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { RenderToDom } from '../render_to_dom'; +import { ErrorStrings } from '../../../i18n'; +import { RendererHandlers } from '../../../types'; + +const { RenderWithFn: strings } = ErrorStrings; + +interface Props { + name: string; + renderFn: ( + domNode: HTMLElement, + config: Record, + handlers: RendererHandlers + ) => void | Promise; + reuseNode: boolean; + handlers: RendererHandlers; + config: Record; + height: number; + width: number; +} + +const style = { height: '100%', width: '100%' }; + +export const RenderWithFn: FC = ({ + name: functionName, + renderFn, + reuseNode = false, + handlers: incomingHandlers, + config, + width, + height, +}) => { + const { services } = useKibana(); + const onError = services.canvas.notify.error; + + const [domNode, setDomNode] = useState(null); + + // Tells us if the component is attempting to re-render into a previously-populated render target. + const firstRender = useRef(true); + // A reference to the node appended to the provided DOM node which is created and optionally replaced. + const renderTarget = useRef(null); + // A reference to the handlers, as the renderFn may mutate them, (via onXYZ functions) + const handlers = useRef(incomingHandlers); + + // Reset the render target, the node appended to the DOM node provided by RenderToDOM. + const resetRenderTarget = useCallback(() => { + if (!domNode) { + return; + } + + if (!firstRender) { + handlers.current.destroy(); + } + + while (domNode.firstChild) { + domNode.removeChild(domNode.firstChild); + } + + const div = document.createElement('div'); + div.style.width = '100%'; + div.style.height = '100%'; + domNode.appendChild(div); + + renderTarget.current = div; + firstRender.current = true; + }, [domNode]); + + useDebounce(() => handlers.current.resize({ height, width }), 150, [height, width]); + + useEffect( + () => () => { + handlers.current.destroy(); + }, + [] + ); + + const render = useCallback(() => { + renderFn(renderTarget.current!, config, handlers.current); + }, [renderTarget, config, renderFn]); + + useEffect(() => { + if (!domNode) { + return; + } + + if (!reuseNode || !renderTarget.current) { + resetRenderTarget(); + } + + try { + render(); + firstRender.current = false; + } catch (err) { + onError(err, { title: strings.getRenderErrorMessage(functionName) }); + } + }, [domNode, functionName, onError, render, resetRenderTarget, reuseNode]); + + return ( +
+ { + setDomNode(node); + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/router/index.ts b/x-pack/plugins/canvas/public/components/router/index.ts index fa857c6f0cd3c6..561ad0e9401f5e 100644 --- a/x-pack/plugins/canvas/public/components/router/index.ts +++ b/x-pack/plugins/canvas/public/components/router/index.ts @@ -5,14 +5,14 @@ */ import { connect } from 'react-redux'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { setFullscreen } from '../../state/actions/transient'; import { enableAutoplay, setRefreshInterval, setAutoplayInterval, } from '../../state/actions/workpad'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Router as Component } from './router'; import { State } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts index f14fc92e028dba..c5c1dbc2fdd6e8 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts @@ -8,14 +8,13 @@ import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { compose, withState } from 'recompose'; import { camelCase } from 'lodash'; -// @ts-ignore Untyped local import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import * as customElementService from '../../lib/custom_element_service'; import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; import { WithKibanaProps } from '../../'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { selectToplevelNodes } from '../../state/actions/transient'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { insertNodes } from '../../state/actions/elements'; import { getSelectedPage } from '../../state/selectors/workpad'; import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric'; diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx index 74f4887601d30c..e3f4e00f4de019 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx @@ -7,9 +7,9 @@ import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiTabbedContent } from '@elastic/eui'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { Datasource } from '../../datasource'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { FunctionFormList } from '../../function_form_list'; import { PositionedElement } from '../../../../types'; import { ComponentStrings } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx index 2e241681ccc6a7..f89ab79a086cfe 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx @@ -5,13 +5,12 @@ */ import React, { Fragment, FunctionComponent } from 'react'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { ElementConfig } from '../element_config'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { PageConfig } from '../page_config'; -// @ts-ignore unconverted component import { WorkpadConfig } from '../workpad_config'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { SidebarSection } from './sidebar_section'; export const GlobalConfig: FunctionComponent = () => ( diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx b/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx index 26f106911e0158..9f1936fdc143b4 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.tsx @@ -5,7 +5,7 @@ */ import React, { FunctionComponent } from 'react'; -// @ts-ignore unconverted component +// @ts-expect-error unconverted component import { SidebarContent } from './sidebar_content'; interface Props { diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.tsx index 0f8204e6bc261c..9a26b438e17c3d 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.tsx @@ -20,13 +20,13 @@ import { CanvasElement } from '../../../types'; import { ComponentStrings } from '../../../i18n'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Navbar } from '../navbar'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { WorkpadManager } from '../workpad_manager'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { PageManager } from '../page_manager'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { Expression } from '../expression'; import { Tray } from './tray'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts index 75bdcd2b0ada1c..8f013f70aefcd8 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts @@ -9,17 +9,17 @@ import { compose, withHandlers, withProps } from 'recompose'; import { Dispatch } from 'redux'; import { State, PositionedElement } from '../../../../types'; import { getClipboardData } from '../../../lib/clipboard'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { flatten } from '../../../lib/aeroelastic/functional'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { globalStateUpdater } from '../../workpad_page/integration_utils'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { crawlTree } from '../../workpad_page/integration_utils'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { insertNodes, elementLayer, removeElements } from '../../../state/actions/elements'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { undoHistory, redoHistory } from '../../../state/actions/history'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { selectToplevelNodes } from '../../../state/actions/transient'; import { getSelectedPage, diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx index fbb5d70dfc55c4..6d9233aaba22b5 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx @@ -19,7 +19,6 @@ import { ElementSpec } from '../../../../types'; import { flattenPanelTree } from '../../../lib/flatten_panel_tree'; import { getId } from '../../../lib/get_id'; import { Popover, ClosePopoverFn } from '../../popover'; -// @ts-ignore Untyped local import { AssetManager } from '../../asset_manager'; import { SavedElementsModal } from '../../saved_elements_modal'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx index a1227b33946785..13b2cace13a408 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx @@ -10,10 +10,10 @@ import { compose, withProps } from 'recompose'; import { Dispatch } from 'redux'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { State, ElementSpec } from '../../../../types'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { elementsRegistry } from '../../../lib/elements_registry'; import { ElementMenu as Component, Props as ComponentProps } from './element_menu'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { addElement } from '../../../state/actions/elements'; import { getSelectedPage } from '../../../state/selectors/workpad'; import { AddEmbeddablePanel } from '../../embeddable_flyout'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/fullscreen_control/fullscreen_control.tsx b/x-pack/plugins/canvas/public/components/workpad_header/fullscreen_control/fullscreen_control.tsx index 5ffa712abee137..77edf9d2264d4f 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/fullscreen_control/fullscreen_control.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/fullscreen_control/fullscreen_control.tsx @@ -6,7 +6,7 @@ import React, { ReactNode, KeyboardEvent } from 'react'; import PropTypes from 'prop-types'; -// @ts-ignore no @types definition +// @ts-expect-error no @types definition import { Shortcuts } from 'react-shortcuts'; import { isTextInput } from '../../../lib/is_text_input'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/index.tsx index d2fece567a8ad9..407b4ff9328116 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/index.tsx @@ -6,11 +6,8 @@ import { connect } from 'react-redux'; import { Dispatch } from 'redux'; -// @ts-ignore untyped local import { canUserWrite } from '../../state/selectors/app'; -// @ts-ignore untyped local import { getSelectedPage, isWriteable } from '../../state/selectors/workpad'; -// @ts-ignore untyped local import { setWriteable } from '../../state/actions/workpad'; import { State } from '../../../types'; import { WorkpadHeader as Component, Props as ComponentProps } from './workpad_header'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts index 53c053811a273d..87b926d93ccb91 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts @@ -5,9 +5,8 @@ */ import { connect } from 'react-redux'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { fetchAllRenderables } from '../../../state/actions/elements'; -// @ts-ignore untyped local import { getInFlight } from '../../../state/selectors/resolved_args'; import { State } from '../../../../types'; import { RefreshControl as Component } from './refresh_control'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts index 64712f0df8d6cd..1e1eac2a1dcf3a 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts @@ -11,7 +11,6 @@ import { getRenderedWorkpad, getRenderedWorkpadExpressions, } from '../../../../state/selectors/workpad'; -// @ts-ignore Untyped local import { downloadRenderedWorkpad, downloadRuntime, diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts index 8a3438e89e8465..45257cd4fe308c 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts @@ -5,7 +5,6 @@ */ import rison from 'rison-node'; -// @ts-ignore Untyped local. import { IBasePath } from 'kibana/public'; import { fetch } from '../../../../common/lib/fetch'; import { CanvasWorkpad } from '../../../../types'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts index 0765973915f776..ddf1a12775cae1 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -10,9 +10,9 @@ import { Dispatch } from 'redux'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { zoomHandlerCreators } from '../../../lib/app_handler_creators'; import { State, CanvasWorkpadBoundingBox } from '../../../../types'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { fetchAllRenderables } from '../../../state/actions/elements'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient'; import { setWriteable, diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx index 4aab8280a9f249..eb4b451896b46b 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx @@ -6,14 +6,13 @@ import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; -// @ts-ignore no @types definition +// @ts-expect-error no @types definition import { Shortcuts } from 'react-shortcuts'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { ComponentStrings } from '../../../i18n'; import { ToolTipShortcut } from '../tool_tip_shortcut/'; -// @ts-ignore untyped local import { RefreshControl } from './refresh_control'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { FullscreenControl } from './fullscreen_control'; import { EditMenu } from './edit_menu'; import { ElementMenu } from './element_menu'; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interaction_boundary.tsx b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interaction_boundary.tsx index d5841a1069ea1e..e1ed7c7db84a05 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interaction_boundary.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_interactive_page/interaction_boundary.tsx @@ -5,7 +5,7 @@ */ import React, { CSSProperties, PureComponent } from 'react'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { WORKPAD_CONTAINER_ID } from '../../../apps/workpad/workpad_app'; interface State { diff --git a/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx b/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx index f9e0ec8a8a541d..1bb3ef330f846f 100644 --- a/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_shortcuts/workpad_shortcuts.tsx @@ -7,7 +7,7 @@ import React, { Component, KeyboardEvent } from 'react'; import isEqual from 'react-fast-compare'; -// @ts-ignore no @types definition +// @ts-expect-error no @types definition import { Shortcuts } from 'react-shortcuts'; import { isTextInput } from '../../lib/is_text_input'; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx index 5fdc88ed624060..863cdd88163c27 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { getDefaultWorkpad } from '../../../../state/defaults'; import { Arguments, ArgumentTypes, BorderStyle, ExtendedTemplate } from '../extended_template'; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx index 4ef17fbe876164..2dbff1b4d916b3 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { getDefaultWorkpad } from '../../../../state/defaults'; import { Argument, Arguments, SimpleTemplate } from '../simple_template'; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx index f9b175e84ec8e4..fa1b2420d46d2c 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { getDefaultWorkpad } from '../../../../state/defaults'; import { SimpleTemplate } from '../simple_template'; diff --git a/x-pack/plugins/canvas/public/functions/asset.ts b/x-pack/plugins/canvas/public/functions/asset.ts index 2f2ad181b264c7..ebd3fd2abdcbb3 100644 --- a/x-pack/plugins/canvas/public/functions/asset.ts +++ b/x-pack/plugins/canvas/public/functions/asset.ts @@ -5,7 +5,7 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; -// @ts-ignore unconverted local lib +// @ts-expect-error unconverted local lib import { getState } from '../state/store'; import { getAssetById } from '../state/selectors/assets'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index 78cd742b44b264..48f4a41c7690a5 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -8,7 +8,7 @@ import { fromExpression } from '@kbn/interpreter/common'; import { get } from 'lodash'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { interpretAst } from '../lib/run_interpreter'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getState } from '../state/store'; import { getGlobalFilters } from '../state/selectors/workpad'; import { ExpressionValueFilter } from '../../types'; diff --git a/x-pack/plugins/canvas/public/functions/timelion.ts b/x-pack/plugins/canvas/public/functions/timelion.ts index abb294d9cc110f..4eb34e838d18a6 100644 --- a/x-pack/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/plugins/canvas/public/functions/timelion.ts @@ -9,7 +9,7 @@ import moment from 'moment-timezone'; import { TimeRange } from 'src/plugins/data/common'; import { ExpressionFunctionDefinition, DatatableRow } from 'src/plugins/expressions/public'; import { fetch } from '../../common/lib/fetch'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { buildBoolArray } from '../../public/lib/build_bool_array'; import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index 64d25b28a8aa04..032873dfa6cf2f 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore untyped Elastic library +// @ts-expect-error untyped Elastic library import { castProvider } from '@kbn/interpreter/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; diff --git a/x-pack/plugins/canvas/public/lib/app_state.ts b/x-pack/plugins/canvas/public/lib/app_state.ts index d431202ba75a4d..a09df3c8cb87da 100644 --- a/x-pack/plugins/canvas/public/lib/app_state.ts +++ b/x-pack/plugins/canvas/public/lib/app_state.ts @@ -6,12 +6,12 @@ import { parse } from 'query-string'; import { get } from 'lodash'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { getInitialState } from '../state/initial_state'; import { getWindow } from './get_window'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { historyProvider } from './history_provider'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { routerProvider } from './router_provider'; import { createTimeInterval, isValidTimeInterval, getTimeInterval } from './time_interval'; import { AppState, AppStateKeys } from '../../types'; diff --git a/x-pack/plugins/canvas/public/lib/build_embeddable_filters.ts b/x-pack/plugins/canvas/public/lib/build_embeddable_filters.ts index c847bfb6516bf6..94d0d16bf79f65 100644 --- a/x-pack/plugins/canvas/public/lib/build_embeddable_filters.ts +++ b/x-pack/plugins/canvas/public/lib/build_embeddable_filters.ts @@ -5,7 +5,7 @@ */ import { ExpressionValueFilter } from '../../types'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { buildBoolArray } from './build_bool_array'; import { TimeRange, esFilters, Filter as DataFilter } from '../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/canvas/public/lib/clipboard.test.ts b/x-pack/plugins/canvas/public/lib/clipboard.test.ts index d10964003ed391..53f92e2184edce 100644 --- a/x-pack/plugins/canvas/public/lib/clipboard.test.ts +++ b/x-pack/plugins/canvas/public/lib/clipboard.test.ts @@ -15,7 +15,7 @@ const get = jest.fn(); describe('clipboard', () => { beforeAll(() => { - // @ts-ignore + // @ts-expect-error Storage.mockImplementation(() => ({ set, get, diff --git a/x-pack/plugins/canvas/public/lib/clone_subgraphs.ts b/x-pack/plugins/canvas/public/lib/clone_subgraphs.ts index c3a3933e06a6d7..7168272211d447 100644 --- a/x-pack/plugins/canvas/public/lib/clone_subgraphs.ts +++ b/x-pack/plugins/canvas/public/lib/clone_subgraphs.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { arrayToMap } from './aeroelastic/functional'; import { getId } from './get_id'; import { PositionedElement } from '../../types'; diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts new file mode 100644 index 00000000000000..4e0c7b217d5b71 --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -0,0 +1,96 @@ +/* + * 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'; +// @ts-ignore untyped local +import { setFilter } from '../state/actions/elements'; +import { updateEmbeddableExpression, fetchEmbeddableRenderable } from '../state/actions/embeddable'; +import { RendererHandlers, CanvasElement } from '../../types'; + +// This class creates stub handlers to ensure every element and renderer fulfills the contract. +// TODO: consider warning if these methods are invoked but not implemented by the renderer...? + +export const createHandlers = (): RendererHandlers => ({ + destroy() {}, + done() {}, + event() {}, + getElementId() { + return ''; + }, + getFilter() { + return ''; + }, + onComplete(fn: () => void) { + this.done = fn; + }, + onDestroy(fn: () => void) { + this.destroy = fn; + }, + // TODO: these functions do not match the `onXYZ` and `xyz` pattern elsewhere. + onEmbeddableDestroyed() {}, + onEmbeddableInputChange() {}, + onResize(fn: (size: { height: number; width: number }) => void) { + this.resize = fn; + }, + reload() {}, + resize(_size: { height: number; width: number }) {}, + setFilter() {}, + update() {}, +}); + +export const assignHandlers = (handlers: Partial = {}): RendererHandlers => + Object.assign(createHandlers(), handlers); + +// TODO: this is a legacy approach we should unravel in the near future. +export const createDispatchedHandlerFactory = ( + dispatch: (action: any) => void +): ((element: CanvasElement) => RendererHandlers) => { + let isComplete = false; + let oldElement: CanvasElement | undefined; + let completeFn = () => {}; + + return (element: CanvasElement) => { + // reset isComplete when element changes + if (!isEqual(oldElement, element)) { + isComplete = false; + oldElement = element; + } + + return assignHandlers({ + setFilter(text: string) { + dispatch(setFilter(text, element.id, true)); + }, + + getFilter() { + return element.filter; + }, + + onComplete(fn: () => void) { + completeFn = fn; + }, + + getElementId: () => element.id, + + onEmbeddableInputChange(embeddableExpression: string) { + dispatch(updateEmbeddableExpression({ elementId: element.id, embeddableExpression })); + }, + + onEmbeddableDestroyed() { + dispatch(fetchEmbeddableRenderable(element.id)); + }, + + done() { + // don't emit if the element is already done + if (isComplete) { + return; + } + + isComplete = true; + completeFn(); + }, + }); + }; +}; diff --git a/x-pack/plugins/canvas/public/lib/create_thunk.ts b/x-pack/plugins/canvas/public/lib/create_thunk.ts index cbcaeeccc8b931..8ce912246ad6f0 100644 --- a/x-pack/plugins/canvas/public/lib/create_thunk.ts +++ b/x-pack/plugins/canvas/public/lib/create_thunk.ts @@ -5,7 +5,7 @@ */ import { Dispatch, Action } from 'redux'; -// @ts-ignore untyped dependency +// @ts-expect-error untyped dependency import { createThunk as createThunkFn } from 'redux-thunks/cjs'; import { State } from '../../types'; diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts index fb038d8b6ace24..d0a63cf3fb5c44 100644 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ b/x-pack/plugins/canvas/public/lib/download_workpad.ts @@ -7,7 +7,7 @@ import fileSaver from 'file-saver'; import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; import { ErrorStrings } from '../../i18n'; import { notifyService } from '../services'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import * as workpadService from './workpad_service'; import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; diff --git a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts index a2bf5a62ec1f7f..8f1a0f0ecf08f9 100644 --- a/x-pack/plugins/canvas/public/lib/element_handler_creators.ts +++ b/x-pack/plugins/canvas/public/lib/element_handler_creators.ts @@ -5,9 +5,7 @@ */ import { camelCase } from 'lodash'; -// @ts-ignore unconverted local file import { getClipboardData, setClipboardData } from './clipboard'; -// @ts-ignore unconverted local file import { cloneSubgraphs } from './clone_subgraphs'; import { notifyService } from '../services'; import * as customElementService from './custom_element_service'; diff --git a/x-pack/plugins/canvas/public/lib/es_service.ts b/x-pack/plugins/canvas/public/lib/es_service.ts index 496751a874b212..5c1131d5fbe351 100644 --- a/x-pack/plugins/canvas/public/lib/es_service.ts +++ b/x-pack/plugins/canvas/public/lib/es_service.ts @@ -7,7 +7,6 @@ import { IndexPatternAttributes } from 'src/plugins/data/public'; import { API_ROUTE } from '../../common/lib/constants'; -// @ts-ignore untyped local import { fetch } from '../../common/lib/fetch'; import { ErrorStrings } from '../../i18n'; import { notifyService } from '../services'; diff --git a/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts b/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts index dc70f778f0e524..4bfe6ff4b141ff 100644 --- a/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts +++ b/x-pack/plugins/canvas/public/lib/sync_filter_expression.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore internal untyped import { fromExpression } from '@kbn/interpreter/common'; import immutable from 'object-path-immutable'; import { get } from 'lodash'; diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 9d2a6b3fdf4f47..4829a94bb0db84 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -24,7 +24,7 @@ import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { argTypeSpecs } from './expression_types/arg_types'; import { transitions } from './transitions'; import { getPluginApi, CanvasApi } from './plugin_api'; diff --git a/x-pack/plugins/canvas/public/registries.ts b/x-pack/plugins/canvas/public/registries.ts index 99f309a9173294..b2881fc0b77999 100644 --- a/x-pack/plugins/canvas/public/registries.ts +++ b/x-pack/plugins/canvas/public/registries.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore untyped module +// @ts-expect-error untyped module import { addRegistries, register } from '@kbn/interpreter/common'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { elementsRegistry } from './lib/elements_registry'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { templatesRegistry } from './lib/templates_registry'; import { tagsRegistry } from './lib/tags_registry'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { transitionsRegistry } from './lib/transitions_registry'; import { @@ -20,7 +20,7 @@ import { modelRegistry, transformRegistry, viewRegistry, - // @ts-ignore untyped local + // @ts-expect-error untyped local } from './expression_types'; import { SetupRegistries } from './plugin_api'; diff --git a/x-pack/plugins/canvas/public/state/actions/embeddable.ts b/x-pack/plugins/canvas/public/state/actions/embeddable.ts index a153cb7f4354de..874d3902773206 100644 --- a/x-pack/plugins/canvas/public/state/actions/embeddable.ts +++ b/x-pack/plugins/canvas/public/state/actions/embeddable.ts @@ -7,7 +7,7 @@ import { Dispatch } from 'redux'; import { createAction } from 'redux-actions'; import { createThunk } from '../../lib/create_thunk'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { fetchRenderable } from './elements'; import { State } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.ts b/x-pack/plugins/canvas/public/state/actions/workpad.ts index 47df38838f8907..419832e404594d 100644 --- a/x-pack/plugins/canvas/public/state/actions/workpad.ts +++ b/x-pack/plugins/canvas/public/state/actions/workpad.ts @@ -8,7 +8,7 @@ import { createAction } from 'redux-actions'; import { without, includes } from 'lodash'; import { createThunk } from '../../lib/create_thunk'; import { getWorkpadColors } from '../selectors/workpad'; -// @ts-ignore +// @ts-expect-error import { fetchAllRenderables } from './elements'; import { CanvasWorkpad } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts b/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts index 11ebdcdc51d4d9..bb7b26919ef20c 100644 --- a/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts +++ b/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts @@ -10,7 +10,7 @@ jest.mock('../../../lib/router_provider'); import { workpadAutoplay } from '../workpad_autoplay'; import { setAutoplayInterval } from '../../../lib/app_state'; import { createTimeInterval } from '../../../lib/time_interval'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { routerProvider } from '../../../lib/router_provider'; const next = jest.fn(); diff --git a/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts b/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts index f90f570bc6ebf4..bf69a862d5c300 100644 --- a/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts +++ b/x-pack/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts @@ -9,7 +9,6 @@ jest.mock('../../../lib/app_state'); import { workpadRefresh } from '../workpad_refresh'; import { inFlightComplete } from '../../actions/resolved_args'; -// @ts-ignore untyped local import { setRefreshInterval } from '../../actions/workpad'; import { setRefreshInterval as setAppStateRefreshInterval } from '../../../lib/app_state'; diff --git a/x-pack/plugins/canvas/public/state/middleware/in_flight.ts b/x-pack/plugins/canvas/public/state/middleware/in_flight.ts index 7ad6f8aee15ed3..028b9f214133ff 100644 --- a/x-pack/plugins/canvas/public/state/middleware/in_flight.ts +++ b/x-pack/plugins/canvas/public/state/middleware/in_flight.ts @@ -9,7 +9,7 @@ import { loadingIndicator as defaultLoadingIndicator, LoadingIndicatorInterface, } from '../../lib/loading_indicator'; -// @ts-ignore +// @ts-expect-error import { convert } from '../../lib/modify_path'; interface InFlightMiddlewareOptions { diff --git a/x-pack/plugins/canvas/public/state/middleware/workpad_autoplay.ts b/x-pack/plugins/canvas/public/state/middleware/workpad_autoplay.ts index dd484521c1b355..f77a1e1ba32956 100644 --- a/x-pack/plugins/canvas/public/state/middleware/workpad_autoplay.ts +++ b/x-pack/plugins/canvas/public/state/middleware/workpad_autoplay.ts @@ -9,9 +9,9 @@ import { State } from '../../../types'; import { getFullscreen } from '../selectors/app'; import { getInFlight } from '../selectors/resolved_args'; import { getWorkpad, getPages, getSelectedPageIndex, getAutoplay } from '../selectors/workpad'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { appUnload } from '../actions/app'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { routerProvider } from '../../lib/router_provider'; import { setAutoplayInterval } from '../../lib/app_state'; import { createTimeInterval } from '../../lib/time_interval'; diff --git a/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.ts b/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.ts index 96a84b22cfcccf..4a17ffb4645326 100644 --- a/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.ts +++ b/x-pack/plugins/canvas/public/state/middleware/workpad_refresh.ts @@ -6,11 +6,10 @@ import { Middleware } from 'redux'; import { State } from '../../../types'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { fetchAllRenderables } from '../actions/elements'; -// @ts-ignore Untyped Local import { setRefreshInterval } from '../actions/workpad'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { appUnload } from '../actions/app'; import { inFlightComplete } from '../actions/resolved_args'; import { getInFlight } from '../selectors/resolved_args'; diff --git a/x-pack/plugins/canvas/public/state/reducers/embeddable.ts b/x-pack/plugins/canvas/public/state/reducers/embeddable.ts index 8642239fa10d37..fdeb5087f26e1e 100644 --- a/x-pack/plugins/canvas/public/state/reducers/embeddable.ts +++ b/x-pack/plugins/canvas/public/state/reducers/embeddable.ts @@ -13,7 +13,7 @@ import { UpdateEmbeddableExpressionPayload, } from '../actions/embeddable'; -// @ts-ignore untyped local +// @ts-expect-error untyped local import { assignNodeProperties } from './elements'; export const embeddableReducer = handleActions< diff --git a/x-pack/plugins/canvas/public/state/selectors/resolved_args.ts b/x-pack/plugins/canvas/public/state/selectors/resolved_args.ts index 9e2036e02f2b93..766e27d95da9b6 100644 --- a/x-pack/plugins/canvas/public/state/selectors/resolved_args.ts +++ b/x-pack/plugins/canvas/public/state/selectors/resolved_args.ts @@ -5,9 +5,9 @@ */ import { get } from 'lodash'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import * as argHelper from '../../lib/resolved_arg'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { prepend } from '../../lib/modify_path'; import { State } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 55bf2a7ea31f7a..0f4953ff56d982 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -5,9 +5,9 @@ */ import { get, omit } from 'lodash'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; -// @ts-ignore Untyped Local +// @ts-expect-error untyped local import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; import { State, CanvasWorkpad, CanvasPage, CanvasElement, ResolvedArgType } from '../../../types'; diff --git a/x-pack/plugins/canvas/public/store.ts b/x-pack/plugins/canvas/public/store.ts index 81edec6ec539ca..ef93a34296da2e 100644 --- a/x-pack/plugins/canvas/public/store.ts +++ b/x-pack/plugins/canvas/public/store.ts @@ -9,9 +9,9 @@ import { destroyStore as destroy, getStore, cloneStore, - // @ts-ignore Untyped local + // @ts-expect-error untyped local } from './state/store'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { getInitialState } from './state/initial_state'; import { CoreSetup } from '../../../../src/core/public'; diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index 7b4e1271cca1df..78a34a58f5f782 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -39,8 +39,6 @@ @import '../components/loading/loading'; @import '../components/navbar/navbar'; @import '../components/page_manager/page_manager'; -@import '../components/palette_picker/palette_picker'; -@import '../components/palette_swatch/palette_swatch'; @import '../components/positionable/positionable'; @import '../components/rotation_handle/rotation_handle'; @import '../components/shape_preview/shape_preview'; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts index db0417434227c4..290175d9062ea8 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts @@ -9,7 +9,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeCreateCustomElementRoute } from './create'; @@ -41,7 +41,7 @@ describe('POST custom element', () => { const router = httpService.createRouter(); initializeCreateCustomElementRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.post.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts index 98b26ec368ab1e..62ce4b9c3593ce 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts @@ -11,7 +11,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; const mockRouteContext = ({ @@ -30,7 +30,7 @@ describe('DELETE custom element', () => { const router = httpService.createRouter(); initializeDeleteCustomElementRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.delete.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts index dead9ded8a14af..d42c97b62e0f39 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts @@ -10,7 +10,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; const mockRouteContext = ({ @@ -29,7 +29,7 @@ describe('Find custom element', () => { const router = httpService.createRouter(); initializeFindCustomElementsRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts index 09b620aeff9bb1..7b4d0eba374199 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts @@ -11,7 +11,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; const mockRouteContext = ({ @@ -30,7 +30,7 @@ describe('GET custom element', () => { const router = httpService.createRouter(); initializeGetCustomElementRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts index 19477458bacb5a..0f954904355ae5 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts @@ -13,7 +13,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; import { okResponse } from '../ok_response'; @@ -55,7 +55,7 @@ describe('PUT custom element', () => { const router = httpService.createRouter(); initializeUpdateCustomElementRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.put.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts index 93fdb4304acc6d..c1918feb7f4ec3 100644 --- a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts @@ -9,7 +9,7 @@ import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'sr import { httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, elasticsearchServiceMock, } from 'src/core/server/mocks'; @@ -29,7 +29,7 @@ describe('Retrieve ES Fields', () => { const router = httpService.createRouter(); initializeESFieldsRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts index 8f3ced13895f67..7a9830124e305e 100644 --- a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts @@ -8,7 +8,7 @@ import { mapValues, keys } from 'lodash'; import { schema } from '@kbn/config-schema'; import { API_ROUTE } from '../../../common/lib'; import { catchErrorHandler } from '../catch_error_handler'; -// @ts-ignore unconverted lib +// @ts-expect-error unconverted lib import { normalizeType } from '../../lib/normalize_type'; import { RouteInitializerDeps } from '..'; diff --git a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts index 75eeb46c890d5d..0267a695ae9fe3 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts @@ -8,7 +8,7 @@ jest.mock('fs'); import fs from 'fs'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { httpServiceMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; +import { httpServiceMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { initializeDownloadShareableWorkpadRoute } from './download'; const mockRouteContext = {} as RequestHandlerContext; @@ -23,7 +23,7 @@ describe('Download Canvas shareables runtime', () => { const router = httpService.createRouter(); initializeDownloadShareableWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/shareables/download.ts b/x-pack/plugins/canvas/server/routes/shareables/download.ts index 3f331c1635e16b..0c86f8472c7910 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/download.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/download.ts @@ -21,7 +21,6 @@ export function initializeDownloadShareableWorkpadRoute(deps: RouteInitializerDe // // The option setting is not for typical use. We're using it here to avoid // problems in Cloud environments. See elastic/kibana#47405. - // @ts-ignore No type for inert Hapi handler // const file = handler.file(SHAREABLE_RUNTIME_FILE, { confine: false }); const file = readFileSync(SHAREABLE_RUNTIME_FILE); return response.ok({ diff --git a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts index 5a2d122c2754be..29dcb4268e6184 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts @@ -8,7 +8,7 @@ jest.mock('archiver'); const archiver = require('archiver') as jest.Mock; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { httpServiceMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; +import { httpServiceMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { initializeZipShareableWorkpadRoute } from './zip'; import { API_ROUTE_SHAREABLE_ZIP } from '../../../common/lib'; import { @@ -29,7 +29,7 @@ describe('Zips Canvas shareables runtime together with workpad', () => { const router = httpService.createRouter(); initializeZipShareableWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.post.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts index 2ed63e7397108a..9cadb50b9a506c 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts @@ -9,7 +9,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeCreateWorkpadRoute } from './create'; @@ -41,7 +41,7 @@ describe('POST workpad', () => { const router = httpService.createRouter(); initializeCreateWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.post.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts index 712ff294003829..32ce30325b60ad 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts @@ -11,7 +11,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; const mockRouteContext = ({ @@ -30,7 +30,7 @@ describe('DELETE workpad', () => { const router = httpService.createRouter(); initializeDeleteWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.delete.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts index e2dd8552379b7a..a87cf7be57d811 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts @@ -10,7 +10,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; const mockRouteContext = ({ @@ -29,7 +29,7 @@ describe('Find workpad', () => { const router = httpService.createRouter(); initializeFindWorkpadsRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts index 9ecd9ceefed8d0..8cc190dc6231cc 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts @@ -11,7 +11,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; import { workpadWithGroupAsElement } from '../../../__tests__/fixtures/workpads'; import { CanvasWorkpad } from '../../../types'; @@ -32,7 +32,7 @@ describe('GET workpad', () => { const router = httpService.createRouter(); initializeGetWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.get.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts index 36ea984447d8ac..6d7ea06852a5e5 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts @@ -12,7 +12,7 @@ import { savedObjectsClientMock, httpServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, } from 'src/core/server/mocks'; import { workpads } from '../../../__tests__/fixtures/workpads'; import { okResponse } from '../ok_response'; @@ -42,7 +42,7 @@ describe('PUT workpad', () => { const router = httpService.createRouter(); initializeUpdateWorkpadRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.put.mock.calls[0][1]; @@ -156,7 +156,7 @@ describe('update assets', () => { const router = httpService.createRouter(); initializeUpdateWorkpadAssetsRoute({ router, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }); routeHandler = router.put.mock.calls[0][1]; diff --git a/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts b/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts index f58111000859ad..f5dcf59dcf45bb 100644 --- a/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts +++ b/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts @@ -6,7 +6,6 @@ import { SampleDataRegistrySetup } from 'src/plugins/home/server'; import { CANVAS as label } from '../../i18n'; -// @ts-ignore Untyped local import { ecommerceSavedObjects, flightsSavedObjects, webLogsSavedObjects } from './index'; export function loadSampleData( @@ -16,9 +15,9 @@ export function loadSampleData( const now = new Date(); const nowTimestamp = now.toISOString(); - // @ts-ignore: Untyped local + // @ts-expect-error: untyped local function updateCanvasWorkpadTimestamps(savedObjects) { - // @ts-ignore: Untyped local + // @ts-expect-error: untyped local return savedObjects.map((savedObject) => { if (savedObject.type === 'canvas-workpad') { savedObject.attributes['@timestamp'] = nowTimestamp; diff --git a/x-pack/plugins/canvas/shareable_runtime/api/__tests__/shareable.test.tsx b/x-pack/plugins/canvas/shareable_runtime/api/__tests__/shareable.test.tsx index d99c9b190f83d8..4b3aa8dc2fb6e2 100644 --- a/x-pack/plugins/canvas/shareable_runtime/api/__tests__/shareable.test.tsx +++ b/x-pack/plugins/canvas/shareable_runtime/api/__tests__/shareable.test.tsx @@ -15,7 +15,7 @@ jest.mock('../../supported_renderers'); describe('Canvas Shareable Workpad API', () => { // Mock the AJAX load of the workpad. beforeEach(function () { - // @ts-ignore Applying a global in Jest is alright. + // @ts-expect-error Applying a global in Jest is alright. global.fetch = jest.fn().mockImplementation(() => { const p = new Promise((resolve, _reject) => { resolve({ diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx index 7b5a5080ae790b..899edee7f04816 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx +++ b/x-pack/plugins/canvas/shareable_runtime/components/__examples__/rendered_element.examples.tsx @@ -7,7 +7,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { ExampleContext } from '../../test/context_example'; -// @ts-ignore +// @ts-expect-error import { image } from '../../../canvas_plugin_src/renderers/image'; import { sharedWorkpads } from '../../test'; import { RenderedElement, RenderedElementComponent } from '../rendered_element'; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx index 1650cbad3a2372..4c7c65511698d9 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx @@ -12,7 +12,6 @@ import { setAutoplayIntervalAction, } from '../../../context'; import { createTimeInterval } from '../../../../public/lib/time_interval'; -// @ts-ignore Untyped local import { CustomInterval } from '../../../../public/components/workpad_header/view_menu/custom_interval'; export type onSetAutoplayFn = (autoplay: boolean) => void; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/rendered_element.tsx b/x-pack/plugins/canvas/shareable_runtime/components/rendered_element.tsx index c4a009db3a376c..6bcc0db92f1ccd 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/rendered_element.tsx +++ b/x-pack/plugins/canvas/shareable_runtime/components/rendered_element.tsx @@ -5,15 +5,15 @@ */ import React, { FC, PureComponent } from 'react'; -// @ts-ignore Untyped library +// @ts-expect-error untyped library import Style from 'style-it'; -// @ts-ignore Untyped local import { Positionable } from '../../public/components/positionable/positionable'; -// @ts-ignore Untyped local +// @ts-expect-error untyped local import { elementToShape } from '../../public/components/workpad_page/utils'; import { CanvasRenderedElement } from '../types'; import { CanvasShareableContext, useCanvasShareableState } from '../context'; import { RendererSpec } from '../../types'; +import { createHandlers } from '../../public/lib/create_handlers'; import css from './rendered_element.module.scss'; @@ -62,17 +62,7 @@ export class RenderedElementComponent extends PureComponent { } try { - // TODO: These are stubbed, but may need implementation. - fn.render(this.ref.current, value.value, { - done: () => {}, - onDestroy: () => {}, - onResize: () => {}, - getElementId: () => '', - setFilter: () => {}, - getFilter: () => '', - onEmbeddableInputChange: () => {}, - onEmbeddableDestroyed: () => {}, - }); + fn.render(this.ref.current, value.value, createHandlers()); } catch (e) { // eslint-disable-next-line no-console console.log(as, e.message); diff --git a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js index 6238aaf5c2fe44..340d1fb418b4cc 100644 --- a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js +++ b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js @@ -4,10 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// This is a JS file because the renderers are not strongly-typed yet. Tagging for -// visibility. -// @ts-ignore Untyped local - import { debug } from '../canvas_plugin_src/renderers/debug'; import { error } from '../canvas_plugin_src/renderers/error'; import { image } from '../canvas_plugin_src/renderers/image'; diff --git a/x-pack/plugins/canvas/shareable_runtime/types.ts b/x-pack/plugins/canvas/shareable_runtime/types.ts index 191c0405d2e2d4..040062346c74f4 100644 --- a/x-pack/plugins/canvas/shareable_runtime/types.ts +++ b/x-pack/plugins/canvas/shareable_runtime/types.ts @@ -5,7 +5,7 @@ */ import { RefObject } from 'react'; -// @ts-ignore Unlinked Webpack Type +// @ts-expect-error Unlinked Webpack Type import ContainerStyle from 'types/interpreter'; import { SavedObject, SavedObjectAttributes } from 'src/core/public'; diff --git a/x-pack/plugins/canvas/types/renderers.ts b/x-pack/plugins/canvas/types/renderers.ts index 2564b045d1cf75..772a16aa94c605 100644 --- a/x-pack/plugins/canvas/types/renderers.ts +++ b/x-pack/plugins/canvas/types/renderers.ts @@ -4,25 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -type GenericCallback = (callback: () => void) => void; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; -export interface RendererHandlers { - /** Handler to invoke when an element has finished rendering */ - done: () => void; +type GenericRendererCallback = (callback: () => void) => void; + +export interface RendererHandlers extends IInterpreterRenderHandlers { + /** Handler to invoke when an element should be destroyed. */ + destroy: () => void; /** Get the id of the element being rendered. Can be used as a unique ID in a render function */ getElementId: () => string; - /** Handler to invoke when an element is deleted or changes to a different render type */ - onDestroy: GenericCallback; - /** Handler to invoke when an element's dimensions have changed*/ - onResize: GenericCallback; /** Retrieves the value of the filter property on the element object persisted on the workpad */ getFilter: () => string; - /** Sets the value of the filter property on the element object persisted on the workpad */ - setFilter: (filter: string) => void; - /** Handler to invoke when the input to a function has changed internally */ - onEmbeddableInputChange: (expression: string) => void; + /** Handler to invoke when a renderer is considered complete */ + onComplete: (fn: () => void) => void; /** Handler to invoke when a rendered embeddable is destroyed */ onEmbeddableDestroyed: () => void; + /** Handler to invoke when the input to a function has changed internally */ + onEmbeddableInputChange: (expression: string) => void; + /** Handler to invoke when an element's dimensions have changed*/ + onResize: GenericRendererCallback; + /** Handler to invoke when an element should be resized. */ + resize: (size: { height: number; width: number }) => void; + /** Sets the value of the filter property on the element object persisted on the workpad */ + setFilter: (filter: string) => void; } export interface RendererSpec { diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index e00c1c111b41b2..8fde66ea820194 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; +import { loggingSystemMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; import { CaseService, CaseConfigureService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; import { RouteDeps } from '../types'; @@ -17,7 +17,7 @@ export const createRoute = async ( const httpService = httpServiceMock.createSetupContract(); const router = httpService.createRouter(); - const log = loggingServiceMock.create().get('case'); + const log = loggingSystemMock.create().get('case'); const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 62e21392f71105..1c3a770da79f55 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -5,6 +5,7 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; @@ -12,6 +13,7 @@ import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; interface CloudConfigType { id?: string; resetPasswordUrl?: string; + deploymentUrl?: string; } interface CloudSetupDependencies { @@ -24,10 +26,14 @@ export interface CloudSetup { } export class CloudPlugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} + private config!: CloudConfigType; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = this.initializerContext.config.get(); + } public async setup(core: CoreSetup, { home }: CloudSetupDependencies) { - const { id, resetPasswordUrl } = this.initializerContext.config.get(); + const { id, resetPasswordUrl } = this.config; const isCloudEnabled = getIsCloudEnabled(id); if (home) { @@ -44,6 +50,16 @@ export class CloudPlugin implements Plugin { } public start(coreStart: CoreStart) { + const { deploymentUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); + if (deploymentUrl) { + coreStart.chrome.setCustomNavLink({ + title: i18n.translate('xpack.cloud.deploymentLinkLabel', { + defaultMessage: 'Manage this deployment', + }), + euiIconType: 'arrowLeft', + href: deploymentUrl, + }); + } } } diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index d899b45aebdfe7..ff8a2c5acdf9ab 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -22,6 +22,7 @@ const configSchema = schema.object({ id: schema.maybe(schema.string()), apm: schema.maybe(apmConfigSchema), resetPasswordUrl: schema.maybe(schema.string()), + deploymentUrl: schema.maybe(schema.string()), }); export type CloudConfigType = TypeOf; @@ -30,6 +31,7 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { id: true, resetPasswordUrl: true, + deploymentUrl: true, }, schema: configSchema, }; diff --git a/x-pack/plugins/dashboard_mode/common/constants.ts b/x-pack/plugins/dashboard_mode/common/constants.ts new file mode 100644 index 00000000000000..f5d36fe9799c7a --- /dev/null +++ b/x-pack/plugins/dashboard_mode/common/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const UI_SETTINGS = { + CONFIG_DASHBOARD_ONLY_MODE_ROLES: 'xpackDashboardMode:roles', +}; diff --git a/x-pack/legacy/plugins/dashboard_mode/common/index.js b/x-pack/plugins/dashboard_mode/common/index.ts similarity index 84% rename from x-pack/legacy/plugins/dashboard_mode/common/index.js rename to x-pack/plugins/dashboard_mode/common/index.ts index 358d0d5b7e0766..60cf0060636d7a 100644 --- a/x-pack/legacy/plugins/dashboard_mode/common/index.js +++ b/x-pack/plugins/dashboard_mode/common/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './constants'; +export { UI_SETTINGS } from './constants'; diff --git a/x-pack/plugins/dashboard_mode/kibana.json b/x-pack/plugins/dashboard_mode/kibana.json index dfe32210250928..4777b9b25be238 100644 --- a/x-pack/plugins/dashboard_mode/kibana.json +++ b/x-pack/plugins/dashboard_mode/kibana.json @@ -3,10 +3,13 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": [ - "xpack", "dashboard_mode" + "xpack", + "dashboard_mode" ], + "optionalPlugins": ["security"], "requiredPlugins": [ - "kibanaLegacy", "dashboard" + "kibanaLegacy", + "dashboard" ], "server": true, "ui": true diff --git a/x-pack/plugins/dashboard_mode/server/index.ts b/x-pack/plugins/dashboard_mode/server/index.ts index 2a8890c2f81acf..671a398734ac1d 100644 --- a/x-pack/plugins/dashboard_mode/server/index.ts +++ b/x-pack/plugins/dashboard_mode/server/index.ts @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginConfigDescriptor } from 'kibana/server'; - +import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; import { schema } from '@kbn/config-schema'; +import { DashboardModeServerPlugin } from './plugin'; + export const config: PluginConfigDescriptor = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), }; -export const plugin = () => ({ - setup() {}, - start() {}, -}); +export function plugin(initializerContext: PluginInitializerContext) { + return new DashboardModeServerPlugin(initializerContext); +} + +export { DashboardModeServerPlugin as Plugin }; diff --git a/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.test.ts b/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.test.ts new file mode 100644 index 00000000000000..2978c48af7414b --- /dev/null +++ b/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { + OnPostAuthHandler, + OnPostAuthToolkit, + KibanaRequest, + LifecycleResponseFactory, + IUiSettingsClient, +} from 'kibana/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; + +import { AuthenticatedUser } from '../../../security/server'; +import { securityMock } from '../../../security/server/mocks'; + +import { setupDashboardModeRequestInterceptor } from './dashboard_mode_request_interceptor'; + +const DASHBOARD_ONLY_MODE_ROLE = 'test_dashboard_only_mode_role'; + +describe('DashboardOnlyModeRequestInterceptor', () => { + const core = coreMock.createSetup(); + const security = securityMock.createSetup(); + + let interceptor: OnPostAuthHandler; + let toolkit: OnPostAuthToolkit; + let uiSettingsMock: any; + + beforeEach(() => { + toolkit = { + next: jest.fn(), + }; + interceptor = setupDashboardModeRequestInterceptor({ + http: core.http, + security, + getUiSettingsClient: () => + (Promise.resolve({ + get: () => Promise.resolve(uiSettingsMock), + }) as unknown) as Promise, + }); + }); + + test('should not redirects for not app/* requests', async () => { + const request = ({ + url: { + path: 'api/test', + }, + } as unknown) as KibanaRequest; + + interceptor(request, {} as LifecycleResponseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalled(); + }); + + test('should not redirects not authenticated users', async () => { + const request = ({ + url: { + path: '/app/home', + }, + } as unknown) as KibanaRequest; + + interceptor(request, {} as LifecycleResponseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalled(); + }); + + describe('request for dashboard-only user', () => { + function testRedirectToDashboardModeApp(url: string) { + describe(`requests to url:"${url}"`, () => { + test('redirects to the dashboard_mode app instead', async () => { + const request = ({ + url: { + path: url, + }, + credentials: { + roles: [DASHBOARD_ONLY_MODE_ROLE], + }, + } as unknown) as KibanaRequest; + + const response = ({ + redirected: jest.fn(), + } as unknown) as LifecycleResponseFactory; + + security.authc.getCurrentUser = jest.fn( + (r: KibanaRequest) => + ({ + roles: [DASHBOARD_ONLY_MODE_ROLE], + } as AuthenticatedUser) + ); + + uiSettingsMock = [DASHBOARD_ONLY_MODE_ROLE]; + + await interceptor(request, response, toolkit); + + expect(response.redirected).toHaveBeenCalledWith({ + headers: { location: `/mock-server-basepath/app/dashboard_mode` }, + }); + }); + }); + } + + testRedirectToDashboardModeApp('/app/kibana'); + testRedirectToDashboardModeApp('/app/kibana#/foo/bar'); + testRedirectToDashboardModeApp('/app/kibana/foo/bar'); + testRedirectToDashboardModeApp('/app/kibana?foo=bar'); + testRedirectToDashboardModeApp('/app/dashboards?foo=bar'); + testRedirectToDashboardModeApp('/app/home?foo=bar'); + }); +}); diff --git a/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.ts b/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.ts new file mode 100644 index 00000000000000..4378c818f087c5 --- /dev/null +++ b/x-pack/plugins/dashboard_mode/server/interceptors/dashboard_mode_request_interceptor.ts @@ -0,0 +1,79 @@ +/* + * 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 { HttpServiceSetup, OnPostAuthHandler, IUiSettingsClient } from 'kibana/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { UI_SETTINGS } from '../../common'; + +const superuserRole = 'superuser'; + +interface DashboardModeRequestInterceptorDependencies { + http: HttpServiceSetup; + security: SecurityPluginSetup; + getUiSettingsClient: () => Promise; +} + +export const setupDashboardModeRequestInterceptor = ({ + http, + security, + getUiSettingsClient, +}: DashboardModeRequestInterceptorDependencies) => + (async (request, response, toolkit) => { + const path = request.url.path || ''; + const isAppRequest = path.startsWith('/app/'); + + if (!isAppRequest) { + return toolkit.next(); + } + + const authenticatedUser = security.authc.getCurrentUser(request); + const roles = authenticatedUser?.roles || []; + + if (!authenticatedUser || roles.length === 0) { + return toolkit.next(); + } + + const uiSettings = await getUiSettingsClient(); + const dashboardOnlyModeRoles = await uiSettings.get( + UI_SETTINGS.CONFIG_DASHBOARD_ONLY_MODE_ROLES + ); + + if (!dashboardOnlyModeRoles) { + return toolkit.next(); + } + + const isDashboardOnlyModeUser = roles.find((role) => dashboardOnlyModeRoles.includes(role)); + const isSuperUser = roles.find((role) => role === superuserRole); + + const enforceDashboardOnlyMode = isDashboardOnlyModeUser && !isSuperUser; + + if (enforceDashboardOnlyMode) { + if ( + path.startsWith('/app/home') || + path.startsWith('/app/kibana') || + path.startsWith('/app/dashboards') + ) { + const dashBoardModeUrl = `${http.basePath.get(request)}/app/dashboard_mode`; + // If the user is in "Dashboard only mode" they should only be allowed to see + // the dashboard app and none others. + + return response.redirected({ + headers: { + location: dashBoardModeUrl, + }, + }); + } + + if (path.startsWith('/app/dashboard_mode')) { + // let through requests to the dashboard_mode app + return toolkit.next(); + } + + return response.notFound(); + } + + return toolkit.next(); + }) as OnPostAuthHandler; diff --git a/x-pack/plugins/dashboard_mode/server/interceptors/index.ts b/x-pack/plugins/dashboard_mode/server/interceptors/index.ts new file mode 100644 index 00000000000000..e0bf175d15029c --- /dev/null +++ b/x-pack/plugins/dashboard_mode/server/interceptors/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { setupDashboardModeRequestInterceptor } from './dashboard_mode_request_interceptor'; diff --git a/x-pack/plugins/dashboard_mode/server/plugin.ts b/x-pack/plugins/dashboard_mode/server/plugin.ts new file mode 100644 index 00000000000000..8b56f71b667cb6 --- /dev/null +++ b/x-pack/plugins/dashboard_mode/server/plugin.ts @@ -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 { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + SavedObjectsClient, + Logger, +} from '../../../../src/core/server'; + +import { SecurityPluginSetup } from '../../security/server'; +import { setupDashboardModeRequestInterceptor } from './interceptors'; + +import { getUiSettings } from './ui_settings'; + +interface DashboardModeServerSetupDependencies { + security?: SecurityPluginSetup; +} + +export class DashboardModeServerPlugin implements Plugin { + private initializerContext: PluginInitializerContext; + private logger?: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup(core: CoreSetup, { security }: DashboardModeServerSetupDependencies) { + this.logger = this.initializerContext.logger.get(); + + core.uiSettings.register(getUiSettings()); + + const getUiSettingsClient = async () => { + const [coreStart] = await core.getStartServices(); + const { savedObjects, uiSettings } = coreStart; + const savedObjectsClient = new SavedObjectsClient(savedObjects.createInternalRepository()); + + return uiSettings.asScopedToClient(savedObjectsClient); + }; + + if (security) { + const dashboardModeRequestInterceptor = setupDashboardModeRequestInterceptor({ + http: core.http, + security, + getUiSettingsClient, + }); + + core.http.registerOnPostAuth(dashboardModeRequestInterceptor); + + this.logger.debug(`registered DashboardModeRequestInterceptor`); + } + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_mode/server/ui_settings.ts b/x-pack/plugins/dashboard_mode/server/ui_settings.ts new file mode 100644 index 00000000000000..f692ec8a33fc91 --- /dev/null +++ b/x-pack/plugins/dashboard_mode/server/ui_settings.ts @@ -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 { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { UiSettingsParams } from 'kibana/server'; +import { UI_SETTINGS } from '../common'; + +const DASHBOARD_ONLY_USER_ROLE = 'kibana_dashboard_only_user'; + +export function getUiSettings(): Record> { + return { + [UI_SETTINGS.CONFIG_DASHBOARD_ONLY_MODE_ROLES]: { + name: i18n.translate('xpack.dashboardMode.uiSettings.dashboardsOnlyRolesTitle', { + defaultMessage: 'Dashboards only roles', + }), + description: i18n.translate('xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDescription', { + defaultMessage: 'Roles that belong to View Dashboards Only mode', + }), + value: [DASHBOARD_ONLY_USER_ROLE], + category: ['dashboard'], + deprecation: { + message: i18n.translate('xpack.dashboardMode.uiSettings.dashboardsOnlyRolesDeprecation', { + defaultMessage: 'This setting is deprecated and will be removed in Kibana 8.0.', + }), + docLinksKey: 'dashboardSettings', + }, + schema: schema.arrayOf(schema.string()), + }, + }; +} diff --git a/x-pack/plugins/encrypted_saved_objects/README.md b/x-pack/plugins/encrypted_saved_objects/README.md index 2f0af9e8667971..0a5e79a96f02a7 100644 --- a/x-pack/plugins/encrypted_saved_objects/README.md +++ b/x-pack/plugins/encrypted_saved_objects/README.md @@ -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( + function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { + return doc.consumer === 'alerting' || doc.consumer === undefined; + }, + (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc => { + 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( + function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { + return doc.consumer === 'alerting' || doc.consumer === undefined; + }, + (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc => { + 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( + function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { + // ... + }, + (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc => { + // ... + }, + // 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 diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts index db07f0f9ce2c0a..3f8074eb15c0c3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts @@ -7,7 +7,7 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); import { first } from 'rxjs/operators'; -import { loggingServiceMock, coreMock } from 'src/core/server/mocks'; +import { loggingSystemMock, coreMock } from 'src/core/server/mocks'; import { createConfig$, ConfigSchema } from './config'; describe('config schema', () => { @@ -60,7 +60,7 @@ describe('createConfig$()', () => { usingEphemeralEncryptionKey: true, }); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.encryptedSavedObjects.encryptionKey. To be able to decrypt encrypted saved objects attributes after restart, please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml", @@ -79,6 +79,6 @@ describe('createConfig$()', () => { usingEphemeralEncryptionKey: false, }); - expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); + expect(loggingSystemMock.collect(contextMock.logger).warn).toEqual([]); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts new file mode 100644 index 00000000000000..620e0016775949 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -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( + function (doc): doc is SavedObjectUnsanitizedDoc { + 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( + function (doc): doc is SavedObjectUnsanitizedDoc { + 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( + function (doc): doc is SavedObjectUnsanitizedDoc { + // migrate doc that have the second field + return ( + typeof (doc as SavedObjectUnsanitizedDoc).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', + } + ); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts new file mode 100644 index 00000000000000..8e9dc1c1389660 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -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 = ( + doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, + context: SavedObjectMigrationContext +) => SavedObjectUnsanitizedDoc; + +type IsMigrationNeededPredicate = ( + encryptedDoc: + | SavedObjectUnsanitizedDoc + | SavedObjectUnsanitizedDoc +) => encryptedDoc is SavedObjectUnsanitizedDoc; + +export type CreateEncryptedSavedObjectsMigrationFn = < + InputAttributes = unknown, + MigratedAttributes = InputAttributes +>( + isMigrationNeededPredicate: IsMigrationNeededPredicate, + migration: SavedObjectMigrationFn, + inputType?: EncryptedSavedObjectTypeRegistration, + migratedType?: EncryptedSavedObjectTypeRegistration +) => SavedObjectOptionalMigrationFn; + +export const getCreateMigration = ( + encryptedSavedObjectsService: Readonly, + 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(descriptor, inputAttributes) + ), + context + ), + (migratedAttributes) => + migratedService.encryptAttributesSync(descriptor, migratedAttributes) + ); + }; +}; + +function mapAttributes(obj: SavedObjectUnsanitizedDoc, mapper: (attributes: T) => T) { + return Object.assign(obj, { + attributes: mapper(obj.attributes), + }); +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts new file mode 100644 index 00000000000000..c692d8698771fe --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts @@ -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; +} + +export const encryptedSavedObjectsServiceMock = { + create: createEncryptedSavedObjectsServiceMock, + createWithTypes(registrations: EncryptedSavedObjectTypeRegistration[] = []) { + const mock = createEncryptedSavedObjectsServiceMock(); + + function processAttributes>( + descriptor: Pick, + 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; + }, +}; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 6ece9d1be8ec8b..42d2e2ffd15163 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -4,43 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; - -jest.mock('@elastic/node-crypto', () => jest.fn()); +import nodeCrypto, { Crypto } from '@elastic/node-crypto'; +import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; import { EncryptedSavedObjectsAuditLogger } from '../audit'; import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service'; import { EncryptionError } from './encryption_error'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { encryptedSavedObjectsAuditLoggerMock } from '../audit/index.mock'; +const crypto = nodeCrypto({ encryptionKey: 'encryption-key-abc' }); + +const mockNodeCrypto: jest.Mocked = { + encrypt: jest.fn(), + decrypt: jest.fn(), + encryptSync: jest.fn(), + decryptSync: jest.fn(), +}; + let service: EncryptedSavedObjectsService; let mockAuditLogger: jest.Mocked; -beforeEach(() => { - mockAuditLogger = encryptedSavedObjectsAuditLoggerMock.create(); +beforeEach(() => { // 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); - }); + 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(); service = new EncryptedSavedObjectsService( - 'encryption-key-abc', - loggingServiceMock.create().get(), + mockNodeCrypto, + loggingSystemMock.create().get(), mockAuditLogger ); }); 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,16 +224,14 @@ 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', - loggingServiceMock.create().get(), + 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,8 +924,8 @@ describe('#decryptAttributes', () => { it('fails if encrypted with another encryption key', async () => { service = new EncryptedSavedObjectsService( - 'encryption-key-abc*', - loggingServiceMock.create().get(), + 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; + + 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); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 5cf3e1c2d65aec..99361107047c27 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -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; - /** * 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, 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>( + private *attributesToEncryptIterator>( descriptor: SavedObjectDescriptor, attributes: T, params?: CommonParameters - ): Promise { + ): 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>( + descriptor: SavedObjectDescriptor, + attributes: T, + params?: CommonParameters + ): Promise { + const iterator = this.attributesToEncryptIterator(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>( + descriptor: SavedObjectDescriptor, + attributes: T, + params?: CommonParameters + ): T { + const iterator = this.attributesToEncryptIterator(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 { + const iterator = this.attributesToDecryptIterator(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>( + descriptor: SavedObjectDescriptor, + attributes: T, + params?: CommonParameters + ): T { + const iterator = this.attributesToDecryptIterator(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>( + 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 = {}; + const decryptedAttributes: Record = {}; 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); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts index 11a0cd6f33307d..3e4983deca6255 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts @@ -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 = new (jest.requireMock( - './encrypted_saved_objects_service' - ).EncryptedSavedObjectsService)(); - - function processAttributes>( - descriptor: Pick, - 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'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts index 0849f0eb320dd3..75445bd24eba84 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts @@ -11,3 +11,4 @@ export { SavedObjectDescriptor, } from './encrypted_saved_objects_service'; export { EncryptionError } from './encryption_error'; +export { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts index 38ac8f254315e1..adec3a3b9fbf40 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts @@ -12,6 +12,7 @@ function createEncryptedSavedObjectsSetupMock() { registerType: jest.fn(), __legacyCompat: { registerLegacyAPI: jest.fn() }, usingEphemeralEncryptionKey: true, + createMigration: jest.fn(), } as jest.Mocked; } diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index 4afd74488f9fed..57108954f2568d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -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, } diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index cdbdd18b9d696c..69777798ddf192 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -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 { - 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; + } + ), }; } diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index ec5d81532e238f..eea19bb1aa7dd5 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -22,7 +22,7 @@ let encryptedSavedObjectsServiceMockInstance: jest.Mocked { mockBaseClient = savedObjectsClientMock.create(); mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); - encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([ + encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.createWithTypes([ { type: 'known-type', attributesToEncrypt: new Set([ diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts index 8e9f12268cd7e2..ef9aed8706e2ce 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts @@ -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({ diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index ada86adf84cfd2..459a2cc65671e6 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -5,7 +5,7 @@ */ import { ClusterClient, Logger } from '../../../../../src/core/server'; -import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { ClusterClientAdapter, IClusterClientAdapter } from './cluster_client_adapter'; import moment from 'moment'; import { findOptionsSchema } from '../event_log_client'; @@ -17,7 +17,7 @@ let clusterClient: EsClusterClient; let clusterClientAdapter: IClusterClientAdapter; beforeEach(() => { - logger = loggingServiceMock.createLogger(); + logger = loggingSystemMock.createLogger(); clusterClient = elasticsearchServiceMock.createClusterClient(); clusterClientAdapter = new ClusterClientAdapter({ logger, diff --git a/x-pack/plugins/event_log/server/es/context.mock.ts b/x-pack/plugins/event_log/server/es/context.mock.ts index c15fee803fb71e..0c9f7b29b64119 100644 --- a/x-pack/plugins/event_log/server/es/context.mock.ts +++ b/x-pack/plugins/event_log/server/es/context.mock.ts @@ -7,14 +7,14 @@ import { EsContext } from './context'; import { namesMock } from './names.mock'; import { IClusterClientAdapter } from './cluster_client_adapter'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { clusterClientAdapterMock } from './cluster_client_adapter.mock'; const createContextMock = () => { const mock: jest.Mocked & { esAdapter: jest.Mocked; } = { - logger: loggingServiceMock.createLogger(), + logger: loggingSystemMock.createLogger(), esNames: namesMock.create(), initialize: jest.fn(), waitTillReady: jest.fn(), diff --git a/x-pack/plugins/event_log/server/es/context.test.ts b/x-pack/plugins/event_log/server/es/context.test.ts index 09fe676a5762ed..6f9ee5875ddb73 100644 --- a/x-pack/plugins/event_log/server/es/context.test.ts +++ b/x-pack/plugins/event_log/server/es/context.test.ts @@ -6,7 +6,7 @@ import { createEsContext } from './context'; import { ClusterClient, Logger } from '../../../../../src/core/server'; -import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; jest.mock('../lib/../../../../package.json', () => ({ version: '1.2.3', })); @@ -16,7 +16,7 @@ let logger: Logger; let clusterClient: EsClusterClient; beforeEach(() => { - logger = loggingServiceMock.createLogger(); + logger = loggingSystemMock.createLogger(); clusterClient = elasticsearchServiceMock.createClusterClient(); }); diff --git a/x-pack/plugins/event_log/server/event_log_service.test.ts b/x-pack/plugins/event_log/server/event_log_service.test.ts index 43883ea4e384ce..2cf68592f2fa17 100644 --- a/x-pack/plugins/event_log/server/event_log_service.test.ts +++ b/x-pack/plugins/event_log/server/event_log_service.test.ts @@ -7,9 +7,9 @@ import { IEventLogConfig } from './types'; import { EventLogService } from './event_log_service'; import { contextMock } from './es/context.mock'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; -const loggingService = loggingServiceMock.create(); +const loggingService = loggingSystemMock.create(); const systemLogger = loggingService.get(); describe('EventLogService', () => { diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index 2bda194a65d133..d4d3df3ef8267c 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -9,20 +9,20 @@ import { ECS_VERSION } from './types'; import { EventLogService } from './event_log_service'; import { EsContext } from './es/context'; import { contextMock } from './es/context.mock'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { delay } from './lib/delay'; import { EVENT_LOGGED_PREFIX } from './event_logger'; const KIBANA_SERVER_UUID = '424-24-2424'; describe('EventLogger', () => { - let systemLogger: ReturnType; + let systemLogger: ReturnType; let esContext: EsContext; let service: IEventLogService; let eventLogger: IEventLogger; beforeEach(() => { - systemLogger = loggingServiceMock.createLogger(); + systemLogger = loggingSystemMock.createLogger(); esContext = contextMock.create(); service = new EventLogService({ esContext, @@ -183,7 +183,7 @@ describe('EventLogger', () => { // return the next logged event; throw if not an event async function waitForLogEvent( - mockLogger: ReturnType, + mockLogger: ReturnType, waitSeconds: number = 1 ): Promise { const result = await waitForLog(mockLogger, waitSeconds); @@ -193,7 +193,7 @@ async function waitForLogEvent( // return the next logged message; throw if it is an event async function waitForLogMessage( - mockLogger: ReturnType, + mockLogger: ReturnType, waitSeconds: number = 1 ): Promise { const result = await waitForLog(mockLogger, waitSeconds); @@ -203,7 +203,7 @@ async function waitForLogMessage( // return the next logged message, if it's an event log entry, parse it async function waitForLog( - mockLogger: ReturnType, + mockLogger: ReturnType, waitSeconds: number = 1 ): Promise { const intervals = 4; diff --git a/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts b/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts index dd6d15a6e48431..b30d83f24f261a 100644 --- a/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts +++ b/x-pack/plugins/event_log/server/lib/bounded_queue.test.ts @@ -5,9 +5,9 @@ */ import { createBoundedQueue } from './bounded_queue'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; -const loggingService = loggingServiceMock.create(); +const loggingService = loggingSystemMock.create(); const logger = loggingService.get(); describe('basic', () => { diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index c2e8cd6129d80f..3d1efc8a479b26 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -16,9 +16,7 @@ import { FeatureConfig } from '../../common'; function createContextMock(licenseType: LicenseType = 'gold') { return { core: coreMock.createRequestHandlerContext(), - licensing: { - license: licensingMock.createLicense({ license: { type: licenseType } }), - }, + licensing: licensingMock.createRequestHandlerContext({ license: { type: licenseType } }), }; } diff --git a/x-pack/plugins/graph/kibana.json b/x-pack/plugins/graph/kibana.json index 4cae14f8939b20..ebe18dba2b58c8 100644 --- a/x-pack/plugins/graph/kibana.json +++ b/x-pack/plugins/graph/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["licensing", "data", "navigation", "savedObjects"], + "requiredPlugins": ["licensing", "data", "navigation", "savedObjects", "kibanaLegacy"], "optionalPlugins": ["home", "features"], "configPath": ["xpack", "graph"] } diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index b46bc88500e0a6..0969b80bc38b0b 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -35,6 +35,7 @@ import { configureAppAngularModule, createTopNavDirective, createTopNavHelper, + KibanaLegacyStart, } from '../../../../src/plugins/kibana_legacy/public'; import './index.scss'; @@ -67,9 +68,11 @@ export interface GraphDependencies { graphSavePolicy: string; overlays: OverlayStart; savedObjects: SavedObjectsStart; + kibanaLegacy: KibanaLegacyStart; } -export const renderApp = ({ appBasePath, element, ...deps }: GraphDependencies) => { +export const renderApp = ({ appBasePath, element, kibanaLegacy, ...deps }: GraphDependencies) => { + kibanaLegacy.loadFontAwesome(); const graphAngularModule = createLocalAngularModule(deps.navigation); configureAppAngularModule( graphAngularModule, diff --git a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx index 191655ec7bc176..51f802edec9d0d 100644 --- a/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/advanced_settings_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiFormRow, EuiFieldNumber, EuiComboBox, EuiSwitch, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SettingsProps } from './settings'; @@ -26,13 +26,30 @@ export function AdvancedSettingsForm({ updateSettings, allFields, }: Pick) { + // keep a local state during changes + const [formState, updateFormState] = useState({ ...advancedSettings }); + // useEffect update localState only based on the main store + useEffect(() => { + updateFormState(advancedSettings); + }, [updateFormState, advancedSettings]); + function updateSetting(key: K, value: AdvancedSettings[K]) { updateSettings({ ...advancedSettings, [key]: value }); } function getNumberUpdater>(key: K) { - return function ({ target: { valueAsNumber } }: { target: { valueAsNumber: number } }) { - updateSetting(key, Number.isNaN(valueAsNumber) ? 1 : valueAsNumber); + return function ({ + target: { valueAsNumber, value }, + }: { + target: { valueAsNumber: number; value: string }; + }) { + // if the value is valid, then update the central store, otherwise only the local store + if (Number.isNaN(valueAsNumber)) { + // localstate update + return updateFormState({ ...formState, [key]: value }); + } + // do not worry about local store here, the useEffect will pick that up and sync it + updateSetting(key, valueAsNumber); }; } @@ -52,7 +69,7 @@ export function AdvancedSettingsForm({ fullWidth min={1} step={1} - value={advancedSettings.sampleSize} + value={formState.sampleSize} onChange={getNumberUpdater('sampleSize')} /> @@ -73,7 +90,7 @@ export function AdvancedSettingsForm({ { defaultMessage: 'Significant links' } )} id="graphSignificance" - checked={advancedSettings.useSignificance} + checked={formState.useSignificance} onChange={({ target: { checked } }) => updateSetting('useSignificance', checked)} /> @@ -91,7 +108,7 @@ export function AdvancedSettingsForm({ fullWidth min={1} step={1} - value={advancedSettings.minDocCount} + value={formState.minDocCount} onChange={getNumberUpdater('minDocCount')} /> @@ -127,11 +144,11 @@ export function AdvancedSettingsForm({ singleSelection={{ asPlainText: true }} options={allFields.map((field) => ({ label: field.name, value: field }))} selectedOptions={ - advancedSettings.sampleDiversityField + formState.sampleDiversityField ? [ { - label: advancedSettings.sampleDiversityField.name, - value: advancedSettings.sampleDiversityField, + label: formState.sampleDiversityField.name, + value: formState.sampleDiversityField, }, ] : [] @@ -145,7 +162,7 @@ export function AdvancedSettingsForm({ /> - {advancedSettings.sampleDiversityField && ( + {formState.sampleDiversityField && ( {advancedSettings.sampleDiversityField.name}{' '} + {formState.sampleDiversityField.name}{' '} {i18n.translate( 'xpack.graph.settings.advancedSettings.maxValuesInputHelpText.fieldText', { @@ -171,7 +188,7 @@ export function AdvancedSettingsForm({ fullWidth min={1} step={1} - value={advancedSettings.maxValuesPerDoc} + value={formState.maxValuesPerDoc} onChange={getNumberUpdater('maxValuesPerDoc')} /> @@ -190,7 +207,7 @@ export function AdvancedSettingsForm({ fullWidth min={1} step={1} - value={advancedSettings.timeoutMillis} + value={formState.timeoutMillis} onChange={getNumberUpdater('timeoutMillis')} append={ diff --git a/x-pack/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/plugins/graph/public/components/settings/settings.test.tsx index b392a26ecf0d35..1efaead002b52f 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.test.tsx @@ -177,6 +177,28 @@ describe('settings', () => { ) ); }); + + it('should let the user edit and empty the field to input a new number', () => { + act(() => { + input('Sample size').prop('onChange')!({ + target: { value: '', valueAsNumber: NaN }, + } as React.ChangeEvent); + }); + // Central state should not be called + expect(dispatchSpy).not.toHaveBeenCalledWith( + updateSettings( + expect.objectContaining({ + timeoutMillis: 10000, + sampleSize: NaN, + }) + ) + ); + + // Update the local state + instance.update(); + // Now check that local state should reflect what the user sent + expect(input('Sample size').prop('value')).toEqual(''); + }); }); describe('blacklist', () => { diff --git a/x-pack/plugins/graph/public/plugin.ts b/x-pack/plugins/graph/public/plugin.ts index e97735c50388f0..5b2566ffab7c02 100644 --- a/x-pack/plugins/graph/public/plugin.ts +++ b/x-pack/plugins/graph/public/plugin.ts @@ -10,7 +10,10 @@ import { AppMountParameters, Plugin } from 'src/core/public'; import { PluginInitializerContext } from 'kibana/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { initAngularBootstrap } from '../../../../src/plugins/kibana_legacy/public'; +import { + initAngularBootstrap, + KibanaLegacyStart, +} from '../../../../src/plugins/kibana_legacy/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; @@ -34,6 +37,7 @@ export interface GraphPluginStartDependencies { navigation: NavigationStart; data: DataPublicPluginStart; savedObjects: SavedObjectsStart; + kibanaLegacy: KibanaLegacyStart; } export class GraphPlugin @@ -85,6 +89,7 @@ export class GraphPlugin core: coreStart, navigation: pluginsStart.navigation, data: pluginsStart.data, + kibanaLegacy: pluginsStart.kibanaLegacy, savedObjectsClient: coreStart.savedObjects.client, addBasePath: core.http.basePath.prepend, getBasePath: core.http.basePath.get, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 56d76da522ac22..907c749f8ec0b0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -35,6 +35,22 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setLoadDataStreamResponse = (response: HttpResponse = []) => { + server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + + const setDeleteDataStreamResponse = (response: HttpResponse = []) => { + server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setDeleteTemplateResponse = (response: HttpResponse = []) => { server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [ 200, @@ -80,6 +96,8 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadTemplatesResponse, setLoadIndicesResponse, setLoadDataStreamsResponse, + setLoadDataStreamResponse, + setDeleteDataStreamResponse, setDeleteTemplateResponse, setLoadTemplateResponse, setCreateTemplateResponse, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 0a49191fdb1496..d85db94d4a970c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -8,6 +8,7 @@ import React from 'react'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; +import { merge } from 'lodash'; import { notificationServiceMock, @@ -33,7 +34,7 @@ export const services = { services.uiMetricService.setup({ reportUiStats() {} } as any); setExtensionsService(services.extensionsService); setUiMetricService(services.uiMetricService); -const appDependencies = { services, core: {}, plugins: {} } as any; +const appDependencies = { services, core: { getUrlForApp: () => {} }, plugins: {} } as any; export const setupEnvironment = () => { // Mock initialization of services @@ -51,8 +52,13 @@ export const setupEnvironment = () => { }; }; -export const WithAppDependencies = (Comp: any) => (props: any) => ( - - - -); +export const WithAppDependencies = (Comp: any, overridingDependencies: any = {}) => ( + props: any +) => { + const mergedDependencies = merge({}, appDependencies, overridingDependencies); + return ( + + + + ); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 4e297118b0fdd9..9889ebe16ba1e0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -5,7 +5,7 @@ */ export type TestSubjects = - | 'aliasesTab' + | 'aliasesTabContent' | 'appTitle' | 'cell' | 'closeDetailsButton' @@ -27,7 +27,7 @@ export type TestSubjects = | 'indicesTab' | 'legacyTemplateTable' | 'manageTemplateButton' - | 'mappingsTab' + | 'mappingsTabContent' | 'noAliasesCallout' | 'noMappingsCallout' | 'noSettingsCallout' @@ -36,7 +36,7 @@ export type TestSubjects = | 'row' | 'sectionError' | 'sectionLoading' - | 'settingsTab' + | 'settingsTabContent' | 'summaryTab' | 'summaryTitle' | 'systemTemplatesSwitch' diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index ef6aca44a1754c..ecea230ecab85e 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -5,6 +5,7 @@ */ import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; import { registerTestBed, @@ -17,27 +18,38 @@ import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; // eslint-disable-line @kbn/eslint/no-restricted-paths import { WithAppDependencies, services, TestSubjects } from '../helpers'; -const testBedConfig: TestBedConfig = { - store: () => indexManagementStore(services as any), - memoryRouter: { - initialEntries: [`/indices`], - componentRoutePath: `/:section(indices|data_streams|templates)`, - }, - doMountAsync: true, -}; - -const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); - export interface DataStreamsTabTestBed extends TestBed { actions: { goToDataStreamsList: () => void; clickEmptyPromptIndexTemplateLink: () => void; clickReloadButton: () => void; + clickNameAt: (index: number) => void; clickIndicesAt: (index: number) => void; + clickDeletActionAt: (index: number) => void; + clickConfirmDelete: () => void; + clickDeletDataStreamButton: () => void; }; + findDeleteActionAt: (index: number) => ReactWrapper; + findDeleteConfirmationModal: () => ReactWrapper; + findDetailPanel: () => ReactWrapper; + findDetailPanelTitle: () => string; + findEmptyPromptIndexTemplateLink: () => ReactWrapper; } -export const setup = async (): Promise => { +export const setup = async (overridingDependencies: any = {}): Promise => { + const testBedConfig: TestBedConfig = { + store: () => indexManagementStore(services as any), + memoryRouter: { + initialEntries: [`/indices`], + componentRoutePath: `/:section(indices|data_streams|templates)`, + }, + doMountAsync: true, + }; + + const initTestBed = registerTestBed( + WithAppDependencies(IndexManagementHome, overridingDependencies), + testBedConfig + ); const testBed = await initTestBed(); /** @@ -48,15 +60,17 @@ export const setup = async (): Promise => { testBed.find('data_streamsTab').simulate('click'); }; - const clickEmptyPromptIndexTemplateLink = async () => { - const { find, component, router } = testBed; - + const findEmptyPromptIndexTemplateLink = () => { + const { find } = testBed; const templateLink = find('dataStreamsEmptyPromptTemplateLink'); + return templateLink; + }; + const clickEmptyPromptIndexTemplateLink = async () => { + const { component, router } = testBed; await act(async () => { - router.navigateTo(templateLink.props().href!); + router.navigateTo(findEmptyPromptIndexTemplateLink().props().href!); }); - component.update(); }; @@ -65,10 +79,15 @@ export const setup = async (): Promise => { find('reloadButton').simulate('click'); }; - const clickIndicesAt = async (index: number) => { - const { component, table, router } = testBed; + const findTestSubjectAt = (testSubject: string, index: number) => { + const { table } = testBed; const { rows } = table.getMetaData('dataStreamTable'); - const indicesLink = findTestSubject(rows[index].reactWrapper, 'indicesLink'); + return findTestSubject(rows[index].reactWrapper, testSubject); + }; + + const clickIndicesAt = async (index: number) => { + const { component, router } = testBed; + const indicesLink = findTestSubjectAt('indicesLink', index); await act(async () => { router.navigateTo(indicesLink.props().href!); @@ -77,20 +96,77 @@ export const setup = async (): Promise => { component.update(); }; + const clickNameAt = async (index: number) => { + const { component, router } = testBed; + const nameLink = findTestSubjectAt('nameLink', index); + + await act(async () => { + router.navigateTo(nameLink.props().href!); + }); + + component.update(); + }; + + const findDeleteActionAt = findTestSubjectAt.bind(null, 'deleteDataStream'); + + const clickDeletActionAt = (index: number) => { + findDeleteActionAt(index).simulate('click'); + }; + + const findDeleteConfirmationModal = () => { + const { find } = testBed; + return find('deleteDataStreamsConfirmation'); + }; + + const clickConfirmDelete = async () => { + const modal = document.body.querySelector('[data-test-subj="deleteDataStreamsConfirmation"]'); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + await act(async () => { + confirmButton!.click(); + }); + }; + + const clickDeletDataStreamButton = () => { + const { find } = testBed; + find('deleteDataStreamButton').simulate('click'); + }; + + const findDetailPanel = () => { + const { find } = testBed; + return find('dataStreamDetailPanel'); + }; + + const findDetailPanelTitle = () => { + const { find } = testBed; + return find('dataStreamDetailPanelTitle').text(); + }; + return { ...testBed, actions: { goToDataStreamsList, clickEmptyPromptIndexTemplateLink, clickReloadButton, + clickNameAt, clickIndicesAt, + clickDeletActionAt, + clickConfirmDelete, + clickDeletDataStreamButton, }, + findDeleteActionAt, + findDeleteConfirmationModal, + findDetailPanel, + findDetailPanelTitle, + findEmptyPromptIndexTemplateLink, }; }; export const createDataStreamPayload = (name: string): DataStream => ({ name, - timeStampField: '@timestamp', + timeStampField: { name: '@timestamp', mapping: { type: 'date' } }, indices: [ { name: 'indexName', diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index efe2e2d0c74aee..dfcbb518694662 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -19,61 +19,38 @@ describe('Data Streams tab', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadIndicesResponse([ - { - health: '', - status: '', - primary: '', - replica: '', - documents: '', - documents_deleted: '', - size: '', - primary_size: '', - name: 'data-stream-index', - data_stream: 'dataStream1', - }, - { - health: 'green', - status: 'open', - primary: 1, - replica: 1, - documents: 10000, - documents_deleted: 100, - size: '156kb', - primary_size: '156kb', - name: 'non-data-stream-index', - }, - ]); - - await act(async () => { - testBed = await setup(); - }); - }); - describe('when there are no data streams', () => { beforeEach(async () => { - const { actions, component } = testBed; - + httpRequestsMockHelpers.setLoadIndicesResponse([]); httpRequestsMockHelpers.setLoadDataStreamsResponse([]); httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); + }); + + test('displays an empty prompt', async () => { + testBed = await setup(); await act(async () => { - actions.goToDataStreamsList(); + testBed.actions.goToDataStreamsList(); }); + const { exists, component } = testBed; component.update(); - }); - - test('displays an empty prompt', async () => { - const { exists } = testBed; expect(exists('sectionLoading')).toBe(false); expect(exists('emptyPrompt')).toBe(true); }); - test('goes to index templates tab when "Get started" link is clicked', async () => { - const { actions, exists } = testBed; + test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { + testBed = await setup({ + plugins: {}, + }); + + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + + const { actions, exists, component } = testBed; + component.update(); await act(async () => { actions.clickEmptyPromptIndexTemplateLink(); @@ -81,32 +58,77 @@ describe('Data Streams tab', () => { expect(exists('templateList')).toBe(true); }); + + test('when Ingest Manager is enabled, links to Ingest Manager', async () => { + testBed = await setup({ + plugins: { ingestManager: { hi: 'ok' } }, + }); + + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + + const { findEmptyPromptIndexTemplateLink, component } = testBed; + component.update(); + + // Assert against the text because the href won't be available, due to dependency upon our core mock. + expect(findEmptyPromptIndexTemplateLink().text()).toBe('Ingest Manager'); + }); }); describe('when there are data streams', () => { beforeEach(async () => { - const { actions, component } = testBed; + httpRequestsMockHelpers.setLoadIndicesResponse([ + { + health: '', + status: '', + primary: '', + replica: '', + documents: '', + documents_deleted: '', + size: '', + primary_size: '', + name: 'data-stream-index', + data_stream: 'dataStream1', + }, + { + health: 'green', + status: 'open', + primary: 1, + replica: 1, + documents: 10000, + documents_deleted: 100, + size: '156kb', + primary_size: '156kb', + name: 'non-data-stream-index', + }, + ]); + + const dataStreamForDetailPanel = createDataStreamPayload('dataStream1'); httpRequestsMockHelpers.setLoadDataStreamsResponse([ - createDataStreamPayload('dataStream1'), + dataStreamForDetailPanel, createDataStreamPayload('dataStream2'), ]); + httpRequestsMockHelpers.setLoadDataStreamResponse(dataStreamForDetailPanel); + + testBed = await setup(); + await act(async () => { - actions.goToDataStreamsList(); + testBed.actions.goToDataStreamsList(); }); - component.update(); + testBed.component.update(); }); test('lists them in the table', async () => { const { table } = testBed; - const { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ - ['dataStream1', '1', '@timestamp', '1'], - ['dataStream2', '1', '@timestamp', '1'], + ['', 'dataStream1', '1', ''], + ['', 'dataStream2', '1', ''], ]); }); @@ -126,12 +148,90 @@ describe('Data Streams tab', () => { test('clicking the indices count navigates to the backing indices', async () => { const { table, actions } = testBed; - await actions.clickIndicesAt(0); - expect(table.getMetaData('indexTable').tableCellsValues).toEqual([ ['', '', '', '', '', '', '', 'dataStream1'], ]); }); + + describe('row actions', () => { + test('can delete', () => { + const { findDeleteActionAt } = testBed; + const deleteAction = findDeleteActionAt(0); + expect(deleteAction.length).toBe(1); + }); + }); + + describe('deleting a data stream', () => { + test('shows a confirmation modal', async () => { + const { + actions: { clickDeletActionAt }, + findDeleteConfirmationModal, + } = testBed; + clickDeletActionAt(0); + const confirmationModal = findDeleteConfirmationModal(); + expect(confirmationModal).toBeDefined(); + }); + + test('sends a request to the Delete API', async () => { + const { + actions: { clickDeletActionAt, clickConfirmDelete }, + } = testBed; + clickDeletActionAt(0); + + httpRequestsMockHelpers.setDeleteDataStreamResponse({ + results: { + dataStreamsDeleted: ['dataStream1'], + errors: [], + }, + }); + + await clickConfirmDelete(); + + const { method, url, requestBody } = server.requests[server.requests.length - 1]; + + expect(method).toBe('POST'); + expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); + expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ + dataStreams: ['dataStream1'], + }); + }); + }); + + describe('detail panel', () => { + test('opens when the data stream name in the table is clicked', async () => { + const { actions, findDetailPanel, findDetailPanelTitle } = testBed; + await actions.clickNameAt(0); + expect(findDetailPanel().length).toBe(1); + expect(findDetailPanelTitle()).toBe('dataStream1'); + }); + + test('deletes the data stream when delete button is clicked', async () => { + const { + actions: { clickNameAt, clickDeletDataStreamButton, clickConfirmDelete }, + } = testBed; + + await clickNameAt(0); + + clickDeletDataStreamButton(); + + httpRequestsMockHelpers.setDeleteDataStreamResponse({ + results: { + dataStreamsDeleted: ['dataStream1'], + errors: [], + }, + }); + + await clickConfirmDelete(); + + const { method, url, requestBody } = server.requests[server.requests.length - 1]; + + expect(method).toBe('POST'); + expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); + expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ + dataStreams: ['dataStream1'], + }); + }); + }); }); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 7c79c7e61174ea..2ff3743cd866c6 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -432,18 +432,18 @@ describe('Index Templates tab', () => { // Navigate and verify all tabs actions.selectDetailsTab('settings'); expect(exists('summaryTab')).toBe(false); - expect(exists('settingsTab')).toBe(true); + expect(exists('settingsTabContent')).toBe(true); actions.selectDetailsTab('aliases'); expect(exists('summaryTab')).toBe(false); - expect(exists('settingsTab')).toBe(false); - expect(exists('aliasesTab')).toBe(true); + expect(exists('settingsTabContent')).toBe(false); + expect(exists('aliasesTabContent')).toBe(true); actions.selectDetailsTab('mappings'); expect(exists('summaryTab')).toBe(false); - expect(exists('settingsTab')).toBe(false); - expect(exists('aliasesTab')).toBe(false); - expect(exists('mappingsTab')).toBe(true); + expect(exists('settingsTabContent')).toBe(false); + expect(exists('aliasesTabContent')).toBe(false); + expect(exists('mappingsTabContent')).toBe(true); }); test('should show an info callout if data is not present', async () => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index f00348aacbf085..11ea29fd9b78c6 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -5,6 +5,7 @@ */ import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; import { registerTestBed, @@ -34,6 +35,8 @@ export interface IndicesTestBed extends TestBed { clickIncludeHiddenIndicesToggle: () => void; clickDataStreamAt: (index: number) => void; }; + findDataStreamDetailPanel: () => ReactWrapper; + findDataStreamDetailPanelTitle: () => string; } export const setup = async (): Promise => { @@ -77,6 +80,16 @@ export const setup = async (): Promise => { component.update(); }; + const findDataStreamDetailPanel = () => { + const { find } = testBed; + return find('dataStreamDetailPanel'); + }; + + const findDataStreamDetailPanelTitle = () => { + const { find } = testBed; + return find('dataStreamDetailPanelTitle').text(); + }; + return { ...testBed, actions: { @@ -85,5 +98,7 @@ export const setup = async (): Promise => { clickIncludeHiddenIndicesToggle, clickDataStreamAt, }, + findDataStreamDetailPanel, + findDataStreamDetailPanelTitle, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index c2d955bb4dfce8..3d6d94d1658559 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -70,10 +70,10 @@ describe('', () => { }, ]); - httpRequestsMockHelpers.setLoadDataStreamsResponse([ - createDataStreamPayload('dataStream1'), - createDataStreamPayload('dataStream2'), - ]); + // The detail panel should still appear even if there are no data streams. + httpRequestsMockHelpers.setLoadDataStreamsResponse([]); + + httpRequestsMockHelpers.setLoadDataStreamResponse(createDataStreamPayload('dataStream1')); testBed = await setup(); @@ -86,13 +86,16 @@ describe('', () => { }); test('navigates to the data stream in the Data Streams tab', async () => { - const { table, actions } = testBed; + const { + findDataStreamDetailPanel, + findDataStreamDetailPanelTitle, + actions: { clickDataStreamAt }, + } = testBed; - await actions.clickDataStreamAt(0); + await clickDataStreamAt(0); - expect(table.getMetaData('dataStreamTable').tableCellsValues).toEqual([ - ['dataStream1', '1', '@timestamp', '1'], - ]); + expect(findDataStreamDetailPanel().length).toBe(1); + expect(findDataStreamDetailPanelTitle()).toBe('dataStream1'); }); }); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index 9d267210a6b318..51528ed9856ce9 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -6,8 +6,10 @@ import { DataStream, DataStreamFromEs } from '../types'; -export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]): DataStream[] { - return dataStreamsFromEs.map(({ name, timestamp_field, indices, generation }) => ({ +export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataStream { + const { name, timestamp_field, indices, generation } = dataStreamFromEs; + + return { name, timeStampField: timestamp_field, indices: indices.map( @@ -17,5 +19,9 @@ export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]) }) ), generation, - })); + }; +} + +export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]): DataStream[] { + return dataStreamsFromEs.map((dataStream) => deserializeDataStream(dataStream)); } diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index fce4d8ccc2502b..4e76a40ced5240 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { deserializeDataStreamList } from './data_stream_serialization'; +export { deserializeDataStream, deserializeDataStreamList } from './data_stream_serialization'; export { deserializeLegacyTemplateList, diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index 5b743296d868bd..772ed43459bcf7 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -4,9 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +interface TimestampFieldFromEs { + name: string; + mapping: { + type: string; + }; +} + +type TimestampField = TimestampFieldFromEs; + export interface DataStreamFromEs { name: string; - timestamp_field: string; + timestamp_field: TimestampFieldFromEs; indices: DataStreamIndexFromEs[]; generation: number; } @@ -18,7 +27,7 @@ export interface DataStreamIndexFromEs { export interface DataStream { name: string; - timeStampField: string; + timeStampField: TimestampField; indices: DataStreamIndex[]; generation: number; } diff --git a/x-pack/plugins/index_management/kibana.json b/x-pack/plugins/index_management/kibana.json index 2e0fa04337b400..40ecb26e8f0c96 100644 --- a/x-pack/plugins/index_management/kibana.json +++ b/x-pack/plugins/index_management/kibana.json @@ -10,7 +10,8 @@ ], "optionalPlugins": [ "security", - "usageCollection" + "usageCollection", + "ingestManager" ], "configPath": ["xpack", "index_management"] } diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index 84938de4169417..c8219071203736 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -6,9 +6,10 @@ import React, { createContext, useContext } from 'react'; import { ScopedHistory } from 'kibana/public'; -import { CoreStart } from '../../../../../src/core/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/public'; +import { CoreStart } from '../../../../../src/core/public'; +import { IngestManagerSetup } from '../../../ingest_manager/public'; import { IndexMgmtMetricsType } from '../types'; import { UiMetricService, NotificationService, HttpService } from './services'; import { ExtensionsService } from '../services'; @@ -22,6 +23,7 @@ export interface AppDependencies { }; plugins: { usageCollection: UsageCollectionSetup; + ingestManager?: IngestManagerSetup; }; services: { uiMetricService: UiMetricService; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx index e8116409def4b8..c78d24f126e297 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx @@ -5,7 +5,7 @@ */ import React, { createContext, useContext } from 'react'; -import { HttpSetup, DocLinksSetup, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, DocLinksStart, NotificationsSetup } from 'src/core/public'; import { getApi, getUseRequest, getSendRequest, getDocumentation } from './lib'; @@ -15,7 +15,7 @@ interface Props { httpClient: HttpSetup; apiBasePath: string; trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void; - docLinks: DocLinksSetup; + docLinks: DocLinksStart; toasts: NotificationsSetup['toasts']; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts index dc27dadf0b8073..9d20ae9d2ec76a 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/documentation.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DocLinksSetup } from 'src/core/public'; +import { DocLinksStart } from 'src/core/public'; -export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksSetup) => { +export const getDocumentation = ({ ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }: DocLinksStart) => { const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/index.ts b/x-pack/plugins/index_management/public/application/components/shared/components/index.ts index 90d66bd1a5da49..b67a9c355e723d 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/index.ts +++ b/x-pack/plugins/index_management/public/application/components/shared/components/index.ts @@ -5,3 +5,10 @@ */ export { TabAliases, TabMappings, TabSettings } from './details_panel'; + +export { + StepAliasesContainer, + StepMappingsContainer, + StepSettingsContainer, + CommonWizardSteps, +} from './wizard_steps'; diff --git a/x-pack/plugins/canvas/public/components/palette_swatch/index.js b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/index.ts similarity index 50% rename from x-pack/plugins/canvas/public/components/palette_swatch/index.js rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/index.ts index 2be37a8338b2b3..ea554ca269d8bd 100644 --- a/x-pack/plugins/canvas/public/components/palette_swatch/index.js +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/index.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; +export { StepAliasesContainer } from './step_aliases_container'; +export { StepMappingsContainer } from './step_mappings_container'; +export { StepSettingsContainer } from './step_settings_container'; -import { PaletteSwatch as Component } from './palette_swatch'; - -export const PaletteSwatch = pure(Component); +export { CommonWizardSteps } from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx similarity index 80% rename from x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index e18846a69b8474..0d28ec4b50c9ab 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -19,17 +19,17 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Forms } from '../../../../shared_imports'; -import { documentationService } from '../../../services/documentation'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { defaultValue: { [key: string]: any }; onChange: (content: Forms.Content) => void; + esDocsBase: string; } export const StepAliases: React.FunctionComponent = React.memo( - ({ defaultValue, onChange }) => { + ({ defaultValue, onChange, esDocsBase }) => { const { jsonContent, setJsonContent, error } = useJsonStep({ defaultValue, onChange, @@ -42,7 +42,7 @@ export const StepAliases: React.FunctionComponent = React.memo(

@@ -53,7 +53,7 @@ export const StepAliases: React.FunctionComponent = React.memo(

@@ -64,13 +64,13 @@ export const StepAliases: React.FunctionComponent = React.memo( @@ -82,13 +82,13 @@ export const StepAliases: React.FunctionComponent = React.memo( } helpText={ = React.memo( showGutter={false} minLines={6} aria-label={i18n.translate( - 'xpack.idxMgmt.templateForm.stepAliases.fieldAliasesAriaLabel', + 'xpack.idxMgmt.formWizard.stepAliases.fieldAliasesAriaLabel', { defaultMessage: 'Aliases code editor', } diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx new file mode 100644 index 00000000000000..a5953ea00a1063 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx @@ -0,0 +1,22 @@ +/* + * 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 from 'react'; + +import { Forms } from '../../../../../shared_imports'; +import { CommonWizardSteps } from './types'; +import { StepAliases } from './step_aliases'; + +interface Props { + esDocsBase: string; +} + +export const StepAliasesContainer: React.FunctionComponent = ({ esDocsBase }) => { + const { defaultValue, updateContent } = Forms.useContent('aliases'); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx similarity index 84% rename from x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx index 800cb519a9393f..2b9b689e17cb92 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx @@ -15,23 +15,23 @@ import { EuiText, } from '@elastic/eui'; -import { Forms } from '../../../../shared_imports'; -import { documentationService } from '../../../services/documentation'; +import { Forms } from '../../../../../shared_imports'; import { MappingsEditor, OnUpdateHandler, LoadMappingsFromJsonButton, IndexSettings, -} from '../../mappings_editor'; +} from '../../../mappings_editor'; interface Props { defaultValue: { [key: string]: any }; onChange: (content: Forms.Content) => void; indexSettings?: IndexSettings; + esDocsBase: string; } export const StepMappings: React.FunctionComponent = React.memo( - ({ defaultValue, onChange, indexSettings }) => { + ({ defaultValue, onChange, indexSettings, esDocsBase }) => { const [mappings, setMappings] = useState(defaultValue); const onMappingsEditorUpdate = useCallback( @@ -58,7 +58,7 @@ export const StepMappings: React.FunctionComponent = React.memo(

@@ -69,7 +69,7 @@ export const StepMappings: React.FunctionComponent = React.memo(

@@ -86,12 +86,12 @@ export const StepMappings: React.FunctionComponent = React.memo( diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx similarity index 64% rename from x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx index 545aec9851592e..34e05d88c651d7 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx @@ -5,18 +5,23 @@ */ import React from 'react'; -import { Forms } from '../../../../shared_imports'; -import { WizardContent } from '../template_form'; +import { Forms } from '../../../../../shared_imports'; +import { CommonWizardSteps } from './types'; import { StepMappings } from './step_mappings'; -export const StepMappingsContainer = () => { - const { defaultValue, updateContent, getData } = Forms.useContent('mappings'); +interface Props { + esDocsBase: string; +} + +export const StepMappingsContainer: React.FunctionComponent = ({ esDocsBase }) => { + const { defaultValue, updateContent, getData } = Forms.useContent('mappings'); return ( ); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx similarity index 81% rename from x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx index 4325852d68aaa2..4eafcee0ef519c 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx @@ -19,17 +19,17 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Forms } from '../../../../shared_imports'; -import { documentationService } from '../../../services/documentation'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { defaultValue: { [key: string]: any }; onChange: (content: Forms.Content) => void; + esDocsBase: string; } export const StepSettings: React.FunctionComponent = React.memo( - ({ defaultValue, onChange }) => { + ({ defaultValue, onChange, esDocsBase }) => { const { jsonContent, setJsonContent, error } = useJsonStep({ defaultValue, onChange, @@ -42,7 +42,7 @@ export const StepSettings: React.FunctionComponent = React.memo(

@@ -53,7 +53,7 @@ export const StepSettings: React.FunctionComponent = React.memo(

@@ -64,12 +64,12 @@ export const StepSettings: React.FunctionComponent = React.memo( @@ -82,13 +82,13 @@ export const StepSettings: React.FunctionComponent = React.memo( } helpText={ {JSON.stringify({ number_of_replicas: 1 })}, @@ -114,7 +114,7 @@ export const StepSettings: React.FunctionComponent = React.memo( showGutter={false} minLines={6} aria-label={i18n.translate( - 'xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsAriaLabel', + 'xpack.idxMgmt.formWizard.stepSettings.fieldIndexSettingsAriaLabel', { defaultMessage: 'Index settings editor', } diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx new file mode 100644 index 00000000000000..c540ddceb95c2f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx @@ -0,0 +1,22 @@ +/* + * 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 from 'react'; + +import { Forms } from '../../../../../shared_imports'; +import { CommonWizardSteps } from './types'; +import { StepSettings } from './step_settings'; + +interface Props { + esDocsBase: string; +} + +export const StepSettingsContainer = React.memo(({ esDocsBase }: Props) => { + const { defaultValue, updateContent } = Forms.useContent('settings'); + + return ( + + ); +}); diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/types.ts similarity index 56% rename from x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/types.ts index 9e5032efa97e26..f8088e2b6e058b 100644 --- a/x-pack/plugins/canvas/public/components/render_with_fn/lib/handlers.js +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/types.ts @@ -4,16 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export class ElementHandlers { - resize() {} +import { Mappings, IndexSettings, Aliases } from '../../../../../../common'; - destroy() {} - - onResize(fn) { - this.resize = fn; - } - - onDestroy(fn) { - this.destroy = fn; - } +export interface CommonWizardSteps { + settings?: IndexSettings; + mappings?: Mappings; + aliases?: Aliases; } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/use_json_step.ts similarity index 96% rename from x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts rename to x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/use_json_step.ts index 4c1b36e3abba52..67799f1e76d855 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/use_json_step.ts @@ -7,7 +7,7 @@ import { useEffect, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { isJSON, Forms } from '../../../../shared_imports'; +import { isJSON, Forms } from '../../../../../shared_imports'; interface Parameters { onChange: (content: Forms.Content) => void; diff --git a/x-pack/plugins/index_management/public/application/components/shared/index.ts b/x-pack/plugins/index_management/public/application/components/shared/index.ts index e015ef72e244fa..897e86c99eca0b 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/index.ts +++ b/x-pack/plugins/index_management/public/application/components/shared/index.ts @@ -4,4 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export { TabAliases, TabMappings, TabSettings } from './components'; +export { + TabAliases, + TabMappings, + TabSettings, + StepAliasesContainer, + StepMappingsContainer, + StepSettingsContainer, + CommonWizardSteps, +} from './components'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts index 95d1222ad2cc9a..b7e3e36e61814d 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts @@ -5,7 +5,4 @@ */ export { StepLogisticsContainer } from './step_logistics_container'; -export { StepAliasesContainer } from './step_aliases_container'; -export { StepMappingsContainer } from './step_mappings_container'; -export { StepSettingsContainer } from './step_settings_container'; export { StepReviewContainer } from './step_review_container'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx deleted file mode 100644 index 634887436f816f..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { Forms } from '../../../../shared_imports'; -import { WizardContent } from '../template_form'; -import { StepAliases } from './step_aliases'; - -export const StepAliasesContainer = () => { - const { defaultValue, updateContent } = Forms.useContent('aliases'); - - return ; -}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx deleted file mode 100644 index 4d7de644a1442b..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { Forms } from '../../../../shared_imports'; -import { WizardContent } from '../template_form'; -import { StepSettings } from './step_settings'; - -export const StepSettingsContainer = React.memo(() => { - const { defaultValue, updateContent } = Forms.useContent('settings'); - - return ; -}); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 9e6d49faac5630..8a2c991aea8d07 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -11,13 +11,14 @@ import { EuiSpacer } from '@elastic/eui'; import { TemplateDeserialized, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from '../../../../common'; import { serializers, Forms } from '../../../shared_imports'; import { SectionError } from '../section_error'; +import { StepLogisticsContainer, StepReviewContainer } from './steps'; import { - StepLogisticsContainer, + CommonWizardSteps, StepSettingsContainer, StepMappingsContainer, StepAliasesContainer, - StepReviewContainer, -} from './steps'; +} from '../shared'; +import { documentationService } from '../../services/documentation'; const { stripEmptyFields } = serializers; const { FormWizard, FormWizardStep } = Forms; @@ -31,11 +32,8 @@ interface Props { isEditing?: boolean; } -export interface WizardContent { +export interface WizardContent extends CommonWizardSteps { logistics: Omit; - settings: TemplateDeserialized['template']['settings']; - mappings: TemplateDeserialized['template']['mappings']; - aliases: TemplateDeserialized['template']['aliases']; } export type WizardSection = keyof WizardContent | 'review'; @@ -183,15 +181,15 @@ export const TemplateForm = ({ - + - + - + diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index e8b6f200fb349f..258f32865720af 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -8,6 +8,7 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public/'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { IngestManagerSetup } from '../../../ingest_manager/public'; import { ExtensionsService } from '../services'; import { IndexMgmtMetricsType } from '../types'; import { AppDependencies } from './app_context'; @@ -28,7 +29,8 @@ export async function mountManagementSection( coreSetup: CoreSetup, usageCollection: UsageCollectionSetup, services: InternalServices, - params: ManagementAppMountParams + params: ManagementAppMountParams, + ingestManager?: IngestManagerSetup ) { const { element, setBreadcrumbs, history } = params; const [core] = await coreSetup.getStartServices(); @@ -44,6 +46,7 @@ export async function mountManagementSection( }, plugins: { usageCollection, + ingestManager, }, services, history, diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index a6c8b83a05f989..577f04a4a7efd1 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButton, EuiFlyout, EuiFlyoutHeader, EuiTitle, @@ -15,14 +16,18 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, } from '@elastic/eui'; import { SectionLoading, SectionError, Error } from '../../../../components'; import { useLoadDataStream } from '../../../../services/api'; +import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; interface Props { dataStreamName: string; - onClose: () => void; + onClose: (shouldReload?: boolean) => void; } /** @@ -36,6 +41,8 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ }) => { const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName); + const [isDeleting, setIsDeleting] = useState(false); + let content; if (isLoading) { @@ -61,44 +68,97 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ /> ); } else if (dataStream) { - content = {JSON.stringify(dataStream)}; + const { timeStampField, generation } = dataStream; + + content = ( + + + + + + {timeStampField.name} + + + + + + {generation} + + ); } return ( - - - -

- {dataStreamName} -

-
-
- - {content} - - - - - - - - - - -
+ <> + {isDeleting ? ( + { + if (data && data.hasDeletedDataStreams) { + onClose(true); + } else { + setIsDeleting(false); + } + }} + dataStreams={[dataStreamName]} + /> + ) : null} + + + + +

+ {dataStreamName} +

+
+
+ + {content} + + + + + onClose()} + data-test-subj="closeDetailsButton" + > + + + + + {!isLoading && !error ? ( + + setIsDeleting(true)} + data-test-subj="deleteDataStreamButton" + > + + + + ) : null} + + +
+ ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index 951c4a0d7f3c31..bad008b665cfbf 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -12,9 +12,13 @@ import { EuiTitle, EuiText, EuiSpacer, EuiEmptyPrompt, EuiLink } from '@elastic/ import { ScopedHistory } from 'kibana/public'; import { reactRouterNavigate } from '../../../../shared_imports'; +import { useAppContext } from '../../../app_context'; import { SectionError, SectionLoading, Error } from '../../../components'; import { useLoadDataStreams } from '../../../services/api'; +import { decodePathFromReactRouter } from '../../../services/routing'; +import { Section } from '../../home'; import { DataStreamTable } from './data_stream_table'; +import { DataStreamDetailPanel } from './data_stream_detail_panel'; interface MatchParams { dataStreamName?: string; @@ -26,6 +30,11 @@ export const DataStreamList: React.FunctionComponent { + const { + core: { getUrlForApp }, + plugins: { ingestManager }, + } = useAppContext(); + const { error, isLoading, data: dataStreams, sendRequest: reload } = useLoadDataStreams(); let content; @@ -67,22 +76,52 @@ export const DataStreamList: React.FunctionComponent - {i18n.translate('xpack.idxMgmt.dataStreamList.emptyPrompt.getStartedLink', { - defaultMessage: 'composable index template', - })} - - ), - }} + defaultMessage="Data streams represent collections of time series indices." /> + {' ' /* We need this space to separate these two sentences. */} + {ingestManager ? ( + + {i18n.translate( + 'xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIngestManagerLink', + { + defaultMessage: 'Ingest Manager', + } + )} + + ), + }} + /> + ) : ( + + {i18n.translate( + 'xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIndexTemplateLink', + { + defaultMessage: 'composable index template', + } + )} + + ), + }} + /> + )}

} data-test-subj="emptyPrompt" @@ -104,24 +143,38 @@ export const DataStreamList: React.FunctionComponent - - {/* TODO: Implement this once we have something to put in here, e.g. storage size, docs count */} - {/* dataStreamName && ( - { - history.push('/data_streams'); - }} - /> - )*/} ); } - return
{content}
; + return ( +
+ {content} + + {/* + If the user has been deep-linked, they'll expect to see the detail panel because it reflects + the URL state, even if there are no data streams or if there was an error loading them. + */} + {dataStreamName && ( + { + history.push(`/${Section.DataStreams}`); + + // If the data stream was deleted, we need to refresh the list. + if (shouldReload) { + reload(); + } + }} + /> + )} +
+ ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx index 54b215e561b462..d01d8fa03a3fae 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiInMemoryTable, EuiBasicTableColumn, EuiButton, EuiLink } from '@elastic/eui'; @@ -13,6 +13,8 @@ import { ScopedHistory } from 'kibana/public'; import { DataStream } from '../../../../../../common/types'; import { reactRouterNavigate } from '../../../../../shared_imports'; import { encodePathForReactRouter } from '../../../../services/routing'; +import { Section } from '../../../home'; +import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; interface Props { dataStreams?: DataStream[]; @@ -27,6 +29,9 @@ export const DataStreamTable: React.FunctionComponent = ({ history, filters, }) => { + const [selection, setSelection] = useState([]); + const [dataStreamsToDelete, setDataStreamsToDelete] = useState([]); + const columns: Array> = [ { field: 'name', @@ -35,7 +40,19 @@ export const DataStreamTable: React.FunctionComponent = ({ }), truncateText: true, sortable: true, - // TODO: Render as a link to open the detail panel + render: (name: DataStream['name'], item: DataStream) => { + return ( + /* eslint-disable-next-line @elastic/eui/href-or-on-click */ + + {name} + + ); + }, }, { field: 'indices', @@ -59,20 +76,27 @@ export const DataStreamTable: React.FunctionComponent = ({ ), }, { - field: 'timeStampField', - name: i18n.translate('xpack.idxMgmt.dataStreamList.table.timeStampFieldColumnTitle', { - defaultMessage: 'Timestamp field', + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionColumnTitle', { + defaultMessage: 'Actions', }), - truncateText: true, - sortable: true, - }, - { - field: 'generation', - name: i18n.translate('xpack.idxMgmt.dataStreamList.table.generationFieldColumnTitle', { - defaultMessage: 'Generation', - }), - truncateText: true, - sortable: true, + actions: [ + { + name: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionDeleteText', { + defaultMessage: 'Delete', + }), + description: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionDeleteDecription', { + defaultMessage: 'Delete this data stream', + }), + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: ({ name }: DataStream) => { + setDataStreamsToDelete([name]); + }, + isPrimary: true, + 'data-test-subj': 'deleteDataStream', + }, + ], }, ]; @@ -88,12 +112,29 @@ export const DataStreamTable: React.FunctionComponent = ({ }, } as const; + const selectionConfig = { + onSelectionChange: setSelection, + }; + const searchConfig = { query: filters, box: { incremental: true, }, - toolsLeft: undefined /* TODO: Actions menu */, + toolsLeft: + selection.length > 0 ? ( + setDataStreamsToDelete(selection.map(({ name }: DataStream) => name))} + color="danger" + > + + + ) : undefined, toolsRight: [ = ({ return ( <> + {dataStreamsToDelete && dataStreamsToDelete.length > 0 ? ( + { + if (data && data.hasDeletedDataStreams) { + reload(); + } else { + setDataStreamsToDelete([]); + } + }} + dataStreams={dataStreamsToDelete} + /> + ) : null} = ({ search={searchConfig} sorting={sorting} isSelectable={true} + selection={selectionConfig} pagination={pagination} rowProps={() => ({ 'data-test-subj': 'row', diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx new file mode 100644 index 00000000000000..fc8e41aa634b47 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx @@ -0,0 +1,149 @@ +/* + * 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, { Fragment } from 'react'; +import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { deleteDataStreams } from '../../../../services/api'; +import { notificationService } from '../../../../services/notification'; + +interface Props { + dataStreams: string[]; + onClose: (data?: { hasDeletedDataStreams: boolean }) => void; +} + +export const DeleteDataStreamConfirmationModal: React.FunctionComponent = ({ + dataStreams, + onClose, +}: { + dataStreams: string[]; + onClose: (data?: { hasDeletedDataStreams: boolean }) => void; +}) => { + const dataStreamsCount = dataStreams.length; + + const handleDeleteDataStreams = () => { + deleteDataStreams(dataStreams).then(({ data: { dataStreamsDeleted, errors }, error }) => { + const hasDeletedDataStreams = dataStreamsDeleted && dataStreamsDeleted.length; + + if (hasDeletedDataStreams) { + const successMessage = + dataStreamsDeleted.length === 1 + ? i18n.translate( + 'xpack.idxMgmt.deleteDataStreamsConfirmationModal.successDeleteSingleNotificationMessageText', + { + defaultMessage: "Deleted data stream '{dataStreamName}'", + values: { dataStreamName: dataStreams[0] }, + } + ) + : i18n.translate( + 'xpack.idxMgmt.deleteDataStreamsConfirmationModal.successDeleteMultipleNotificationMessageText', + { + defaultMessage: + 'Deleted {numSuccesses, plural, one {# data stream} other {# data streams}}', + values: { numSuccesses: dataStreamsDeleted.length }, + } + ); + + onClose({ hasDeletedDataStreams }); + notificationService.showSuccessToast(successMessage); + } + + if (error || (errors && errors.length)) { + const hasMultipleErrors = + (errors && errors.length > 1) || (error && dataStreams.length > 1); + + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.idxMgmt.deleteDataStreamsConfirmationModal.multipleErrorsNotificationMessageText', + { + defaultMessage: 'Error deleting {count} data streams', + values: { + count: (errors && errors.length) || dataStreams.length, + }, + } + ) + : i18n.translate( + 'xpack.idxMgmt.deleteDataStreamsConfirmationModal.errorNotificationMessageText', + { + defaultMessage: "Error deleting data stream '{name}'", + values: { name: (errors && errors[0].name) || dataStreams[0] }, + } + ); + + notificationService.showDangerToast(errorMessage); + } + }); + }; + + return ( + + + } + onCancel={() => onClose()} + onConfirm={handleDeleteDataStreams} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + + + } + color="danger" + iconType="alert" + > +

+ +

+
+ + + +

+ +

+ +
    + {dataStreams.map((name) => ( +
  • {name}
  • + ))} +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/index.ts b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/index.ts new file mode 100644 index 00000000000000..eaa4a8fc2de02b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DeleteDataStreamConfirmationModal } from './delete_data_stream_confirmation_modal'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx index 807229fb362676..ab4ce6a61a9b64 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx @@ -30,7 +30,6 @@ import { UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, } from '../../../../../../../common/constants'; -import { TemplateDeserialized } from '../../../../../../../common'; import { TemplateDeleteModal, SectionLoading, @@ -41,7 +40,8 @@ import { useLoadIndexTemplate } from '../../../../../services/api'; import { decodePathFromReactRouter } from '../../../../../services/routing'; import { SendRequestResponse } from '../../../../../../shared_imports'; import { useServices } from '../../../../../app_context'; -import { TabSummary, TabMappings, TabSettings, TabAliases } from '../../template_details/tabs'; +import { TabAliases, TabMappings, TabSettings } from '../../../../../components/shared'; +import { TabSummary } from '../../template_details/tabs'; interface Props { template: { name: string; isLegacy?: boolean }; @@ -83,15 +83,6 @@ const TABS = [ }, ]; -const tabToComponentMap: { - [key: string]: React.FunctionComponent<{ templateDetails: TemplateDeserialized }>; -} = { - [SUMMARY_TAB_ID]: TabSummary, - [SETTINGS_TAB_ID]: TabSettings, - [MAPPINGS_TAB_ID]: TabMappings, - [ALIASES_TAB_ID]: TabAliases, -}; - const tabToUiMetricMap: { [key: string]: string } = { [SUMMARY_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, [SETTINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, @@ -144,7 +135,19 @@ export const LegacyTemplateDetails: React.FunctionComponent = ({ /> ); } else if (templateDetails) { - const Content = tabToComponentMap[activeTab]; + const { + template: { settings, mappings, aliases }, + } = templateDetails; + + const tabToComponentMap: Record = { + [SUMMARY_TAB_ID]: , + [SETTINGS_TAB_ID]: , + [MAPPINGS_TAB_ID]: , + [ALIASES_TAB_ID]: , + }; + + const tabContent = tabToComponentMap[activeTab]; + const managedTemplateCallout = isManaged ? ( = ({ - + {tabContent} ); } diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts index 7af28f4688f48b..08ebda2b5e437c 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts @@ -5,6 +5,3 @@ */ export { TabSummary } from './tab_summary'; -export { TabMappings } from './tab_mappings'; -export { TabSettings } from './tab_settings'; -export { TabAliases } from './tab_aliases'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_aliases.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_aliases.tsx deleted file mode 100644 index fa7d734ad0d2ba..00000000000000 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_aliases.tsx +++ /dev/null @@ -1,41 +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 from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeBlock, EuiCallOut } from '@elastic/eui'; -import { TemplateDeserialized } from '../../../../../../../common'; - -interface Props { - templateDetails: TemplateDeserialized; -} - -export const TabAliases: React.FunctionComponent = ({ templateDetails }) => { - const { - template: { aliases }, - } = templateDetails; - - if (aliases && Object.keys(aliases).length) { - return ( -
- {JSON.stringify(aliases, null, 2)} -
- ); - } - - return ( - - } - iconType="pin" - data-test-subj="noAliasesCallout" - /> - ); -}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_mappings.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_mappings.tsx deleted file mode 100644 index 6e0257c6b377bb..00000000000000 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_mappings.tsx +++ /dev/null @@ -1,41 +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 from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeBlock, EuiCallOut } from '@elastic/eui'; -import { TemplateDeserialized } from '../../../../../../../common'; - -interface Props { - templateDetails: TemplateDeserialized; -} - -export const TabMappings: React.FunctionComponent = ({ templateDetails }) => { - const { - template: { mappings }, - } = templateDetails; - - if (mappings && Object.keys(mappings).length) { - return ( -
- {JSON.stringify(mappings, null, 2)} -
- ); - } - - return ( - - } - iconType="pin" - data-test-subj="noMappingsCallout" - /> - ); -}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_settings.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_settings.tsx deleted file mode 100644 index 8f75c2cb77801b..00000000000000 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_settings.tsx +++ /dev/null @@ -1,41 +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 from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeBlock, EuiCallOut } from '@elastic/eui'; -import { TemplateDeserialized } from '../../../../../../../common'; - -interface Props { - templateDetails: TemplateDeserialized; -} - -export const TabSettings: React.FunctionComponent = ({ templateDetails }) => { - const { - template: { settings }, - } = templateDetails; - - if (settings && Object.keys(settings).length) { - return ( -
- {JSON.stringify(settings, null, 2)} -
- ); - } - - return ( - - } - iconType="pin" - data-test-subj="noSettingsCallout" - /> - ); -}; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index 5ad84395d24c2b..d7874ec2dcf325 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -53,14 +53,21 @@ export function useLoadDataStreams() { }); } -// TODO: Implement this API endpoint once we have content to surface in the detail panel. export function useLoadDataStream(name: string) { - return useRequest({ - path: `${API_BASE_PATH}/data_stream/${encodeURIComponent(name)}`, + return useRequest({ + path: `${API_BASE_PATH}/data_streams/${encodeURIComponent(name)}`, method: 'get', }); } +export async function deleteDataStreams(dataStreams: string[]) { + return sendRequest({ + path: `${API_BASE_PATH}/delete_data_streams`, + method: 'post', + body: { dataStreams }, + }); +} + export async function loadIndices() { const response = await httpService.httpClient.get(`${API_BASE_PATH}/indices`); return response.data ? response.data : response; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index fdf07c43a0c8b2..ccccccce197662 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -20,6 +20,10 @@ class DocumentationService { this.kibanaDocsBase = `${docsBase}/kibana/${DOC_LINK_VERSION}`; } + public getEsDocsBase() { + return this.esDocsBase; + } + public getSettingsDocumentationLink() { return `${this.esDocsBase}/index-modules.html#index-modules-settings`; } diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index 94d9bccdc63caa..aec25ee3247d69 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -8,6 +8,8 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from '../../../../src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; + +import { IngestManagerSetup } from '../../ingest_manager/public'; import { UIM_APP_NAME, PLUGIN } from '../common/constants'; import { httpService } from './application/services/http'; @@ -25,6 +27,7 @@ export interface IndexManagementPluginSetup { } interface PluginsDependencies { + ingestManager?: IngestManagerSetup; usageCollection: UsageCollectionSetup; management: ManagementSetup; } @@ -42,7 +45,7 @@ export class IndexMgmtUIPlugin { public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): IndexManagementPluginSetup { const { http, notifications } = coreSetup; - const { usageCollection, management } = plugins; + const { ingestManager, usageCollection, management } = plugins; httpService.setup(http); notificationService.setup(notifications); @@ -60,7 +63,7 @@ export class IndexMgmtUIPlugin { uiMetricService: this.uiMetricService, extensionsService: this.extensionsService, }; - return mountManagementSection(coreSetup, usageCollection, services, params); + return mountManagementSection(coreSetup, usageCollection, services, params, ingestManager); }, }); diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index 6b1bf47512b211..6c0fbe3dd6a652 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -20,6 +20,20 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); + dataManagement.getDataStream = ca({ + urls: [ + { + fmt: '/_data_stream/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'GET', + }); + // We don't allow the user to create a data stream in the UI or API. We're just adding this here // to enable the API integration tests. dataManagement.createDataStream = ca({ diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts index 56c514e30f2427..4aaf2b1bc5ed56 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/index.ts @@ -6,8 +6,11 @@ import { RouteDependencies } from '../../../types'; -import { registerGetAllRoute } from './register_get_route'; +import { registerGetOneRoute, registerGetAllRoute } from './register_get_route'; +import { registerDeleteRoute } from './register_delete_route'; export function registerDataStreamRoutes(dependencies: RouteDependencies) { + registerGetOneRoute(dependencies); registerGetAllRoute(dependencies); + registerDeleteRoute(dependencies); } diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_delete_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_delete_route.ts new file mode 100644 index 00000000000000..45b185bcd053b5 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_delete_route.ts @@ -0,0 +1,52 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../index'; +import { wrapEsError } from '../../helpers'; + +const bodySchema = schema.object({ + dataStreams: schema.arrayOf(schema.string()), +}); + +export function registerDeleteRoute({ router, license }: RouteDependencies) { + router.post( + { + path: addBasePath('/delete_data_streams'), + validate: { body: bodySchema }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.dataManagement!.client; + const { dataStreams } = req.body as TypeOf; + + const response: { dataStreamsDeleted: string[]; errors: any[] } = { + dataStreamsDeleted: [], + errors: [], + }; + + await Promise.all( + dataStreams.map(async (name: string) => { + try { + await callAsCurrentUser('dataManagement.deleteDataStream', { + name, + }); + + return response.dataStreamsDeleted.push(name); + } catch (e) { + return response.errors.push({ + name, + error: wrapEsError(e), + }); + } + }) + ); + + return res.ok({ body: response }); + }) + ); +} diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index 9128556130bf45..5f4e625348333d 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { deserializeDataStreamList } from '../../../../common/lib'; +import { schema, TypeOf } from '@kbn/config-schema'; + +import { deserializeDataStream, deserializeDataStreamList } from '../../../../common/lib'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; @@ -32,3 +34,40 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou }) ); } + +export function registerGetOneRoute({ router, license, lib: { isEsError } }: RouteDependencies) { + const paramsSchema = schema.object({ + name: schema.string(), + }); + + router.get( + { + path: addBasePath('/data_streams/{name}'), + validate: { params: paramsSchema }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { name } = req.params as TypeOf; + const { callAsCurrentUser } = ctx.dataManagement!.client; + + try { + const dataStream = await callAsCurrentUser('dataManagement.getDataStream', { name }); + + if (dataStream[0]) { + const body = deserializeDataStream(dataStream[0]); + return res.ok({ body }); + } + + return res.notFound(); + } catch (e) { + if (isEsError(e)) { + return res.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index a6184080cb7746..0c1e5090def914 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -5,6 +5,7 @@ */ import * as rt from 'io-ts'; +import { ItemTypeRT } from '../../inventory_models/types'; // TODO: Have threshold and inventory alerts import these types from this file instead of from their // local directories @@ -39,7 +40,16 @@ const baseAlertRequestParamsRT = rt.intersection([ sourceId: rt.string, }), rt.type({ - lookback: rt.union([rt.literal('h'), rt.literal('d'), rt.literal('w'), rt.literal('M')]), + lookback: rt.union([ + rt.literal('ms'), + rt.literal('s'), + rt.literal('m'), + rt.literal('h'), + rt.literal('d'), + rt.literal('w'), + rt.literal('M'), + rt.literal('y'), + ]), criteria: rt.array(rt.any), alertInterval: rt.string, }), @@ -61,10 +71,13 @@ export type MetricThresholdAlertPreviewRequestParams = rt.TypeOf< const inventoryAlertPreviewRequestParamsRT = rt.intersection([ baseAlertRequestParamsRT, rt.type({ - nodeType: rt.string, + nodeType: ItemTypeRT, alertType: rt.literal(METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID), }), ]); +export type InventoryAlertPreviewRequestParams = rt.TypeOf< + typeof inventoryAlertPreviewRequestParamsRT +>; export const alertPreviewRequestParamsRT = rt.union([ metricThresholdAlertPreviewRequestParamsRT, @@ -80,3 +93,6 @@ export const alertPreviewSuccessResponsePayloadRT = rt.type({ tooManyBuckets: rt.number, }), }); +export type AlertPreviewSuccessResponsePayload = rt.TypeOf< + typeof alertPreviewSuccessResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index 4701182c96813b..4e23f1985d4509 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -13,6 +13,9 @@ "alerts", "triggers_actions_ui" ], + "optionalPlugins": [ + "ml" + ], "server": true, "ui": true, "configPath": ["xpack", "infra"] diff --git a/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts b/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts new file mode 100644 index 00000000000000..0db1cd57e093f1 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/common/get_alert_preview.ts @@ -0,0 +1,51 @@ +/* + * 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 * as rt from 'io-ts'; +import { HttpSetup } from 'src/core/public'; +import { + INFRA_ALERT_PREVIEW_PATH, + METRIC_THRESHOLD_ALERT_TYPE_ID, + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + alertPreviewRequestParamsRT, + alertPreviewSuccessResponsePayloadRT, +} from '../../../common/alerting/metrics'; + +async function getAlertPreview({ + fetch, + params, + alertType, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; + alertType: + | typeof METRIC_THRESHOLD_ALERT_TYPE_ID + | typeof METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID; +}): Promise> { + return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, { + method: 'POST', + body: JSON.stringify({ + ...params, + alertType, + }), + }); +} + +export const getMetricThresholdAlertPreview = ({ + fetch, + params, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; +}) => getAlertPreview({ fetch, params, alertType: METRIC_THRESHOLD_ALERT_TYPE_ID }); + +export const getInventoryAlertPreview = ({ + fetch, + params, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; +}) => getAlertPreview({ fetch, params, alertType: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID }); diff --git a/x-pack/plugins/infra/public/alerting/common/index.ts b/x-pack/plugins/infra/public/alerting/common/index.ts new file mode 100644 index 00000000000000..33f9c856e71666 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/common/index.ts @@ -0,0 +1,55 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from './get_alert_preview'; + +export const previewOptions = [ + { + value: 'h', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', { + defaultMessage: 'Last hour', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', { + defaultMessage: 'hour', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', { + defaultMessage: 'Last day', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', { + defaultMessage: 'day', + }), + }, + { + value: 'w', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', { + defaultMessage: 'Last week', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', { + defaultMessage: 'week', + }), + }, + { + value: 'M', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', { + defaultMessage: 'Last month', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', { + defaultMessage: 'month', + }), + }, +]; + +export const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { + defaultMessage: 'time', +}); +export const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { + defaultMessage: 'times', +}); diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx similarity index 84% rename from x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx index 47a0f037816bc1..04642a01c15b4d 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx @@ -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 = () => { > - + ); }; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx similarity index 73% rename from x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index ce14897991e60b..ef73d6ff96e412 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { debounce } from 'lodash'; +import { debounce, pick } from 'lodash'; +import { Unit } from '@elastic/datemath'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; import { EuiFlexGroup, @@ -15,9 +16,20 @@ import { EuiFormRow, EuiButtonEmpty, EuiFieldSearch, + EuiSelect, + EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { + previewOptions, + firedTimeLabel, + firedTimesLabel, + getInventoryAlertPreview as getAlertPreview, +} from '../../../alerting/common'; +import { AlertPreviewSuccessResponsePayload } from '../../../../common/alerting/metrics/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds'; import { Comparator, // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -52,6 +64,8 @@ import { NodeTypeExpression } from './node_type'; import { InfraWaffleMapOptions } from '../../../lib/lib'; import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; +import { validateMetricThreshold } from './validation'; + const FILTER_TYPING_DEBOUNCE_MS = 500; interface AlertContextMeta { @@ -65,18 +79,16 @@ interface Props { alertParams: { criteria: InventoryMetricConditions[]; nodeType: InventoryItemType; - groupBy?: string; filterQuery?: string; filterQueryText?: string; sourceId?: string; }; + alertInterval: string; alertsContext: AlertsContextValue; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; } -type TimeUnit = 's' | 'm' | 'h' | 'd'; - const defaultExpression = { metric: 'cpu' as SnapshotMetricType, comparator: Comparator.GT, @@ -86,7 +98,7 @@ const defaultExpression = { } as InventoryMetricConditions; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertsContext } = props; + const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -94,7 +106,32 @@ export const Expressions: React.FC = (props) => { toastWarning: alertsContext.toastNotifications.addWarning, }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('m'); + const [timeUnit, setTimeUnit] = useState('m'); + + const [previewLookbackInterval, setPreviewLookbackInterval] = useState('h'); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [previewResult, setPreviewResult] = useState( + null + ); + + const previewIntervalError = useMemo(() => { + const intervalInSeconds = getIntervalInSeconds(alertInterval); + const lookbackInSeconds = getIntervalInSeconds(`1${previewLookbackInterval}`); + if (intervalInSeconds >= lookbackInSeconds) { + return true; + } + return false; + }, [previewLookbackInterval, alertInterval]); + + const isPreviewDisabled = useMemo(() => { + if (previewIntervalError) return true; + const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any); + const hasValidationErrors = Object.values(validationResult.errors).some((result) => + Object.values(result).some((arr) => Array.isArray(arr) && arr.length) + ); + return hasValidationErrors; + }, [alertParams.criteria, previewIntervalError]); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -173,7 +210,7 @@ export const Expressions: React.FC = (props) => { ...c, timeUnit: tu, })); - setTimeUnit(tu as TimeUnit); + setTimeUnit(tu as Unit); setAlertParams('criteria', criteria); }, [alertParams.criteria, setAlertParams] @@ -216,6 +253,33 @@ export const Expressions: React.FC = (props) => { } }, [alertsContext.metadata, derivedIndexPattern, setAlertParams]); + const onSelectPreviewLookbackInterval = useCallback((e) => { + setPreviewLookbackInterval(e.target.value); + setPreviewResult(null); + }, []); + + const onClickPreview = useCallback(async () => { + setIsPreviewLoading(true); + setPreviewResult(null); + setPreviewError(false); + try { + const result = await getAlertPreview({ + fetch: alertsContext.http.fetch, + params: { + ...pick(alertParams, 'criteria', 'nodeType'), + sourceId: alertParams.sourceId, + lookback: previewLookbackInterval as Unit, + alertInterval, + }, + }); + setPreviewResult(result); + } catch (e) { + setPreviewError(true); + } finally { + setIsPreviewLoading(false); + } + }, [alertParams, alertInterval, alertsContext, previewLookbackInterval]); + useEffect(() => { const md = alertsContext.metadata; if (!alertParams.nodeType) { @@ -332,6 +396,91 @@ export const Expressions: React.FC = (props) => {
+ + <> + + + + + + + {i18n.translate('xpack.infra.metrics.alertFlyout.testAlertTrigger', { + defaultMessage: 'Test alert trigger', + })} + + + + + {previewResult && ( + <> + + + {previewResult.resultTotals.fired}, + lookback: previewOptions.find((e) => e.value === previewLookbackInterval) + ?.shortText, + }} + />{' '} + {previewResult.numberOfGroups}, + groupName: alertParams.nodeType, + plural: previewResult.numberOfGroups !== 1 ? 's' : '', + }} + /> + + + )} + {previewIntervalError && ( + <> + + + check every, + }} + /> + + + )} + {previewError && ( + <> + + + + + + )} + + + ); }; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx rename to x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx diff --git a/x-pack/plugins/infra/public/alerting/inventory/hooks/use_inventory_alert_prefill.ts b/x-pack/plugins/infra/public/alerting/inventory/hooks/use_inventory_alert_prefill.ts new file mode 100644 index 00000000000000..d659057b95ed92 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/inventory/hooks/use_inventory_alert_prefill.ts @@ -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('host'); + const [filterQuery, setFilterQuery] = useState(); + const [metric, setMetric] = useState({ type: 'cpu' }); + + return { + nodeType, + filterQuery, + metric, + setNodeType, + setFilterQuery, + setMetric, + }; +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts similarity index 73% rename from x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts rename to x-pack/plugins/infra/public/alerting/inventory/index.ts index 0cb564ec2194e2..7503e5673fcd96 100644 --- a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -6,19 +6,20 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; -import { validateMetricThreshold } from './validation'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../server/lib/alerting/inventory_metric_threshold/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; +import { validateMetricThreshold } from './components/validation'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; -export function getInventoryMetricAlertType(): AlertTypeModel { +export function createInventoryMetricAlertType(): AlertTypeModel { return { id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, name: i18n.translate('xpack.infra.metrics.inventory.alertFlyout.alertName', { defaultMessage: 'Inventory', }), iconClass: 'bell', - alertParamsExpression: React.lazy(() => import('./expression')), + alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( 'xpack.infra.metrics.alerting.inventory.threshold.defaultActionMessage', diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx index d26575f65dfec5..384a93e796dbe3 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx @@ -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 = () => { > - + ); }; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx new file mode 100644 index 00000000000000..f6119107ac1331 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 startMocks = coreMock.createStart(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + + const context: AlertsContextValue = { + http: mocks.http, + toastNotifications: mocks.notifications.toasts, + actionTypeRegistry: actionTypeRegistryMock.create() as any, + alertTypeRegistry: alertTypeRegistryMock.create() as any, + docLinks: startMocks.docLinks, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + metadata: { + currentOptions, + }, + }; + + const wrapper = mountWithIntl( + 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', + }, + ]); + }); +}); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index febf849ccc9438..f45474f2844844 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -4,9 +4,9 @@ * 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 { HttpSetup } from 'src/core/public'; import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; import { EuiSpacer, @@ -24,15 +24,18 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { + previewOptions, + firedTimeLabel, + firedTimesLabel, + getMetricThresholdAlertPreview as getAlertPreview, +} from '../../common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds'; import { Comparator, Aggregators, - INFRA_ALERT_PREVIEW_PATH, - alertPreviewRequestParamsRT, alertPreviewSuccessResponsePayloadRT, - METRIC_THRESHOLD_ALERT_TYPE_ID, } from '../../../../common/alerting/metrics'; import { ForLastExpression, @@ -49,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'; @@ -57,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; alertInterval: string; setAlertParams(key: string, value: any): void; @@ -78,22 +74,7 @@ const defaultExpression = { timeSize: 1, timeUnit: 'm', } as MetricExpression; - -async function getAlertPreview({ - fetch, - params, -}: { - fetch: HttpSetup['fetch']; - params: rt.TypeOf; -}): Promise> { - return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, { - method: 'POST', - body: JSON.stringify({ - ...params, - alertType: METRIC_THRESHOLD_ALERT_TYPE_ID, - }), - }); -} +export { defaultExpression }; export const Expressions: React.FC = (props) => { const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; @@ -260,6 +241,13 @@ export const Expressions: React.FC = (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); @@ -275,7 +263,7 @@ export const Expressions: React.FC = (props) => { params: { ...pick(alertParams, 'criteria', 'groupBy', 'filterQuery'), sourceId: alertParams.sourceId, - lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', + lookback: previewLookbackInterval as Unit, alertInterval, }, }); @@ -299,6 +287,10 @@ export const Expressions: React.FC = (props) => { preFillAlertFilter(); } + if (!alertParams.groupBy) { + preFillAlertGroupBy(); + } + if (!alertParams.sourceId) { setAlertParams('sourceId', source?.id || 'default'); } @@ -319,11 +311,12 @@ export const Expressions: React.FC = (props) => { }, [previewLookbackInterval, alertInterval]); const isPreviewDisabled = useMemo(() => { + if (previewIntervalError) return true; const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any); const hasValidationErrors = Object.values(validationResult.errors).some((result) => Object.values(result).some((arr) => Array.isArray(arr) && arr.length) ); - return hasValidationErrors || previewIntervalError; + return hasValidationErrors; }, [alertParams.criteria, previewIntervalError]); return ( @@ -477,7 +470,7 @@ export const Expressions: React.FC = (props) => { id="selectPreviewLookbackInterval" value={previewLookbackInterval} onChange={onSelectPreviewLookbackInterval} - options={previewOptions} + options={previewDOMOptions} /> @@ -600,51 +593,9 @@ export const Expressions: React.FC = (props) => { ); }; -const previewOptions = [ - { - value: 'h', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', { - defaultMessage: 'Last hour', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', { - defaultMessage: 'hour', - }), - }, - { - value: 'd', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', { - defaultMessage: 'Last day', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', { - defaultMessage: 'day', - }), - }, - { - value: 'w', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', { - defaultMessage: 'Last week', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', { - defaultMessage: 'week', - }), - }, - { - value: 'M', - text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', { - defaultMessage: 'Last month', - }), - shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', { - defaultMessage: 'month', - }), - }, -]; - -const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { - defaultMessage: 'time', -}); -const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { - defaultMessage: 'times', -}); +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 diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index 0b43883728a6f7..1ca7f7bff83ede 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -27,6 +27,7 @@ describe('ExpressionChart', () => { groupBy?: string ) { const mocks = coreMock.createSetup(); + const startMocks = coreMock.createStart(); const [ { application: { capabilities }, @@ -38,7 +39,7 @@ describe('ExpressionChart', () => { toastNotifications: mocks.notifications.toasts, actionTypeRegistry: actionTypeRegistryMock.create() as any, alertTypeRegistry: alertTypeRegistryMock.create() as any, - docLinks: mocks.docLinks, + docLinks: startMocks.docLinks, capabilities: { ...capabilities, actions: { diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx index da342f0a454203..2221d3cd4fe120 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx @@ -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.', }) ); } diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metric_threshold_alert_prefill.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metric_threshold_alert_prefill.ts new file mode 100644 index 00000000000000..366d6aa7003e63 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metric_threshold_alert_prefill.ts @@ -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({ + groupBy: undefined, + filterQuery: undefined, + metrics: [], + }); + + const { groupBy, filterQuery, metrics } = prefillOptionsState; + + return { + groupBy, + filterQuery, + metrics, + setPrefillOptions(newState: MetricThresholdPrefillOptions) { + if (!isEqual(newState, prefillOptionsState)) setPrefillOptionsState(newState); + }, + }; +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index feeec4b0ce8bf0..2f8d7ec0ba6f48 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -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; +} diff --git a/x-pack/plugins/infra/public/alerting/use_alert_prefill.ts b/x-pack/plugins/infra/public/alerting/use_alert_prefill.ts new file mode 100644 index 00000000000000..eff2fe462509f4 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/use_alert_prefill.ts @@ -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); diff --git a/x-pack/plugins/infra/public/assets/anomaly_chart_minified.svg b/x-pack/plugins/infra/public/assets/anomaly_chart_minified.svg new file mode 100644 index 00000000000000..dd1b39248bba25 --- /dev/null +++ b/x-pack/plugins/infra/public/assets/anomaly_chart_minified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts index 7f2982f221a3cf..72099e9b1b4b6e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts @@ -13,3 +13,4 @@ export * from './missing_results_privileges_prompt'; export * from './missing_setup_privileges_prompt'; export * from './ml_unavailable_prompt'; export * from './setup_status_unknown_prompt'; +export * from './subscription_splash_content'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx new file mode 100644 index 00000000000000..e0e293b1cc3e75 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx @@ -0,0 +1,174 @@ +/* + * 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, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiText, + EuiButton, + EuiButtonEmpty, + EuiImage, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { LoadingPage } from '../../loading_page'; + +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { euiStyled } from '../../../../../observability/public'; +import { useTrialStatus } from '../../../hooks/use_trial_status'; + +export const SubscriptionSplashContent: React.FC = () => { + const { services } = useKibana(); + const { loadState, isTrialAvailable, checkTrialAvailability } = useTrialStatus(); + + useEffect(() => { + checkTrialAvailability(); + }, [checkTrialAvailability]); + + if (loadState === 'pending') { + return ( + + ); + } + + const canStartTrial = isTrialAvailable && loadState === 'resolved'; + + let title; + let description; + let cta; + + if (canStartTrial) { + title = ( + + ); + + description = ( + + ); + + cta = ( + + + + ); + } else { + title = ( + + ); + + description = ( + + ); + + cta = ( + + + + ); + } + + return ( + + + + + + +

{title}

+
+ + +

{description}

+
+ +
{cta}
+
+ + + +
+ + +

+ +

+
+ + + +
+
+
+
+ ); +}; + +const SubscriptionPage = euiStyled(EuiPage)` + height: 100% +`; + +const SubscriptionPageContent = euiStyled(EuiPageContent)` + max-width: 768px !important; +`; + +const SubscriptionPageFooter = euiStyled.div` + background: ${(props) => props.theme.eui.euiColorLightestShade}; + margin: 0 -${(props) => props.theme.eui.paddingSizes.l} -${(props) => + props.theme.eui.paddingSizes.l}; + padding: ${(props) => props.theme.eui.paddingSizes.l}; +`; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_actions_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_actions_column.tsx deleted file mode 100644 index e27de7fd6b5a89..00000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_actions_column.tsx +++ /dev/null @@ -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 = ({ - 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 = ( - - - - ); - - const items = [ - - {LOG_DETAILS_LABEL} - , - ]; - - if (onViewLogInContext !== undefined) { - items.push( - - {LOG_VIEW_IN_CONTEXT_LABEL} - - ); - } - - return ( - - {isHovered || isMenuOpen ? ( - - - - - - ) : null} - - ); -}; - -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; -`; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx new file mode 100644 index 00000000000000..4aa81846d90ef9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -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 = ({ + 'aria-label': ariaLabel, + isOpen, + onOpen, + onClose, + items, +}) => { + const closeMenuAndCall = useMemo(() => { + return (callback: LogEntryContextMenuItem['onClick']) => { + return () => { + onClose(); + callback(); + }; + }; + }, [onClose]); + + const button = ( + + + + ); + + const wrappedItems = useMemo(() => { + return items.map((item, i) => ( + + {item.label} + + )); + }, [items, closeMenuAndCall]); + + return ( + + + + + + + + ); +}; + +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); +`; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 0d971151dd95c2..2d53203a60e4f0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -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; @@ -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]} > - + {isHovered || isMenuOpen ? ( + + ) : null} ) : null} diff --git a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx index a04897d9c738d5..2c76b3bb925ee1 100644 --- a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx +++ b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx @@ -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 ); diff --git a/x-pack/plugins/infra/public/hooks/use_trial_status.tsx b/x-pack/plugins/infra/public/hooks/use_trial_status.tsx new file mode 100644 index 00000000000000..9cc118d09c7e0a --- /dev/null +++ b/x-pack/plugins/infra/public/hooks/use_trial_status.tsx @@ -0,0 +1,51 @@ +/* + * 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 { boolean } from 'io-ts'; +import { i18n } from '@kbn/i18n'; + +import { useState } from 'react'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { API_BASE_PATH as LICENSE_MANAGEMENT_API_BASE_PATH } from '../../../license_management/common/constants'; +import { useTrackedPromise } from '../utils/use_tracked_promise'; +import { decodeOrThrow } from '../../common/runtime_types'; + +interface UseTrialStatusState { + loadState: 'uninitialized' | 'pending' | 'resolved' | 'rejected'; + isTrialAvailable: boolean; + checkTrialAvailability: () => void; +} + +export function useTrialStatus(): UseTrialStatusState { + const { services } = useKibana(); + const [isTrialAvailable, setIsTrialAvailable] = useState(false); + + const [loadState, checkTrialAvailability] = useTrackedPromise( + { + createPromise: async () => { + const response = await services.http.get(`${LICENSE_MANAGEMENT_API_BASE_PATH}/start_trial`); + return decodeOrThrow(boolean)(response); + }, + onResolve: (response) => { + setIsTrialAvailable(response); + }, + onReject: (error) => { + services.notifications.toasts.addDanger( + i18n.translate('xpack.infra.trialStatus.trialStatusNetworkErrorMessage', { + defaultMessage: 'We could not determine if the trial license is available', + }) + ); + }, + }, + [services] + ); + + return { + loadState: loadState.state, + isTrialAvailable, + checkTrialAvailability, + }; +} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 04b472ceb59c84..5d9adb8a4f6ec6 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -12,7 +12,7 @@ import { LogAnalysisSetupStatusUnknownPrompt, MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, - MlUnavailablePrompt, + SubscriptionSplashContent, } from '../../../components/logging/log_analysis_setup'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; @@ -50,7 +50,7 @@ export const LogEntryCategoriesPageContent = () => { } else if (hasFailedLoadingSource) { return ; } else if (!hasLogAnalysisCapabilites) { - return ; + return ; } else if (!hasLogAnalysisReadCapabilities) { return ; } else if (setupStatus.type === 'initializing') { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index fc07289f02fe7f..4ec05a9778512a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -12,7 +12,7 @@ import { LogAnalysisSetupStatusUnknownPrompt, MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, - MlUnavailablePrompt, + SubscriptionSplashContent, } from '../../../components/logging/log_analysis_setup'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; @@ -50,7 +50,7 @@ export const LogEntryRatePageContent = () => { } else if (hasFailedLoadingSource) { return ; } else if (!hasLogAnalysisCapabilites) { - return ; + return ; } else if (!hasLogAnalysisReadCapabilities) { return ; } else if (setupStatus.type === 'initializing') { diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 78b7f86993cbde..c5047dbdf3bb57 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -17,7 +17,6 @@ import { HelpCenterContent } from '../../components/help_center_content'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; import { ColumnarPage } from '../../components/page'; -import { useLogAnalysisCapabilitiesContext } from '../../containers/logs/log_analysis'; import { useLogSourceContext } from '../../containers/logs/log_source'; import { RedirectWithQueryParams } from '../../utils/redirect_with_query_params'; import { LogEntryCategoriesPage } from './log_entry_categories'; @@ -28,7 +27,6 @@ import { AlertDropdown } from '../../components/alerting/logs/alert_dropdown'; export const LogsPageContent: React.FunctionComponent = () => { const uiCapabilities = useKibana().services.application?.capabilities; - const logAnalysisCapabilities = useLogAnalysisCapabilitiesContext(); const { initialize } = useLogSourceContext(); @@ -79,13 +77,7 @@ export const LogsPageContent: React.FunctionComponent = () => { - + diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 05296fbf6b0a32..121748f8e5220b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -29,8 +29,9 @@ import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; -import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown'; +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 ( - - - - - - - + + + + + + -
- - - - - - - - - - - - {ADD_DATA_LABEL} - - - - + - - - ( - - {({ configuration, createDerivedIndexPattern }) => ( - - - {configuration ? ( - - ) : ( - - )} - - )} - - )} +
- - - - - - + + + + + + + + + + + + {ADD_DATA_LABEL} + + + + + + + + ( + + {({ configuration, createDerivedIndexPattern }) => ( + + + {configuration ? ( + + ) : ( + + )} + + )} + + )} + /> + + + + + + + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 3441b6bf2c1b9e..d9132615213836 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo, useState } from 'react'; -import { AlertFlyout } from '../../../../../components/alerting/inventory/alert_flyout'; +import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../../../link_to'; import { createUptimeLink } from '../../lib/create_uptime_link'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts new file mode 100644 index 00000000000000..93b6b635183dda --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.test.ts @@ -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 = {}; +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); + }); +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts index 63d9d08796f052..d4fb1356be77ef 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_filters.ts @@ -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, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.test.ts new file mode 100644 index 00000000000000..579073e9500d0c --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index 975e33cf2415fe..a3132c83849791 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -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, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx index 1381ed9da656a9..c35e9f17bdcc36 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx @@ -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 }) => ( - - {children} - - ), - }); +let PREFILL: Record = {}; +jest.mock('../../../../alerting/use_alert_prefill', () => ({ + useAlertPrefillContext: () => ({ + metricThresholdPrefill: { + setPrefillOptions(opts: Record) { + 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); + }); }); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 56595c09aadded..8abdffd39ed3aa 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -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(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, diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index b3765db43335a3..496e788efc060f 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -13,7 +13,7 @@ import { } from 'kibana/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { createMetricThresholdAlertType } from './alerting/metric_threshold'; -import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type'; +import { createInventoryMetricAlertType } from './alerting/inventory'; import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; import { registerStartSingleton } from './legacy_singletons'; import { registerFeatures } from './register_feature'; @@ -29,7 +29,7 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getInventoryMetricAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createInventoryMetricAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType()); diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index d00afbc7b497a6..905b7dfa314bd6 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse, GenericParams } from 'elasticsearch'; +import { GenericParams, SearchResponse } from 'elasticsearch'; import { Lifecycle } from 'hapi'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { RouteMethod, RouteConfig } from '../../../../../../../src/core/server'; -import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../plugins/features/server'; -import { SpacesPluginSetup } from '../../../../../../plugins/spaces/server'; +import { RouteConfig, RouteMethod } from '../../../../../../../src/core/server'; +import { HomeServerPluginSetup } from '../../../../../../../src/plugins/home/server'; import { VisTypeTimeseriesSetup } from '../../../../../../../src/plugins/vis_type_timeseries/server'; import { APMPluginSetup } from '../../../../../../plugins/apm/server'; -import { HomeServerPluginSetup } from '../../../../../../../src/plugins/home/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../plugins/features/server'; +import { SpacesPluginSetup } from '../../../../../../plugins/spaces/server'; import { PluginSetupContract as AlertingPluginContract } from '../../../../../alerts/server'; +import { MlPluginSetup } from '../../../../../ml/server'; -// NP_TODO: Compose real types from plugins we depend on, no "any" export interface InfraServerPluginDeps { home: HomeServerPluginSetup; spaces: SpacesPluginSetup; @@ -24,6 +24,7 @@ export interface InfraServerPluginDeps { features: FeaturesPluginSetup; apm: APMPluginSetup; alerts: AlertingPluginContract; + ml?: MlPluginSetup; } export interface CallWithRequestParams extends GenericParams { diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts new file mode 100644 index 00000000000000..c55f50e229b698 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -0,0 +1,136 @@ +/* + * 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 { mapValues, last } from 'lodash'; +import moment from 'moment'; +import { + InfraDatabaseSearchResponse, + CallWithRequestParams, +} from '../../adapters/framework/adapter_types'; +import { Comparator, InventoryMetricConditions } from './types'; +import { AlertServices } from '../../../../../alerts/server'; +import { InfraSnapshot } from '../../snapshot'; +import { parseFilterQuery } from '../../../utils/serialized_query'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; +import { InfraSourceConfiguration } from '../../sources'; + +interface ConditionResult { + shouldFire: boolean | boolean[]; + currentValue?: number | null; + metric: string; + isNoData: boolean; + isError: boolean; +} + +export const evaluateCondition = async ( + condition: InventoryMetricConditions, + nodeType: InventoryItemType, + sourceConfiguration: InfraSourceConfiguration, + callCluster: AlertServices['callCluster'], + filterQuery?: string, + lookbackSize?: number +): Promise> => { + const { comparator, metric } = condition; + let { threshold } = condition; + + const timerange = { + to: Date.now(), + from: moment().subtract(condition.timeSize, condition.timeUnit).toDate().getTime(), + interval: condition.timeUnit, + } as InfraTimerangeInput; + if (lookbackSize) { + timerange.lookbackSize = lookbackSize; + } + + const currentValues = await getData( + callCluster, + nodeType, + metric, + timerange, + sourceConfiguration, + filterQuery + ); + + threshold = threshold.map((n) => convertMetricValue(metric, n)); + + const comparisonFunction = comparatorMap[comparator]; + + return mapValues(currentValues, (value) => ({ + shouldFire: + value !== undefined && + value !== null && + (Array.isArray(value) + ? value.map((v) => comparisonFunction(Number(v), threshold)) + : comparisonFunction(value, threshold)), + metric, + isNoData: value === null, + isError: value === undefined, + ...(!Array.isArray(value) ? { currentValue: value } : {}), + })); +}; + +const getData = async ( + callCluster: AlertServices['callCluster'], + nodeType: InventoryItemType, + metric: SnapshotMetricType, + timerange: InfraTimerangeInput, + sourceConfiguration: InfraSourceConfiguration, + filterQuery?: string +) => { + const snapshot = new InfraSnapshot(); + const esClient = ( + options: CallWithRequestParams + ): Promise> => callCluster('search', options); + + const options = { + filterQuery: parseFilterQuery(filterQuery), + nodeType, + groupBy: [], + sourceConfiguration, + metric: { type: metric }, + timerange, + includeTimeseries: Boolean(timerange.lookbackSize), + }; + + const { nodes } = await snapshot.getNodes(esClient, options); + + return nodes.reduce((acc, n) => { + const nodePathItem = last(n.path); + if (n.metric?.value && n.metric?.timeseries) { + const { timeseries } = n.metric; + const values = timeseries.rows.map((row) => row.metric_0) as Array; + acc[nodePathItem.label] = values; + } else { + acc[nodePathItem.label] = n.metric && n.metric.value; + } + return acc; + }, {} as Record | undefined | null>); +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +// Some metrics in the UI are in a different unit that what we store in ES. +const convertMetricValue = (metric: SnapshotMetricType, value: number) => { + if (converters[metric]) { + return converters[metric](value); + } else { + return value; + } +}; +const converters: Record number> = { + cpu: (n) => Number(n) / 100, + memory: (n) => Number(n) / 100, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 5a34a6665e781d..99e653b2d67894 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -3,27 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { mapValues, last, get } from 'lodash'; +import { first, get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import moment from 'moment'; -import { - InfraDatabaseSearchResponse, - CallWithRequestParams, -} from '../../adapters/framework/adapter_types'; -import { Comparator, AlertStates, InventoryMetricConditions } from './types'; -import { AlertServices, AlertExecutorOptions } from '../../../../../alerts/server'; -import { InfraSnapshot } from '../../snapshot'; -import { parseFilterQuery } from '../../../utils/serialized_query'; +import { AlertStates, InventoryMetricConditions } from './types'; +import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; -import { InfraSourceConfiguration } from '../../sources'; import { InfraBackendLibs } from '../../infra_types'; import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; import { createFormatter } from '../../../../common/formatters'; +import { evaluateCondition } from './evaluate_condition'; interface InventoryMetricThresholdParams { criteria: InventoryMetricConditions[]; - groupBy: string | undefined; filterQuery: string | undefined; nodeType: InventoryItemType; sourceId?: string; @@ -41,11 +32,13 @@ export const createInventoryMetricThresholdExecutor = ( ); const results = await Promise.all( - criteria.map((c) => evaluateCondtion(c, nodeType, source.configuration, services, filterQuery)) + criteria.map((c) => + evaluateCondition(c, nodeType, source.configuration, services.callCluster, filterQuery) + ) ); - const invenotryItems = Object.keys(results[0]); - for (const item of invenotryItems) { + const inventoryItems = Object.keys(first(results)); + for (const item of inventoryItems) { const alertInstance = services.alertInstanceFactory(`${alertId}-${item}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => result[item].shouldFire); @@ -79,93 +72,6 @@ export const createInventoryMetricThresholdExecutor = ( } }; -interface ConditionResult { - shouldFire: boolean; - currentValue?: number | null; - isNoData: boolean; - isError: boolean; -} - -const evaluateCondtion = async ( - condition: InventoryMetricConditions, - nodeType: InventoryItemType, - sourceConfiguration: InfraSourceConfiguration, - services: AlertServices, - filterQuery?: string -): Promise> => { - const { comparator, metric } = condition; - let { threshold } = condition; - - const currentValues = await getData( - services, - nodeType, - metric, - { - to: Date.now(), - from: moment().subtract(condition.timeSize, condition.timeUnit).toDate().getTime(), - interval: condition.timeUnit, - }, - sourceConfiguration, - filterQuery - ); - - threshold = threshold.map((n) => convertMetricValue(metric, n)); - - const comparisonFunction = comparatorMap[comparator]; - - return mapValues(currentValues, (value) => ({ - shouldFire: value !== undefined && value !== null && comparisonFunction(value, threshold), - metric, - currentValue: value, - isNoData: value === null, - isError: value === undefined, - })); -}; - -const getData = async ( - services: AlertServices, - nodeType: InventoryItemType, - metric: SnapshotMetricType, - timerange: InfraTimerangeInput, - sourceConfiguration: InfraSourceConfiguration, - filterQuery?: string -) => { - const snapshot = new InfraSnapshot(); - const esClient = ( - options: CallWithRequestParams - ): Promise> => - services.callCluster('search', options); - - const options = { - filterQuery: parseFilterQuery(filterQuery), - nodeType, - groupBy: [], - sourceConfiguration, - metric: { type: metric }, - timerange, - }; - - const { nodes } = await snapshot.getNodes(esClient, options); - - return nodes.reduce((acc, n) => { - const nodePathItem = last(n.path); - acc[nodePathItem.label] = n.metric && n.metric.value; - return acc; - }, {} as Record); -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; - const mapToConditionsLookup = ( list: any[], mapFn: (value: any, index: number, array: any[]) => unknown @@ -184,19 +90,6 @@ export const FIRED_ACTIONS = { }), }; -// Some metrics in the UI are in a different unit that what we store in ES. -const convertMetricValue = (metric: SnapshotMetricType, value: number) => { - if (converters[metric]) { - return converters[metric](value); - } else { - return value; - } -}; -const converters: Record number> = { - cpu: (n) => Number(n) / 100, - memory: (n) => Number(n) / 100, -}; - const formatMetric = (metric: SnapshotMetricType, value: number) => { // if (SnapshotCustomMetricInputRT.is(metric)) { // const formatter = createFormatterForMetric(metric); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts new file mode 100644 index 00000000000000..6e8c624e61c49a --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -0,0 +1,83 @@ +/* + * 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 { Unit } from '@elastic/datemath'; +import { first } from 'lodash'; +import { InventoryMetricConditions } from './types'; +import { IScopedClusterClient } from '../../../../../../../src/core/server'; +import { InfraSource } from '../../../../common/http_api/source_api'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; +import { evaluateCondition } from './evaluate_condition'; + +interface InventoryMetricThresholdParams { + criteria: InventoryMetricConditions[]; + filterQuery: string | undefined; + nodeType: InventoryItemType; + sourceId?: string; +} + +interface PreviewInventoryMetricThresholdAlertParams { + callCluster: IScopedClusterClient['callAsCurrentUser']; + params: InventoryMetricThresholdParams; + config: InfraSource['configuration']; + lookback: Unit; + alertInterval: string; +} + +export const previewInventoryMetricThresholdAlert = async ({ + callCluster, + params, + config, + lookback, + alertInterval, +}: PreviewInventoryMetricThresholdAlertParams) => { + const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; + + const { timeSize, timeUnit } = criteria[0]; + const bucketInterval = `${timeSize}${timeUnit}`; + const bucketIntervalInSeconds = getIntervalInSeconds(bucketInterval); + + const lookbackInterval = `1${lookback}`; + const lookbackIntervalInSeconds = getIntervalInSeconds(lookbackInterval); + const lookbackSize = Math.ceil(lookbackIntervalInSeconds / bucketIntervalInSeconds); + + const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); + const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; + + const results = await Promise.all( + criteria.map((c) => + evaluateCondition(c, nodeType, config, callCluster, filterQuery, lookbackSize) + ) + ); + + const inventoryItems = Object.keys(first(results)); + const previewResults = inventoryItems.map((item) => { + const isNoData = results.some((result) => result[item].isNoData); + if (isNoData) { + return null; + } + const isError = results.some((result) => result[item].isError); + if (isError) { + return undefined; + } + + const numberOfResultBuckets = lookbackSize; + const numberOfExecutionBuckets = Math.floor(numberOfResultBuckets / alertResultsPerExecution); + return [...Array(numberOfExecutionBuckets)].reduce( + (totalFired, _, i) => + totalFired + + (results.every((result) => { + const shouldFire = result[item].shouldFire as boolean[]; + return shouldFire[Math.floor(i * alertResultsPerExecution)]; + }) + ? 1 + : 0), + 0 + ); + }); + + return previewResults; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts index 73ee1ab6b76159..ec1caad30a4d73 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts @@ -3,6 +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 { Unit } from '@elastic/datemath'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; @@ -23,12 +24,10 @@ export enum AlertStates { ERROR, } -export type TimeUnit = 's' | 'm' | 'h' | 'd'; - export interface InventoryMetricConditions { metric: SnapshotMetricType; timeSize: number; - timeUnit: TimeUnit; + timeUnit: Unit; sourceId?: string; threshold: number[]; comparator: Comparator; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index 7aa8367f7678ca..52637d52175a42 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -5,6 +5,7 @@ */ import { first, zip } from 'lodash'; +import { Unit } from '@elastic/datemath'; import { TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, isTooManyBucketsPreviewException, @@ -25,7 +26,7 @@ interface PreviewMetricThresholdAlertParams { filterQuery: string | undefined; }; config: InfraSource['configuration']; - lookback: 'h' | 'd' | 'w' | 'M'; + lookback: Unit; alertInterval: string; end?: number; overrideLookbackIntervalInSeconds?: number; diff --git a/x-pack/plugins/infra/server/lib/compose/kibana.ts b/x-pack/plugins/infra/server/lib/compose/kibana.ts deleted file mode 100644 index 626b9d46bbde30..00000000000000 --- a/x-pack/plugins/infra/server/lib/compose/kibana.ts +++ /dev/null @@ -1,59 +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 { FrameworkFieldsAdapter } from '../adapters/fields/framework_fields_adapter'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { InfraKibanaLogEntriesAdapter } from '../adapters/log_entries/kibana_log_entries_adapter'; -import { KibanaMetricsAdapter } from '../adapters/metrics/kibana_metrics_adapter'; -import { InfraElasticsearchSourceStatusAdapter } from '../adapters/source_status'; -import { InfraFieldsDomain } from '../domains/fields_domain'; -import { InfraLogEntriesDomain } from '../domains/log_entries_domain'; -import { InfraMetricsDomain } from '../domains/metrics_domain'; -import { InfraBackendLibs, InfraDomainLibs } from '../infra_types'; -import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from '../log_analysis'; -import { InfraSnapshot } from '../snapshot'; -import { InfraSourceStatus } from '../source_status'; -import { InfraSources } from '../sources'; -import { InfraConfig } from '../../../server'; -import { CoreSetup } from '../../../../../../src/core/server'; -import { InfraServerPluginDeps } from '../adapters/framework/adapter_types'; - -export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServerPluginDeps) { - const framework = new KibanaFramework(core, config, plugins); - const sources = new InfraSources({ - config, - }); - const sourceStatus = new InfraSourceStatus(new InfraElasticsearchSourceStatusAdapter(framework), { - sources, - }); - const snapshot = new InfraSnapshot(); - const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); - const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); - - // TODO: separate these out individually and do away with "domains" as a temporary group - const domainLibs: InfraDomainLibs = { - fields: new InfraFieldsDomain(new FrameworkFieldsAdapter(framework), { - sources, - }), - logEntries: new InfraLogEntriesDomain(new InfraKibanaLogEntriesAdapter(framework), { - framework, - sources, - }), - metrics: new InfraMetricsDomain(new KibanaMetricsAdapter(framework)), - }; - - const libs: InfraBackendLibs = { - configuration: config, // NP_TODO: Do we ever use this anywhere? - framework, - logEntryCategoriesAnalysis, - logEntryRateAnalysis, - snapshot, - sources, - sourceStatus, - ...domainLibs, - }; - - return libs; -} diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 51c433557f4fc8..9896ad6ac1cd19 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -8,7 +8,6 @@ import { InfraSourceConfiguration } from '../../common/graphql/types'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; -import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './log_analysis'; import { InfraSnapshot } from './snapshot'; import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; @@ -31,8 +30,6 @@ export interface InfraDomainLibs { export interface InfraBackendLibs extends InfraDomainLibs { configuration: InfraConfig; framework: KibanaFramework; - logEntryCategoriesAnalysis: LogEntryCategoriesAnalysis; - logEntryRateAnalysis: LogEntryRateAnalysis; snapshot: InfraSnapshot; sources: InfraSources; sourceStatus: InfraSourceStatus; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index d0a6ae0fc93575..4298ccb61bbedd 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import type { IScopedClusterClient } from 'src/core/server'; import { compareDatasetsByMaximumAnomalyScore, getJobId, @@ -13,7 +13,7 @@ import { } from '../../../common/log_analysis'; import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import type { MlAnomalyDetectors, MlSystem } from '../../types'; import { InsufficientLogAnalysisMlJobConfigurationError, NoLogAnalysisMlJobError, @@ -39,7 +39,6 @@ import { LogEntryDatasetBucket, logEntryDatasetsResponseRT, } from './queries/log_entry_data_sets'; -import { createMlJobsQuery, mlJobsResponseRT } from './queries/ml_jobs'; import { createTopLogEntryCategoriesQuery, topLogEntryCategoriesResponseRT, @@ -47,489 +46,470 @@ import { const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; -export class LogEntryCategoriesAnalysis { - constructor( - private readonly libs: { - framework: KibanaFramework; - } - ) {} - - public async getTopLogEntryCategories( - requestContext: RequestHandlerContext, - request: KibanaRequest, - sourceId: string, - startTime: number, - endTime: number, - categoryCount: number, - datasets: string[], - histograms: HistogramParameters[] - ) { - const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); - - const logEntryCategoriesCountJobId = getJobId( - this.libs.framework.getSpaceId(request), - sourceId, - logEntryCategoriesJobTypes[0] - ); - - const { - topLogEntryCategories, - timing: { spans: fetchTopLogEntryCategoriesAggSpans }, - } = await this.fetchTopLogEntryCategories( - requestContext, - logEntryCategoriesCountJobId, - startTime, - endTime, - categoryCount, - datasets - ); - - const categoryIds = topLogEntryCategories.map(({ categoryId }) => categoryId); - - const { - logEntryCategoriesById, - timing: { spans: fetchTopLogEntryCategoryPatternsSpans }, - } = await this.fetchLogEntryCategories( - requestContext, - logEntryCategoriesCountJobId, - categoryIds - ); - - const { - categoryHistogramsById, - timing: { spans: fetchTopLogEntryCategoryHistogramsSpans }, - } = await this.fetchTopLogEntryCategoryHistograms( - requestContext, - logEntryCategoriesCountJobId, - categoryIds, - histograms - ); - - const topLogEntryCategoriesSpan = finalizeTopLogEntryCategoriesSpan(); - - return { - data: topLogEntryCategories.map((topCategory) => ({ - ...topCategory, - regularExpression: logEntryCategoriesById[topCategory.categoryId]?._source.regex ?? '', - histograms: categoryHistogramsById[topCategory.categoryId] ?? [], - })), - timing: { - spans: [ - topLogEntryCategoriesSpan, - ...fetchTopLogEntryCategoriesAggSpans, - ...fetchTopLogEntryCategoryPatternsSpans, - ...fetchTopLogEntryCategoryHistogramsSpans, - ], - }, - }; - } - - public async getLogEntryCategoryDatasets( - requestContext: RequestHandlerContext, - request: KibanaRequest, - sourceId: string, - startTime: number, - endTime: number - ) { - const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); - - const logEntryCategoriesCountJobId = getJobId( - this.libs.framework.getSpaceId(request), - sourceId, - logEntryCategoriesJobTypes[0] - ); - - let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; - let afterLatestBatchKey: CompositeDatasetKey | undefined; - let esSearchSpans: TracingSpan[] = []; - - while (true) { - const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); - - const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( - await this.libs.framework.callWithRequest( - requestContext, - 'search', - createLogEntryDatasetsQuery( - logEntryCategoriesCountJobId, - startTime, - endTime, - COMPOSITE_AGGREGATION_BATCH_SIZE, - afterLatestBatchKey - ) - ) - ); - - if (logEntryDatasetsResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` - ); - } - - const { - after_key: afterKey, - buckets: latestBatchBuckets, - } = logEntryDatasetsResponse.aggregations.dataset_buckets; - - logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; - afterLatestBatchKey = afterKey; - esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; - - if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { - break; - } - } - - const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); - - return { - data: logEntryDatasetBuckets.map( - (logEntryDatasetBucket) => logEntryDatasetBucket.key.dataset - ), - timing: { - spans: [logEntryDatasetsSpan, ...esSearchSpans], - }, +export async function getTopLogEntryCategories( + context: { + infra: { + mlSystem: MlSystem; + spaceId: string; }; - } - - public async getLogEntryCategoryExamples( - requestContext: RequestHandlerContext, - request: KibanaRequest, - sourceId: string, - startTime: number, - endTime: number, - categoryId: number, - exampleCount: number - ) { - const finalizeLogEntryCategoryExamplesSpan = startTracingSpan( - 'get category example log entries' - ); - - const logEntryCategoriesCountJobId = getJobId( - this.libs.framework.getSpaceId(request), - sourceId, - logEntryCategoriesJobTypes[0] - ); - - const { - mlJob, - timing: { spans: fetchMlJobSpans }, - } = await this.fetchMlJob(requestContext, logEntryCategoriesCountJobId); - - const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); - const indices = customSettings?.logs_source_config?.indexPattern; - const timestampField = customSettings?.logs_source_config?.timestampField; - - if (indices == null || timestampField == null) { - throw new InsufficientLogAnalysisMlJobConfigurationError( - `Failed to find index configuration for ml job ${logEntryCategoriesCountJobId}` - ); - } - - const { - logEntryCategoriesById, - timing: { spans: fetchLogEntryCategoriesSpans }, - } = await this.fetchLogEntryCategories(requestContext, logEntryCategoriesCountJobId, [ - categoryId, - ]); - const category = logEntryCategoriesById[categoryId]; - - if (category == null) { - throw new UnknownCategoryError(categoryId); - } - - const { - examples, - timing: { spans: fetchLogEntryCategoryExamplesSpans }, - } = await this.fetchLogEntryCategoryExamples( - requestContext, - indices, - timestampField, - startTime, - endTime, - category._source.terms, - exampleCount - ); - - const logEntryCategoryExamplesSpan = finalizeLogEntryCategoryExamplesSpan(); + }, + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[], + histograms: HistogramParameters[] +) { + const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + topLogEntryCategories, + timing: { spans: fetchTopLogEntryCategoriesAggSpans }, + } = await fetchTopLogEntryCategories( + context, + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ); + + const categoryIds = topLogEntryCategories.map(({ categoryId }) => categoryId); + + const { + logEntryCategoriesById, + timing: { spans: fetchTopLogEntryCategoryPatternsSpans }, + } = await fetchLogEntryCategories(context, logEntryCategoriesCountJobId, categoryIds); + + const { + categoryHistogramsById, + timing: { spans: fetchTopLogEntryCategoryHistogramsSpans }, + } = await fetchTopLogEntryCategoryHistograms( + context, + logEntryCategoriesCountJobId, + categoryIds, + histograms + ); + + const topLogEntryCategoriesSpan = finalizeTopLogEntryCategoriesSpan(); + + return { + data: topLogEntryCategories.map((topCategory) => ({ + ...topCategory, + regularExpression: logEntryCategoriesById[topCategory.categoryId]?._source.regex ?? '', + histograms: categoryHistogramsById[topCategory.categoryId] ?? [], + })), + timing: { + spans: [ + topLogEntryCategoriesSpan, + ...fetchTopLogEntryCategoriesAggSpans, + ...fetchTopLogEntryCategoryPatternsSpans, + ...fetchTopLogEntryCategoryHistogramsSpans, + ], + }, + }; +} - return { - data: examples, - timing: { - spans: [ - logEntryCategoryExamplesSpan, - ...fetchMlJobSpans, - ...fetchLogEntryCategoriesSpans, - ...fetchLogEntryCategoryExamplesSpans, - ], - }, +export async function getLogEntryCategoryDatasets( + context: { + infra: { + mlSystem: MlSystem; + spaceId: string; }; - } - - private async fetchTopLogEntryCategories( - requestContext: RequestHandlerContext, - logEntryCategoriesCountJobId: string, - startTime: number, - endTime: number, - categoryCount: number, - datasets: string[] - ) { - const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); - - const topLogEntryCategoriesResponse = decodeOrThrow(topLogEntryCategoriesResponseRT)( - await this.libs.framework.callWithRequest( - requestContext, - 'search', - createTopLogEntryCategoriesQuery( + }, + sourceId: string, + startTime: number, + endTime: number +) { + const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + let esSearchSpans: TracingSpan[] = []; + + while (true) { + const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); + + const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( + await context.infra.mlSystem.mlAnomalySearch( + createLogEntryDatasetsQuery( logEntryCategoriesCountJobId, startTime, endTime, - categoryCount, - datasets + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey ) ) ); - const esSearchSpan = finalizeEsSearchSpan(); - - if (topLogEntryCategoriesResponse._shards.total === 0) { + if (logEntryDatasetsResponse._shards.total === 0) { throw new NoLogAnalysisResultsIndexError( `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` ); } - const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( - (topCategoryBucket) => { - const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< - Record - >( - (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ - ...accumulatedMaximumAnomalyScores, - [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, - }), - {} - ); - - return { - categoryId: parseCategoryId(topCategoryBucket.key), - logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, - datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets - .map((datasetBucket) => ({ - name: datasetBucket.key, - maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, - })) - .sort(compareDatasetsByMaximumAnomalyScore) - .reverse(), - maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, - }; - } - ); + const { + after_key: afterKey, + buckets: latestBatchBuckets, + } = logEntryDatasetsResponse.aggregations.dataset_buckets; - return { - topLogEntryCategories, - timing: { - spans: [esSearchSpan], - }, + logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); + + return { + data: logEntryDatasetBuckets.map((logEntryDatasetBucket) => logEntryDatasetBucket.key.dataset), + timing: { + spans: [logEntryDatasetsSpan, ...esSearchSpans], + }, + }; +} + +export async function getLogEntryCategoryExamples( + context: { + core: { elasticsearch: { legacy: { client: IScopedClusterClient } } }; + infra: { + mlAnomalyDetectors: MlAnomalyDetectors; + mlSystem: MlSystem; + spaceId: string; }; + }, + sourceId: string, + startTime: number, + endTime: number, + categoryId: number, + exampleCount: number +) { + const finalizeLogEntryCategoryExamplesSpan = startTracingSpan('get category example log entries'); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await fetchMlJob(context, logEntryCategoriesCountJobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${logEntryCategoriesCountJobId}` + ); } - private async fetchLogEntryCategories( - requestContext: RequestHandlerContext, - logEntryCategoriesCountJobId: string, - categoryIds: number[] - ) { - if (categoryIds.length === 0) { - return { - logEntryCategoriesById: {}, - timing: { spans: [] }, - }; - } + const { + logEntryCategoriesById, + timing: { spans: fetchLogEntryCategoriesSpans }, + } = await fetchLogEntryCategories(context, logEntryCategoriesCountJobId, [categoryId]); + const category = logEntryCategoriesById[categoryId]; + + if (category == null) { + throw new UnknownCategoryError(categoryId); + } - const finalizeEsSearchSpan = startTracingSpan('Fetch category patterns from ES'); + const { + examples, + timing: { spans: fetchLogEntryCategoryExamplesSpans }, + } = await fetchLogEntryCategoryExamples( + context, + indices, + timestampField, + startTime, + endTime, + category._source.terms, + exampleCount + ); + + const logEntryCategoryExamplesSpan = finalizeLogEntryCategoryExamplesSpan(); + + return { + data: examples, + timing: { + spans: [ + logEntryCategoryExamplesSpan, + ...fetchMlJobSpans, + ...fetchLogEntryCategoriesSpans, + ...fetchLogEntryCategoryExamplesSpans, + ], + }, + }; +} - const logEntryCategoriesResponse = decodeOrThrow(logEntryCategoriesResponseRT)( - await this.libs.framework.callWithRequest( - requestContext, - 'search', - createLogEntryCategoriesQuery(logEntryCategoriesCountJobId, categoryIds) +async function fetchTopLogEntryCategories( + context: { infra: { mlSystem: MlSystem } }, + logEntryCategoriesCountJobId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[] +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); + + const topLogEntryCategoriesResponse = decodeOrThrow(topLogEntryCategoriesResponseRT)( + await context.infra.mlSystem.mlAnomalySearch( + createTopLogEntryCategoriesQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets ) - ); + ) + ); - const esSearchSpan = finalizeEsSearchSpan(); + const esSearchSpan = finalizeEsSearchSpan(); - const logEntryCategoriesById = logEntryCategoriesResponse.hits.hits.reduce< - Record - >( - (accumulatedCategoriesById, categoryHit) => ({ - ...accumulatedCategoriesById, - [categoryHit._source.category_id]: categoryHit, - }), - {} + if (topLogEntryCategoriesResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` ); - - return { - logEntryCategoriesById, - timing: { - spans: [esSearchSpan], - }, - }; } - private async fetchTopLogEntryCategoryHistograms( - requestContext: RequestHandlerContext, - logEntryCategoriesCountJobId: string, - categoryIds: number[], - histograms: HistogramParameters[] - ) { - if (categoryIds.length === 0 || histograms.length === 0) { + const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( + (topCategoryBucket) => { + const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< + Record + >( + (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ + ...accumulatedMaximumAnomalyScores, + [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, + }), + {} + ); + return { - categoryHistogramsById: {}, - timing: { spans: [] }, + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets + .map((datasetBucket) => ({ + name: datasetBucket.key, + maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, + })) + .sort(compareDatasetsByMaximumAnomalyScore) + .reverse(), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, }; } + ); + + return { + topLogEntryCategories, + timing: { + spans: [esSearchSpan], + }, + }; +} - const finalizeEsSearchSpan = startTracingSpan('Fetch category histograms from ES'); - - const categoryHistogramsReponses = await Promise.all( - histograms.map(({ bucketCount, endTime, id: histogramId, startTime }) => - this.libs.framework - .callWithRequest( - requestContext, - 'search', - createLogEntryCategoryHistogramsQuery( - logEntryCategoriesCountJobId, - categoryIds, - startTime, - endTime, - bucketCount - ) - ) - .then(decodeOrThrow(logEntryCategoryHistogramsResponseRT)) - .then((response) => ({ - histogramId, - histogramBuckets: response.aggregations.filters_categories.buckets, - })) - ) - ); - - const esSearchSpan = finalizeEsSearchSpan(); - - const categoryHistogramsById = Object.values(categoryHistogramsReponses).reduce< - Record< - number, - Array<{ - histogramId: string; - buckets: Array<{ - bucketDuration: number; - logEntryCount: number; - startTime: number; - }>; - }> - > - >( - (outerAccumulatedHistograms, { histogramId, histogramBuckets }) => - Object.entries(histogramBuckets).reduce( - (innerAccumulatedHistograms, [categoryBucketKey, categoryBucket]) => { - const categoryId = parseCategoryId(categoryBucketKey); - return { - ...innerAccumulatedHistograms, - [categoryId]: [ - ...(innerAccumulatedHistograms[categoryId] ?? []), - { - histogramId, - buckets: categoryBucket.histogram_timestamp.buckets.map((bucket) => ({ - bucketDuration: categoryBucket.histogram_timestamp.meta.bucketDuration, - logEntryCount: bucket.sum_actual.value, - startTime: bucket.key, - })), - }, - ], - }; - }, - outerAccumulatedHistograms - ), - {} - ); - +async function fetchLogEntryCategories( + context: { infra: { mlSystem: MlSystem } }, + logEntryCategoriesCountJobId: string, + categoryIds: number[] +) { + if (categoryIds.length === 0) { return { - categoryHistogramsById, - timing: { - spans: [esSearchSpan], - }, + logEntryCategoriesById: {}, + timing: { spans: [] }, }; } - private async fetchMlJob( - requestContext: RequestHandlerContext, - logEntryCategoriesCountJobId: string - ) { - const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - - const { - jobs: [mlJob], - } = decodeOrThrow(mlJobsResponseRT)( - await this.libs.framework.callWithRequest( - requestContext, - 'transport.request', - createMlJobsQuery([logEntryCategoriesCountJobId]) - ) - ); - - const mlGetJobSpan = finalizeMlGetJobSpan(); - - if (mlJob == null) { - throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`); - } + const finalizeEsSearchSpan = startTracingSpan('Fetch category patterns from ES'); + + const logEntryCategoriesResponse = decodeOrThrow(logEntryCategoriesResponseRT)( + await context.infra.mlSystem.mlAnomalySearch( + createLogEntryCategoriesQuery(logEntryCategoriesCountJobId, categoryIds) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const logEntryCategoriesById = logEntryCategoriesResponse.hits.hits.reduce< + Record + >( + (accumulatedCategoriesById, categoryHit) => ({ + ...accumulatedCategoriesById, + [categoryHit._source.category_id]: categoryHit, + }), + {} + ); + + return { + logEntryCategoriesById, + timing: { + spans: [esSearchSpan], + }, + }; +} +async function fetchTopLogEntryCategoryHistograms( + context: { infra: { mlSystem: MlSystem } }, + logEntryCategoriesCountJobId: string, + categoryIds: number[], + histograms: HistogramParameters[] +) { + if (categoryIds.length === 0 || histograms.length === 0) { return { - mlJob, - timing: { - spans: [mlGetJobSpan], - }, + categoryHistogramsById: {}, + timing: { spans: [] }, }; } - private async fetchLogEntryCategoryExamples( - requestContext: RequestHandlerContext, - indices: string, - timestampField: string, - startTime: number, - endTime: number, - categoryQuery: string, - exampleCount: number - ) { - const finalizeEsSearchSpan = startTracingSpan('Fetch examples from ES'); + const finalizeEsSearchSpan = startTracingSpan('Fetch category histograms from ES'); - const { - hits: { hits }, - } = decodeOrThrow(logEntryCategoryExamplesResponseRT)( - await this.libs.framework.callWithRequest( - requestContext, - 'search', - createLogEntryCategoryExamplesQuery( - indices, - timestampField, - startTime, - endTime, - categoryQuery, - exampleCount + const categoryHistogramsReponses = await Promise.all( + histograms.map(({ bucketCount, endTime, id: histogramId, startTime }) => + context.infra.mlSystem + .mlAnomalySearch( + createLogEntryCategoryHistogramsQuery( + logEntryCategoriesCountJobId, + categoryIds, + startTime, + endTime, + bucketCount + ) ) - ) - ); + .then(decodeOrThrow(logEntryCategoryHistogramsResponseRT)) + .then((response) => ({ + histogramId, + histogramBuckets: response.aggregations.filters_categories.buckets, + })) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const categoryHistogramsById = Object.values(categoryHistogramsReponses).reduce< + Record< + number, + Array<{ + histogramId: string; + buckets: Array<{ + bucketDuration: number; + logEntryCount: number; + startTime: number; + }>; + }> + > + >( + (outerAccumulatedHistograms, { histogramId, histogramBuckets }) => + Object.entries(histogramBuckets).reduce( + (innerAccumulatedHistograms, [categoryBucketKey, categoryBucket]) => { + const categoryId = parseCategoryId(categoryBucketKey); + return { + ...innerAccumulatedHistograms, + [categoryId]: [ + ...(innerAccumulatedHistograms[categoryId] ?? []), + { + histogramId, + buckets: categoryBucket.histogram_timestamp.buckets.map((bucket) => ({ + bucketDuration: categoryBucket.histogram_timestamp.meta.bucketDuration, + logEntryCount: bucket.sum_actual.value, + startTime: bucket.key, + })), + }, + ], + }; + }, + outerAccumulatedHistograms + ), + {} + ); + + return { + categoryHistogramsById, + timing: { + spans: [esSearchSpan], + }, + }; +} - const esSearchSpan = finalizeEsSearchSpan(); +async function fetchMlJob( + context: { infra: { mlAnomalyDetectors: MlAnomalyDetectors } }, + logEntryCategoriesCountJobId: string +) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - return { - examples: hits.map((hit) => ({ - dataset: hit._source.event?.dataset ?? '', - message: hit._source.message ?? '', - timestamp: hit.sort[0], - })), - timing: { - spans: [esSearchSpan], - }, - }; + const { + jobs: [mlJob], + } = await context.infra.mlAnomalyDetectors.jobs(logEntryCategoriesCountJobId); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`); } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; +} + +async function fetchLogEntryCategoryExamples( + requestContext: { core: { elasticsearch: { legacy: { client: IScopedClusterClient } } } }, + indices: string, + timestampField: string, + startTime: number, + endTime: number, + categoryQuery: string, + exampleCount: number +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch examples from ES'); + + const { + hits: { hits }, + } = decodeOrThrow(logEntryCategoryExamplesResponseRT)( + await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( + 'search', + createLogEntryCategoryExamplesQuery( + indices, + timestampField, + startTime, + endTime, + categoryQuery, + exampleCount + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map((hit) => ({ + dataset: hit._source.event?.dataset ?? '', + message: hit._source.message ?? '', + timestamp: hit.sort[0], + })), + timing: { + spans: [esSearchSpan], + }, + }; } const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 28c1674841973c..125cc2b196e097 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -7,10 +7,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; import { getJobId } from '../../../common/log_analysis'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, @@ -18,126 +16,114 @@ import { LogRateModelPlotBucket, CompositeTimestampPartitionKey, } from './queries'; +import { MlSystem } from '../../types'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; -export class LogEntryRateAnalysis { - constructor( - private readonly libs: { - framework: KibanaFramework; - } - ) {} - - public getJobIds(request: KibanaRequest, sourceId: string) { - return { - logEntryRate: getJobId(this.libs.framework.getSpaceId(request), sourceId, 'log-entry-rate'), +export async function getLogEntryRateBuckets( + context: { + infra: { + mlSystem: MlSystem; + spaceId: string; }; - } + }, + sourceId: string, + startTime: number, + endTime: number, + bucketDuration: number +) { + const logRateJobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); + let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; + let afterLatestBatchKey: CompositeTimestampPartitionKey | undefined; - public async getLogEntryRateBuckets( - requestContext: RequestHandlerContext, - request: KibanaRequest, - sourceId: string, - startTime: number, - endTime: number, - bucketDuration: number - ) { - const logRateJobId = this.getJobIds(request, sourceId).logEntryRate; - let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; - let afterLatestBatchKey: CompositeTimestampPartitionKey | undefined; + while (true) { + const mlModelPlotResponse = await context.infra.mlSystem.mlAnomalySearch( + createLogEntryRateQuery( + logRateJobId, + startTime, + endTime, + bucketDuration, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ); - while (true) { - const mlModelPlotResponse = await this.libs.framework.callWithRequest( - requestContext, - 'search', - createLogEntryRateQuery( - logRateJobId, - startTime, - endTime, - bucketDuration, - COMPOSITE_AGGREGATION_BATCH_SIZE, - afterLatestBatchKey - ) + if (mlModelPlotResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to query ml result index for job ${logRateJobId}.` ); + } - if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml result index for job ${logRateJobId}.` - ); - } - - const { after_key: afterKey, buckets: latestBatchBuckets } = pipe( - logRateModelPlotResponseRT.decode(mlModelPlotResponse), - map((response) => response.aggregations.timestamp_partition_buckets), - fold(throwErrors(createPlainError), identity) - ); + const { after_key: afterKey, buckets: latestBatchBuckets } = pipe( + logRateModelPlotResponseRT.decode(mlModelPlotResponse), + map((response) => response.aggregations.timestamp_partition_buckets), + fold(throwErrors(createPlainError), identity) + ); - mlModelPlotBuckets = [...mlModelPlotBuckets, ...latestBatchBuckets]; - afterLatestBatchKey = afterKey; + mlModelPlotBuckets = [...mlModelPlotBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; - if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { - break; - } + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; } + } - return mlModelPlotBuckets.reduce< - Array<{ - partitions: Array<{ - analysisBucketCount: number; - anomalies: Array<{ - actualLogEntryRate: number; - anomalyScore: number; - duration: number; - startTime: number; - typicalLogEntryRate: number; - }>; - averageActualLogEntryRate: number; - maximumAnomalyScore: number; - numberOfLogEntries: number; - partitionId: string; + return mlModelPlotBuckets.reduce< + Array<{ + partitions: Array<{ + analysisBucketCount: number; + anomalies: Array<{ + actualLogEntryRate: number; + anomalyScore: number; + duration: number; + startTime: number; + typicalLogEntryRate: number; }>; - startTime: number; - }> - >((histogramBuckets, timestampPartitionBucket) => { - const previousHistogramBucket = histogramBuckets[histogramBuckets.length - 1]; - const partition = { - analysisBucketCount: timestampPartitionBucket.filter_model_plot.doc_count, - anomalies: timestampPartitionBucket.filter_records.top_hits_record.hits.hits.map( - ({ _source: record }) => ({ - actualLogEntryRate: record.actual[0], - anomalyScore: record.record_score, - duration: record.bucket_span * 1000, - startTime: record.timestamp, - typicalLogEntryRate: record.typical[0], - }) - ), - averageActualLogEntryRate: - timestampPartitionBucket.filter_model_plot.average_actual.value || 0, - maximumAnomalyScore: - timestampPartitionBucket.filter_records.maximum_record_score.value || 0, - numberOfLogEntries: timestampPartitionBucket.filter_model_plot.sum_actual.value || 0, - partitionId: timestampPartitionBucket.key.partition, - }; - if ( - previousHistogramBucket && - previousHistogramBucket.startTime === timestampPartitionBucket.key.timestamp - ) { - return [ - ...histogramBuckets.slice(0, -1), - { - ...previousHistogramBucket, - partitions: [...previousHistogramBucket.partitions, partition], - }, - ]; - } else { - return [ - ...histogramBuckets, - { - partitions: [partition], - startTime: timestampPartitionBucket.key.timestamp, - }, - ]; - } - }, []); - } + averageActualLogEntryRate: number; + maximumAnomalyScore: number; + numberOfLogEntries: number; + partitionId: string; + }>; + startTime: number; + }> + >((histogramBuckets, timestampPartitionBucket) => { + const previousHistogramBucket = histogramBuckets[histogramBuckets.length - 1]; + const partition = { + analysisBucketCount: timestampPartitionBucket.filter_model_plot.doc_count, + anomalies: timestampPartitionBucket.filter_records.top_hits_record.hits.hits.map( + ({ _source: record }) => ({ + actualLogEntryRate: record.actual[0], + anomalyScore: record.record_score, + duration: record.bucket_span * 1000, + startTime: record.timestamp, + typicalLogEntryRate: record.typical[0], + }) + ), + averageActualLogEntryRate: + timestampPartitionBucket.filter_model_plot.average_actual.value || 0, + maximumAnomalyScore: timestampPartitionBucket.filter_records.maximum_record_score.value || 0, + numberOfLogEntries: timestampPartitionBucket.filter_model_plot.sum_actual.value || 0, + partitionId: timestampPartitionBucket.key.partition, + }; + if ( + previousHistogramBucket && + previousHistogramBucket.startTime === timestampPartitionBucket.key.timestamp + ) { + return [ + ...histogramBuckets.slice(0, -1), + { + ...previousHistogramBucket, + partitions: [...previousHistogramBucket.partitions, partition], + }, + ]; + } else { + return [ + ...histogramBuckets, + { + partitions: [partition], + startTime: timestampPartitionBucket.key.timestamp, + }, + ]; + } + }, []); } diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts index f1e68d34fdae3c..eacf29b303db05 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -4,10 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; - -export const getMlResultIndex = (jobId: string) => `${ML_ANOMALY_INDEX_PREFIX}${jobId}`; - export const defaultRequestParameters = { allowNoIndices: true, ignoreUnavailable: true, @@ -15,6 +11,16 @@ export const defaultRequestParameters = { trackTotalHits: false, }; +export const createJobIdFilters = (jobId: string) => [ + { + term: { + job_id: { + value: jobId, + }, + }, + }, +]; + export const createTimeRangeFilters = (startTime: number, endTime: number) => [ { range: { @@ -26,12 +32,10 @@ export const createTimeRangeFilters = (startTime: number, endTime: number) => [ }, ]; -export const createResultTypeFilters = (resultType: 'model_plot' | 'record') => [ +export const createResultTypeFilters = (resultTypes: Array<'model_plot' | 'record'>) => [ { - term: { - result_type: { - value: resultType, - }, + terms: { + result_type: resultTypes, }, }, ]; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts index 2681a4c037f5df..c7ad60eeaabc2c 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts @@ -5,9 +5,8 @@ */ import * as rt from 'io-ts'; - import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; -import { defaultRequestParameters, getMlResultIndex, createCategoryIdFilters } from './common'; +import { createCategoryIdFilters, createJobIdFilters, defaultRequestParameters } from './common'; export const createLogEntryCategoriesQuery = ( logEntryCategoriesJobId: string, @@ -17,12 +16,14 @@ export const createLogEntryCategoriesQuery = ( body: { query: { bool: { - filter: [...createCategoryIdFilters(categoryIds)], + filter: [ + ...createJobIdFilters(logEntryCategoriesJobId), + ...createCategoryIdFilters(categoryIds), + ], }, }, _source: ['category_id', 'regex', 'terms'], }, - index: getMlResultIndex(logEntryCategoriesJobId), size: categoryIds.length, }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts index 67087f3b4775b6..5fdafb5123251f 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts @@ -5,13 +5,12 @@ */ import * as rt from 'io-ts'; - import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { + createJobIdFilters, createResultTypeFilters, createTimeRangeFilters, defaultRequestParameters, - getMlResultIndex, } from './common'; export const createLogEntryCategoryHistogramsQuery = ( @@ -26,8 +25,9 @@ export const createLogEntryCategoryHistogramsQuery = ( query: { bool: { filter: [ + ...createJobIdFilters(logEntryCategoriesJobId), ...createTimeRangeFilters(startTime, endTime), - ...createResultTypeFilters('model_plot'), + ...createResultTypeFilters(['model_plot']), ...createCategoryFilters(categoryIds), ], }, @@ -41,7 +41,6 @@ export const createLogEntryCategoryHistogramsQuery = ( }, }, }, - index: getMlResultIndex(logEntryCategoriesJobId), size: 0, }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts index b41a21a21b6a6e..dd22bedae8b2ae 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -5,9 +5,13 @@ */ import * as rt from 'io-ts'; - import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; -import { defaultRequestParameters, getMlResultIndex } from './common'; +import { + createJobIdFilters, + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, +} from './common'; export const createLogEntryDatasetsQuery = ( logEntryAnalysisJobId: string, @@ -21,21 +25,9 @@ export const createLogEntryDatasetsQuery = ( query: { bool: { filter: [ - { - range: { - timestamp: { - gte: startTime, - lt: endTime, - }, - }, - }, - { - term: { - result_type: { - value: 'model_plot', - }, - }, - }, + ...createJobIdFilters(logEntryAnalysisJobId), + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters(['model_plot']), ], }, }, @@ -58,7 +50,6 @@ export const createLogEntryDatasetsQuery = ( }, }, }, - index: getMlResultIndex(logEntryAnalysisJobId), size: 0, }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index def7caf578b94f..269850e292636e 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -5,8 +5,12 @@ */ import * as rt from 'io-ts'; - -import { defaultRequestParameters, getMlResultIndex } from './common'; +import { + createJobIdFilters, + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, +} from './common'; export const createLogEntryRateQuery = ( logRateJobId: string, @@ -21,19 +25,9 @@ export const createLogEntryRateQuery = ( query: { bool: { filter: [ - { - range: { - timestamp: { - gte: startTime, - lt: endTime, - }, - }, - }, - { - terms: { - result_type: ['model_plot', 'record'], - }, - }, + ...createJobIdFilters(logRateJobId), + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters(['model_plot', 'record']), { term: { detector_index: { @@ -118,7 +112,6 @@ export const createLogEntryRateQuery = ( }, }, }, - index: getMlResultIndex(logRateJobId), size: 0, }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 517d31865e3580..6fa7156240508e 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -5,13 +5,12 @@ */ import * as rt from 'io-ts'; - import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { + createJobIdFilters, createResultTypeFilters, createTimeRangeFilters, defaultRequestParameters, - getMlResultIndex, } from './common'; export const createTopLogEntryCategoriesQuery = ( @@ -27,6 +26,7 @@ export const createTopLogEntryCategoriesQuery = ( query: { bool: { filter: [ + ...createJobIdFilters(logEntryCategoriesJobId), ...createTimeRangeFilters(startTime, endTime), ...createDatasetsFilters(datasets), { @@ -35,7 +35,7 @@ export const createTopLogEntryCategoriesQuery = ( { bool: { filter: [ - ...createResultTypeFilters('model_plot'), + ...createResultTypeFilters(['model_plot']), { range: { actual: { @@ -48,7 +48,7 @@ export const createTopLogEntryCategoriesQuery = ( }, { bool: { - filter: createResultTypeFilters('record'), + filter: createResultTypeFilters(['record']), }, }, ], @@ -119,7 +119,6 @@ export const createTopLogEntryCategoriesQuery = ( }, }, }, - index: getMlResultIndex(logEntryCategoriesJobId), size: 0, }); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 2fd614830c05df..8062c48d986178 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -19,7 +19,6 @@ import { InfraElasticsearchSourceStatusAdapter } from './lib/adapters/source_sta import { InfraFieldsDomain } from './lib/domains/fields_domain'; import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain'; import { InfraMetricsDomain } from './lib/domains/metrics_domain'; -import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './lib/log_analysis'; import { InfraSnapshot } from './lib/snapshot'; import { InfraSourceStatus } from './lib/source_status'; import { InfraSources } from './lib/sources'; @@ -31,6 +30,7 @@ import { registerAlertTypes } from './lib/alerting'; import { infraSourceConfigurationSavedObjectType } from './lib/sources'; import { metricsExplorerViewSavedObjectType } from '../common/saved_objects/metrics_explorer_view'; import { inventoryViewSavedObjectType } from '../common/saved_objects/inventory_view'; +import { InfraRequestHandlerContext } from './types'; export const config = { schema: schema.object({ @@ -106,8 +106,6 @@ export class InfraServerPlugin { } ); const snapshot = new InfraSnapshot(); - const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); - const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // register saved object types core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); @@ -115,6 +113,8 @@ export class InfraServerPlugin { core.savedObjects.registerType(inventoryViewSavedObjectType); // TODO: separate these out individually and do away with "domains" as a temporary group + // and make them available via the request context so we can do away with + // the wrapper classes const domainLibs: InfraDomainLibs = { fields: new InfraFieldsDomain(new FrameworkFieldsAdapter(framework), { sources, @@ -129,8 +129,6 @@ export class InfraServerPlugin { this.libs = { configuration: this.config, framework, - logEntryCategoriesAnalysis, - logEntryRateAnalysis, snapshot, sources, sourceStatus, @@ -151,6 +149,25 @@ export class InfraServerPlugin { initInfraServer(this.libs); registerAlertTypes(plugins.alerts, this.libs); + core.http.registerRouteHandlerContext( + 'infra', + (context, request): InfraRequestHandlerContext => { + const mlSystem = + context.ml && + plugins.ml?.mlSystemProvider(context.ml?.mlClient.callAsCurrentUser, request); + const mlAnomalyDetectors = + context.ml && + plugins.ml?.anomalyDetectorsProvider(context.ml?.mlClient.callAsCurrentUser); + const spaceId = plugins.spaces?.spacesService.getSpaceId(request) || 'default'; + + return { + mlAnomalyDetectors, + mlSystem, + spaceId, + }; + } + ); + // Telemetry UsageCollector.registerUsageCollector(plugins.usageCollection); diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index f4eed041481f6a..d11425a4f4cb07 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -12,8 +12,10 @@ import { alertPreviewRequestParamsRT, alertPreviewSuccessResponsePayloadRT, MetricThresholdAlertPreviewRequestParams, + InventoryAlertPreviewRequestParams, } from '../../../common/alerting/metrics'; import { createValidationFunction } from '../../../common/runtime_types'; +import { previewInventoryMetricThresholdAlert } from '../../lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert'; import { previewMetricThresholdAlert } from '../../lib/alerting/metric_threshold/preview_metric_threshold_alert'; import { InfraBackendLibs } from '../../lib/infra_types'; @@ -76,8 +78,35 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { - // TODO: Add inventory preview functionality - return response.ok({}); + const { nodeType } = request.body as InventoryAlertPreviewRequestParams; + const previewResult = await previewInventoryMetricThresholdAlert({ + callCluster, + params: { criteria, filterQuery, nodeType }, + lookback, + config: source.configuration, + alertInterval, + }); + + const numberOfGroups = previewResult.length; + const resultTotals = previewResult.reduce( + (totals, groupResult) => { + if (groupResult === null) return { ...totals, noData: totals.noData + 1 }; + if (isNaN(groupResult)) return { ...totals, error: totals.error + 1 }; + return { ...totals, fired: totals.fired + groupResult }; + }, + { + fired: 0, + noData: 0, + error: 0, + } + ); + + return response.ok({ + body: alertPreviewSuccessResponsePayloadRT.encode({ + numberOfGroups, + resultTotals, + }), + }); } default: throw new Error('Unknown alert type'); diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts index d335774c85f384..f9f31f28dffeb6 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -5,36 +5,29 @@ */ import Boom from 'boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { schema } from '@kbn/config-schema'; -import { InfraBackendLibs } from '../../../lib/infra_types'; import { - LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, getLogEntryCategoriesRequestPayloadRT, getLogEntryCategoriesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, } from '../../../../common/http_api/log_analysis'; -import { throwErrors } from '../../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; - -const anyObject = schema.object({}, { unknowns: 'allow' }); +import { createValidationFunction } from '../../../../common/runtime_types'; +import type { InfraBackendLibs } from '../../../lib/infra_types'; +import { + getTopLogEntryCategories, + NoLogAnalysisResultsIndexError, +} from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; -export const initGetLogEntryCategoriesRoute = ({ - framework, - logEntryCategoriesAnalysis, -}: InfraBackendLibs) => { +export const initGetLogEntryCategoriesRoute = ({ framework }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, validate: { - // short-circuit forced @kbn/config-schema validation so we can do io-ts validation - body: anyObject, + body: createValidationFunction(getLogEntryCategoriesRequestPayloadRT), }, }, - async (requestContext, request, response) => { + framework.router.handleLegacyErrors(async (requestContext, request, response) => { const { data: { categoryCount, @@ -43,18 +36,13 @@ export const initGetLogEntryCategoriesRoute = ({ timeRange: { startTime, endTime }, datasets, }, - } = pipe( - getLogEntryCategoriesRequestPayloadRT.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + } = request.body; try { - const { - data: topLogEntryCategories, - timing, - } = await logEntryCategoriesAnalysis.getTopLogEntryCategories( + assertHasInfraMlPlugins(requestContext); + + const { data: topLogEntryCategories, timing } = await getTopLogEntryCategories( requestContext, - request, sourceId, startTime, endTime, @@ -76,18 +64,22 @@ export const initGetLogEntryCategoriesRoute = ({ timing, }), }); - } catch (e) { - const { statusCode = 500, message = 'Unknown error occurred' } = e; + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } - if (e instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message } }); + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); } return response.customError({ - statusCode, - body: { message }, + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, }); } - } + }) ); }; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts index 730e32dee2fbe9..69b1e942464fd5 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -4,54 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import Boom from 'boom'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; - import { getLogEntryCategoryDatasetsRequestPayloadRT, getLogEntryCategoryDatasetsSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, } from '../../../../common/http_api/log_analysis'; -import { throwErrors } from '../../../../common/runtime_types'; -import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; - -const anyObject = schema.object({}, { unknowns: 'allow' }); +import { createValidationFunction } from '../../../../common/runtime_types'; +import type { InfraBackendLibs } from '../../../lib/infra_types'; +import { + getLogEntryCategoryDatasets, + NoLogAnalysisResultsIndexError, +} from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; -export const initGetLogEntryCategoryDatasetsRoute = ({ - framework, - logEntryCategoriesAnalysis, -}: InfraBackendLibs) => { +export const initGetLogEntryCategoryDatasetsRoute = ({ framework }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, validate: { - // short-circuit forced @kbn/config-schema validation so we can do io-ts validation - body: anyObject, + body: createValidationFunction(getLogEntryCategoryDatasetsRequestPayloadRT), }, }, - async (requestContext, request, response) => { + framework.router.handleLegacyErrors(async (requestContext, request, response) => { const { data: { sourceId, timeRange: { startTime, endTime }, }, - } = pipe( - getLogEntryCategoryDatasetsRequestPayloadRT.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + } = request.body; try { - const { - data: logEntryCategoryDatasets, - timing, - } = await logEntryCategoriesAnalysis.getLogEntryCategoryDatasets( + assertHasInfraMlPlugins(requestContext); + + const { data: logEntryCategoryDatasets, timing } = await getLogEntryCategoryDatasets( requestContext, - request, sourceId, startTime, endTime @@ -65,18 +53,22 @@ export const initGetLogEntryCategoryDatasetsRoute = ({ timing, }), }); - } catch (e) { - const { statusCode = 500, message = 'Unknown error occurred' } = e; + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } - if (e instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message } }); + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); } return response.customError({ - statusCode, - body: { message }, + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, }); } - } + }) ); }; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts index 44f466cc77c89d..217180c0290f72 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts @@ -4,37 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; import Boom from 'boom'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; - import { getLogEntryCategoryExamplesRequestPayloadRT, getLogEntryCategoryExamplesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, } from '../../../../common/http_api/log_analysis'; -import { throwErrors } from '../../../../common/runtime_types'; -import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; - -const anyObject = schema.object({}, { unknowns: 'allow' }); +import { createValidationFunction } from '../../../../common/runtime_types'; +import type { InfraBackendLibs } from '../../../lib/infra_types'; +import { + getLogEntryCategoryExamples, + NoLogAnalysisResultsIndexError, +} from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; -export const initGetLogEntryCategoryExamplesRoute = ({ - framework, - logEntryCategoriesAnalysis, -}: InfraBackendLibs) => { +export const initGetLogEntryCategoryExamplesRoute = ({ framework }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, validate: { - // short-circuit forced @kbn/config-schema validation so we can do io-ts validation - body: anyObject, + body: createValidationFunction(getLogEntryCategoryExamplesRequestPayloadRT), }, }, - async (requestContext, request, response) => { + framework.router.handleLegacyErrors(async (requestContext, request, response) => { const { data: { categoryId, @@ -42,18 +35,13 @@ export const initGetLogEntryCategoryExamplesRoute = ({ sourceId, timeRange: { startTime, endTime }, }, - } = pipe( - getLogEntryCategoryExamplesRequestPayloadRT.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + } = request.body; try { - const { - data: logEntryCategoryExamples, - timing, - } = await logEntryCategoriesAnalysis.getLogEntryCategoryExamples( + assertHasInfraMlPlugins(requestContext); + + const { data: logEntryCategoryExamples, timing } = await getLogEntryCategoryExamples( requestContext, - request, sourceId, startTime, endTime, @@ -69,18 +57,22 @@ export const initGetLogEntryCategoryExamplesRoute = ({ timing, }), }); - } catch (e) { - const { statusCode = 500, message = 'Unknown error occurred' } = e; + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } - if (e instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message } }); + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); } return response.customError({ - statusCode, - body: { message }, + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, }); } - } + }) ); }; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 38dc0a790a7a3d..ae86102980c166 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -5,11 +5,6 @@ */ import Boom from 'boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { schema } from '@kbn/config-schema'; import { InfraBackendLibs } from '../../../lib/infra_types'; import { LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH, @@ -17,57 +12,61 @@ import { getLogEntryRateSuccessReponsePayloadRT, GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; -import { throwErrors } from '../../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; - -const anyObject = schema.object({}, { unknowns: 'allow' }); +import { createValidationFunction } from '../../../../common/runtime_types'; +import { NoLogAnalysisResultsIndexError, getLogEntryRateBuckets } from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; -export const initGetLogEntryRateRoute = ({ framework, logEntryRateAnalysis }: InfraBackendLibs) => { +export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH, validate: { - // short-circuit forced @kbn/config-schema validation so we can do io-ts validation - body: anyObject, + body: createValidationFunction(getLogEntryRateRequestPayloadRT), }, }, - async (requestContext, request, response) => { + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { sourceId, timeRange, bucketDuration }, + } = request.body; + try { - const payload = pipe( - getLogEntryRateRequestPayloadRT.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + assertHasInfraMlPlugins(requestContext); - const logEntryRateBuckets = await logEntryRateAnalysis.getLogEntryRateBuckets( + const logEntryRateBuckets = await getLogEntryRateBuckets( requestContext, - request, - payload.data.sourceId, - payload.data.timeRange.startTime, - payload.data.timeRange.endTime, - payload.data.bucketDuration + sourceId, + timeRange.startTime, + timeRange.endTime, + bucketDuration ); return response.ok({ body: getLogEntryRateSuccessReponsePayloadRT.encode({ data: { - bucketDuration: payload.data.bucketDuration, + bucketDuration, histogramBuckets: logEntryRateBuckets, totalNumberOfLogEntries: getTotalNumberOfLogEntries(logEntryRateBuckets), }, }), }); - } catch (e) { - const { statusCode = 500, message = 'Unknown error occurred' } = e; - if (e instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message } }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; } + + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); + } + return response.customError({ - statusCode, - body: { message }, + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, }); } - } + }) ); }; diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts new file mode 100644 index 00000000000000..735569a790f64d --- /dev/null +++ b/x-pack/plugins/infra/server/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { MlPluginSetup } from '../../ml/server'; + +export type MlSystem = ReturnType; +export type MlAnomalyDetectors = ReturnType; + +export interface InfraMlRequestHandlerContext { + mlAnomalyDetectors?: MlAnomalyDetectors; + mlSystem?: MlSystem; +} + +export interface InfraSpacesRequestHandlerContext { + spaceId: string; +} + +export type InfraRequestHandlerContext = InfraMlRequestHandlerContext & + InfraSpacesRequestHandlerContext; + +declare module 'src/core/server' { + interface RequestHandlerContext { + infra?: InfraRequestHandlerContext; + } +} diff --git a/x-pack/plugins/infra/server/utils/request_context.ts b/x-pack/plugins/infra/server/utils/request_context.ts new file mode 100644 index 00000000000000..30855d74d9e30b --- /dev/null +++ b/x-pack/plugins/infra/server/utils/request_context.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import { InfraMlRequestHandlerContext, InfraRequestHandlerContext } from '../types'; + +export class MissingContextValuesError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class NoMlPluginError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function assertHasInfraPlugins( + context: Context +): asserts context is Context & { infra: Context['infra'] } { + if (context.infra == null) { + throw new MissingContextValuesError('Failed to access "infra" context values.'); + } +} + +export function assertHasInfraMlPlugins( + context: Context +): asserts context is Context & { + infra: Context['infra'] & Required; +} { + assertHasInfraPlugins(context); + + if (context.infra?.mlAnomalyDetectors == null || context.infra?.mlSystem == null) { + throw new NoMlPluginError('Failed to access ML plugin.'); + } +} diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index ea61d97145795e..d17b4115e64aba 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -1712,6 +1712,198 @@ }, "success": true } + }, + "required-package": { + "value": { + "response": { + "format_version": "1.0.0", + "name": "endpoint", + "title": "Elastic Endpoint", + "version": "0.3.0", + "readme": "/package/endpoint/0.3.0/docs/README.md", + "license": "basic", + "description": "This is the Elastic Endpoint package.", + "type": "solution", + "categories": [ + "security" + ], + "release": "beta", + "requirement": { + "kibana": { + "versions": ">7.4.0" + } + }, + "icons": [ + { + "src": "/package/endpoint/0.3.0/img/logo-endpoint-64-color.svg", + "size": "16x16", + "type": "image/svg+xml" + } + ], + "assets": { + "kibana": { + "dashboard": [ + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "dashboard", + "file": "826759f0-7074-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/dashboard/826759f0-7074-11ea-9bc8-6b38f4d29a16.json" + } + ], + "map": [ + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "map", + "file": "a3a3bd10-706b-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/map/a3a3bd10-706b-11ea-9bc8-6b38f4d29a16.json" + } + ], + "visualization": [ + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "visualization", + "file": "1cfceda0-728b-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/visualization/1cfceda0-728b-11ea-9bc8-6b38f4d29a16.json" + }, + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "visualization", + "file": "1e525190-7074-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/visualization/1e525190-7074-11ea-9bc8-6b38f4d29a16.json" + }, + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "visualization", + "file": "55387750-729c-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/visualization/55387750-729c-11ea-9bc8-6b38f4d29a16.json" + }, + { + "pkgkey": "endpoint-0.3.0", + "service": "kibana", + "type": "visualization", + "file": "92b1edc0-706a-11ea-9bc8-6b38f4d29a16.json", + "path": "endpoint-0.3.0/kibana/visualization/92b1edc0-706a-11ea-9bc8-6b38f4d29a16.json" + } + ] + } + }, + "datasets": [ + { + "id": "endpoint", + "title": "Endpoint Events", + "release": "experimental", + "type": "events", + "package": "endpoint", + "path": "events" + }, + { + "id": "endpoint.metadata", + "title": "Endpoint Metadata", + "release": "experimental", + "type": "metrics", + "package": "endpoint", + "path": "metadata" + }, + { + "id": "endpoint.policy", + "title": "Endpoint Policy Response", + "release": "experimental", + "type": "metrics", + "package": "endpoint", + "path": "policy" + }, + { + "id": "endpoint.telemetry", + "title": "Endpoint Telemetry", + "release": "experimental", + "type": "metrics", + "package": "endpoint", + "path": "telemetry" + } + ], + "datasources": [ + { + "name": "endpoint", + "title": "Endpoint data source", + "description": "Interact with the endpoint.", + "inputs": null, + "multiple": false + } + ], + "download": "/epr/endpoint/endpoint-0.3.0.tar.gz", + "path": "/package/endpoint/0.3.0", + "latestVersion": "0.3.0", + "removable": false, + "status": "installed", + "savedObject": { + "id": "endpoint", + "type": "epm-packages", + "updated_at": "2020-06-23T21:44:59.319Z", + "version": "Wzk4LDFd", + "attributes": { + "installed": [ + { + "id": "826759f0-7074-11ea-9bc8-6b38f4d29a16", + "type": "dashboard" + }, + { + "id": "1cfceda0-728b-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "1e525190-7074-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "55387750-729c-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "92b1edc0-706a-11ea-9bc8-6b38f4d29a16", + "type": "visualization" + }, + { + "id": "a3a3bd10-706b-11ea-9bc8-6b38f4d29a16", + "type": "map" + }, + { + "id": "events-endpoint", + "type": "index-template" + }, + { + "id": "metrics-endpoint.metadata", + "type": "index-template" + }, + { + "id": "metrics-endpoint.policy", + "type": "index-template" + }, + { + "id": "metrics-endpoint.telemetry", + "type": "index-template" + } + ], + "es_index_patterns": { + "events": "events-endpoint-*", + "metadata": "metrics-endpoint.metadata-*", + "policy": "metrics-endpoint.policy-*", + "telemetry": "metrics-endpoint.telemetry-*" + }, + "name": "endpoint", + "version": "0.3.0", + "internal": false, + "removable": false + }, + "references": [] + } + }, + "success": true + } } } } @@ -3822,6 +4014,9 @@ }, "path": { "type": "string" + }, + "removable": { + "type": "boolean" } }, "required": [ diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index cc9e23dc9388f6..599165d2bfd981 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -58,7 +58,6 @@ export interface RegistryPackage { icons?: RegistryImage[]; assets?: string[]; internal?: boolean; - removable?: boolean; format_version: string; datasets?: Dataset[]; datasources?: RegistryDatasource[]; @@ -206,6 +205,7 @@ interface PackageAdditions { title: string; latestVersion: string; assets: AssetsGroupedByServiceByType; + removable?: boolean; } // Managers public HTTP response types diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts index eb212050ef53ef..294e10aabe4efc 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts @@ -12,6 +12,7 @@ export * from './fleet_setup'; export * from './epm'; export * from './enrollment_api_key'; export * from './install_script'; +export * from './ingest_setup'; export * from './output'; export * from './settings'; export * from './app'; diff --git a/x-pack/plugins/maps/public/classes/sources/vector_feature_types.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/ingest_setup.ts similarity index 72% rename from x-pack/plugins/maps/public/classes/sources/vector_feature_types.ts rename to x-pack/plugins/ingest_manager/common/types/rest_spec/ingest_setup.ts index 9f03357e17dad5..17f4023fc8bead 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_feature_types.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/ingest_setup.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export enum VECTOR_SHAPE_TYPES { - POINT = 'POINT', - LINE = 'LINE', - POLYGON = 'POLYGON', +export interface PostIngestSetupResponse { + isInitialized: boolean; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index 7939feed801430..6f23c0ce608509 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -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`} > { const layoutProps = { from, cancelUrl, - cancelOnClick: cancelClickHandler, + onCancel: cancelClickHandler, agentConfig, packageInfo, }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx index d47eea80da8b72..af39cb87f18c93 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx @@ -242,7 +242,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => { }; return ( - + {isLoadingData ? ( ) : loadingError || !agentConfig || !packageInfo ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 281a8d3a9745c3..75d05567551491 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -489,6 +489,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { className="fleet__agentList__table" + data-test-subj="fleetAgentListTable" loading={isLoading && agentsRequest.isInitialRequest} hasActions={true} noItemsMessage={ diff --git a/x-pack/plugins/ingest_manager/public/index.ts b/x-pack/plugins/ingest_manager/public/index.ts index 9f4893ac6e499f..ac56349b30c13e 100644 --- a/x-pack/plugins/ingest_manager/public/index.ts +++ b/x-pack/plugins/ingest_manager/public/index.ts @@ -6,7 +6,7 @@ import { PluginInitializerContext } from 'src/core/public'; import { IngestManagerPlugin } from './plugin'; -export { IngestManagerStart } from './plugin'; +export { IngestManagerSetup, IngestManagerStart } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => { return new IngestManagerPlugin(initializerContext); diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 3eb2fad339b7d0..4a10a26151e780 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { LicensingPluginSetup } from '../../licensing/public'; -import { PLUGIN_ID } from '../common/constants'; +import { PLUGIN_ID, CheckPermissionsResponse, PostIngestSetupResponse } from '../common'; import { IngestManagerConfigType } from '../common/types'; import { setupRouteService, appRoutesService } from '../common'; @@ -22,16 +22,17 @@ import { registerDatasource } from './applications/ingest_manager/sections/agent export { IngestManagerConfigType } from '../common/types'; -export type IngestManagerSetup = void; +// We need to provide an object instead of void so that dependent plugins know when Ingest Manager +// is disabled. +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface IngestManagerSetup {} + /** * Describes public IngestManager plugin contract returned at the `start` stage. */ export interface IngestManagerStart { registerDatasource: typeof registerDatasource; - success: boolean; - error?: { - message: string; - }; + success: Promise; } export interface IngestManagerSetupDeps { @@ -75,24 +76,34 @@ export class IngestManagerPlugin }; }, }); + + return {}; } public async start(core: CoreStart): Promise { + let successPromise: IngestManagerStart['success']; try { - const permissionsResponse = await core.http.get(appRoutesService.getCheckPermissionsPath()); - if (permissionsResponse.success) { - const { isInitialized: success } = await core.http.post(setupRouteService.getSetupPath()); - return { success, registerDatasource }; + const permissionsResponse = await core.http.get( + appRoutesService.getCheckPermissionsPath() + ); + + if (permissionsResponse?.success) { + successPromise = core.http + .post(setupRouteService.getSetupPath()) + .then(({ isInitialized }) => + isInitialized ? Promise.resolve(true) : Promise.reject(new Error('Unknown setup error')) + ); } else { - throw new Error(permissionsResponse.error); + throw new Error(permissionsResponse?.error || 'Unknown permissions error'); } } catch (error) { - return { - success: false, - error: { message: error.body?.message || 'Unknown error' }, - registerDatasource, - }; + successPromise = Promise.reject(error); } + + return { + success: successPromise, + registerDatasource, + }; } public stop() {} diff --git a/x-pack/plugins/ingest_manager/server/constants/index.ts b/x-pack/plugins/ingest_manager/server/constants/index.ts index 4d60b9031414ee..ebcce6320ec4b9 100644 --- a/x-pack/plugins/ingest_manager/server/constants/index.ts +++ b/x-pack/plugins/ingest_manager/server/constants/index.ts @@ -36,7 +36,7 @@ export { PACKAGES_SAVED_OBJECT_TYPE, INDEX_PATTERN_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, - GLOBAL_SETTINGS_SAVED_OBJECT_TYPE as GLOBAL_SETTINGS_SAVED_OBJET_TYPE, + GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, // Defaults DEFAULT_AGENT_CONFIG, DEFAULT_OUTPUT, diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index f6b2d7ccc6d480..1e9011c9dfe4f7 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -11,6 +11,7 @@ export { IngestManagerSetupContract, IngestManagerSetupDeps, IngestManagerStartContract, + ExternalCallback, } from './plugin'; export const config = { @@ -42,6 +43,8 @@ export const config = { export type IngestManagerConfigType = TypeOf; +export { DatasourceServiceInterface } from './services/datasource'; + export const plugin = (initializerContext: PluginInitializerContext) => { return new IngestManagerPlugin(initializerContext); }; diff --git a/x-pack/plugins/ingest_manager/server/mocks.ts b/x-pack/plugins/ingest_manager/server/mocks.ts new file mode 100644 index 00000000000000..3bdef14dc85a01 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/mocks.ts @@ -0,0 +1,36 @@ +/* + * 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 { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { IngestManagerAppContext } from './plugin'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; +import { securityMock } from '../../security/server/mocks'; +import { DatasourceServiceInterface } from './services/datasource'; + +export const createAppContextStartContractMock = (): IngestManagerAppContext => { + return { + encryptedSavedObjectsStart: encryptedSavedObjectsMock.createStart(), + savedObjects: savedObjectsServiceMock.createStartContract(), + security: securityMock.createSetup(), + logger: loggingSystemMock.create().get(), + isProductionMode: true, + kibanaVersion: '8.0.0', + }; +}; + +export const createDatasourceServiceMock = () => { + return { + assignPackageStream: jest.fn(), + buildDatasourceFromPackage: jest.fn(), + bulkCreate: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + get: jest.fn(), + getByIDs: jest.fn(), + list: jest.fn(), + update: jest.fn(), + } as jest.Mocked; +}; diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 13301df471c539..fcdb6387fed3ae 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -45,15 +45,16 @@ import { registerSettingsRoutes, registerAppRoutes, } from './routes'; -import { IngestManagerConfigType } from '../common'; +import { IngestManagerConfigType, NewDatasource } from '../common'; import { appContextService, licenseService, ESIndexPatternSavedObjectService, ESIndexPatternService, AgentService, + datasourceService, } from './services'; -import { getAgentStatusById } from './services/agents'; +import { getAgentStatusById, authenticateAgentWithAccessToken } from './services/agents'; import { CloudSetup } from '../../cloud/server'; import { agentCheckinState } from './services/agents/checkin/state'; @@ -92,12 +93,31 @@ const allSavedObjectTypes = [ ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, ]; +/** + * Callbacks supported by the Ingest plugin + */ +export type ExternalCallback = [ + 'datasourceCreate', + (newDatasource: NewDatasource) => Promise +]; + +export type ExternalCallbacksStorage = Map>; + /** * Describes public IngestManager plugin contract returned at the `startup` stage. */ export interface IngestManagerStartContract { esIndexPatternService: ESIndexPatternService; agentService: AgentService; + /** + * Services for Ingest's Datasources + */ + datasourceService: typeof datasourceService; + /** + * Register callbacks for inclusion in ingest API processing + * @param args + */ + registerExternalCallback: (...args: ExternalCallback) => void; } export class IngestManagerPlugin @@ -217,7 +237,7 @@ export class IngestManagerPlugin encryptedSavedObjects: EncryptedSavedObjectsPluginStart; } ) { - appContextService.start({ + await appContextService.start({ encryptedSavedObjectsStart: plugins.encryptedSavedObjects, encryptedSavedObjectsSetup: this.encryptedSavedObjectsSetup, security: this.security, @@ -236,6 +256,11 @@ export class IngestManagerPlugin esIndexPatternService: new ESIndexPatternSavedObjectService(), agentService: { getAgentStatusById, + authenticateAgentWithAccessToken, + }, + datasourceService, + registerExternalCallback: (...args: ExternalCallback) => { + return appContextService.addExternalCallback(...args); }, }; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.test.ts index 84923d5c336642..aaed189ae3dddf 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.test.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.test.ts @@ -77,7 +77,7 @@ describe('test acks handlers', () => { id: 'action1', }, ]), - getAgentByAccessAPIKeyId: jest.fn().mockReturnValueOnce({ + authenticateAgentWithAccessToken: jest.fn().mockReturnValueOnce({ id: 'agent', }), getSavedObjectsClientContract: jest.fn().mockReturnValueOnce(mockSavedObjectsClient), diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts index 83d894295c3126..0b719d8a67df74 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts @@ -9,7 +9,6 @@ import { RequestHandler } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { PostAgentAcksRequestSchema } from '../../types/rest_spec'; -import * as APIKeyService from '../../services/api_keys'; import { AcksService } from '../../services/agents'; import { AgentEvent } from '../../../common/types/models'; import { PostAgentAcksResponse } from '../../../common/types/rest_spec'; @@ -24,8 +23,7 @@ export const postAgentAcksHandlerBuilder = function ( return async (context, request, response) => { try { const soClient = ackService.getSavedObjectsClientContract(request); - const res = APIKeyService.parseApiKeyFromHeaders(request.headers); - const agent = await ackService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId as string); + const agent = await ackService.authenticateAgentWithAccessToken(soClient, request); const agentEvents = request.body.events as AgentEvent[]; // validate that all events are for the authorized agent obtained from the api key diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index 0d1c77b8d697fd..d31498599a2b65 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -171,8 +171,7 @@ export const postAgentCheckinHandler: RequestHandler< > = async (context, request, response) => { try { const soClient = appContextService.getInternalUserSOClient(request); - const res = APIKeyService.parseApiKeyFromHeaders(request.headers); - const agent = await AgentService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId); + const agent = await AgentService.authenticateAgentWithAccessToken(soClient, request); const abortController = new AbortController(); request.events.aborted$.subscribe(() => { abortController.abort(); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 87eee4622c80b6..eaab46c7b455c3 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -109,7 +109,7 @@ export const registerRoutes = (router: IRouter) => { }, postAgentAcksHandlerBuilder({ acknowledgeAgentActions: AgentService.acknowledgeAgentActions, - getAgentByAccessAPIKeyId: AgentService.getAgentByAccessAPIKeyId, + authenticateAgentWithAccessToken: AgentService.authenticateAgentWithAccessToken, getSavedObjectsClientContract: appContextService.getInternalUserSOClient.bind( appContextService ), diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/datasource_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/datasource_handlers.test.ts new file mode 100644 index 00000000000000..07cbeb8b2cec56 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/datasource/datasource_handlers.test.ts @@ -0,0 +1,332 @@ +/* + * 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 { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; +import { IRouter, KibanaRequest, Logger, RequestHandler, RouteConfig } from 'kibana/server'; +import { registerRoutes } from './index'; +import { DATASOURCE_API_ROUTES } from '../../../common/constants'; +import { xpackMocks } from '../../../../../mocks'; +import { appContextService } from '../../services'; +import { createAppContextStartContractMock } from '../../mocks'; +import { DatasourceServiceInterface, ExternalCallback } from '../..'; +import { CreateDatasourceRequestSchema } from '../../types/rest_spec'; +import { datasourceService } from '../../services'; + +const datasourceServiceMock = datasourceService as jest.Mocked; + +jest.mock('../../services/datasource', (): { + datasourceService: jest.Mocked; +} => { + return { + datasourceService: { + assignPackageStream: jest.fn((packageInfo, dataInputs) => Promise.resolve(dataInputs)), + buildDatasourceFromPackage: jest.fn(), + bulkCreate: jest.fn(), + create: jest.fn((soClient, newData) => + Promise.resolve({ + ...newData, + id: '1', + revision: 1, + updated_at: new Date().toISOString(), + updated_by: 'elastic', + created_at: new Date().toISOString(), + created_by: 'elastic', + }) + ), + delete: jest.fn(), + get: jest.fn(), + getByIDs: jest.fn(), + list: jest.fn(), + update: jest.fn(), + }, + }; +}); + +jest.mock('../../services/epm/packages', () => { + return { + ensureInstalledPackage: jest.fn(() => Promise.resolve()), + getPackageInfo: jest.fn(() => Promise.resolve()), + }; +}); + +describe('When calling datasource', () => { + let routerMock: jest.Mocked; + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + let context: ReturnType; + let response: ReturnType; + + beforeAll(() => { + routerMock = httpServiceMock.createRouter(); + registerRoutes(routerMock); + }); + + beforeEach(() => { + appContextService.start(createAppContextStartContractMock()); + context = xpackMocks.createRequestHandlerContext(); + response = httpServerMock.createResponseFactory(); + }); + + afterEach(() => { + jest.clearAllMocks(); + appContextService.stop(); + }); + + describe('create api handler', () => { + const getCreateKibanaRequest = ( + newData?: typeof CreateDatasourceRequestSchema.body + ): KibanaRequest => { + return httpServerMock.createKibanaRequest< + undefined, + undefined, + typeof CreateDatasourceRequestSchema.body + >({ + path: routeConfig.path, + method: 'post', + body: newData || { + name: 'endpoint-1', + description: '', + config_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + enabled: true, + output_id: '', + inputs: [], + namespace: 'default', + package: { name: 'endpoint', title: 'Elastic Endpoint', version: '0.5.0' }, + }, + }); + }; + + // Set the routeConfig and routeHandler to the Create API + beforeAll(() => { + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(DATASOURCE_API_ROUTES.CREATE_PATTERN) + )!; + }); + + describe('and external callbacks are registered', () => { + const callbackCallingOrder: string[] = []; + + // Callback one adds an input that includes a `config` property + const callbackOne: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('one'); + const newDs = { + ...ds, + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + one: { + value: 'inserted by callbackOne', + }, + }, + }, + ], + }; + return newDs; + }); + + // Callback two adds an additional `input[0].config` property + const callbackTwo: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('two'); + const newDs = { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + two: { + value: 'inserted by callbackTwo', + }, + }, + }, + ], + }; + return newDs; + }); + + beforeEach(() => { + appContextService.addExternalCallback('datasourceCreate', callbackOne); + appContextService.addExternalCallback('datasourceCreate', callbackTwo); + }); + + afterEach(() => (callbackCallingOrder.length = 0)); + + it('should call external callbacks in expected order', async () => { + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(callbackCallingOrder).toEqual(['one', 'two']); + }); + + it('should feed datasource returned by last callback', async () => { + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(callbackOne).toHaveBeenCalledWith({ + config_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }); + expect(callbackTwo).toHaveBeenCalledWith({ + config_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + one: { + value: 'inserted by callbackOne', + }, + }, + }, + ], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }); + }); + + it('should create with data from callback', async () => { + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(datasourceServiceMock.create.mock.calls[0][1]).toEqual({ + config_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [ + { + config: { + one: { + value: 'inserted by callbackOne', + }, + two: { + value: 'inserted by callbackTwo', + }, + }, + enabled: true, + streams: [], + type: 'endpoint', + }, + ], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }); + }); + + describe('and a callback throws an exception', () => { + const callbackThree: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('three'); + throw new Error('callbackThree threw error on purpose'); + }); + + const callbackFour: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('four'); + return { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + four: { + value: 'inserted by callbackFour', + }, + }, + }, + ], + }; + }); + + beforeEach(() => { + appContextService.addExternalCallback('datasourceCreate', callbackThree); + appContextService.addExternalCallback('datasourceCreate', callbackFour); + }); + + it('should skip over callback exceptions and still execute other callbacks', async () => { + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(callbackCallingOrder).toEqual(['one', 'two', 'three', 'four']); + }); + + it('should log errors', async () => { + const errorLogger = (appContextService.getLogger() as jest.Mocked).error; + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(errorLogger.mock.calls).toEqual([ + ['An external registered [datasourceCreate] callback failed when executed'], + [new Error('callbackThree threw error on purpose')], + ]); + }); + + it('should create datasource with last successful returned datasource', async () => { + const request = getCreateKibanaRequest(); + await routeHandler(context, request, response); + expect(response.ok).toHaveBeenCalled(); + expect(datasourceServiceMock.create.mock.calls[0][1]).toEqual({ + config_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [ + { + config: { + one: { + value: 'inserted by callbackOne', + }, + two: { + value: 'inserted by callbackTwo', + }, + four: { + value: 'inserted by callbackFour', + }, + }, + enabled: true, + streams: [], + type: 'endpoint', + }, + ], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts index 09daec3370400c..4f83d24a846ea7 100644 --- a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts @@ -14,6 +14,7 @@ import { CreateDatasourceRequestSchema, UpdateDatasourceRequestSchema, DeleteDatasourcesRequestSchema, + NewDatasource, } from '../../types'; import { CreateDatasourceResponse, DeleteDatasourcesResponse } from '../../../common'; @@ -76,23 +77,50 @@ export const createDatasourceHandler: RequestHandler< const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - const newData = { ...request.body }; + const logger = appContextService.getLogger(); + let newData = { ...request.body }; try { + // If we have external callbacks, then process those now before creating the actual datasource + const externalCallbacks = appContextService.getExternalCallbacks('datasourceCreate'); + if (externalCallbacks && externalCallbacks.size > 0) { + let updatedNewData: NewDatasource = newData; + + for (const callback of externalCallbacks) { + try { + // ensure that the returned value by the callback passes schema validation + updatedNewData = CreateDatasourceRequestSchema.body.validate( + await callback(updatedNewData) + ); + } catch (error) { + // Log the error, but keep going and process the other callbacks + logger.error('An external registered [datasourceCreate] callback failed when executed'); + logger.error(error); + } + } + + // The type `NewDatasource` and the `DatasourceBaseSchema` are incompatible. + // `NewDatasrouce` defines `namespace` as optional string, which means that `undefined` is a + // valid value, however, the schema defines it as string with a minimum length of 1. + // Here, we need to cast the value back to the schema type and ignore the TS error. + // @ts-ignore + newData = updatedNewData as typeof CreateDatasourceRequestSchema.body; + } + // Make sure the datasource package is installed - if (request.body.package?.name) { + if (newData.package?.name) { await ensureInstalledPackage({ savedObjectsClient: soClient, - pkgName: request.body.package.name, + pkgName: newData.package.name, callCluster, }); const pkgInfo = await getPackageInfo({ savedObjectsClient: soClient, - pkgName: request.body.package.name, - pkgVersion: request.body.package.version, + pkgName: newData.package.name, + pkgVersion: newData.package.version, }); newData.inputs = (await datasourceService.assignPackageStream( pkgInfo, - request.body.inputs + newData.inputs )) as TypeOf['inputs']; } @@ -103,6 +131,7 @@ export const createDatasourceHandler: RequestHandler< body, }); } catch (e) { + logger.error(e); return response.customError({ statusCode: 500, body: { message: e.message }, diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 98083434173908..1daa63800f4ee5 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; import { outputService, appContextService } from '../../services'; -import { GetFleetStatusResponse } from '../../../common'; +import { GetFleetStatusResponse, PostIngestSetupResponse } from '../../../common'; import { setupIngestManager, setupFleet } from '../../services/setup'; import { PostFleetSetupRequestSchema } from '../../types'; import { IngestManagerError, getHTTPResponseCode } from '../../errors'; @@ -83,9 +83,10 @@ export const ingestManagerSetupHandler: RequestHandler = async (context, request const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const logger = appContextService.getLogger(); try { + const body: PostIngestSetupResponse = { isInitialized: true }; await setupIngestManager(soClient, callCluster); return response.ok({ - body: { isInitialized: true }, + body, }); } catch (e) { if (e instanceof IngestManagerError) { diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 703ddb521c8317..1199c9d198e3a1 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -15,7 +15,7 @@ import { AGENT_EVENT_SAVED_OBJECT_TYPE, AGENT_ACTION_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, - GLOBAL_SETTINGS_SAVED_OBJET_TYPE, + GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, } from '../constants'; import { migrateDatasourcesToV790 } from './migrations/datasources_v790'; import { migrateAgentConfigToV790 } from './migrations/agent_config_v790'; @@ -26,8 +26,8 @@ import { migrateAgentConfigToV790 } from './migrations/agent_config_v790'; */ const savedObjectTypes: { [key: string]: SavedObjectsType } = { - [GLOBAL_SETTINGS_SAVED_OBJET_TYPE]: { - name: GLOBAL_SETTINGS_SAVED_OBJET_TYPE, + [GLOBAL_SETTINGS_SAVED_OBJECT_TYPE]: { + name: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, hidden: false, namespaceType: 'agnostic', management: { @@ -246,7 +246,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { internal: { type: 'boolean' }, removable: { type: 'boolean' }, es_index_patterns: { - dynamic: 'false', + enabled: false, type: 'object', }, installed: { diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts index 81ba9754e8aa48..a1b48a879bb890 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -140,9 +140,9 @@ export interface AcksService { actionIds: AgentEvent[] ) => Promise; - getAgentByAccessAPIKeyId: ( + authenticateAgentWithAccessToken: ( soClient: SavedObjectsClientContract, - accessAPIKeyId: string + request: KibanaRequest ) => Promise; getSavedObjectsClientContract: (kibanaRequest: KibanaRequest) => SavedObjectsClientContract; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/authenticate.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/authenticate.test.ts new file mode 100644 index 00000000000000..b56ca4ca8cc177 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/authenticate.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; +import { savedObjectsClientMock } from 'src/core/server/mocks'; + +import { authenticateAgentWithAccessToken } from './authenticate'; + +describe('test agent autenticate services', () => { + it('should succeed with a valid API key and an active agent', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + mockSavedObjectsClient.find.mockReturnValue( + Promise.resolve({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: 'agent1', + type: 'agent', + references: [], + score: 0, + attributes: { + active: true, + access_api_key_id: 'pedTuHIBTEDt93wW0Fhr', + }, + }, + ], + }) + ); + await authenticateAgentWithAccessToken(mockSavedObjectsClient, { + auth: { isAuthenticated: true }, + headers: { + authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', + }, + } as KibanaRequest); + }); + + it('should throw if the request is not authenticated', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + mockSavedObjectsClient.find.mockReturnValue( + Promise.resolve({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: 'agent1', + type: 'agent', + references: [], + score: 0, + attributes: { + active: true, + access_api_key_id: 'pedTuHIBTEDt93wW0Fhr', + }, + }, + ], + }) + ); + expect( + authenticateAgentWithAccessToken(mockSavedObjectsClient, { + auth: { isAuthenticated: false }, + headers: { + authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', + }, + } as KibanaRequest) + ).rejects.toThrow(/Request not authenticated/); + }); + + it('should throw if the ApiKey headers is malformed', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + mockSavedObjectsClient.find.mockReturnValue( + Promise.resolve({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: 'agent1', + type: 'agent', + references: [], + score: 0, + attributes: { + active: false, + access_api_key_id: 'pedTuHIBTEDt93wW0Fhr', + }, + }, + ], + }) + ); + expect( + authenticateAgentWithAccessToken(mockSavedObjectsClient, { + auth: { isAuthenticated: true }, + headers: { + authorization: 'aaaa', + }, + } as KibanaRequest) + ).rejects.toThrow(/Authorization header is malformed/); + }); + + it('should throw if the agent is not active', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + mockSavedObjectsClient.find.mockReturnValue( + Promise.resolve({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [ + { + id: 'agent1', + type: 'agent', + references: [], + score: 0, + attributes: { + active: false, + access_api_key_id: 'pedTuHIBTEDt93wW0Fhr', + }, + }, + ], + }) + ); + expect( + authenticateAgentWithAccessToken(mockSavedObjectsClient, { + auth: { isAuthenticated: true }, + headers: { + authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', + }, + } as KibanaRequest) + ).rejects.toThrow(/Agent inactive/); + }); + + it('should throw if there is no agent matching the API key', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + mockSavedObjectsClient.find.mockReturnValue( + Promise.resolve({ + page: 1, + per_page: 100, + total: 1, + saved_objects: [], + }) + ); + expect( + authenticateAgentWithAccessToken(mockSavedObjectsClient, { + auth: { isAuthenticated: true }, + headers: { + authorization: 'ApiKey cGVkVHVISUJURUR0OTN3VzBGaHI6TnU1U0JtbHJSeC12Rm9qQWpoSHlUZw==', + }, + } as KibanaRequest) + ).rejects.toThrow(/Agent not found/); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/authenticate.ts b/x-pack/plugins/ingest_manager/server/services/agents/authenticate.ts new file mode 100644 index 00000000000000..2515a02da4e781 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/authenticate.ts @@ -0,0 +1,30 @@ +/* + * 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 Boom from 'boom'; +import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { Agent } from '../../types'; +import * as APIKeyService from '../api_keys'; +import { getAgentByAccessAPIKeyId } from './crud'; + +export async function authenticateAgentWithAccessToken( + soClient: SavedObjectsClientContract, + request: KibanaRequest +): Promise { + if (!request.auth.isAuthenticated) { + throw Boom.unauthorized('Request not authenticated'); + } + let res: { apiKey: string; apiKeyId: string }; + try { + res = APIKeyService.parseApiKeyFromHeaders(request.headers); + } catch (err) { + throw Boom.unauthorized(err.message); + } + + const agent = await getAgentByAccessAPIKeyId(soClient, res.apiKeyId); + + return agent; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index 257091af0ebd0a..400c099af4e936 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -14,3 +14,4 @@ export * from './crud'; export * from './update'; export * from './actions'; export * from './reassign'; +export * from './authenticate'; diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index 5ed6f7c5e54d18..4d109b73d12d90 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -12,7 +12,7 @@ import { } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; -import { IngestManagerAppContext } from '../plugin'; +import { ExternalCallback, ExternalCallbacksStorage, IngestManagerAppContext } from '../plugin'; import { CloudSetup } from '../../../cloud/server'; class AppContextService { @@ -27,6 +27,7 @@ class AppContextService { private cloud?: CloudSetup; private logger: Logger | undefined; private httpSetup?: HttpServiceSetup; + private externalCallbacks: ExternalCallbacksStorage = new Map(); public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjectsStart?.getClient(); @@ -47,7 +48,9 @@ class AppContextService { } } - public stop() {} + public stop() { + this.externalCallbacks.clear(); + } public getEncryptedSavedObjects() { if (!this.encryptedSavedObjects) { @@ -121,6 +124,19 @@ class AppContextService { } return this.kibanaVersion; } + + public addExternalCallback(type: ExternalCallback[0], callback: ExternalCallback[1]) { + if (!this.externalCallbacks.has(type)) { + this.externalCallbacks.set(type, new Set()); + } + this.externalCallbacks.get(type)!.add(callback); + } + + public getExternalCallbacks(type: ExternalCallback[0]) { + if (this.externalCallbacks) { + return this.externalCallbacks.get(type); + } + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index 3ad94ea8191d4d..f3f460d2a74206 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -307,4 +307,5 @@ async function _assignPackageStreamToStream( return { ...stream }; } +export type DatasourceServiceInterface = DatasourceService; export const datasourceService = new DatasourceService(); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 7d5e6d6e883878..a261eec899d7c2 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import { Installation, InstallationStatus, PackageInfo, KibanaAssetType } from '../../../types'; import * as Registry from '../registry'; -import { createInstallableFrom } from './index'; +import { createInstallableFrom, isRequiredPackage } from './index'; export { fetchFile as getFile, SearchParams } from '../registry'; @@ -79,10 +79,7 @@ export async function getPackageInfo(options: { getInstallationObject({ savedObjectsClient, pkgName }), Registry.fetchFindLatestPackage(pkgName), Registry.getArchiveInfo(pkgName, pkgVersion), - ] as const); - // adding `as const` due to regression in TS 3.7.2 - // see https://github.com/microsoft/TypeScript/issues/34925#issuecomment-550021453 - // and https://github.com/microsoft/TypeScript/pull/33707#issuecomment-550718523 + ]); // add properties that aren't (or aren't yet) on Registry response const updated = { @@ -90,6 +87,7 @@ export async function getPackageInfo(options: { latestVersion: latestPackage.version, title: item.title || nameAsTitle(item.name), assets: Registry.groupPathsByService(assets || []), + removable: !isRequiredPackage(pkgName), }; return createInstallableFrom(updated, savedObject); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index d49e0e661440f3..b79f9178ad6af4 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -26,6 +26,16 @@ export { export { installKibanaAssets, installPackage, ensureInstalledPackage } from './install'; export { removeInstallation } from './remove'; +type RequiredPackage = 'system' | 'endpoint'; +const requiredPackages: Record = { + system: true, + endpoint: true, +}; + +export function isRequiredPackage(value: string): value is RequiredPackage { + return value in requiredPackages; +} + export class PackageNotInstalledError extends Error { constructor(pkgkey: string) { super(`${pkgkey} is not installed`); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 736711f9152e9a..910283549abdfc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -19,7 +19,7 @@ import { import { installIndexPatterns } from '../kibana/index_pattern/install'; import * as Registry from '../registry'; import { getObject } from './get_objects'; -import { getInstallation, getInstallationObject } from './index'; +import { getInstallation, getInstallationObject, isRequiredPackage } from './index'; import { installTemplates } from '../elasticsearch/template/install'; import { generateESIndexPatterns } from '../elasticsearch/template/template'; import { installPipelines } from '../elasticsearch/ingest_pipeline/install'; @@ -104,7 +104,8 @@ export async function installPackage(options: { throw Boom.badRequest('Cannot install or update to an out-of-date package'); const reinstall = pkgVersion === installedPkg?.attributes.version; - const { internal = false, removable = true } = registryPackageInfo; + const removable = !isRequiredPackage(pkgName); + const { internal = false } = registryPackageInfo; // delete the previous version's installation's SO kibana assets before installing new ones // in case some assets were removed in the new version diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx index 37d2ea2e2c0696..7ad9aed3c44a46 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -130,13 +130,6 @@ const createActions = (testBed: TestBed) => { }); }, - duplicateProcessor(processorSelector: string) { - find(`${processorSelector}.moreMenu.button`).simulate('click'); - act(() => { - find(`${processorSelector}.moreMenu.duplicateButton`).simulate('click'); - }); - }, - startAndCancelMove(processorSelector: string) { act(() => { find(`${processorSelector}.moveItemButton`).simulate('click'); @@ -147,6 +140,13 @@ const createActions = (testBed: TestBed) => { }); }, + duplicateProcessor(processorSelector: string) { + find(`${processorSelector}.moreMenu.button`).simulate('click'); + act(() => { + find(`${processorSelector}.moreMenu.duplicateButton`).simulate('click'); + }); + }, + toggleOnFailure() { find('pipelineEditorOnFailureToggle').simulate('click'); }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss new file mode 100644 index 00000000000000..8d17a3970d94f8 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss @@ -0,0 +1,2 @@ +$dropZoneZIndex: 1; /* Prevent the next item down from obscuring the button */ +$cancelButtonZIndex: 2; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx index 64364e921a3a11..5bbea4b89b0539 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import classNames from 'classnames'; import React, { FunctionComponent, useState } from 'react'; import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover, EuiButtonIcon } from '@elastic/eui'; @@ -12,6 +13,7 @@ import { editorItemMessages } from './messages'; interface Props { disabled: boolean; + hidden: boolean; showAddOnFailure: boolean; onDuplicate: () => void; onDelete: () => void; @@ -20,9 +22,13 @@ interface Props { } export const ContextMenu: FunctionComponent = (props) => { - const { showAddOnFailure, onDuplicate, onAddOnFailure, onDelete, disabled } = props; + const { showAddOnFailure, onDuplicate, onAddOnFailure, onDelete, disabled, hidden } = props; const [isOpen, setIsOpen] = useState(false); + const containerClasses = classNames({ + 'pipelineProcessorsEditor__item--displayNone': hidden, + }); + const contextMenuItems = [ = (props) => { ].filter(Boolean) as JSX.Element[]; return ( - setIsOpen(false)} - button={ - setIsOpen((v) => !v)} - iconType="boxesHorizontal" - aria-label={editorItemMessages.moreButtonAriaLabel} - /> - } - > - - +
+ setIsOpen(false)} + button={ + setIsOpen((v) => !v)} + iconType="boxesHorizontal" + aria-label={editorItemMessages.moreButtonAriaLabel} + /> + } + > + + +
); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index 691d90b23efefe..00ac8d4f6d729a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -25,33 +25,17 @@ export const InlineTextInput: FunctionComponent = ({ const [isShowingTextInput, setIsShowingTextInput] = useState(false); const [textValue, setTextValue] = useState(text ?? ''); - const containerClasses = classNames({ + const containerClasses = classNames('pipelineProcessorsEditor__item__textContainer', { 'pipelineProcessorsEditor__item__textContainer--notEditing': !isShowingTextInput && !disabled, }); - const content = - isShowingTextInput && !disabled ? ( - el?.focus()} - onChange={(event) => setTextValue(event.target.value)} - /> - ) : ( - -
- {text || {placeholder}} -
-
- ); - const submitChange = useCallback(() => { - setIsShowingTextInput(false); - onChange(textValue); + // Give any on blur handlers the chance to complete if the user is + // tabbing over this component. + setTimeout(() => { + setIsShowingTextInput(false); + onChange(textValue); + }); }, [setIsShowingTextInput, onChange, textValue]); useEffect(() => { @@ -71,14 +55,27 @@ export const InlineTextInput: FunctionComponent = ({ }; }, [isShowingTextInput, submitChange, setIsShowingTextInput]); - return ( -
setIsShowingTextInput(true)} - onBlur={submitChange} - > - {content} + return isShowingTextInput && !disabled ? ( +
+ el?.focus()} + onChange={(event) => setTextValue(event.target.value)} + /> +
+ ) : ( +
setIsShowingTextInput(true)}> + +
+ {text || {placeholder}} +
+
); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts index 67dbf2708d6656..913902d2955030 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts @@ -10,12 +10,9 @@ export const editorItemMessages = { moveButtonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.item.moveButtonLabel', { defaultMessage: 'Move this processor', }), - editorButtonLabel: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.item.editButtonAriaLabel', - { - defaultMessage: 'Edit this processor', - } - ), + editButtonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.item.editButtonAriaLabel', { + defaultMessage: 'Edit this processor', + }), duplicateButtonLabel: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.duplicateButtonLabel', { @@ -31,7 +28,7 @@ export const editorItemMessages = { cancelMoveButtonLabel: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.item.cancelMoveButtonAriaLabel', { - defaultMessage: 'Cancel moving this processor', + defaultMessage: 'Cancel move', } ), deleteButtonLabel: i18n.translate( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss index 1e97191c3e6628..6b5e1180846063 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss @@ -1,3 +1,5 @@ +@import '../shared'; + .pipelineProcessorsEditor__item { transition: border-color 1s; min-height: 50px; @@ -5,11 +7,23 @@ border: 1px solid $euiColorPrimary; } + &--displayNone { + display: none; + } + &--dimmed { - background-color: $euiColorLightestShade; box-shadow: none; } + // Remove the box-shadow on all nested items + .pipelineProcessorsEditor__item { + box-shadow: none !important; + } + + &__processorTypeLabel { + line-height: $euiButtonHeightSmall; + } + &__textContainer { padding: 4px; border-radius: 2px; @@ -35,4 +49,9 @@ height: 21px; min-width: 150px; } + + &__cancelMoveButton { + // Ensure that the cancel button is above the drop zones + z-index: $cancelButtonZIndex; + } } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index 382a53384fe9db..0fe804adaeb48e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import React, { FunctionComponent, memo } from 'react'; import { EuiButtonIcon, + EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, @@ -54,21 +55,35 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( state: { editor, processorsDispatch }, } = usePipelineProcessorsContext(); - const disabled = editor.mode.id !== 'idle'; - const selected = processor.id === movingProcessor?.id; - const isBeingEdited = + const isDisabled = editor.mode.id !== 'idle'; + const isInMoveMode = Boolean(movingProcessor); + const isMovingThisProcessor = processor.id === movingProcessor?.id; + const isEditingThisProcessor = editor.mode.id === 'editingProcessor' && processor.id === editor.mode.arg.processor.id; - const isDimmed = - (editor.mode.id === 'editingProcessor' && !isBeingEdited) || - (editor.mode.id === 'movingProcessor' && !selected); + const isEditingOtherProcessor = + editor.mode.id === 'editingProcessor' && !isEditingThisProcessor; + const isMovingOtherProcessor = editor.mode.id === 'movingProcessor' && !isMovingThisProcessor; + const isDimmed = isEditingOtherProcessor || isMovingOtherProcessor; - const panelClasses = classNames({ - 'pipelineProcessorsEditor__item--selected': selected || isBeingEdited, + const panelClasses = classNames('pipelineProcessorsEditor__item', { + 'pipelineProcessorsEditor__item--selected': isMovingThisProcessor || isEditingThisProcessor, 'pipelineProcessorsEditor__item--dimmed': isDimmed, }); + const actionElementClasses = classNames({ + 'pipelineProcessorsEditor__item--displayNone': isInMoveMode, + }); + + const inlineTextInputContainerClasses = classNames({ + 'pipelineProcessorsEditor__item--displayNone': isInMoveMode && !processor.options.description, + }); + + const cancelMoveButtonClasses = classNames('pipelineProcessorsEditor__item__cancelMoveButton', { + 'pipelineProcessorsEditor__item--displayNone': !isMovingThisProcessor, + }); + return ( - + = memo( - + {processor.type} - - { - editor.setMode({ - id: 'editingProcessor', - arg: { processor, selector }, - }); - }} - /> - - - {selected ? ( - - ) : ( - - - - )} - - + { let nextOptions: Record; if (!nextDescription) { @@ -149,12 +131,50 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( placeholder={editorItemMessages.descriptionPlaceholder} /> + + {!isInMoveMode && ( + + { + editor.setMode({ + id: 'editingProcessor', + arg: { processor, selector }, + }); + }} + /> + + )} + + + {!isInMoveMode && ( + + + + )} + + + + {editorItemMessages.cancelMoveButtonLabel} + +
); - return isVisible ? ( + return isUnavailable ? ( {content} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss index 87f03355424cd0..2feb71f21a4f5e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss @@ -1,4 +1,5 @@ @import '@elastic/eui/src/global_styling/variables/size'; +@import '../shared'; .pipelineProcessorsEditor__tree { &__container { @@ -30,16 +31,18 @@ pointer-events: none; } } + $dropZoneButtonHeight: 60px; + $dropZoneButtonOffsetY: $dropZoneButtonHeight * -0.5; &__dropZoneButton { position: absolute; padding: 0; - height: 60px; + height: $dropZoneButtonHeight; + margin-top: $dropZoneButtonOffsetY; width: 100%; - margin-top: -30px; opacity: 0; text-decoration: none !important; - z-index: 1; /* Prevent the next item down from obscuring the button */ + z-index: $dropZoneZIndex; &--visible { pointer-events: visible !important; diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 53498a8e5afa1e..cd6fbf96d67500 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -124,12 +124,7 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: ( - id?: string, - returnToOrigin?: boolean, - originatingApp?: string | undefined, - newlyCreated?: boolean - ) => void; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; originatingApp: string | undefined; onAppLeave: AppMountParameters['onAppLeave']; history: History; @@ -168,14 +163,7 @@ describe('Lens App', () => { load: jest.fn(), save: jest.fn(), }, - redirectTo: jest.fn( - ( - id?: string, - returnToOrigin?: boolean, - originatingApp?: string | undefined, - newlyCreated?: boolean - ) => {} - ), + redirectTo: jest.fn((id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => {}), onAppLeave: jest.fn(), history: createMemoryHistory(), } as unknown) as jest.Mocked<{ @@ -186,12 +174,7 @@ describe('Lens App', () => { storage: Storage; docId?: string; docStorage: SavedObjectStore; - redirectTo: ( - id?: string, - returnToOrigin?: boolean, - originatingApp?: string | undefined, - newlyCreated?: boolean - ) => void; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; originatingApp: string | undefined; onAppLeave: AppMountParameters['onAppLeave']; history: History; @@ -533,7 +516,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, undefined, true); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -553,7 +536,7 @@ describe('Lens App', () => { expression: 'kibana 3', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, undefined, true); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', undefined, true); inst.setProps({ docId: 'aaa' }); @@ -621,7 +604,7 @@ describe('Lens App', () => { title: 'hello there', }); - expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, undefined, true); + expect(args.redirectTo).toHaveBeenCalledWith('aaa', true, true); }); it('saves app filters and does not save pinned filters', async () => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index fc8d5dd9eb3951..0ab547bed6d37c 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -43,7 +43,6 @@ interface State { isLoading: boolean; isSaveModalVisible: boolean; indexPatternsForTopNav: IndexPatternInstance[]; - originatingApp: string | undefined; persistedDoc?: Document; lastKnownDoc?: Document; @@ -65,7 +64,7 @@ export function App({ docId, docStorage, redirectTo, - originatingAppFromUrl, + originatingApp, navigation, onAppLeave, history, @@ -77,13 +76,8 @@ export function App({ storage: IStorageWrapper; docId?: string; docStorage: SavedObjectStore; - redirectTo: ( - id?: string, - returnToOrigin?: boolean, - originatingApp?: string | undefined, - newlyCreated?: boolean - ) => void; - originatingAppFromUrl?: string | undefined; + redirectTo: (id?: string, returnToOrigin?: boolean, newlyCreated?: boolean) => void; + originatingApp?: string | undefined; onAppLeave: AppMountParameters['onAppLeave']; history: History; }) { @@ -98,7 +92,6 @@ export function App({ isSaveModalVisible: false, indexPatternsForTopNav: [], query: { query: '', language }, - originatingApp: originatingAppFromUrl, dateRange: { fromDate: currentRange.from, toDate: currentRange.to, @@ -316,7 +309,7 @@ export function App({ lastKnownDoc: newDoc, })); if (docId !== id || saveProps.returnToOrigin) { - redirectTo(id, saveProps.returnToOrigin, state.originatingApp, newlyCreated); + redirectTo(id, saveProps.returnToOrigin, newlyCreated); } }) .catch((e) => { @@ -356,7 +349,7 @@ export function App({
{ if (isSaveable && lastKnownDoc) { setState((s) => ({ ...s, isSaveModalVisible: true })); @@ -509,7 +502,7 @@ export function App({
{lastKnownDoc && state.isSaveModalVisible && ( runSave(props)} onClose={() => setState((s) => ({ ...s, isSaveModalVisible: false }))} documentInfo={{ diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index e6a9119ad43068..7a33241792a58f 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -10,9 +10,8 @@ import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { parse } from 'query-string'; -import { removeQueryParam, Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry'; @@ -29,7 +28,7 @@ export async function mountApp( createEditorFrame: EditorFrameStart['createInstance'] ) { const [coreStart, startDependencies] = await core.getStartServices(); - const { data: dataStart, navigation } = startDependencies; + const { data: dataStart, navigation, embeddable } = startDependencies; const savedObjectsClient = coreStart.savedObjects.client; addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks); @@ -37,6 +36,10 @@ export async function mountApp( i18n.translate('xpack.lens.pageTitle', { defaultMessage: 'Lens' }) ); + const stateTransfer = embeddable?.getStateTransfer(params.history); + const { originatingApp } = + stateTransfer?.getIncomingOriginatingApp({ keysToRemoveAfterFetch: ['originatingApp'] }) || {}; + const instance = await createEditorFrame(); setReportManager( @@ -49,7 +52,6 @@ export async function mountApp( routeProps: RouteComponentProps<{ id?: string }>, id?: string, returnToOrigin?: boolean, - originatingApp?: string, newlyCreated?: boolean ) => { if (!id) { @@ -59,11 +61,9 @@ export async function mountApp( } else if (!!originatingApp && id && returnToOrigin) { routeProps.history.push(`/edit/${id}`); - if (originatingApp === 'dashboards') { - const addLensId = newlyCreated ? id : ''; - startDependencies.dashboard.addEmbeddableToDashboard({ - embeddableId: addLensId, - embeddableType: LENS_EMBEDDABLE_TYPE, + if (newlyCreated && stateTransfer) { + stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { + state: { id, type: LENS_EMBEDDABLE_TYPE }, }); } else { coreStart.application.navigateToApp(originatingApp); @@ -73,11 +73,6 @@ export async function mountApp( const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { trackUiEvent('loaded'); - const urlParams = parse(routeProps.location.search) as Record; - const originatingAppFromUrl = urlParams.embeddableOriginatingApp; - if (urlParams.embeddableOriginatingApp) { - removeQueryParam(routeProps.history, 'embeddableOriginatingApp'); - } return ( - redirectTo(routeProps, id, returnToOrigin, originatingApp, newlyCreated) + redirectTo={(id, returnToOrigin, newlyCreated) => + redirectTo(routeProps, id, returnToOrigin, newlyCreated) } - originatingAppFromUrl={originatingAppFromUrl} + originatingApp={originatingApp} onAppLeave={params.onAppLeave} history={routeProps.history} /> diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 3000c9321b3b9e..25458d86f0e12e 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -6,7 +6,7 @@ import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; -import { EmbeddableSetup } from 'src/plugins/embeddable/public'; +import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public'; import { VisualizationsSetup } from 'src/plugins/visualizations/public'; import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; @@ -26,7 +26,6 @@ import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import './index.scss'; -import { DashboardStart } from '../../../../src/plugins/dashboard/public'; export interface LensPluginSetupDependencies { kibanaLegacy: KibanaLegacySetup; @@ -41,7 +40,7 @@ export interface LensPluginStartDependencies { expressions: ExpressionsStart; navigation: NavigationPublicPluginStart; uiActions: UiActionsStart; - dashboard: DashboardStart; + embeddable: EmbeddableStart; } export class LensPlugin { diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts index 29bff402939581..4942d21f64ee22 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts @@ -5,9 +5,19 @@ */ import { BehaviorSubject } from 'rxjs'; -import { licenseMock } from '../common/licensing.mock'; +import { licenseMock } from '../common/licensing.mock'; import { createRouteHandlerContext } from './licensing_route_handler_context'; +import { featureUsageMock } from './services/feature_usage_service.mock'; +import { FeatureUsageServiceStart } from './services'; +import { StartServicesAccessor } from 'src/core/server'; +import { LicensingPluginStart } from './types'; + +const createStartServices = ( + featureUsage: FeatureUsageServiceStart = featureUsageMock.createStart() +): StartServicesAccessor<{}, LicensingPluginStart> => { + return async () => [{} as any, {}, { featureUsage } as LicensingPluginStart]; +}; describe('createRouteHandlerContext', () => { it('returns a function providing the last license value', async () => { @@ -15,7 +25,7 @@ describe('createRouteHandlerContext', () => { const secondLicense = licenseMock.createLicense(); const license$ = new BehaviorSubject(firstLicense); - const routeHandler = createRouteHandlerContext(license$); + const routeHandler = createRouteHandlerContext(license$, createStartServices()); const firstCtx = await routeHandler({} as any, {} as any, {} as any); license$.next(secondLicense); @@ -24,4 +34,14 @@ describe('createRouteHandlerContext', () => { expect(firstCtx.license).toBe(firstLicense); expect(secondCtx.license).toBe(secondLicense); }); + + it('returns a the feature usage API', async () => { + const license$ = new BehaviorSubject(licenseMock.createLicense()); + const featureUsage = featureUsageMock.createStart(); + + const routeHandler = createRouteHandlerContext(license$, createStartServices(featureUsage)); + const ctx = await routeHandler({} as any, {} as any, {} as any); + + expect(ctx.featureUsage).toBe(featureUsage); + }); }); diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts index 42cb0959fc373a..736a2151a3dbd9 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IContextProvider, RequestHandler } from 'src/core/server'; +import { IContextProvider, RequestHandler, StartServicesAccessor } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { ILicense } from '../common/types'; +import { LicensingPluginStart } from './types'; /** * Create a route handler context for access to Kibana license information. @@ -16,9 +17,16 @@ import { ILicense } from '../common/types'; * @public */ export function createRouteHandlerContext( - license$: Observable + license$: Observable, + getStartServices: StartServicesAccessor<{}, LicensingPluginStart> ): IContextProvider, 'licensing'> { return async function licensingRouteHandlerContext() { - return { license: await license$.pipe(take(1)).toPromise() }; + const [, , { featureUsage }] = await getStartServices(); + const license = await license$.pipe(take(1)).toPromise(); + + return { + featureUsage, + license, + }; }; } diff --git a/x-pack/plugins/licensing/server/mocks.ts b/x-pack/plugins/licensing/server/mocks.ts index 0d154f76d51345..1a2b543b47df54 100644 --- a/x-pack/plugins/licensing/server/mocks.ts +++ b/x-pack/plugins/licensing/server/mocks.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { BehaviorSubject } from 'rxjs'; -import { LicensingPluginSetup, LicensingPluginStart } from './types'; +import { + LicensingPluginSetup, + LicensingPluginStart, + LicensingRequestHandlerContext, +} from './types'; import { licenseMock } from '../common/licensing.mock'; import { featureUsageMock } from './services/feature_usage_service.mock'; @@ -43,8 +47,20 @@ const createStartMock = (): jest.Mocked => { return mock; }; +const createRequestHandlerContextMock = ( + ...options: Parameters +): jest.Mocked => { + const mock: jest.Mocked = { + license: licenseMock.createLicense(...options), + featureUsage: featureUsageMock.createStart(), + }; + + return mock; +}; + export const licensingMock = { createSetup: createSetupMock, createStart: createStartMock, + createRequestHandlerContext: createRequestHandlerContextMock, ...licenseMock, }; diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 3e31eae9453833..9d76472b51cd26 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -12,7 +12,7 @@ import { LicensingPlugin } from './plugin'; import { coreMock, elasticsearchServiceMock, - loggingServiceMock, + loggingSystemMock, } from '../../../../src/core/server/mocks'; import { IClusterClient } from '../../../../src/core/server/'; @@ -173,7 +173,7 @@ describe('licensing plugin', () => { await flushPromises(); - const loggedMessages = loggingServiceMock.collect(pluginInitContextMock.logger).debug; + const loggedMessages = loggingSystemMock.collect(pluginInitContextMock.logger).debug; expect( loggedMessages.some(([message]) => diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index e1aa4a1b32517e..0a6964b1b829d1 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -128,7 +128,10 @@ export class LicensingPlugin implements Plugin, 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', () => { diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.test.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.test.ts new file mode 100644 index 00000000000000..152f85233aa1a4 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.test.ts @@ -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({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts index c98cb8d2bba72c..8f8f8d105b6241 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -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; diff --git a/x-pack/plugins/lists/common/schemas/types/entries.mock.ts b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts index 1926cb09db119a..8af18c970c6ae8 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts @@ -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, }); diff --git a/x-pack/plugins/lists/common/schemas/types/entries.test.ts b/x-pack/plugins/lists/common/schemas/types/entries.test.ts index a13d4c0347e455..01f82f12f2b2c7 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.test.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.test.ts @@ -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 & { value: string } = { + test('it should not validate when "list" is not expected value', () => { + const payload: Omit & { 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 & { + 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; diff --git a/x-pack/plugins/lists/common/schemas/types/entries.ts b/x-pack/plugins/lists/common/schemas/types/entries.ts index e3625dbe083346..c379f77b862c8e 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.ts @@ -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; 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; @@ -52,7 +52,7 @@ export type EntryExists = t.TypeOf; 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 }), }) diff --git a/x-pack/plugins/lists/common/schemas/types/index.ts b/x-pack/plugins/lists/common/schemas/types/index.ts index 8e4b28b31d95cc..97f2b0f59a5fdb 100644 --- a/x-pack/plugins/lists/common/schemas/types/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/index.ts @@ -5,5 +5,6 @@ */ export * from './default_comments_array'; export * from './default_entries_array'; +export * from './default_namespace'; export * from './comments'; export * from './entries'; diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts index 33f58ba65d3c36..31f22108028a68 100644 --- a/x-pack/plugins/lists/server/index.ts +++ b/x-pack/plugins/lists/server/index.ts @@ -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 }; diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 10f9b1f4383f5f..57bc63e6f7e358 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -105,6 +105,16 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { field: { type: 'keyword', }, + list: { + properties: { + id: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + }, + }, operator: { type: 'keyword', }, diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json new file mode 100644 index 00000000000000..e1dab72c1c7f6f --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json @@ -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" } + } + ] +} diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json index 1516fa5057e50e..1ece2268f3cf64 100644 --- a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json +++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json @@ -1,5 +1,5 @@ { "id": "hand_inserted_item_id", "list_id": "list-ip", - "value": "127.0.0.1" + "value": "10.4.2.140" } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index be3de22fa011e8..f7374ba91f8fec 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -25,16 +25,18 @@ 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 = 'maps-telemetry'; -export const MAP_APP_PATH = `app/${APP_ID}`; +export const MAPS_APP_PATH = `app/${APP_ID}`; +export const MAP_PATH = 'map'; export const GIS_API_PATH = `api/${APP_ID}`; export const INDEX_SETTINGS_API_PATH = `${GIS_API_PATH}/indexSettings`; export const FONTS_API_PATH = `${GIS_API_PATH}/fonts`; -export const MAP_BASE_URL = `/${MAP_APP_PATH}/${MAP_SAVED_OBJECT_TYPE}`; - -export function createMapPath(id: string) { +const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`; +export function getNewMapPath() { + return MAP_BASE_URL; +} +export function getExistingMapPath(id: string) { return `${MAP_BASE_URL}/${id}`; } @@ -233,3 +235,15 @@ export enum LAYER_WIZARD_CATEGORY { REFERENCE = 'REFERENCE', SOLUTIONS = 'SOLUTIONS', } + +export enum VECTOR_SHAPE_TYPE { + POINT = 'POINT', + LINE = 'LINE', + POLYGON = 'POLYGON', +} + +// https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/#data-expressions +export enum MB_LOOKUP_FUNCTION { + GET = 'get', + FEATURE_STATE = 'feature-state', +} diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx index f7fb0078764c43..f55a7434d12173 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx @@ -11,8 +11,7 @@ import { Adapters } from 'src/plugins/inspector/public'; import { FileLayer } from '@elastic/ems-client'; import { Attribution, ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { AbstractVectorSource, GeoJsonWithMeta, IVectorSource } from '../vector_source'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; -import { SOURCE_TYPES, FIELD_ORIGIN } from '../../../../common/constants'; +import { SOURCE_TYPES, FIELD_ORIGIN, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getEmsFileLayers } from '../../../meta'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { UpdateSourceEditor } from './update_source_editor'; @@ -179,8 +178,8 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc return Promise.all(promises); } - async getSupportedShapeTypes(): Promise { - return [VECTOR_SHAPE_TYPES.POLYGON]; + async getSupportedShapeTypes(): Promise { + return [VECTOR_SHAPE_TYPE.POLYGON]; } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index c05c1f2dd7c1e6..b613f577067bac 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -7,7 +7,6 @@ import React from 'react'; import uuid from 'uuid/v4'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { convertCompositeRespToGeoJson, convertRegularRespToGeoJson } from './convert_to_geojson'; import { UpdateSourceEditor } from './update_source_editor'; import { @@ -15,6 +14,7 @@ import { DEFAULT_MAX_BUCKETS_LIMIT, RENDER_AS, GRID_RESOLUTION, + VECTOR_SHAPE_TYPE, } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -326,10 +326,10 @@ export class ESGeoGridSource extends AbstractESAggSource { async getSupportedShapeTypes() { if (this._descriptor.requestType === RENDER_AS.GRID) { - return [VECTOR_SHAPE_TYPES.POLYGON]; + return [VECTOR_SHAPE_TYPE.POLYGON]; } - return [VECTOR_SHAPE_TYPES.POINT]; + return [VECTOR_SHAPE_TYPE.POINT]; } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index fda73bc0f73a04..076e7a758a4fbd 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -7,10 +7,9 @@ import React from 'react'; import uuid from 'uuid/v4'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; -import { SOURCE_TYPES } from '../../../../common/constants'; +import { SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; @@ -61,7 +60,7 @@ export class ESPewPewSource extends AbstractESAggSource { } async getSupportedShapeTypes() { - return [VECTOR_SHAPE_TYPES.LINE]; + return [VECTOR_SHAPE_TYPE.LINE]; } async getImmutableProperties() { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index 51dd57ffad0d15..c8f14f1dc6a4b6 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -7,7 +7,6 @@ import _ from 'lodash'; import React from 'react'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { AbstractESSource } from '../es_source'; import { getSearchService } from '../../../kibana_services'; import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; @@ -18,6 +17,7 @@ import { DEFAULT_MAX_BUCKETS_LIMIT, SORT_ORDER, SCALING_TYPES, + VECTOR_SHAPE_TYPE, } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; @@ -471,10 +471,10 @@ export class ESSearchSource extends AbstractESSource { } if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { - return [VECTOR_SHAPE_TYPES.POINT]; + return [VECTOR_SHAPE_TYPE.POINT]; } - return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + return [VECTOR_SHAPE_TYPE.POINT, VECTOR_SHAPE_TYPE.LINE, VECTOR_SHAPE_TYPE.POLYGON]; } getSourceTooltipContent(sourceDataRequest) { diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts index 86a1589a7a0305..03b91df22d3cab 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts @@ -8,8 +8,7 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import { AbstractSource, ImmutableSourceProperty } from '../source'; import { BoundsFilters, GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; -import { MAX_ZOOM, MIN_ZOOM, SOURCE_TYPES } from '../../../../common/constants'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { MAX_ZOOM, MIN_ZOOM, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { IField } from '../../fields/field'; import { registerSource } from '../source_registry'; import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; @@ -116,8 +115,8 @@ export class MVTSingleLayerVectorSource extends AbstractSource }; } - async getSupportedShapeTypes(): Promise { - return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + async getSupportedShapeTypes(): Promise { + return [VECTOR_SHAPE_TYPE.POINT, VECTOR_SHAPE_TYPE.LINE, VECTOR_SHAPE_TYPE.POLYGON]; } canFormatFeatureProperties() { diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts index 711b7d600d74d9..99a7478cd83626 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts @@ -16,7 +16,7 @@ import { VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; -import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; @@ -68,7 +68,7 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc getFields(): Promise; getFieldByName(fieldName: string): IField | null; getSyncMeta(): VectorSourceSyncMeta; - getSupportedShapeTypes(): Promise; + getSupportedShapeTypes(): Promise; canFormatFeatureProperties(): boolean; getApplyGlobalQuery(): boolean; getFieldNames(): string[]; diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js index ccf6c7963c9b4e..ecb13bb875721d 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js @@ -9,7 +9,7 @@ import { AbstractSource } from './../source'; import * as topojson from 'topojson-client'; import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { VECTOR_SHAPE_TYPES } from './../vector_feature_types'; +import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; export class AbstractVectorSource extends AbstractSource { static async getGeoJson({ format, featureCollectionPath, fetchUrl }) { @@ -127,7 +127,7 @@ export class AbstractVectorSource extends AbstractSource { } async getSupportedShapeTypes() { - return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + return [VECTOR_SHAPE_TYPE.POINT, VECTOR_SHAPE_TYPE.LINE, VECTOR_SHAPE_TYPE.POLYGON]; } getSourceTooltipContent(/* sourceDataRequest */) { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js index 3424a972fed062..7856a4ddaff395 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js @@ -16,7 +16,6 @@ import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_bor import { OrientationEditor } from './orientation/orientation_editor'; import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; -import { VECTOR_SHAPE_TYPES } from '../../../sources/vector_feature_types'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui'; @@ -26,6 +25,7 @@ import { LABEL_BORDER_SIZES, VECTOR_STYLES, STYLE_TYPE, + VECTOR_SHAPE_TYPE, } from '../../../../../common/constants'; export class VectorStyleEditor extends Component { @@ -96,11 +96,11 @@ export class VectorStyleEditor extends Component { } if (this.state.selectedFeature === null) { - let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; + let selectedFeature = VECTOR_SHAPE_TYPE.POLYGON; if (this.props.isPointsOnly) { - selectedFeature = VECTOR_SHAPE_TYPES.POINT; + selectedFeature = VECTOR_SHAPE_TYPE.POINT; } else if (this.props.isLinesOnly) { - selectedFeature = VECTOR_SHAPE_TYPES.LINE; + selectedFeature = VECTOR_SHAPE_TYPE.LINE; } this.setState({ selectedFeature: selectedFeature, @@ -414,30 +414,30 @@ export class VectorStyleEditor extends Component { if (supportedFeatures.length === 1) { switch (supportedFeatures[0]) { - case VECTOR_SHAPE_TYPES.POINT: + case VECTOR_SHAPE_TYPE.POINT: return this._renderPointProperties(); - case VECTOR_SHAPE_TYPES.LINE: + case VECTOR_SHAPE_TYPE.LINE: return this._renderLineProperties(); - case VECTOR_SHAPE_TYPES.POLYGON: + case VECTOR_SHAPE_TYPE.POLYGON: return this._renderPolygonProperties(); } } const featureButtons = [ { - id: VECTOR_SHAPE_TYPES.POINT, + id: VECTOR_SHAPE_TYPE.POINT, label: i18n.translate('xpack.maps.vectorStyleEditor.pointLabel', { defaultMessage: 'Points', }), }, { - id: VECTOR_SHAPE_TYPES.LINE, + id: VECTOR_SHAPE_TYPE.LINE, label: i18n.translate('xpack.maps.vectorStyleEditor.lineLabel', { defaultMessage: 'Lines', }), }, { - id: VECTOR_SHAPE_TYPES.POLYGON, + id: VECTOR_SHAPE_TYPE.POLYGON, label: i18n.translate('xpack.maps.vectorStyleEditor.polygonLabel', { defaultMessage: 'Polygons', }), @@ -445,9 +445,9 @@ export class VectorStyleEditor extends Component { ]; let styleProperties = this._renderPolygonProperties(); - if (selectedFeature === VECTOR_SHAPE_TYPES.LINE) { + if (selectedFeature === VECTOR_SHAPE_TYPE.LINE) { styleProperties = this._renderLineProperties(); - } else if (selectedFeature === VECTOR_SHAPE_TYPES.POINT) { + } else if (selectedFeature === VECTOR_SHAPE_TYPE.POINT) { styleProperties = this._renderPointProperties(); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap index b4843324a0def0..631a6117a111db 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Should render categorical legend with breaks 1`] = ` +exports[`renderLegendDetailRow Should render categorical legend with breaks 1`] = `
+ + + Other + + } + styleName="icon" + symbolId="square" + /> +
`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.js index 4c02dee762e9da..556bb2b79e836e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.js @@ -13,7 +13,7 @@ import { GRADIENT_INTERVALS, } from '../../color_utils'; import React from 'react'; -import { COLOR_MAP_TYPE } from '../../../../../common/constants'; +import { COLOR_MAP_TYPE, MB_LOOKUP_FUNCTION } from '../../../../../common/constants'; import { isCategoricalStopsInvalid, getOtherCategoryLabel, @@ -152,7 +152,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { makeMbClampedNumberExpression({ minValue: rangeFieldMeta.min, maxValue: rangeFieldMeta.max, - lookupFunction: 'feature-state', + lookupFunction: MB_LOOKUP_FUNCTION.FEATURE_STATE, fallback: lessThanFirstStopValue, fieldName: targetName, }), diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.js index c7620512710dc5..665317569e5e8a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.js @@ -23,7 +23,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { getNumberOfCategories() { const palette = getIconPalette(this._options.iconPaletteId); - return palette ? palette.length : 0; + return palette.length; } syncIconWithMb(symbolLayerId, mbMap, iconPixelSize) { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx index 505c08ac35ba7e..132c0b3f276034 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.test.tsx @@ -34,8 +34,8 @@ const makeProperty = (options: Partial, field: IField = mock ); }; -describe('DynamicIconProperty', () => { - it('should derive category number from palettes', async () => { +describe('getNumberOfCategories', () => { + test('should derive category number from palettes', async () => { const filled = makeProperty({ iconPaletteId: 'filledShapes', }); @@ -47,15 +47,53 @@ describe('DynamicIconProperty', () => { }); }); -test('Should render categorical legend with breaks', async () => { - const iconStyle = makeProperty({ - iconPaletteId: 'filledShapes', +describe('renderLegendDetailRow', () => { + test('Should render categorical legend with breaks', async () => { + const iconStyle = makeProperty({ + iconPaletteId: 'filledShapes', + }); + + const legendRow = iconStyle.renderLegendDetailRow({ isPointsOnly: true, isLinesOnly: false }); + const component = shallow(legendRow); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + + expect(component).toMatchSnapshot(); }); +}); - const legendRow = iconStyle.renderLegendDetailRow({ isPointsOnly: true, isLinesOnly: false }); - const component = shallow(legendRow); - await new Promise((resolve) => process.nextTick(resolve)); - component.update(); +describe('get mapbox icon-image expression (via internal _getMbIconImageExpression)', () => { + describe('categorical icon palette', () => { + test('should return mapbox expression for pre-defined icon palette', async () => { + const iconStyle = makeProperty({ + iconPaletteId: 'filledShapes', + }); + expect(iconStyle._getMbIconImageExpression(15)).toEqual([ + 'match', + ['to-string', ['get', 'foobar']], + 'US', + 'circle-15', + 'CN', + 'marker-15', + 'square-15', + ]); + }); - expect(component).toMatchSnapshot(); + test('should return mapbox expression for custom icon palette', async () => { + const iconStyle = makeProperty({ + useCustomIconMap: true, + customIconStops: [ + { stop: null, icon: 'circle' }, + { stop: 'MX', icon: 'marker' }, + ], + }); + expect(iconStyle._getMbIconImageExpression(15)).toEqual([ + 'match', + ['to-string', ['get', 'foobar']], + 'MX', + 'marker-15', + 'circle-15', + ]); + }); + }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.js index a0af2fbb939d85..662d1ccf33b95d 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.js @@ -12,7 +12,7 @@ import { LARGE_MAKI_ICON_SIZE, SMALL_MAKI_ICON_SIZE, } from '../symbol_utils'; -import { VECTOR_STYLES } from '../../../../../common/constants'; +import { MB_LOOKUP_FUNCTION, VECTOR_STYLES } from '../../../../../common/constants'; import _ from 'lodash'; import React from 'react'; @@ -60,7 +60,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { minValue: rangeFieldMeta.min, maxValue: rangeFieldMeta.max, fallback: 0, - lookupFunction: 'get', + lookupFunction: MB_LOOKUP_FUNCTION.GET, fieldName: targetName, }), rangeFieldMeta.min, @@ -109,7 +109,9 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } _getMbDataDrivenSize({ targetName, minSize, maxSize, minValue, maxValue }) { - const lookup = this.supportsMbFeatureState() ? 'feature-state' : 'get'; + const lookup = this.supportsMbFeatureState() + ? MB_LOOKUP_FUNCTION.FEATURE_STATE + : MB_LOOKUP_FUNCTION.GET; const stops = minValue === maxValue ? [maxValue, maxSize] : [minValue, minSize, maxValue, maxSize]; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.js b/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts similarity index 60% rename from x-pack/plugins/maps/public/classes/styles/vector/style_util.test.js rename to x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts index eb4c6708fb2dd9..6c1f060383d052 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts @@ -5,58 +5,67 @@ */ import { isOnlySingleFeatureType, assignCategoriesToPalette, dynamicRound } from './style_util'; -import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; +import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; describe('isOnlySingleFeatureType', () => { describe('source supports single feature type', () => { - const supportedFeatures = [VECTOR_SHAPE_TYPES.POINT]; + const supportedFeatures = [VECTOR_SHAPE_TYPE.POINT]; + const hasFeatureType = { + [VECTOR_SHAPE_TYPE.POINT]: false, + [VECTOR_SHAPE_TYPE.LINE]: false, + [VECTOR_SHAPE_TYPE.POLYGON]: false, + }; test('Is only single feature type when only supported feature type is target feature type', () => { - expect(isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures)).toBe(true); + expect( + isOnlySingleFeatureType(VECTOR_SHAPE_TYPE.POINT, supportedFeatures, hasFeatureType) + ).toBe(true); }); test('Is not single feature type when only supported feature type is not target feature type', () => { - expect(isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE, supportedFeatures)).toBe(false); + expect( + isOnlySingleFeatureType(VECTOR_SHAPE_TYPE.LINE, supportedFeatures, hasFeatureType) + ).toBe(false); }); }); describe('source supports multiple feature types', () => { const supportedFeatures = [ - VECTOR_SHAPE_TYPES.POINT, - VECTOR_SHAPE_TYPES.LINE, - VECTOR_SHAPE_TYPES.POLYGON, + VECTOR_SHAPE_TYPE.POINT, + VECTOR_SHAPE_TYPE.LINE, + VECTOR_SHAPE_TYPE.POLYGON, ]; test('Is only single feature type when data only has target feature type', () => { const hasFeatureType = { - [VECTOR_SHAPE_TYPES.POINT]: true, - [VECTOR_SHAPE_TYPES.LINE]: false, - [VECTOR_SHAPE_TYPES.POLYGON]: false, + [VECTOR_SHAPE_TYPE.POINT]: true, + [VECTOR_SHAPE_TYPE.LINE]: false, + [VECTOR_SHAPE_TYPE.POLYGON]: false, }; expect( - isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures, hasFeatureType) + isOnlySingleFeatureType(VECTOR_SHAPE_TYPE.POINT, supportedFeatures, hasFeatureType) ).toBe(true); }); test('Is not single feature type when data has multiple feature types', () => { const hasFeatureType = { - [VECTOR_SHAPE_TYPES.POINT]: true, - [VECTOR_SHAPE_TYPES.LINE]: true, - [VECTOR_SHAPE_TYPES.POLYGON]: true, + [VECTOR_SHAPE_TYPE.POINT]: true, + [VECTOR_SHAPE_TYPE.LINE]: true, + [VECTOR_SHAPE_TYPE.POLYGON]: true, }; expect( - isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE, supportedFeatures, hasFeatureType) + isOnlySingleFeatureType(VECTOR_SHAPE_TYPE.LINE, supportedFeatures, hasFeatureType) ).toBe(false); }); test('Is not single feature type when data does not have target feature types', () => { const hasFeatureType = { - [VECTOR_SHAPE_TYPES.POINT]: false, - [VECTOR_SHAPE_TYPES.LINE]: true, - [VECTOR_SHAPE_TYPES.POLYGON]: false, + [VECTOR_SHAPE_TYPE.POINT]: false, + [VECTOR_SHAPE_TYPE.LINE]: true, + [VECTOR_SHAPE_TYPE.POLYGON]: false, }; expect( - isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures, hasFeatureType) + isOnlySingleFeatureType(VECTOR_SHAPE_TYPE.POINT, supportedFeatures, hasFeatureType) ).toBe(false); }); }); @@ -64,7 +73,12 @@ describe('isOnlySingleFeatureType', () => { describe('assignCategoriesToPalette', () => { test('Categories and palette values have same length', () => { - const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; + const categories = [ + { key: 'alpah', count: 1 }, + { key: 'bravo', count: 1 }, + { key: 'charlie', count: 1 }, + { key: 'delta', count: 1 }, + ]; const paletteValues = ['red', 'orange', 'yellow', 'green']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ @@ -72,31 +86,39 @@ describe('assignCategoriesToPalette', () => { { stop: 'bravo', style: 'orange' }, { stop: 'charlie', style: 'yellow' }, ], - fallback: 'green', + fallbackSymbolId: 'green', }); }); test('Should More categories than palette values', () => { - const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; + const categories = [ + { key: 'alpah', count: 1 }, + { key: 'bravo', count: 1 }, + { key: 'charlie', count: 1 }, + { key: 'delta', count: 1 }, + ]; const paletteValues = ['red', 'orange', 'yellow']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ { stop: 'alpah', style: 'red' }, { stop: 'bravo', style: 'orange' }, ], - fallback: 'yellow', + fallbackSymbolId: 'yellow', }); }); test('Less categories than palette values', () => { - const categories = [{ key: 'alpah' }, { key: 'bravo' }]; + const categories = [ + { key: 'alpah', count: 1 }, + { key: 'bravo', count: 1 }, + ]; const paletteValues = ['red', 'orange', 'yellow', 'green', 'blue']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ { stop: 'alpah', style: 'red' }, { stop: 'bravo', style: 'orange' }, ], - fallback: 'yellow', + fallbackSymbolId: 'yellow', }); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.js b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts similarity index 57% rename from x-pack/plugins/maps/public/classes/styles/vector/style_util.js rename to x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index 3b62dcb27dced6..d190a62e6f300a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -5,6 +5,8 @@ */ import { i18n } from '@kbn/i18n'; +import { MB_LOOKUP_FUNCTION, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { Category } from '../../../../common/descriptor_types'; export function getOtherCategoryLabel() { return i18n.translate('xpack.maps.styles.categorical.otherCategoryLabel', { @@ -12,29 +14,32 @@ export function getOtherCategoryLabel() { }); } -export function getComputedFieldName(styleName, fieldName) { +export function getComputedFieldName(styleName: string, fieldName: string) { return `${getComputedFieldNamePrefix(fieldName)}__${styleName}`; } -export function getComputedFieldNamePrefix(fieldName) { +export function getComputedFieldNamePrefix(fieldName: string) { return `__kbn__dynamic__${fieldName}`; } -export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatureType) { +export function isOnlySingleFeatureType( + featureType: VECTOR_SHAPE_TYPE, + supportedFeatures: VECTOR_SHAPE_TYPE[], + hasFeatureType: { [key in keyof typeof VECTOR_SHAPE_TYPE]: boolean } +): boolean { if (supportedFeatures.length === 1) { return supportedFeatures[0] === featureType; } const featureTypes = Object.keys(hasFeatureType); - return featureTypes.reduce((isOnlyTargetFeatureType, featureTypeKey) => { + // @ts-expect-error + return featureTypes.reduce((accumulator: boolean, featureTypeKey: VECTOR_SHAPE_TYPE) => { const hasFeature = hasFeatureType[featureTypeKey]; - return featureTypeKey === featureType - ? isOnlyTargetFeatureType && hasFeature - : isOnlyTargetFeatureType && !hasFeature; + return featureTypeKey === featureType ? accumulator && hasFeature : accumulator && !hasFeature; }, true); } -export function dynamicRound(value) { +export function dynamicRound(value: number | string) { if (typeof value !== 'number') { return value; } @@ -49,13 +54,19 @@ export function dynamicRound(value) { return precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision + 1)); } -export function assignCategoriesToPalette({ categories, paletteValues }) { +export function assignCategoriesToPalette({ + categories, + paletteValues, +}: { + categories: Category[]; + paletteValues: string[]; +}) { const stops = []; - let fallback = null; + let fallbackSymbolId = null; - if (categories && categories.length && paletteValues && paletteValues.length) { + if (categories.length && paletteValues.length) { const maxLength = Math.min(paletteValues.length, categories.length + 1); - fallback = paletteValues[maxLength - 1]; + fallbackSymbolId = paletteValues[maxLength - 1]; for (let i = 0; i < maxLength - 1; i++) { stops.push({ stop: categories[i].key, @@ -66,7 +77,7 @@ export function assignCategoriesToPalette({ categories, paletteValues }) { return { stops, - fallback, + fallbackSymbolId, }; } @@ -76,6 +87,12 @@ export function makeMbClampedNumberExpression({ minValue, maxValue, fallback, +}: { + lookupFunction: MB_LOOKUP_FUNCTION; + fieldName: string; + minValue: number; + maxValue: number; + fallback: number; }) { const clamp = ['max', ['min', ['to-number', [lookupFunction, fieldName]], maxValue], minValue]; return [ @@ -83,7 +100,7 @@ export function makeMbClampedNumberExpression({ [ 'case', ['==', [lookupFunction, fieldName], null], - minValue - 1, //== does a JS-y like check where returns true for null and undefined + minValue - 1, // == does a JS-y like check where returns true for null and undefined clamp, ], fallback, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js index 1672af8eccff8a..04df9d73d75cd8 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js @@ -140,5 +140,5 @@ export function getIconPaletteOptions(isDarkMode) { export function getIconPalette(paletteId) { const palette = ICON_PALETTES.find(({ id }) => id === paletteId); - return palette ? [...palette.icons] : null; + return palette ? [...palette.icons] : []; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js index 989ac268c05521..04a5381fa25927 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js @@ -16,12 +16,12 @@ import { SOURCE_FORMATTERS_DATA_REQUEST_ID, LAYER_STYLE_TYPE, DEFAULT_ICON, + VECTOR_SHAPE_TYPE, VECTOR_STYLES, } from '../../../../common/constants'; import { StyleMeta } from './style_meta'; import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; -import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; import { getComputedFieldName, isOnlySingleFeatureType } from './style_util'; import { StaticStyleProperty } from './properties/static_style_property'; import { DynamicStyleProperty } from './properties/dynamic_style_property'; @@ -249,24 +249,24 @@ export class VectorStyle extends AbstractStyle { const supportedFeatures = await this._source.getSupportedShapeTypes(); const hasFeatureType = { - [VECTOR_SHAPE_TYPES.POINT]: false, - [VECTOR_SHAPE_TYPES.LINE]: false, - [VECTOR_SHAPE_TYPES.POLYGON]: false, + [VECTOR_SHAPE_TYPE.POINT]: false, + [VECTOR_SHAPE_TYPE.LINE]: false, + [VECTOR_SHAPE_TYPE.POLYGON]: false, }; if (supportedFeatures.length > 1) { for (let i = 0; i < features.length; i++) { const feature = features[i]; - if (!hasFeatureType[VECTOR_SHAPE_TYPES.POINT] && POINTS.includes(feature.geometry.type)) { - hasFeatureType[VECTOR_SHAPE_TYPES.POINT] = true; + if (!hasFeatureType[VECTOR_SHAPE_TYPE.POINT] && POINTS.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPE.POINT] = true; } - if (!hasFeatureType[VECTOR_SHAPE_TYPES.LINE] && LINES.includes(feature.geometry.type)) { - hasFeatureType[VECTOR_SHAPE_TYPES.LINE] = true; + if (!hasFeatureType[VECTOR_SHAPE_TYPE.LINE] && LINES.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPE.LINE] = true; } if ( - !hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] && + !hasFeatureType[VECTOR_SHAPE_TYPE.POLYGON] && POLYGONS.includes(feature.geometry.type) ) { - hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] = true; + hasFeatureType[VECTOR_SHAPE_TYPE.POLYGON] = true; } } } @@ -274,17 +274,17 @@ export class VectorStyle extends AbstractStyle { const styleMeta = { geometryTypes: { isPointsOnly: isOnlySingleFeatureType( - VECTOR_SHAPE_TYPES.POINT, + VECTOR_SHAPE_TYPE.POINT, supportedFeatures, hasFeatureType ), isLinesOnly: isOnlySingleFeatureType( - VECTOR_SHAPE_TYPES.LINE, + VECTOR_SHAPE_TYPE.LINE, supportedFeatures, hasFeatureType ), isPolygonsOnly: isOnlySingleFeatureType( - VECTOR_SHAPE_TYPES.POLYGON, + VECTOR_SHAPE_TYPE.POLYGON, supportedFeatures, hasFeatureType ), diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js index 426f1d6afa952d..a0dc07b8e545bb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js @@ -6,8 +6,7 @@ import { VectorStyle } from './vector_style'; import { DataRequest } from '../../util/data_request'; -import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; -import { FIELD_ORIGIN, STYLE_TYPE } from '../../../../common/constants'; +import { FIELD_ORIGIN, STYLE_TYPE, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; jest.mock('../../../kibana_services'); jest.mock('ui/new_platform'); @@ -28,7 +27,7 @@ class MockField { class MockSource { constructor({ supportedShapeTypes } = {}) { - this._supportedShapeTypes = supportedShapeTypes || Object.values(VECTOR_SHAPE_TYPES); + this._supportedShapeTypes = supportedShapeTypes || Object.values(VECTOR_SHAPE_TYPE); } getSupportedShapeTypes() { return this._supportedShapeTypes; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index a9216e48177627..1620e3058be678 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -32,17 +32,9 @@ exports[`LayerPanel is rendered 1`] = ` - - - + { - dispatch(fitToLayerExtent(layerId)); - }, updateSourceProp: (id, propName, value, newLayerType) => dispatch(updateSourceProp(id, propName, value, newLayerType)), }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js index f34c402a4d4171..14252dcfc067d5 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js @@ -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 { - - - + diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index c73225fc4285b3..8fb0ecb50b28bb 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -12,7 +12,7 @@ import { IContainer, } from '../../../../../src/plugins/embeddable/public'; import '../index.scss'; -import { createMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; +import { getExistingMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; import { LayerDescriptor } from '../../common/descriptor_types'; import { MapEmbeddableInput } from './types'; import { lazyLoadMapModules } from '../lazy_load_bundle'; @@ -113,7 +113,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { { layerList, title: savedMap.title, - editUrl: getHttp().basePath.prepend(createMapPath(savedObjectId)), + editUrl: getHttp().basePath.prepend(getExistingMapPath(savedObjectId)), indexPatterns, editable: await this.isEditable(), settings, diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.js b/x-pack/plugins/maps/public/maps_vis_type_alias.js index cb7b3db17eab58..d90674f0f7725b 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.js +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.js @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE } from '../common/constants'; +import { APP_ID, APP_ICON, MAP_PATH } from '../common/constants'; import { getShowMapVisualizationTypes, getVisualizations } from './kibana_services'; export function getMapsVisTypeAlias() { @@ -28,7 +28,7 @@ The Maps app offers more functionality and is easier to use.`, return { aliasApp: APP_ID, - aliasPath: `/${MAP_SAVED_OBJECT_TYPE}`, + aliasPath: `/${MAP_PATH}`, name: APP_ID, title: i18n.translate('xpack.maps.visTypeAlias.title', { defaultMessage: 'Maps', diff --git a/x-pack/plugins/maps/public/routing/bootstrap/services/saved_gis_map.js b/x-pack/plugins/maps/public/routing/bootstrap/services/saved_gis_map.js index f24c7be65afa35..f8c783f673bab9 100644 --- a/x-pack/plugins/maps/public/routing/bootstrap/services/saved_gis_map.js +++ b/x-pack/plugins/maps/public/routing/bootstrap/services/saved_gis_map.js @@ -19,7 +19,7 @@ import { import { getIsLayerTOCOpen, getOpenTOCDetails } from '../../../selectors/ui_selectors'; import { copyPersistentState } from '../../../reducers/util'; import { extractReferences, injectReferences } from '../../../../common/migrations/references'; -import { MAP_BASE_URL, MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants'; +import { getExistingMapPath, MAP_SAVED_OBJECT_TYPE } from '../../../../common/constants'; import { getStore } from '../../store_operations'; export function createSavedGisMapClass(services) { @@ -76,7 +76,7 @@ export function createSavedGisMapClass(services) { } getFullPath() { - return `${MAP_BASE_URL}/${this.id}`; + return getExistingMapPath(this.id); } getLayerList() { diff --git a/x-pack/plugins/maps/public/routing/page_elements/breadcrumbs.js b/x-pack/plugins/maps/public/routing/page_elements/breadcrumbs.js index 36a355719d945c..de2ee42b491715 100644 --- a/x-pack/plugins/maps/public/routing/page_elements/breadcrumbs.js +++ b/x-pack/plugins/maps/public/routing/page_elements/breadcrumbs.js @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { getCoreChrome } from '../../kibana_services'; -import { MAP_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { MAP_PATH } from '../../../common/constants'; import _ from 'lodash'; import { getLayerListRaw } from '../../selectors/map_selectors'; import { copyPersistentState } from '../../reducers/util'; @@ -31,7 +31,7 @@ function hasUnsavedChanges(savedMap, initialLayerListConfig) { } export const updateBreadcrumbs = (savedMap, initialLayerListConfig, currentPath = '') => { - const isOnMapNow = currentPath.startsWith(`/${MAP_SAVED_OBJECT_TYPE}`); + const isOnMapNow = currentPath.startsWith(`/${MAP_PATH}`); const breadCrumbs = isOnMapNow ? [ { diff --git a/x-pack/plugins/maps/server/maps_telemetry/collectors/register.ts b/x-pack/plugins/maps/server/maps_telemetry/collectors/register.ts index 383d7773663c68..f54776f5ab629f 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/collectors/register.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/collectors/register.ts @@ -6,8 +6,6 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getMapsTelemetry } from '../maps_telemetry'; -// @ts-ignore -import { TELEMETRY_TYPE } from '../../../common/constants'; import { MapsConfigType } from '../../../config'; export function registerMapsUsageCollector( @@ -19,7 +17,7 @@ export function registerMapsUsageCollector( } const mapsUsageCollector = usageCollection.makeUsageCollector({ - type: TELEMETRY_TYPE, + type: 'maps', isReady: () => true, fetch: async () => await getMapsTelemetry(config), }); diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 463d3f3b3939d9..0e29eca2446422 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -11,12 +11,7 @@ import { SavedObjectAttribute, } from 'kibana/server'; import { IFieldType, IIndexPattern } from 'src/plugins/data/public'; -import { - SOURCE_TYPES, - ES_GEO_FIELD_TYPE, - MAP_SAVED_OBJECT_TYPE, - TELEMETRY_TYPE, -} from '../../common/constants'; +import { SOURCE_TYPES, ES_GEO_FIELD_TYPE, MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; import { LayerDescriptor } from '../../common/descriptor_types'; import { MapSavedObject } from '../../common/map_saved_object_type'; // @ts-ignore @@ -186,9 +181,5 @@ export async function getMapsTelemetry(config: MapsConfigType) { const settings: SavedObjectAttribute = { showMapVisualizationTypes: config.showMapVisualizationTypes, }; - const mapsTelemetry = buildMapsTelemetry({ mapSavedObjects, indexPatternSavedObjects, settings }); - return await savedObjectsClient.create(TELEMETRY_TYPE, mapsTelemetry, { - id: TELEMETRY_TYPE, - overwrite: true, - }); + return buildMapsTelemetry({ mapSavedObjects, indexPatternSavedObjects, settings }); } diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index f2331b9a1a9600..60f3a9b68202c8 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -14,8 +14,8 @@ import { getFlightsSavedObjects } from './sample_data/flights_saved_objects.js'; // @ts-ignore import { getWebLogsSavedObjects } from './sample_data/web_logs_saved_objects.js'; import { registerMapsUsageCollector } from './maps_telemetry/collectors/register'; -import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE, createMapPath } from '../common/constants'; -import { mapSavedObjects, mapsTelemetrySavedObjects } from './saved_objects'; +import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE, getExistingMapPath } from '../common/constants'; +import { mapSavedObjects } from './saved_objects'; import { MapsXPackConfig } from '../config'; // @ts-ignore import { setInternalRepository } from './kibana_server_services'; @@ -58,7 +58,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addAppLinksToSampleDataset('ecommerce', [ { - path: createMapPath('2c9c1f60-1909-11e9-919b-ffe5949a18d2'), + path: getExistingMapPath('2c9c1f60-1909-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, @@ -80,7 +80,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addAppLinksToSampleDataset('flights', [ { - path: createMapPath('5dd88580-1906-11e9-919b-ffe5949a18d2'), + path: getExistingMapPath('5dd88580-1906-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, @@ -101,7 +101,7 @@ export class MapsPlugin implements Plugin { home.sampleData.addSavedObjectsToSampleDataset('logs', getWebLogsSavedObjects()); home.sampleData.addAppLinksToSampleDataset('logs', [ { - path: createMapPath('de71f4f0-1902-11e9-919b-ffe5949a18d2'), + path: getExistingMapPath('de71f4f0-1902-11e9-919b-ffe5949a18d2'), label: sampleDataLinkLabel, icon: APP_ICON, }, @@ -191,7 +191,6 @@ export class MapsPlugin implements Plugin { }, }); - core.savedObjects.registerType(mapsTelemetrySavedObjects); core.savedObjects.registerType(mapSavedObjects); registerMapsUsageCollector(usageCollection, currentConfig); diff --git a/x-pack/plugins/maps/server/saved_objects/index.ts b/x-pack/plugins/maps/server/saved_objects/index.ts index c4b779183a2dee..804d720a13ab05 100644 --- a/x-pack/plugins/maps/server/saved_objects/index.ts +++ b/x-pack/plugins/maps/server/saved_objects/index.ts @@ -3,5 +3,4 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { mapsTelemetrySavedObjects } from './maps_telemetry'; export { mapSavedObjects } from './map'; diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index 0fcadc5a972033..ce9d5791378647 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObjectsType } from 'src/core/server'; -import { APP_ICON, createMapPath } from '../../common/constants'; +import { APP_ICON, getExistingMapPath } from '../../common/constants'; // @ts-ignore import { migrations } from './migrations'; @@ -31,7 +31,7 @@ export const mapSavedObjects: SavedObjectsType = { }, getInAppUrl(obj) { return { - path: createMapPath(obj.id), + path: getExistingMapPath(obj.id), uiCapabilitiesPath: 'maps.show', }; }, diff --git a/x-pack/plugins/maps/server/saved_objects/maps_telemetry.ts b/x-pack/plugins/maps/server/saved_objects/maps_telemetry.ts deleted file mode 100644 index 2512bf3094bcfb..00000000000000 --- a/x-pack/plugins/maps/server/saved_objects/maps_telemetry.ts +++ /dev/null @@ -1,46 +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 { SavedObjectsType } from 'src/core/server'; - -export const mapsTelemetrySavedObjects: SavedObjectsType = { - name: 'maps-telemetry', - hidden: false, - namespaceType: 'agnostic', - mappings: { - properties: { - settings: { - properties: { - showMapVisualizationTypes: { type: 'boolean' }, - }, - }, - indexPatternsWithGeoFieldCount: { type: 'long' }, - indexPatternsWithGeoPointFieldCount: { type: 'long' }, - indexPatternsWithGeoShapeFieldCount: { type: 'long' }, - mapsTotalCount: { type: 'long' }, - timeCaptured: { type: 'date' }, - attributesPerMap: { - properties: { - dataSourcesCount: { - properties: { - min: { type: 'long' }, - max: { type: 'long' }, - avg: { type: 'long' }, - }, - }, - layersCount: { - properties: { - min: { type: 'long' }, - max: { type: 'long' }, - avg: { type: 'long' }, - }, - }, - layerTypesCount: { dynamic: 'true', properties: {} }, - emsVectorLayersCount: { dynamic: 'true', properties: {} }, - }, - }, - }, - }, -}; diff --git a/x-pack/plugins/maps/server/tutorials/ems/index.ts b/x-pack/plugins/maps/server/tutorials/ems/index.ts index e96af89e526851..be15120cb19e14 100644 --- a/x-pack/plugins/maps/server/tutorials/ems/index.ts +++ b/x-pack/plugins/maps/server/tutorials/ems/index.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { TutorialsCategory } from '../../../../../../src/plugins/home/server'; -import { MAP_BASE_URL } from '../../../common/constants'; +import { getNewMapPath } from '../../../common/constants'; export function emsBoundariesSpecProvider({ emsLandingPageUrl, @@ -64,7 +64,7 @@ Indexing EMS administrative boundaries in Elasticsearch allows for search on bou 2. Click `Add layer`, then select `Upload GeoJSON`.\n\ 3. Upload the GeoJSON file and click `Import file`.', values: { - newMapUrl: prependBasePath(MAP_BASE_URL), + newMapUrl: prependBasePath(getNewMapPath()), }, }), }, diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 1fef0e6e2ecba9..7ea4ceccf578d5 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -6,6 +6,7 @@ import _ from 'lodash'; import semver from 'semver'; +import { Duration } from 'moment'; // @ts-ignore import numeral from '@elastic/numeral'; @@ -433,7 +434,7 @@ export function basicJobValidation( messages.push({ id: 'bucket_span_empty' }); valid = false; } else { - if (isValidTimeFormat(job.analysis_config.bucket_span)) { + if (isValidTimeInterval(job.analysis_config.bucket_span)) { messages.push({ id: 'bucket_span_valid', bucketSpan: job.analysis_config.bucket_span, @@ -490,14 +491,14 @@ export function basicDatafeedValidation(datafeed: Datafeed): ValidationResults { if (datafeed) { let queryDelayMessage = { id: 'query_delay_valid' }; - if (isValidTimeFormat(datafeed.query_delay) === false) { + if (isValidTimeInterval(datafeed.query_delay) === false) { queryDelayMessage = { id: 'query_delay_invalid' }; valid = false; } messages.push(queryDelayMessage); let frequencyMessage = { id: 'frequency_valid' }; - if (isValidTimeFormat(datafeed.frequency) === false) { + if (isValidTimeInterval(datafeed.frequency) === false) { frequencyMessage = { id: 'frequency_invalid' }; valid = false; } @@ -591,12 +592,33 @@ export function validateGroupNames(job: Job): ValidationResults { }; } -function isValidTimeFormat(value: string | undefined): boolean { +/** + * Parses the supplied string to a time interval suitable for use in an ML anomaly + * detection job or datafeed. + * @param value the string to parse + * @return {Duration} the parsed interval, or null if it does not represent a valid + * time interval. + */ +export function parseTimeIntervalForJob(value: string | undefined): Duration | null { + if (value === undefined) { + return null; + } + + // Must be a valid interval, greater than zero, + // and if specified in ms must be a multiple of 1000ms. + const interval = parseInterval(value, true); + return interval !== null && interval.asMilliseconds() !== 0 && interval.milliseconds() === 0 + ? interval + : null; +} + +// Checks that the value for a field which represents a time interval, +// such as a job bucket span or datafeed query delay, is valid. +function isValidTimeInterval(value: string | undefined): boolean { if (value === undefined) { return true; } - const interval = parseInterval(value); - return interval !== null && interval.asMilliseconds() !== 0; + return parseTimeIntervalForJob(value) !== null; } // Returns the latest of the last source data and last processed bucket timestamp, diff --git a/x-pack/plugins/ml/common/util/parse_interval.test.ts b/x-pack/plugins/ml/common/util/parse_interval.test.ts index 1717b2f0dd80be..be7ca2d55eecf1 100644 --- a/x-pack/plugins/ml/common/util/parse_interval.test.ts +++ b/x-pack/plugins/ml/common/util/parse_interval.test.ts @@ -7,7 +7,7 @@ import { parseInterval } from './parse_interval'; describe('ML parse interval util', () => { - test('correctly parses an interval containing unit and value', () => { + test('should correctly parse an interval containing a valid unit and value', () => { expect(parseInterval('1d')!.as('d')).toBe(1); expect(parseInterval('2y')!.as('y')).toBe(2); expect(parseInterval('5M')!.as('M')).toBe(5); @@ -20,15 +20,25 @@ describe('ML parse interval util', () => { expect(parseInterval('0s')!.as('h')).toBe(0); }); - test('correctly handles zero value intervals', () => { + test('should correctly handle zero value intervals', () => { expect(parseInterval('0h')!.as('h')).toBe(0); expect(parseInterval('0d')).toBe(null); }); - test('returns null for an invalid interval', () => { + test('should return null for an invalid interval', () => { expect(parseInterval('')).toBe(null); expect(parseInterval('234asdf')).toBe(null); expect(parseInterval('m')).toBe(null); expect(parseInterval('1.5h')).toBe(null); }); + + test('should correctly check for whether the interval units are valid Elasticsearch time units', () => { + expect(parseInterval('100s', true)!.as('s')).toBe(100); + expect(parseInterval('5m', true)!.as('m')).toBe(5); + expect(parseInterval('24h', true)!.as('h')).toBe(24); + expect(parseInterval('7d', true)!.as('d')).toBe(7); + expect(parseInterval('1w', true)).toBe(null); + expect(parseInterval('1M', true)).toBe(null); + expect(parseInterval('1y', true)).toBe(null); + }); }); diff --git a/x-pack/plugins/ml/common/util/parse_interval.ts b/x-pack/plugins/ml/common/util/parse_interval.ts index 0f348f43d47b30..da6cd9db67792e 100644 --- a/x-pack/plugins/ml/common/util/parse_interval.ts +++ b/x-pack/plugins/ml/common/util/parse_interval.ts @@ -16,7 +16,15 @@ const INTERVAL_STRING_RE = new RegExp('^([0-9]*)\\s*(' + dateMath.units.join('|' // for units of hour or less. const SUPPORT_ZERO_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h']; +// List of time units which are supported for use in Elasticsearch durations +// (such as anomaly detection job bucket spans) +// See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units +const SUPPORT_ES_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h', 'd']; + // Parses an interval String, such as 7d, 1h or 30m to a moment duration. +// Optionally carries out an additional check that the interval is supported as a +// time unit by Elasticsearch, as units greater than 'd' for example cannot be used +// for anomaly detection job bucket spans. // Differs from the Kibana ui/utils/parse_interval in the following ways: // 1. A value-less interval such as 'm' is not allowed - in line with the ML back-end // not accepting such interval Strings for the bucket span of a job. @@ -25,7 +33,7 @@ const SUPPORT_ZERO_DURATION_UNITS: SupportedUnits[] = ['ms', 's', 'm', 'h']; // to work with units less than 'day'. // 3. Fractional intervals e.g. 1.5h or 4.5d are not allowed, in line with the behaviour // of the Elasticsearch date histogram aggregation. -export function parseInterval(interval: string): Duration | null { +export function parseInterval(interval: string, checkValidEsUnit = false): Duration | null { const matches = String(interval).trim().match(INTERVAL_STRING_RE); if (!Array.isArray(matches) || matches.length < 3) { return null; @@ -36,8 +44,13 @@ export function parseInterval(interval: string): Duration | null { const unit = matches[2] as SupportedUnits; // In line with moment.js, only allow zero value intervals when the unit is less than 'day'. - // And check for isNaN as e.g. valueless 'm' will pass the regex test. - if (isNaN(value) || (value < 1 && SUPPORT_ZERO_DURATION_UNITS.indexOf(unit) === -1)) { + // And check for isNaN as e.g. valueless 'm' will pass the regex test, + // plus an optional check that the unit is not w/M/y which are not fully supported by ES. + if ( + isNaN(value) || + (value < 1 && SUPPORT_ZERO_DURATION_UNITS.indexOf(unit) === -1) || + (checkValidEsUnit === true && SUPPORT_ES_DURATION_UNITS.indexOf(unit) === -1) + ) { return null; } diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e9d4aff3484b1c..f93e7bc19f9603 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -15,7 +15,8 @@ "usageCollection", "share", "embeddable", - "uiActions" + "uiActions", + "kibanaLegacy" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index b871d857f7fded..3df176ff25cb41 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -78,6 +78,8 @@ export const renderApp = ( urlGenerators: deps.share.urlGenerators, }); + deps.kibanaLegacy.loadFontAwesome(); + const mlLicense = setLicenseCache(deps.licensing); appMountParams.onAppLeave((actions) => actions.default()); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 16d888a9da27b7..ac455120dca83f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -25,11 +25,18 @@ export enum ANALYSIS_CONFIG_TYPE { } export enum ANALYSIS_ADVANCED_FIELDS { + ETA = 'eta', + FEATURE_BAG_FRACTION = 'feature_bag_fraction', FEATURE_INFLUENCE_THRESHOLD = 'feature_influence_threshold', GAMMA = 'gamma', LAMBDA = 'lambda', MAX_TREES = 'max_trees', + METHOD = 'method', + N_NEIGHBORS = 'n_neighbors', + NUM_TOP_CLASSES = 'num_top_classes', NUM_TOP_FEATURE_IMPORTANCE_VALUES = 'num_top_feature_importance_values', + OUTLIER_FRACTION = 'outlier_fraction', + RANDOMIZE_SEED = 'randomize_seed', } export enum OUTLIER_ANALYSIS_METHOD { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 8b137ac72361c7..bc9bb0cce5ae8a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useMemo } from 'react'; +import React, { FC, Fragment, useMemo, useEffect, useState } from 'react'; import { EuiAccordion, EuiFieldNumber, @@ -23,9 +23,11 @@ import { getModelMemoryLimitErrors } from '../../../analytics_management/hooks/u import { ANALYSIS_CONFIG_TYPE, NUM_TOP_FEATURE_IMPORTANCE_VALUES_MIN, + ANALYSIS_ADVANCED_FIELDS, } from '../../../../common/analytics'; import { DEFAULT_MODEL_MEMORY_LIMIT } from '../../../analytics_management/hooks/use_create_analytics_form/state'; import { ANALYTICS_STEPS } from '../../page'; +import { fetchExplainData } from '../shared'; import { ContinueButton } from '../continue_button'; import { OutlierHyperParameters } from './outlier_hyper_parameters'; @@ -33,23 +35,39 @@ export function getNumberValue(value?: number) { return value === undefined ? '' : +value; } +export type AdvancedParamErrors = { + [key in ANALYSIS_ADVANCED_FIELDS]?: string; +}; + export const AdvancedStepForm: FC = ({ actions, state, setCurrentStep, }) => { + const [advancedParamErrors, setAdvancedParamErrors] = useState({}); + const [fetchingAdvancedParamErrors, setFetchingAdvancedParamErrors] = useState(false); + const { setFormState } = actions; const { form, isJobCreated } = state; const { computeFeatureInfluence, + eta, + featureBagFraction, featureInfluenceThreshold, + gamma, jobType, + lambda, + maxTrees, + method, modelMemoryLimit, modelMemoryLimitValidationResult, + nNeighbors, numTopClasses, numTopFeatureImportanceValues, numTopFeatureImportanceValuesValid, + outlierFraction, predictionFieldName, + randomizeSeed, } = form; const mmlErrors = useMemo(() => getModelMemoryLimitErrors(modelMemoryLimitValidationResult), [ @@ -61,6 +79,43 @@ export const AdvancedStepForm: FC = ({ const mmlInvalid = modelMemoryLimitValidationResult !== null; + const isStepInvalid = + mmlInvalid || + Object.keys(advancedParamErrors).length > 0 || + fetchingAdvancedParamErrors === true; + + useEffect(() => { + setFetchingAdvancedParamErrors(true); + (async function () { + const { success, errorMessage } = await fetchExplainData(form); + const paramErrors: AdvancedParamErrors = {}; + + if (!success) { + // Check which field is invalid + Object.values(ANALYSIS_ADVANCED_FIELDS).forEach((param) => { + if (errorMessage.includes(`[${param}]`)) { + paramErrors[param] = errorMessage; + } + }); + } + setFetchingAdvancedParamErrors(false); + setAdvancedParamErrors(paramErrors); + })(); + }, [ + eta, + featureBagFraction, + featureInfluenceThreshold, + gamma, + lambda, + maxTrees, + method, + nNeighbors, + numTopClasses, + numTopFeatureImportanceValues, + outlierFraction, + randomizeSeed, + ]); + const outlierDetectionAdvancedConfig = ( @@ -126,6 +181,10 @@ export const AdvancedStepForm: FC = ({ 'The minimum outlier score that a document needs to have in order to calculate its feature influence score. Value range: 0-1. Defaults to 0.1.', } )} + isInvalid={ + advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.FEATURE_INFLUENCE_THRESHOLD] !== undefined + } + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.FEATURE_INFLUENCE_THRESHOLD]} > @@ -315,14 +374,24 @@ export const AdvancedStepForm: FC = ({ > {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( - + + )} + {isRegOrClassJob && ( + )} - {isRegOrClassJob && } { setCurrentStep(ANALYTICS_STEPS.DETAILS); }} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx index 144a0621060032..620e81e30a0c48 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx @@ -8,11 +8,16 @@ import React, { FC, Fragment } from 'react'; import { EuiFieldNumber, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; -import { getNumberValue } from './advanced_step_form'; +import { AdvancedParamErrors, getNumberValue } from './advanced_step_form'; +import { ANALYSIS_ADVANCED_FIELDS } from '../../../../common/analytics'; const MAX_TREES_LIMIT = 2000; -export const HyperParameters: FC = ({ actions, state }) => { +interface Props extends CreateAnalyticsFormProps { + advancedParamErrors: AdvancedParamErrors; +} + +export const HyperParameters: FC = ({ actions, state, advancedParamErrors }) => { const { setFormState } = actions; const { eta, featureBagFraction, gamma, lambda, maxTrees, randomizeSeed } = state.form; @@ -28,6 +33,8 @@ export const HyperParameters: FC = ({ actions, state } defaultMessage: 'Regularization parameter to prevent overfitting on the training data set. Must be a non negative value.', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA]} > = ({ actions, state } helpText={i18n.translate('xpack.ml.dataframe.analytics.create.maxTreesText', { defaultMessage: 'The maximum number of trees the forest is allowed to contain.', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES]} > = ({ actions, state } defaultMessage: 'Multiplies a linear penalty associated with the size of individual trees in the forest. Must be non-negative value.', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA]} > = ({ actions, state } helpText={i18n.translate('xpack.ml.dataframe.analytics.create.etaText', { defaultMessage: 'The shrinkage applied to the weights. Must be between 0.001 and 1.', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ETA] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ETA]} > = ({ actions, state } defaultMessage: 'The fraction of features used when selecting a random bag for each candidate split.', })} + isInvalid={ + advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.FEATURE_BAG_FRACTION] !== undefined + } + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.FEATURE_BAG_FRACTION]} > = ({ actions, state } = ({ actions, state }) => { +interface Props extends CreateAnalyticsFormProps { + advancedParamErrors: AdvancedParamErrors; +} + +export const OutlierHyperParameters: FC = ({ actions, state, advancedParamErrors }) => { const { setFormState } = actions; const { method, nNeighbors, outlierFraction, standardizationEnabled } = state.form; @@ -27,6 +31,8 @@ export const OutlierHyperParameters: FC = ({ actions, defaultMessage: 'Sets the method that outlier detection uses. If not set, uses an ensemble of different methods and normalises and combines their individual outlier scores to obtain the overall outlier score. We recommend to use the ensemble method', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.METHOD] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.METHOD]} > ({ @@ -51,6 +57,8 @@ export const OutlierHyperParameters: FC = ({ actions, defaultMessage: 'The value for how many nearest neighbors each method of outlier detection will use to calculate its outlier score. When not set, different values will be used for different ensemble members. Must be a positive integer', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.N_NEIGHBORS] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.N_NEIGHBORS]} > = ({ actions, defaultMessage: 'Sets the proportion of the data set that is assumed to be outlying prior to outlier detection.', })} + isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.OUTLIER_FRACTION] !== undefined} + error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.OUTLIER_FRACTION]} > )} {tableItems.length > 0 && ( - + = ({ const { currentSavedSearch, currentIndexPattern } = mlContext; const { savedSearchQuery, savedSearchQueryStr } = useSavedSearch(); + const [loadingFieldOptions, setLoadingFieldOptions] = useState(false); + const [fieldOptionsFetchFail, setFieldOptionsFetchFail] = useState(false); + const [loadingDepVarOptions, setLoadingDepVarOptions] = useState(false); + const [dependentVariableFetchFail, setDependentVariableFetchFail] = useState(false); + const [dependentVariableOptions, setDependentVariableOptions] = useState< + EuiComboBoxOptionOption[] + >([]); + const [excludesTableItems, setExcludesTableItems] = useState([]); + const [maxDistinctValuesError, setMaxDistinctValuesError] = useState( + undefined + ); + const { setEstimatedModelMemoryLimit, setFormState } = actions; const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state; const firstUpdate = useRef(true); const { dependentVariable, - dependentVariableFetchFail, - dependentVariableOptions, excludes, - excludesTableItems, - fieldOptionsFetchFail, jobConfigQuery, jobConfigQueryString, jobType, - loadingDepVarOptions, - loadingFieldOptions, - maxDistinctValuesError, modelMemoryLimit, previousJobType, requiredFieldsError, @@ -109,30 +120,20 @@ export const ConfigurationStepForm: FC = ({ requiredFieldsError !== undefined; const loadDepVarOptions = async (formState: State['form']) => { - setFormState({ - loadingDepVarOptions: true, - maxDistinctValuesError: undefined, - }); + setLoadingDepVarOptions(true); + setMaxDistinctValuesError(undefined); + try { if (currentIndexPattern !== undefined) { - const formStateUpdate: { - loadingDepVarOptions: boolean; - dependentVariableFetchFail: boolean; - dependentVariableOptions: State['form']['dependentVariableOptions']; - dependentVariable?: State['form']['dependentVariable']; - } = { - loadingDepVarOptions: false, - dependentVariableFetchFail: false, - dependentVariableOptions: [] as State['form']['dependentVariableOptions'], - }; - + const depVarOptions = []; + let depVarUpdate = dependentVariable; // Get fields and filter for supported types for job type const { fields } = newJobCapsService; let resetDependentVariable = true; for (const field of fields) { if (shouldAddAsDepVarOption(field, jobType)) { - formStateUpdate.dependentVariableOptions.push({ + depVarOptions.push({ label: field.id, }); @@ -143,13 +144,16 @@ export const ConfigurationStepForm: FC = ({ } if (resetDependentVariable) { - formStateUpdate.dependentVariable = ''; + depVarUpdate = ''; } - - setFormState(formStateUpdate); + setDependentVariableOptions(depVarOptions); + setLoadingDepVarOptions(false); + setDependentVariableFetchFail(false); + setFormState({ dependentVariable: depVarUpdate }); } } catch (e) { - setFormState({ loadingDepVarOptions: false, dependentVariableFetchFail: true }); + setLoadingDepVarOptions(false); + setDependentVariableFetchFail(true); } }; @@ -165,72 +169,48 @@ export const ConfigurationStepForm: FC = ({ // Reset if jobType changes (jobType requires dependent_variable to be set - // which won't be the case if switching from outlier detection) if (jobTypeChanged) { - setFormState({ - loadingFieldOptions: true, - }); + setLoadingFieldOptions(true); } - try { - const jobConfig = getJobConfigFromFormState(form); - delete jobConfig.dest; - delete jobConfig.model_memory_limit; - const resp: DfAnalyticsExplainResponse = await ml.dataFrameAnalytics.explainDataFrameAnalytics( - jobConfig - ); - const expectedMemoryWithoutDisk = resp.memory_estimation?.expected_memory_without_disk; + const { success, expectedMemory, fieldSelection, errorMessage } = await fetchExplainData(form); + if (success) { if (shouldUpdateEstimatedMml) { - setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); + setEstimatedModelMemoryLimit(expectedMemory); } - const fieldSelection: FieldSelectionItem[] | undefined = resp.field_selection; - - let hasRequiredFields = false; - if (fieldSelection) { - for (let i = 0; i < fieldSelection.length; i++) { - const field = fieldSelection[i]; - if (field.is_included === true && field.is_required === false) { - hasRequiredFields = true; - break; - } - } - } + const hasRequiredFields = fieldSelection.some( + (field) => field.is_included === true && field.is_required === false + ); - // If job type has changed load analysis field options again if (jobTypeChanged) { + setLoadingFieldOptions(false); + setFieldOptionsFetchFail(false); + setMaxDistinctValuesError(undefined); + setExcludesTableItems(fieldSelection ? fieldSelection : []); setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), - excludesTableItems: fieldSelection ? fieldSelection : [], - loadingFieldOptions: false, - fieldOptionsFetchFail: false, - maxDistinctValuesError: undefined, + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}), requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, }); } else { setFormState({ - ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemory } : {}), requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, }); } - } catch (e) { + } else { let maxDistinctValuesErrorMessage; - if ( jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - e.body && - e.body.message !== undefined && - e.body.message.includes('status_exception') && - (e.body.message.includes('must have at most') || - e.body.message.includes('must have at least')) + errorMessage.includes('status_exception') && + (errorMessage.includes('must have at most') || errorMessage.includes('must have at least')) ) { - maxDistinctValuesErrorMessage = e.body.message; + maxDistinctValuesErrorMessage = errorMessage; } if ( - e.body && - e.body.message !== undefined && - e.body.message.includes('status_exception') && - e.body.message.includes('Unable to estimate memory usage as no documents') + errorMessage.includes('status_exception') && + errorMessage.includes('Unable to estimate memory usage as no documents') ) { toastNotifications.addWarning( i18n.translate('xpack.ml.dataframe.analytics.create.allDocsMissingFieldsErrorMessage', { @@ -241,15 +221,17 @@ export const ConfigurationStepForm: FC = ({ }) ); } + const fallbackModelMemoryLimit = jobType !== undefined ? DEFAULT_MODEL_MEMORY_LIMIT[jobType] : DEFAULT_MODEL_MEMORY_LIMIT.outlier_detection; + setEstimatedModelMemoryLimit(fallbackModelMemoryLimit); + setLoadingFieldOptions(false); + setFieldOptionsFetchFail(true); + setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setFormState({ - fieldOptionsFetchFail: true, - maxDistinctValuesError: maxDistinctValuesErrorMessage, - loadingFieldOptions: false, ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), }); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx index 8d51848a25f500..0d1690cf179463 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx @@ -19,6 +19,7 @@ import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/us import { Messages } from '../shared'; import { ANALYTICS_STEPS } from '../../page'; import { BackToListPanel } from '../back_to_list_panel'; +import { ProgressStats } from './progress_stats'; interface Props extends CreateAnalyticsFormProps { step: ANALYTICS_STEPS; @@ -27,8 +28,10 @@ interface Props extends CreateAnalyticsFormProps { export const CreateStep: FC = ({ actions, state, step }) => { const { createAnalyticsJob, startAnalyticsJob } = actions; const { isAdvancedEditorValidJson, isJobCreated, isJobStarted, isValid, requestMessages } = state; + const { jobId } = state.form; const [checked, setChecked] = useState(true); + const [showProgress, setShowProgress] = useState(false); if (step !== ANALYTICS_STEPS.CREATE) return null; @@ -36,6 +39,7 @@ export const CreateStep: FC = ({ actions, state, step }) => { await createAnalyticsJob(); if (checked) { + setShowProgress(true); startAnalyticsJob(); } }; @@ -82,6 +86,7 @@ export const CreateStep: FC = ({ actions, state, step }) => { )} + {isJobCreated === true && showProgress && } {isJobCreated === true && } ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx new file mode 100644 index 00000000000000..8cee63d3c4c841 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/progress_stats.tsx @@ -0,0 +1,110 @@ +/* + * 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, { FC, useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../../../../../contexts/kibana'; +import { getDataFrameAnalyticsProgressPhase } from '../../../analytics_management/components/analytics_list/common'; +import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics'; +import { ml } from '../../../../../services/ml_api_service'; +import { DataFrameAnalyticsId } from '../../../../common/analytics'; + +export const PROGRESS_REFRESH_INTERVAL_MS = 1000; + +export const ProgressStats: FC<{ jobId: DataFrameAnalyticsId }> = ({ jobId }) => { + const [initialized, setInitialized] = useState(false); + const [currentProgress, setCurrentProgress] = useState< + | { + currentPhase: number; + progress: number; + totalPhases: number; + } + | undefined + >(undefined); + + const { + services: { notifications }, + } = useMlKibana(); + + useEffect(() => { + setInitialized(true); + }, []); + + useEffect(() => { + const interval = setInterval(async () => { + try { + const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId); + const jobStats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) + ? analyticsStats.data_frame_analytics[0] + : undefined; + + if (jobStats !== undefined) { + const progressStats = getDataFrameAnalyticsProgressPhase(jobStats); + setCurrentProgress(progressStats); + if ( + progressStats.currentPhase === progressStats.totalPhases && + progressStats.progress === 100 + ) { + clearInterval(interval); + } + } else { + clearInterval(interval); + } + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analytics.create.analyticsProgressErrorMessage', { + defaultMessage: 'An error occurred getting progress stats for analytics job {jobId}', + values: { jobId }, + }) + ); + clearInterval(interval); + } + }, PROGRESS_REFRESH_INTERVAL_MS); + + return () => clearInterval(interval); + }, [initialized]); + + if (currentProgress === undefined) return null; + + return ( + <> + + + + {i18n.translate('xpack.ml.dataframe.analytics.create.analyticsProgressTitle', { + defaultMessage: 'Progress', + })} + + + + + + + + {i18n.translate('xpack.ml.dataframe.analytics.create.analyticsProgressPhaseTitle', { + defaultMessage: 'Phase', + })}{' '} + {currentProgress.currentPhase}/{currentProgress.totalPhases} + + + + + + + + {`${currentProgress.progress}%`} + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts new file mode 100644 index 00000000000000..655a5e6a593048 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -0,0 +1,48 @@ +/* + * 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 { ml } from '../../../../../services/ml_api_service'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; +import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; +import { + getJobConfigFromFormState, + State, +} from '../../../analytics_management/hooks/use_create_analytics_form/state'; + +export interface FetchExplainDataReturnType { + success: boolean; + expectedMemory: string; + fieldSelection: FieldSelectionItem[]; + errorMessage: string; +} + +export const fetchExplainData = async (formState: State['form']) => { + const jobConfig = getJobConfigFromFormState(formState); + let errorMessage = ''; + let success = true; + let expectedMemory = ''; + let fieldSelection: FieldSelectionItem[] = []; + + try { + delete jobConfig.dest; + delete jobConfig.model_memory_limit; + const resp: DfAnalyticsExplainResponse = await ml.dataFrameAnalytics.explainDataFrameAnalytics( + jobConfig + ); + expectedMemory = resp.memory_estimation?.expected_memory_without_disk; + fieldSelection = resp.field_selection || []; + } catch (error) { + success = false; + errorMessage = extractErrorMessage(error); + } + + return { + success, + expectedMemory, + fieldSelection, + errorMessage, + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/index.ts index ed3f9ef2e93843..45545cf98e0d6f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/index.ts @@ -5,3 +5,4 @@ */ export { Messages } from './messages'; +export { fetchExplainData } from './fetch_explain_data'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx index 966ef33a1ac8b5..ff718277a88a71 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx @@ -144,7 +144,7 @@ export const Page: FC = ({ jobId }) => { - +

{jobId === undefined && ( ; + item: DataFrameAnalyticsListRow; createAnalyticsForm: CreateAnalyticsFormProps; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx index ff0658e8daccde..b47b23f668530f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { DeepReadonly } from '../../../../../../../common/types/common'; import { checkPermission, @@ -21,6 +20,7 @@ import { isClassificationAnalysis, } from '../../../../common/analytics'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; +import { useMlKibana } from '../../../../../contexts/kibana'; import { CloneAction } from './action_clone'; import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; @@ -29,87 +29,123 @@ import { stopAnalytics } from '../../services/analytics_service'; import { StartAction } from './action_start'; import { DeleteAction } from './action_delete'; -export const AnalyticsViewAction = { - isPrimary: true, - render: (item: DataFrameAnalyticsListRow) => { - const analysisType = getAnalysisType(item.config.analysis); - const isDisabled = - !isRegressionAnalysis(item.config.analysis) && - !isOutlierAnalysis(item.config.analysis) && - !isClassificationAnalysis(item.config.analysis); - - const url = getResultsUrl(item.id, analysisType); - return ( - (window.location.href = url)} - size="xs" - color="text" - iconType="visTable" - aria-label={i18n.translate('xpack.ml.dataframe.analyticsList.viewAriaLabel', { - defaultMessage: 'View', - })} - data-test-subj="mlAnalyticsJobViewButton" - > - {i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { - defaultMessage: 'View', - })} - - ); - }, +interface Props { + item: DataFrameAnalyticsListRow; + isManagementTable: boolean; +} + +const AnalyticsViewButton: FC = ({ item, isManagementTable }) => { + const { + services: { + application: { navigateToUrl, navigateToApp }, + }, + } = useMlKibana(); + + const analysisType = getAnalysisType(item.config.analysis); + const isDisabled = + !isRegressionAnalysis(item.config.analysis) && + !isOutlierAnalysis(item.config.analysis) && + !isClassificationAnalysis(item.config.analysis); + + const url = getResultsUrl(item.id, analysisType); + const navigator = isManagementTable + ? () => navigateToApp('ml', { path: url }) + : () => navigateToUrl(url); + + return ( + + {i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { + defaultMessage: 'View', + })} + + ); }; -export const getActions = (createAnalyticsForm: CreateAnalyticsFormProps) => { +interface Action { + isPrimary?: boolean; + render: (item: DataFrameAnalyticsListRow) => any; +} + +export const getAnalyticsViewAction = (isManagementTable: boolean = false): Action => ({ + isPrimary: true, + render: (item: DataFrameAnalyticsListRow) => ( + + ), +}); + +export const getActions = ( + createAnalyticsForm: CreateAnalyticsFormProps, + isManagementTable: boolean +) => { const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); + const actions: Action[] = [getAnalyticsViewAction(isManagementTable)]; - return [ - AnalyticsViewAction, - { - render: (item: DataFrameAnalyticsListRow) => { - if (!isDataFrameAnalyticsRunning(item.stats.state)) { - return ; - } - - const buttonStopText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', { - defaultMessage: 'Stop', - }); - - const stopButton = ( - stopAnalytics(item)} - aria-label={buttonStopText} - data-test-subj="mlAnalyticsJobStopButton" - > - {buttonStopText} - - ); - if (!canStartStopDataFrameAnalytics) { - return ( - - {stopButton} - - ); - } - - return stopButton; - }, - }, - { - render: (item: DataFrameAnalyticsListRow) => { - return ; - }, - }, - { - render: (item: DeepReadonly) => { - return ; - }, - }, - ]; + if (isManagementTable === false) { + actions.push( + ...[ + { + render: (item: DataFrameAnalyticsListRow) => { + if (!isDataFrameAnalyticsRunning(item.stats.state)) { + return ; + } + + const buttonStopText = i18n.translate( + 'xpack.ml.dataframe.analyticsList.stopActionName', + { + defaultMessage: 'Stop', + } + ); + + const stopButton = ( + stopAnalytics(item)} + aria-label={buttonStopText} + data-test-subj="mlAnalyticsJobStopButton" + > + {buttonStopText} + + ); + if (!canStartStopDataFrameAnalytics) { + return ( + + {stopButton} + + ); + } + + return stopButton; + }, + }, + { + render: (item: DataFrameAnalyticsListRow) => { + return ; + }, + }, + { + render: (item: DataFrameAnalyticsListRow) => { + return ; + }, + }, + ] + ); + } + + return actions; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx index 236a8083a95e6c..a3d2e65386c199 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx @@ -33,7 +33,7 @@ import { DataFrameAnalyticsListRow, DataFrameAnalyticsStats, } from './common'; -import { getActions, AnalyticsViewAction } from './actions'; +import { getActions } from './actions'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -148,8 +148,7 @@ export const getColumns = ( isMlEnabledInSpace: boolean = true, createAnalyticsForm?: CreateAnalyticsFormProps ) => { - const actions = - isManagementTable === true ? [AnalyticsViewAction] : getActions(createAnalyticsForm!); + const actions = getActions(createAnalyticsForm!, isManagementTable); function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index e0622efe35ab63..5998c62eeacea9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -122,5 +122,5 @@ export function isCompletedAnalyticsJob(stats: DataFrameAnalyticsStats) { } export function getResultsUrl(jobId: string, analysisType: string) { - return `ml#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId},analysisType:${analysisType}))`; + return `#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId},analysisType:${analysisType}))`; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 1353a35d8ecc61..8bace7b4f59523 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -116,16 +116,7 @@ export const validateNumTopFeatureImportanceValues = ( }; export const validateAdvancedEditor = (state: State): State => { - const { - jobIdEmpty, - jobIdValid, - jobIdExists, - jobType, - createIndexPattern, - excludes, - maxDistinctValuesError, - requiredFieldsError, - } = state.form; + const { jobIdEmpty, jobIdValid, jobIdExists, jobType, createIndexPattern, excludes } = state.form; const { jobConfig } = state; state.advancedEditorMessages = []; @@ -330,8 +321,6 @@ export const validateAdvancedEditor = (state: State): State => { state.form.destinationIndexPatternTitleExists = destinationIndexPatternTitleExists; state.isValid = - maxDistinctValuesError === undefined && - requiredFieldsError === undefined && excludesValid && trainingPercentValid && state.form.modelMemoryLimitUnitValid && @@ -396,10 +385,8 @@ const validateForm = (state: State): State => { destinationIndexPatternTitleExists, createIndexPattern, dependentVariable, - maxDistinctValuesError, modelMemoryLimit, numTopFeatureImportanceValuesValid, - requiredFieldsError, } = state.form; const { estimatedModelMemoryLimit } = state; @@ -414,8 +401,6 @@ const validateForm = (state: State): State => { state.form.modelMemoryLimitValidationResult = mmlValidationResult; state.isValid = - maxDistinctValuesError === undefined && - requiredFieldsError === undefined && !jobTypeEmpty && !mmlValidationResult && !jobIdEmpty && diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 8a07704e399109..241866b56c5c8b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -4,14 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiComboBoxOptionOption } from '@elastic/eui'; import { DeepPartial, DeepReadonly } from '../../../../../../../common/types/common'; import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../../ml_nodes_check'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { - FieldSelectionItem, isClassificationAnalysis, isRegressionAnalysis, DataFrameAnalyticsId, @@ -52,8 +50,6 @@ export interface State { computeFeatureInfluence: string; createIndexPattern: boolean; dependentVariable: DependentVariable; - dependentVariableFetchFail: boolean; - dependentVariableOptions: EuiComboBoxOptionOption[]; description: string; destinationIndex: EsIndexName; destinationIndexNameExists: boolean; @@ -62,11 +58,8 @@ export interface State { destinationIndexPatternTitleExists: boolean; eta: undefined | number; excludes: string[]; - excludesTableItems: FieldSelectionItem[]; - excludesOptions: EuiComboBoxOptionOption[]; featureBagFraction: undefined | number; featureInfluenceThreshold: undefined | number; - fieldOptionsFetchFail: boolean; gamma: undefined | number; jobId: DataFrameAnalyticsId; jobIdExists: boolean; @@ -77,9 +70,7 @@ export interface State { jobConfigQuery: any; jobConfigQueryString: string | undefined; lambda: number | undefined; - loadingDepVarOptions: boolean; loadingFieldOptions: boolean; - maxDistinctValuesError: string | undefined; maxTrees: undefined | number; method: undefined | string; modelMemoryLimit: string | undefined; @@ -124,8 +115,6 @@ export const getInitialState = (): State => ({ computeFeatureInfluence: 'true', createIndexPattern: true, dependentVariable: '', - dependentVariableFetchFail: false, - dependentVariableOptions: [], description: '', destinationIndex: '', destinationIndexNameExists: false, @@ -136,10 +125,7 @@ export const getInitialState = (): State => ({ excludes: [], featureBagFraction: undefined, featureInfluenceThreshold: undefined, - fieldOptionsFetchFail: false, gamma: undefined, - excludesTableItems: [], - excludesOptions: [], jobId: '', jobIdExists: false, jobIdEmpty: true, @@ -149,9 +135,7 @@ export const getInitialState = (): State => ({ jobConfigQuery: { match_all: {} }, jobConfigQueryString: undefined, lambda: undefined, - loadingDepVarOptions: false, loadingFieldOptions: false, - maxDistinctValuesError: undefined, maxTrees: undefined, method: undefined, modelMemoryLimit: undefined, @@ -311,6 +295,9 @@ export const getJobConfigFromFormState = ( n_neighbors: formState.nNeighbors, }, formState.outlierFraction && { outlier_fraction: formState.outlierFraction }, + formState.featureInfluenceThreshold && { + feature_influence_threshold: formState.featureInfluenceThreshold, + }, formState.standardizationEnabled && { standardization_enabled: formState.standardizationEnabled, } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx index c0b7d63e623ce2..07442124959d0d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/page.tsx @@ -48,7 +48,7 @@ export const Page: FC = () => {

  = ({ onClose, @@ -225,7 +225,7 @@ export const AddToDashboardControl: FC = ({ @@ -234,7 +234,7 @@ export const AddToDashboardControl: FC = ({ label={ } > diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 89a0c458287373..d8c4dab150fb51 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -155,7 +155,7 @@ export class JobCreator { } protected _setBucketSpanMs(bucketSpan: BucketSpan) { - const bs = parseInterval(bucketSpan); + const bs = parseInterval(bucketSpan, true); this._bucketSpanMs = bs === null ? 0 : bs.asMilliseconds(); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/single_metric_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/single_metric_job_creator.ts index febfc5ca3eb9e5..e884da5470cc5a 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/single_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/single_metric_job_creator.ts @@ -76,7 +76,7 @@ export class SingleMetricJobCreator extends JobCreator { const functionName = this._aggs[0].dslName; const timeField = this._job_config.data_description.time_field; - const duration = parseInterval(this._job_config.analysis_config.bucket_span); + const duration = parseInterval(this._job_config.analysis_config.bucket_span, true); if (duration === null) { return; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts index d5cc1cf535a787..b97841542f76af 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/util.ts @@ -142,7 +142,7 @@ export function populateValidationMessages( basicValidations.bucketSpan.message = msg; } else if (validationResults.contains('bucket_span_invalid')) { basicValidations.bucketSpan.valid = false; - basicValidations.bucketSpan.message = invalidTimeFormatMessage( + basicValidations.bucketSpan.message = invalidTimeIntervalMessage( jobConfig.analysis_config.bucket_span ); } @@ -163,12 +163,12 @@ export function populateValidationMessages( if (validationResults.contains('query_delay_invalid')) { basicValidations.queryDelay.valid = false; - basicValidations.queryDelay.message = invalidTimeFormatMessage(datafeedConfig.query_delay); + basicValidations.queryDelay.message = invalidTimeIntervalMessage(datafeedConfig.query_delay); } if (validationResults.contains('frequency_invalid')) { basicValidations.frequency.valid = false; - basicValidations.frequency.message = invalidTimeFormatMessage(datafeedConfig.frequency); + basicValidations.frequency.message = invalidTimeIntervalMessage(datafeedConfig.frequency); } } @@ -202,16 +202,18 @@ export function checkForExistingJobAndGroupIds( }; } -function invalidTimeFormatMessage(value: string | undefined) { +function invalidTimeIntervalMessage(value: string | undefined) { return i18n.translate( 'xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage', { defaultMessage: - '{value} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.', + '{value} is not a valid time interval format e.g. {thirtySeconds}, {tenMinutes}, {oneHour}, {sevenDays}. It also needs to be higher than zero.', values: { value, + thirtySeconds: '30s', tenMinutes: '10m', oneHour: '1h', + sevenDays: '7d', }, } ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx index 4602ceeec905f9..6b2048f062f0f5 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/create_result_callout.tsx @@ -86,12 +86,12 @@ export const CreateResultCallout: FC = memo( fill={true} href={resultsUrl} aria-label={i18n.translate('xpack.ml.newJob.recognize.viewResultsAriaLabel', { - defaultMessage: 'View Results', + defaultMessage: 'View results', })} > diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 4a41f3e45001de..e3c45c6cd0b04f 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; +import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public'; import { getDocLinks } from '../../../../util/dependency_cache'; // @ts-ignore undeclared module @@ -65,13 +66,12 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { ]; } -export const JobsListPage: FC<{ I18nContext: CoreStart['i18n']['Context'] }> = ({ - I18nContext, -}) => { +export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { const [initialized, setInitialized] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = getTabs(isMlEnabledInSpace); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); + const I18nContext = coreStart.i18n.Context; const check = async () => { try { @@ -122,46 +122,48 @@ export const JobsListPage: FC<{ I18nContext: CoreStart['i18n']['Context'] }> = ( return ( - - - - -

- {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { - defaultMessage: 'Machine Learning Jobs', - })} -

-
- - - {currentTabId === 'anomaly_detection_jobs' - ? anomalyDetectionDocsLabel - : analyticsDocsLabel} - - -
-
- - - - {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { - defaultMessage: 'View machine learning analytics and anomaly detection jobs.', - })} - - - - {renderTabs()} -
+ + + + + +

+ {i18n.translate('xpack.ml.management.jobsList.jobsListTitle', { + defaultMessage: 'Machine Learning Jobs', + })} +

+
+ + + {currentTabId === 'anomaly_detection_jobs' + ? anomalyDetectionDocsLabel + : analyticsDocsLabel} + + +
+
+ + + + {i18n.translate('xpack.ml.management.jobsList.jobsListTagline', { + defaultMessage: 'View machine learning analytics and anomaly detection jobs.', + })} + + + + {renderTabs()} +
+
); }; diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 5d1fc6f0a3c920..b16f680a2a362f 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -14,8 +14,7 @@ import { getJobsListBreadcrumbs } from '../breadcrumbs'; import { setDependencyCache, clearCache } from '../../util/dependency_cache'; const renderApp = (element: HTMLElement, coreStart: CoreStart) => { - const I18nContext = coreStart.i18n.Context; - ReactDOM.render(React.createElement(JobsListPage, { I18nContext }), element); + ReactDOM.render(React.createElement(JobsListPage, { coreStart }), element); return () => { unmountComponentAtNode(element); clearCache(); diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx index c379cd702daee0..65e7ba9e8ab52e 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx @@ -89,7 +89,7 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { body={

{i18n.translate('xpack.ml.overview.analyticsList.emptyPromptText', { - defaultMessage: `Data frame analytics enable you to perform different analyses of your data and annotates it with the results. The job puts the annotated data and a copy of the source data in a new index.`, + defaultMessage: `Data frame analytics enables you to perform outlier detection, regression, or classification analysis on your data and annotates it with the results. The job puts the annotated data and a copy of the source data in a new index.`, })}

} diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx index c3b8e8dd4e27f9..f2e6ff7885b166 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx @@ -23,7 +23,7 @@ import { getTaskStateBadge, progressColumn, } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/columns'; -import { AnalyticsViewAction } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/actions'; +import { getAnalyticsViewAction } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/actions'; import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils'; const MlInMemoryTable = mlInMemoryTableFactory(); @@ -82,7 +82,7 @@ export const AnalyticsTable: FC = ({ items }) => { name: i18n.translate('xpack.ml.overview.analyticsList.tableActionLabel', { defaultMessage: 'Actions', }), - actions: [AnalyticsViewAction], + actions: [getAnalyticsViewAction()], width: '100px', }, ]; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts index 6cab23eb187c7c..a618534d7ae005 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -70,12 +70,12 @@ describe('DashboardService', () => { gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', embeddableConfig: { - title: 'ML anomaly swimlane for fb_population_1', + title: 'ML anomaly swim lane for fb_population_1', jobIds: ['fb_population_1'], limit: 5, swimlaneType: 'overall', }, - title: 'ML anomaly swimlane for fb_population_1', + title: 'ML anomaly swim lane for fb_population_1', }, { version: '8.0.0', @@ -118,12 +118,12 @@ describe('DashboardService', () => { gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', embeddableConfig: { - title: 'ML anomaly swimlane for fb_population_1', + title: 'ML anomaly swim lane for fb_population_1', jobIds: ['fb_population_1'], limit: 5, swimlaneType: 'overall', }, - title: 'ML anomaly swimlane for fb_population_1', + title: 'ML anomaly swim lane for fb_population_1', }, { version: '8.0.0', diff --git a/x-pack/plugins/ml/public/application/services/explorer_service.ts b/x-pack/plugins/ml/public/application/services/explorer_service.ts index 717ed3ba64c378..0944328db00523 100644 --- a/x-pack/plugins/ml/public/application/services/explorer_service.ts +++ b/x-pack/plugins/ml/public/application/services/explorer_service.ts @@ -55,8 +55,8 @@ export class ExplorerService { const intervalSeconds = this.timeBuckets.getInterval().asSeconds(); - // if the swimlane cell widths are too small they will not be visible - // calculate how many buckets will be drawn before the swimlanes are actually rendered + // if the swim lane cell widths are too small they will not be visible + // calculate how many buckets will be drawn before the swim lanes are actually rendered // and increase the interval to widen the cells if they're going to be smaller than 8px // this has to be done at this stage so all searches use the same interval const timerangeSeconds = (bounds.max!.valueOf() - bounds.min!.valueOf()) / 1000; @@ -81,7 +81,7 @@ export class ExplorerService { } /** - * Loads overall swimlane data + * Loads overall swim lane data * @param selectedJobs * @param chartWidth */ @@ -97,13 +97,13 @@ export class ExplorerService { const bounds = this.getTimeBounds(); - // Ensure the search bounds align to the bucketing interval used in the swimlane so + // Ensure the search bounds align to the bucketing interval used in the swim lane so // that the first and last buckets are complete. const searchBounds = getBoundsRoundedToInterval(bounds, interval, false); const selectedJobIds = selectedJobs.map((d) => d.id); // Load the overall bucket scores by time. - // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets + // Pass the interval in seconds as the swim lane relies on a fixed number of seconds between buckets // which wouldn't be the case if e.g. '1M' was used. // Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works // to ensure the search is inclusive of end time. @@ -125,7 +125,7 @@ export class ExplorerService { ); // eslint-disable-next-line no-console - console.log('Explorer overall swimlane data set:', overallSwimlaneData); + console.log('Explorer overall swim lane data set:', overallSwimlaneData); return overallSwimlaneData; } @@ -158,7 +158,7 @@ export class ExplorerService { const selectedJobIds = selectedJobs.map((d) => d.id); // load scores by influencer/jobId value and time. - // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets + // Pass the interval in seconds as the swim lane relies on a fixed number of seconds between buckets // which wouldn't be the case if e.g. '1M' was used. const interval = `${swimlaneBucketInterval.asSeconds()}s`; @@ -199,7 +199,7 @@ export class ExplorerService { swimlaneBucketInterval.asSeconds() ); // eslint-disable-next-line no-console - console.log('Explorer view by swimlane data set:', viewBySwimlaneData); + console.log('Explorer view by swim lane data set:', viewBySwimlaneData); return viewBySwimlaneData; } @@ -227,7 +227,7 @@ export class ExplorerService { }; // Store the earliest and latest times of the data returned by the ES aggregations, - // These will be used for calculating the earliest and latest times for the swimlane charts. + // These will be used for calculating the earliest and latest times for the swim lane charts. Object.entries(scoresByTime).forEach(([timeMs, score]) => { const time = Number(timeMs) / 1000; dataset.points.push({ @@ -250,7 +250,7 @@ export class ExplorerService { viewBySwimlaneFieldName: string, interval: number ): OverallSwimlaneData { - // Processes the scores for the 'view by' swimlane. + // Processes the scores for the 'view by' swim lane. // Sorts the lanes according to the supplied array of lane // values in the order in which they should be displayed, // or pass an empty array to sort lanes according to max score over all time. @@ -259,7 +259,7 @@ export class ExplorerService { points: [], laneLabels: [], interval, - // Set the earliest and latest to be the same as the overall swimlane. + // Set the earliest and latest to be the same as the overall swim lane. earliest: bounds.earliest, latest: bounds.latest, }; @@ -295,7 +295,7 @@ export class ExplorerService { }); } else { // Sort lanes according to supplied order - // e.g. when a cell in the overall swimlane has been selected. + // e.g. when a cell in the overall swim lane has been selected. // Find the index of each lane label from the actual data set, // rather than using sortedLaneValues as-is, just in case they differ. dataset.laneLabels = dataset.laneLabels.sort((a, b) => { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index 6d32fca6a645c1..af6944d7ae2d24 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -109,7 +109,6 @@ export type MlApiServices = ReturnType; export const ml = mlApiServicesProvider(new HttpService(proxyHttpStart)); export function mlApiServicesProvider(httpService: HttpService) { - const { http } = httpService; return { getJobs(obj?: { jobId?: string }) { const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; @@ -142,14 +141,14 @@ export function mlApiServicesProvider(httpService: HttpService) { }, closeJob({ jobId }: { jobId: string }) { - return http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}/_close`, method: 'POST', }); }, forceCloseJob({ jobId }: { jobId: string }) { - return http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, method: 'POST', }); @@ -278,14 +277,14 @@ export function mlApiServicesProvider(httpService: HttpService) { }, stopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ + return httpService.http({ path: `${basePath()}/datafeeds/${datafeedId}/_stop`, method: 'POST', }); }, forceStopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ + return httpService.http({ path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, method: 'POST', }); @@ -697,7 +696,7 @@ export function mlApiServicesProvider(httpService: HttpService) { }, getModelSnapshots(jobId: string, snapshotId?: string) { - return http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ snapshotId !== undefined ? `/${snapshotId}` : '' }`, @@ -709,7 +708,7 @@ export function mlApiServicesProvider(httpService: HttpService) { snapshotId: string, body: { description?: string; retain?: boolean } ) { - return http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, method: 'POST', body: JSON.stringify(body), @@ -717,7 +716,7 @@ export function mlApiServicesProvider(httpService: HttpService) { }, deleteModelSnapshot(jobId: string, snapshotId: string) { - return http({ + return httpService.http({ path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, method: 'DELETE', }); diff --git a/x-pack/plugins/ml/public/application/util/time_buckets.js b/x-pack/plugins/ml/public/application/util/time_buckets.js index 1915a4ce6516bc..19d499faf6c8dd 100644 --- a/x-pack/plugins/ml/public/application/util/time_buckets.js +++ b/x-pack/plugins/ml/public/application/util/time_buckets.js @@ -14,7 +14,11 @@ import { getFieldFormats, getUiSettings } from './dependency_cache'; import { FIELD_FORMAT_IDS, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; const unitsDesc = dateMath.unitsDesc; -const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer converted to days for ES intervals. + +// Index of the list of time interval units at which larger units (i.e. weeks, months, years) need +// need to be converted to multiples of the largest unit supported in ES aggregation intervals (i.e. days). +// Note that similarly the largest interval supported for ML bucket spans is 'd'. +const timeUnitsMaxSupportedIndex = unitsDesc.indexOf('w'); const calcAuto = timeBucketsCalcAutoIntervalProvider(); @@ -383,9 +387,11 @@ export function calcEsInterval(duration) { const val = duration.as(unit); // find a unit that rounds neatly if (val >= 1 && Math.floor(val) === val) { - // if the unit is "large", like years, but isn't set to 1, ES will throw an error. + // Apart from for date histograms, ES only supports time units up to 'd', + // meaning we can't for example use 'w' for job bucket spans. + // See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#time-units // So keep going until we get out of the "large" units. - if (i <= largeMax && val !== 1) { + if (i <= timeUnitsMaxSupportedIndex) { continue; } diff --git a/x-pack/plugins/ml/public/application/util/time_buckets.test.js b/x-pack/plugins/ml/public/application/util/time_buckets.test.js index 250c7255f5b99f..6ebd518841bd1d 100644 --- a/x-pack/plugins/ml/public/application/util/time_buckets.test.js +++ b/x-pack/plugins/ml/public/application/util/time_buckets.test.js @@ -232,14 +232,14 @@ describe('ML - time buckets', () => { expression: '3d', }); expect(calcEsInterval(moment.duration(7, 'd'))).toEqual({ - value: 1, - unit: 'w', - expression: '1w', + value: 7, + unit: 'd', + expression: '7d', }); expect(calcEsInterval(moment.duration(1, 'w'))).toEqual({ - value: 1, - unit: 'w', - expression: '1w', + value: 7, + unit: 'd', + expression: '7d', }); expect(calcEsInterval(moment.duration(4, 'w'))).toEqual({ value: 28, @@ -247,19 +247,19 @@ describe('ML - time buckets', () => { expression: '28d', }); expect(calcEsInterval(moment.duration(1, 'M'))).toEqual({ - value: 1, - unit: 'M', - expression: '1M', + value: 30, + unit: 'd', + expression: '30d', }); expect(calcEsInterval(moment.duration(12, 'M'))).toEqual({ - value: 1, - unit: 'y', - expression: '1y', + value: 365, + unit: 'd', + expression: '365d', }); expect(calcEsInterval(moment.duration(1, 'y'))).toEqual({ - value: 1, - unit: 'y', - expression: '1y', + value: 365, + unit: 'd', + expression: '365d', }); }); }); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index b4b25db452bdb7..3b4562628051e0 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -32,7 +32,7 @@ export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; export const getDefaultPanelTitle = (jobIds: JobId[]) => i18n.translate('xpack.ml.swimlaneEmbeddable.title', { - defaultMessage: 'ML anomaly swimlane for {jobIds}', + defaultMessage: 'ML anomaly swim lane for {jobIds}', values: { jobIds: jobIds.join(', ') }, }); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index 09091b21e49b6d..37c2cfb3e029b8 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -39,7 +39,7 @@ export class AnomalySwimlaneEmbeddableFactory public getDisplayName() { return i18n.translate('xpack.ml.components.jobAnomalyScoreEmbeddable.displayName', { - defaultMessage: 'ML Anomaly Swimlane', + defaultMessage: 'ML Anomaly Swim Lane', }); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index 4c93b9ef232391..4977ece54bb57f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx @@ -92,7 +92,7 @@ export const AnomalySwimlaneInitializer: FC = ( @@ -121,7 +121,7 @@ export const AnomalySwimlaneInitializer: FC = ( label={ } > @@ -131,7 +131,7 @@ export const AnomalySwimlaneInitializer: FC = ( color="primary" isFullWidth legend={i18n.translate('xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel', { - defaultMessage: 'Swimlane type', + defaultMessage: 'Swim lane type', })} options={swimlaneTypeOptions} idSelected={swimlaneType} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx index 0bba9b59f7bf73..db2b9d55cfabbf 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx @@ -69,7 +69,7 @@ export const ExplorerSwimlaneContainer: FC = ({ title={ } color="danger" diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index be2ebb3caa4161..7f7544a44efa7f 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -30,10 +30,12 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { registerEmbeddables } from './embeddables'; import { UiActionsSetup } from '../../../../src/plugins/ui_actions/public'; import { registerMlUiActions } from './ui_actions'; +import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; export interface MlStartDependencies { data: DataPublicPluginStart; share: SharePluginStart; + kibanaLegacy: KibanaLegacyStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -70,6 +72,7 @@ export class MlPlugin implements Plugin { { data: pluginsStart.data, share: pluginsStart.share, + kibanaLegacy: pluginsStart.kibanaLegacy, security: pluginsSetup.security, licensing: pluginsSetup.licensing, management: pluginsSetup.management, diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts index ff83d79adff67e..4b1d7ee733dcfd 100644 --- a/x-pack/plugins/ml/public/shared.ts +++ b/x-pack/plugins/ml/public/shared.ts @@ -14,10 +14,9 @@ export * from '../common/types/audit_message'; export * from '../common/util/anomaly_utils'; export * from '../common/util/errors'; - export * from '../common/util/validators'; export * from './application/formatters/metric_change_description'; - export * from './application/components/data_grid'; export * from './application/data_frame_analytics/common'; +export * from './application/util/date_utils'; diff --git a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx index 915fe3cc8f9481..312b9f31124b13 100644 --- a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx @@ -32,7 +32,7 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt }, getDisplayName: () => i18n.translate('xpack.ml.actions.editSwimlaneTitle', { - defaultMessage: 'Edit swimlane', + defaultMessage: 'Edit swim lane', }), execute: async ({ embeddable }: EditSwimlanePanelContext) => { if (!embeddable) { diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index f9999a06f38ed0..0aae4388e73992 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -133,11 +133,11 @@ describe('ML - validateJob', () => { }); }; it('invalid bucket span formats', () => { - const invalidBucketSpanFormats = ['a', '10', '$']; + const invalidBucketSpanFormats = ['a', '10', '$', '500ms', '1w', '2M', '1y']; return bucketSpanFormatTests(invalidBucketSpanFormats, 'bucket_span_invalid'); }); it('valid bucket span formats', () => { - const validBucketSpanFormats = ['1s', '4h', '10d', '6w', '2m', '3y']; + const validBucketSpanFormats = ['5000ms', '1s', '2m', '4h', '10d']; return bucketSpanFormatTests(validBucketSpanFormats, 'bucket_span_valid'); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js index 46d05d3cf76376..7dc2ad7ff3b8f3 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js @@ -5,9 +5,8 @@ */ import { estimateBucketSpanFactory } from '../../models/bucket_span_estimator'; -import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; +import { mlFunctionToESAggregation, parseTimeIntervalForJob } from '../../../common/util/job_utils'; import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation'; -import { parseInterval } from '../../../common/util/parse_interval'; import { validateJobObject } from './validate_job_object'; @@ -65,8 +64,11 @@ export async function validateBucketSpan( } const messages = []; - const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span); - if (parsedBucketSpan === null || parsedBucketSpan.asMilliseconds() === 0) { + + // Bucket span must be a valid interval, greater than 0, + // and if specified in ms must be a multiple of 1000ms + const parsedBucketSpan = parseTimeIntervalForJob(job.analysis_config.bucket_span); + if (parsedBucketSpan === null) { messages.push({ id: 'bucket_span_invalid' }); return messages; } diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index be6c9a7157aeb6..f60ca66b092f91 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -78,7 +78,7 @@ export async function validateTimeRange( } // check for minimum time range (25 buckets or 2 hours, whichever is longer) - const interval = parseInterval(job.analysis_config.bucket_span); + const interval = parseInterval(job.analysis_config.bucket_span, true); if (interval === null) { messages.push({ id: 'bucket_span_invalid' }); } else { diff --git a/x-pack/plugins/monitoring/public/angular/index.ts b/x-pack/plugins/monitoring/public/angular/index.ts index 3f2a51a898d1f3..69d97a5e3bdc35 100644 --- a/x-pack/plugins/monitoring/public/angular/index.ts +++ b/x-pack/plugins/monitoring/public/angular/index.ts @@ -25,12 +25,22 @@ export class AngularApp { isCloud, pluginInitializerContext, externalConfig, + kibanaLegacy, } = deps; const app: IModule = localAppModule(deps); app.run(($injector: angular.auto.IInjectorService) => { this.injector = $injector; Legacy.init( - { core, element, data, navigation, isCloud, pluginInitializerContext, externalConfig }, + { + core, + element, + data, + navigation, + isCloud, + pluginInitializerContext, + externalConfig, + kibanaLegacy, + }, this.injector ); }); diff --git a/x-pack/plugins/monitoring/public/components/apm/instance/instance.js b/x-pack/plugins/monitoring/public/components/apm/instance/instance.js index cb7187a8c0753d..396d2258edd0c8 100644 --- a/x-pack/plugins/monitoring/public/components/apm/instance/instance.js +++ b/x-pack/plugins/monitoring/public/components/apm/instance/instance.js @@ -21,18 +21,22 @@ import { FormattedMessage } from '@kbn/i18n/react'; export function ApmServerInstance({ summary, metrics, ...props }) { const seriesToShow = [ + metrics.apm_requests, metrics.apm_responses_valid, + metrics.apm_responses_errors, + metrics.apm_acm_request_count, + + metrics.apm_acm_response, + metrics.apm_acm_response_errors, metrics.apm_output_events_rate_success, metrics.apm_output_events_rate_failure, - metrics.apm_requests, metrics.apm_transformations, - metrics.apm_cpu, - metrics.apm_memory, + metrics.apm_memory, metrics.apm_os_load, ]; @@ -56,8 +60,10 @@ export function ApmServerInstance({ summary, metrics, ...props }) {

- - + + + + {charts} diff --git a/x-pack/plugins/monitoring/public/components/apm/instances/instances.js b/x-pack/plugins/monitoring/public/components/apm/instances/instances.js index 9d0d2c5aefa563..7754af1be85881 100644 --- a/x-pack/plugins/monitoring/public/components/apm/instances/instances.js +++ b/x-pack/plugins/monitoring/public/components/apm/instances/instances.js @@ -15,6 +15,7 @@ import { EuiPageContent, EuiSpacer, EuiScreenReaderOnly, + EuiPanel, } from '@elastic/eui'; import { Status } from './status'; import { formatMetric } from '../../../lib/format_number'; @@ -154,7 +155,9 @@ export function ApmServerInstances({ apms, setupMode }) {

- + + + {setupModeCallout} - + + + {charts} diff --git a/x-pack/plugins/monitoring/public/components/chart/get_color.js b/x-pack/plugins/monitoring/public/components/chart/get_color.js index e4a5777bb6efe3..868b914a16c8ab 100644 --- a/x-pack/plugins/monitoring/public/components/chart/get_color.js +++ b/x-pack/plugins/monitoring/public/components/chart/get_color.js @@ -13,10 +13,14 @@ * @param {Integer} index: index of the chart series, 0-3 * @returns {String} Hex color to use for chart series at the given index */ +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; + export function getColor(app, index) { let seriesColors; if (app === 'elasticsearch') { seriesColors = ['#3ebeb0', '#3b73ac', '#f08656', '#6c478f']; + } else if (app === 'apm') { + seriesColors = euiPaletteColorBlind(); } else { // for kibana, and fallback (e.g., Logstash and Beats) seriesColors = ['#e8488b', '#3b73ac', '#3cab63', '#6c478f']; diff --git a/x-pack/plugins/monitoring/public/components/logstash/listing/listing.js b/x-pack/plugins/monitoring/public/components/logstash/listing/listing.js index 8e2c43e44ee118..78eb982a95dd7e 100644 --- a/x-pack/plugins/monitoring/public/components/logstash/listing/listing.js +++ b/x-pack/plugins/monitoring/public/components/logstash/listing/listing.js @@ -62,7 +62,7 @@ export class Listing extends PureComponent { return (
- + {name}
diff --git a/x-pack/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js b/x-pack/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js index 1b22bc6823bb83..4cacf91913ab94 100644 --- a/x-pack/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js +++ b/x-pack/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js @@ -46,7 +46,7 @@ export class PipelineListing extends Component { field: 'id', sortable: true, render: (id) => ( - + {id} ), diff --git a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js b/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js index bef0fce4cd088b..ec325673ddfda0 100644 --- a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js +++ b/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js @@ -72,7 +72,9 @@ const getColumns = () => [ render: (name, node) => { if (node) { return ( - {name} + + {name} + ); } diff --git a/x-pack/plugins/monitoring/public/directives/main/index.js b/x-pack/plugins/monitoring/public/directives/main/index.js index 97ec66c9b3415b..eda32cd39c0d0b 100644 --- a/x-pack/plugins/monitoring/public/directives/main/index.js +++ b/x-pack/plugins/monitoring/public/directives/main/index.js @@ -133,7 +133,7 @@ export class MonitoringMainController { this.pipelineHashShort = shortenPipelineHash(this.pipelineHash); this.onChangePipelineHash = () => { window.location.hash = getSafeForExternalLink( - `/logstash/pipelines/${this.pipelineId}/${this.pipelineHash}` + `#/logstash/pipelines/${this.pipelineId}/${this.pipelineHash}` ); }; } diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index 24383028e558c9..de8c8d59b78bff 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -70,6 +70,7 @@ export class MonitoringPlugin const { AngularApp } = await import('./angular'); const deps: MonitoringPluginDependencies = { navigation: pluginsStart.navigation, + kibanaLegacy: pluginsStart.kibanaLegacy, element: params.element, core: coreStart, data: pluginsStart.data, @@ -78,6 +79,7 @@ export class MonitoringPlugin externalConfig: this.getExternalConfig(), }; + pluginsStart.kibanaLegacy.loadFontAwesome(); this.setInitialTimefilter(deps); this.overrideAlertingEmailDefaults(deps); diff --git a/x-pack/plugins/monitoring/public/services/breadcrumbs.js b/x-pack/plugins/monitoring/public/services/breadcrumbs.js index 44422a42f5f0a1..63bac2975dc206 100644 --- a/x-pack/plugins/monitoring/public/services/breadcrumbs.js +++ b/x-pack/plugins/monitoring/public/services/breadcrumbs.js @@ -76,6 +76,7 @@ function getKibanaBreadcrumbs(mainInstance) { }) ) ); + breadcrumbs.push(createCrumb(null, mainInstance.instance)); } else { // don't link to Overview when we're possibly on Overview or its sibling tabs breadcrumbs.push(createCrumb(null, 'Kibana')); @@ -160,6 +161,7 @@ function getApmBreadcrumbs(mainInstance) { }) ) ); + breadcrumbs.push(createCrumb(null, mainInstance.instance)); } else { // don't link to Overview when we're possibly on Overview or its sibling tabs breadcrumbs.push(createCrumb(null, apmLabel)); diff --git a/x-pack/plugins/monitoring/public/types.ts b/x-pack/plugins/monitoring/public/types.ts index b8c854f4e7ee0d..6266755a041206 100644 --- a/x-pack/plugins/monitoring/public/types.ts +++ b/x-pack/plugins/monitoring/public/types.ts @@ -7,6 +7,7 @@ import { PluginInitializerContext, CoreStart } from 'kibana/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { MonitoringConfig } from '../server'; @@ -14,6 +15,7 @@ export { MonitoringConfig } from '../server'; export interface MonitoringPluginDependencies { navigation: NavigationStart; data: DataPublicPluginStart; + kibanaLegacy: KibanaLegacyStart; element: HTMLElement; core: CoreStart; isCloud: boolean; diff --git a/x-pack/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap b/x-pack/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap index 1cc442cb15993e..74916fb0a0789f 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap +++ b/x-pack/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap @@ -2,6 +2,416 @@ exports[`Metrics should export metric objects that match a snapshot 1`] = ` Object { + "apm_acm_request_count": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.request.count", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "HTTP Requests received by agent configuration managemen", + "field": "beats_stats.metrics.apm-server.acm.request.count", + "format": "0,0.[00]", + "label": "Count", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Requests Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_count": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.count", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "HTTP requests responded to by APM Server", + "field": "beats_stats.metrics.apm-server.acm.response.count", + "format": "0,0.[00]", + "label": "Count", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Count Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_count": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.count", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "HTTP errors count", + "field": "beats_stats.metrics.apm-server.acm.response.errors.count", + "format": "0,0.[00]", + "label": "Error Count", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Error Count Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_forbidden": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.forbidden", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "Forbidden HTTP requests rejected count", + "field": "beats_stats.metrics.apm-server.acm.response.errors.forbidden", + "format": "0,0.[00]", + "label": "Count", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Errors Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_invalidquery": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.invalidquery", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "Invalid HTTP query", + "field": "beats_stats.metrics.apm-server.acm.response.errors.invalidquery", + "format": "0,0.[00]", + "label": "Invalid Query", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Invalid Query Errors Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_method": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.method", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "HTTP requests rejected due to incorrect HTTP method", + "field": "beats_stats.metrics.apm-server.acm.response.errors.method", + "format": "0,0.[00]", + "label": "Method", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Method Errors Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_unauthorized": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.unauthorized", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "Unauthorized HTTP requests rejected count", + "field": "beats_stats.metrics.apm-server.acm.response.errors.unauthorized", + "format": "0,0.[00]", + "label": "Unauthorized", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Unauthorized Errors Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_errors_unavailable": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.errors.unavailable", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "Unavailable HTTP response count. Possible misconfiguration or unsupported version of Kibana", + "field": "beats_stats.metrics.apm-server.acm.response.errors.unavailable", + "format": "0,0.[00]", + "label": "Unavailable", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Unavailable Errors Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_valid_notmodified": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.valid.notmodified", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "304 Not modified response count", + "field": "beats_stats.metrics.apm-server.acm.response.valid.notmodified", + "format": "0,0.[00]", + "label": "Not Modified", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response Not Modified Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, + "apm_acm_response_valid_ok": ApmEventsRateClusterMetric { + "aggs": Object { + "beats_uuids": Object { + "aggs": Object { + "event_rate_per_beat": Object { + "max": Object { + "field": "beats_stats.metrics.apm-server.acm.response.valid.ok", + }, + }, + }, + "terms": Object { + "field": "beats_stats.beat.uuid", + "size": 10000, + }, + }, + "event_rate": Object { + "sum_bucket": Object { + "buckets_path": "beats_uuids>event_rate_per_beat", + "gap_policy": "skip", + }, + }, + "metric_deriv": Object { + "derivative": Object { + "buckets_path": "event_rate", + "gap_policy": "skip", + "unit": "1s", + }, + }, + }, + "app": "apm", + "derivative": true, + "description": "200 OK response count", + "field": "beats_stats.metrics.apm-server.acm.response.valid.ok", + "format": "0,0.[00]", + "label": "OK", + "metricAgg": "max", + "timestampField": "beats_stats.timestamp", + "title": "Response OK Count Agent Configuration Management", + "units": "/s", + "uuidField": "cluster_uuid", + }, "apm_cpu_total": ApmCpuUtilizationMetric { "app": "apm", "calculation": [Function], @@ -80,7 +490,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -93,7 +503,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Output Acked Events Rate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_output_events_active": ApmEventsRateClusterMetric { @@ -121,7 +531,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -134,7 +544,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Output Active Events Rate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_output_events_dropped": ApmEventsRateClusterMetric { @@ -162,7 +572,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -175,7 +585,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Output Dropped Events Rate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_output_events_failed": ApmEventsRateClusterMetric { @@ -203,7 +613,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -216,7 +626,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Output Failed Events Rate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_output_events_total": ApmEventsRateClusterMetric { @@ -244,7 +654,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -257,7 +667,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Output Events Rate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_processor_error_transformations": ApmEventsRateClusterMetric { @@ -285,7 +695,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -298,7 +708,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Transformations", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_processor_metric_transformations": ApmEventsRateClusterMetric { @@ -326,7 +736,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -339,7 +749,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Transformations", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_processor_span_transformations": ApmEventsRateClusterMetric { @@ -367,7 +777,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -380,7 +790,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Transformations", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_processor_transaction_transformations": ApmEventsRateClusterMetric { @@ -408,7 +818,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -421,7 +831,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Processed Events", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_requests": ApmEventsRateClusterMetric { @@ -449,7 +859,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -462,7 +872,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Request Count Intake API", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_count": ApmEventsRateClusterMetric { @@ -490,7 +900,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -503,7 +913,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Response Count Intake API", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_closed": ApmEventsRateClusterMetric { @@ -531,7 +941,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -544,7 +954,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Closed", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_concurrency": ApmEventsRateClusterMetric { @@ -572,7 +982,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -585,7 +995,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Concurrency", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_decode": ApmEventsRateClusterMetric { @@ -613,7 +1023,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -626,7 +1036,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Decode", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_forbidden": ApmEventsRateClusterMetric { @@ -654,7 +1064,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -667,7 +1077,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Forbidden", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_internal": ApmEventsRateClusterMetric { @@ -695,7 +1105,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -708,7 +1118,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Internal", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_method": ApmEventsRateClusterMetric { @@ -736,7 +1146,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -749,7 +1159,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Method", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_queue": ApmEventsRateClusterMetric { @@ -777,7 +1187,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -790,7 +1200,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Queue", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_ratelimit": ApmEventsRateClusterMetric { @@ -818,7 +1228,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -831,7 +1241,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Rate limit", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_toolarge": ApmEventsRateClusterMetric { @@ -859,7 +1269,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -872,7 +1282,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Response Errors Intake API", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_unauthorized": ApmEventsRateClusterMetric { @@ -900,7 +1310,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -913,7 +1323,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Unauthorized", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_errors_validate": ApmEventsRateClusterMetric { @@ -941,7 +1351,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -954,7 +1364,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Validate", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_valid_accepted": ApmEventsRateClusterMetric { @@ -982,7 +1392,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -995,7 +1405,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Accepted", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_responses_valid_ok": ApmEventsRateClusterMetric { @@ -1023,7 +1433,7 @@ Object { "derivative": Object { "buckets_path": "event_rate", "gap_policy": "skip", - "unit": "1m", + "unit": "1s", }, }, }, @@ -1036,7 +1446,7 @@ Object { "metricAgg": "max", "timestampField": "beats_stats.timestamp", "title": "Ok", - "units": "/m", + "units": "/s", "uuidField": "cluster_uuid", }, "apm_system_os_load_1": ApmMetric { diff --git a/x-pack/plugins/monitoring/server/lib/metrics/apm/classes.js b/x-pack/plugins/monitoring/server/lib/metrics/apm/classes.js index fc8a45669bd947..840f5d5cb239e0 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/apm/classes.js +++ b/x-pack/plugins/monitoring/server/lib/metrics/apm/classes.js @@ -7,6 +7,7 @@ import { ClusterMetric, Metric } from '../classes'; import { SMALL_FLOAT, LARGE_FLOAT } from '../../../../common/formatting'; import { i18n } from '@kbn/i18n'; +import { NORMALIZED_DERIVATIVE_UNIT } from '../../../../common/constants'; export class ApmClusterMetric extends ClusterMetric { constructor(opts) { @@ -76,8 +77,8 @@ export class ApmEventsRateClusterMetric extends ApmClusterMetric { derivative: true, format: LARGE_FLOAT, metricAgg: 'max', - units: i18n.translate('xpack.monitoring.metrics.apm.perMinuteUnitLabel', { - defaultMessage: '/m', + units: i18n.translate('xpack.monitoring.metrics.apm.perSecondUnitLabel', { + defaultMessage: '/s', }), }); @@ -105,7 +106,7 @@ export class ApmEventsRateClusterMetric extends ApmClusterMetric { derivative: { buckets_path: 'event_rate', gap_policy: 'skip', - unit: '1m', + unit: NORMALIZED_DERIVATIVE_UNIT, }, }, }; diff --git a/x-pack/plugins/monitoring/server/lib/metrics/apm/metrics.js b/x-pack/plugins/monitoring/server/lib/metrics/apm/metrics.js index db2eae591f3598..1d063a57bbb5b5 100644 --- a/x-pack/plugins/monitoring/server/lib/metrics/apm/metrics.js +++ b/x-pack/plugins/monitoring/server/lib/metrics/apm/metrics.js @@ -449,4 +449,143 @@ export const metrics = { } ), }), + apm_acm_response_count: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.count', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.countTitle', { + defaultMessage: 'Response Count Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.countLabel', { + defaultMessage: 'Count', + }), + description: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.countDescription', { + defaultMessage: 'HTTP requests responded to by APM Server', + }), + }), + apm_acm_response_errors_count: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.count', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errorCountTitle', { + defaultMessage: 'Response Error Count Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errorCountLabel', { + defaultMessage: 'Error Count', + }), + description: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errorCountDescription', { + defaultMessage: 'HTTP errors count', + }), + }), + apm_acm_response_valid_ok: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.valid.ok', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.validOkTitle', { + defaultMessage: 'Response OK Count Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.validOkLabel', { + defaultMessage: 'OK', + }), + description: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.validOkDescription', { + defaultMessage: '200 OK response count', + }), + }), + apm_acm_response_valid_notmodified: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.valid.notmodified', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.validNotModifiedTitle', { + defaultMessage: 'Response Not Modified Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.validNotModifiedLabel', { + defaultMessage: 'Not Modified', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.validNotModifiedDescription', + { + defaultMessage: '304 Not modified response count', + } + ), + }), + apm_acm_response_errors_forbidden: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.forbidden', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.forbiddenTitle', { + defaultMessage: 'Response Errors Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.forbiddenLabel', { + defaultMessage: 'Count', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.errors.forbiddenDescription', + { + defaultMessage: 'Forbidden HTTP requests rejected count', + } + ), + }), + apm_acm_response_errors_unauthorized: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.unauthorized', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.unauthorizedTitle', { + defaultMessage: 'Response Unauthorized Errors Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.unauthorizedLabel', { + defaultMessage: 'Unauthorized', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.errors.unauthorizedDescription', + { + defaultMessage: 'Unauthorized HTTP requests rejected count', + } + ), + }), + apm_acm_response_errors_unavailable: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.unavailable', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.unavailableTitle', { + defaultMessage: 'Response Unavailable Errors Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.unavailableLabel', { + defaultMessage: 'Unavailable', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.errors.unavailableDescription', + { + defaultMessage: + 'Unavailable HTTP response count. Possible misconfiguration or unsupported version of Kibana', + } + ), + }), + apm_acm_response_errors_method: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.method', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.methodTitle', { + defaultMessage: 'Response Method Errors Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.methodLabel', { + defaultMessage: 'Method', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.errors.methodDescription', + { + defaultMessage: 'HTTP requests rejected due to incorrect HTTP method', + } + ), + }), + apm_acm_response_errors_invalidquery: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.response.errors.invalidquery', + title: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.invalidqueryTitle', { + defaultMessage: 'Response Invalid Query Errors Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmResponse.errors.invalidqueryLabel', { + defaultMessage: 'Invalid Query', + }), + description: i18n.translate( + 'xpack.monitoring.metrics.apm.acmResponse.errors.invalidqueryDescription', + { + defaultMessage: 'Invalid HTTP query', + } + ), + }), + apm_acm_request_count: new ApmEventsRateClusterMetric({ + field: 'beats_stats.metrics.apm-server.acm.request.count', + title: i18n.translate('xpack.monitoring.metrics.apm.acmRequest.countTitle', { + defaultMessage: 'Requests Agent Configuration Management', + }), + label: i18n.translate('xpack.monitoring.metrics.apm.acmRequest.countTitleLabel', { + defaultMessage: 'Count', + }), + description: i18n.translate('xpack.monitoring.metrics.apm.acmRequest.countTitleDescription', { + defaultMessage: 'HTTP Requests received by agent configuration managemen', + }), + }), }; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/apm/instance.js b/x-pack/plugins/monitoring/server/routes/api/v1/apm/instance.js index 0ff9e834b924c8..16921e998f2964 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/apm/instance.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/apm/instance.js @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { getMetrics } from '../../../../lib/details/get_metrics'; -import { metricSet } from './metric_set_overview'; +import { metricSet } from './metric_set_instance'; import { handleError } from '../../../../lib/errors'; import { getApmInfo } from '../../../../lib/apm'; import { INDEX_PATTERN_BEATS } from '../../../../../common/constants'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/apm/metric_set_instance.js b/x-pack/plugins/monitoring/server/routes/api/v1/apm/metric_set_instance.js index 9ce96d386ae2f7..5ace433c295da3 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/apm/metric_set_instance.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/apm/metric_set_instance.js @@ -59,4 +59,27 @@ export const metricSet = [ ], name: 'apm_transformations', }, + { + keys: [ + 'apm_acm_response_count', + 'apm_acm_response_errors_count', + 'apm_acm_response_valid_ok', + 'apm_acm_response_valid_notmodified', + ], + name: 'apm_acm_response', + }, + { + keys: [ + 'apm_acm_response_errors_forbidden', + 'apm_acm_response_errors_unauthorized', + 'apm_acm_response_errors_unavailable', + 'apm_acm_response_errors_method', + 'apm_acm_response_errors_invalidquery', + ], + name: 'apm_acm_response_errors', + }, + { + keys: ['apm_acm_request_count'], + name: 'apm_acm_request_count', + }, ]; diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts new file mode 100644 index 00000000000000..71c2c942239fdc --- /dev/null +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -0,0 +1,365 @@ +/* + * 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 { registerDataHandler, getDataHandler } from './data_handler'; + +const params = { + startTime: '0', + endTime: '1', + bucketSize: '10s', +}; + +describe('registerDataHandler', () => { + describe('APM', () => { + registerDataHandler({ + appName: 'apm', + fetchData: async () => { + return { + title: 'apm', + appLink: '/apm', + stats: { + services: { + label: 'services', + type: 'number', + value: 1, + }, + transactions: { + label: 'transactions', + type: 'number', + value: 1, + }, + }, + series: { + transactions: { + label: 'transactions', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => true, + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('apm'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('apm'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'apm', + appLink: '/apm', + stats: { + services: { + label: 'services', + type: 'number', + value: 1, + }, + transactions: { + label: 'transactions', + type: 'number', + value: 1, + }, + }, + series: { + transactions: { + label: 'transactions', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Logs', () => { + registerDataHandler({ + appName: 'infra_logs', + fetchData: async () => { + return { + title: 'logs', + appLink: '/logs', + stats: { + foo: { + label: 'Foo', + type: 'number', + value: 1, + }, + bar: { + label: 'bar', + type: 'number', + value: 1, + }, + }, + series: { + foo: { + label: 'Foo', + coordinates: [{ x: 1 }], + }, + bar: { + label: 'Bar', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => true, + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('infra_logs'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('infra_logs'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'logs', + appLink: '/logs', + stats: { + foo: { + label: 'Foo', + type: 'number', + value: 1, + }, + bar: { + label: 'bar', + type: 'number', + value: 1, + }, + }, + series: { + foo: { + label: 'Foo', + coordinates: [{ x: 1 }], + }, + bar: { + label: 'Bar', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Uptime', () => { + registerDataHandler({ + appName: 'uptime', + fetchData: async () => { + return { + title: 'uptime', + appLink: '/uptime', + stats: { + monitors: { + label: 'Monitors', + type: 'number', + value: 1, + }, + up: { + label: 'Up', + type: 'number', + value: 1, + }, + down: { + label: 'Down', + type: 'number', + value: 1, + }, + }, + series: { + down: { + label: 'Down', + coordinates: [{ x: 1 }], + }, + up: { + label: 'Up', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => true, + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('uptime'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('uptime'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'uptime', + appLink: '/uptime', + stats: { + monitors: { + label: 'Monitors', + type: 'number', + value: 1, + }, + up: { + label: 'Up', + type: 'number', + value: 1, + }, + down: { + label: 'Down', + type: 'number', + value: 1, + }, + }, + series: { + down: { + label: 'Down', + coordinates: [{ x: 1 }], + }, + up: { + label: 'Up', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Metrics', () => { + registerDataHandler({ + appName: 'infra_metrics', + fetchData: async () => { + return { + title: 'metrics', + appLink: '/metrics', + stats: { + hosts: { + label: 'hosts', + type: 'number', + value: 1, + }, + cpu: { + label: 'cpu', + type: 'number', + value: 1, + }, + memory: { + label: 'memory', + type: 'number', + value: 1, + }, + disk: { + label: 'disk', + type: 'number', + value: 1, + }, + inboundTraffic: { + label: 'inboundTraffic', + type: 'number', + value: 1, + }, + outboundTraffic: { + label: 'outboundTraffic', + type: 'number', + value: 1, + }, + }, + series: { + inboundTraffic: { + label: 'inbound Traffic', + coordinates: [{ x: 1 }], + }, + outboundTraffic: { + label: 'outbound Traffic', + coordinates: [{ x: 1 }], + }, + }, + }; + }, + hasData: async () => true, + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('infra_metrics'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('infra_metrics'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'metrics', + appLink: '/metrics', + stats: { + hosts: { + label: 'hosts', + type: 'number', + value: 1, + }, + cpu: { + label: 'cpu', + type: 'number', + value: 1, + }, + memory: { + label: 'memory', + type: 'number', + value: 1, + }, + disk: { + label: 'disk', + type: 'number', + value: 1, + }, + inboundTraffic: { + label: 'inboundTraffic', + type: 'number', + value: 1, + }, + outboundTraffic: { + label: 'outboundTraffic', + type: 'number', + value: 1, + }, + }, + series: { + inboundTraffic: { + label: 'inbound Traffic', + coordinates: [{ x: 1 }], + }, + outboundTraffic: { + label: 'outbound Traffic', + coordinates: [{ x: 1 }], + }, + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('apm'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index 30a7357404d23b..288da3d78bf36b 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,21 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FetchData, HasData } from './typings/data_handler'; +import { ObservabilityFetchDataResponse, FetchDataResponse } from './typings/fetch_data_response'; import { ObservabilityApp } from '../typings/common'; -interface DataHandler { - fetchData: FetchData; +interface FetchDataParams { + // The start timestamp in milliseconds of the queried time interval + startTime: string; + // The end timestamp in milliseconds of the queried time interval + endTime: string; + // The aggregation bucket size in milliseconds if applicable to the data source + bucketSize: string; +} + +export type FetchData = ( + fetchDataParams: FetchDataParams +) => Promise; + +export type HasData = () => Promise; + +interface DataHandler { + fetchData: FetchData; hasData: HasData; } const dataHandlers: Partial> = {}; -export type RegisterDataHandler = (params: { appName: ObservabilityApp } & DataHandler) => void; -export const registerDataHandler: RegisterDataHandler = ({ appName, fetchData, hasData }) => { +export function registerDataHandler({ + appName, + fetchData, + hasData, +}: { appName: T } & DataHandler) { dataHandlers[appName] = { fetchData, hasData }; -}; +} -export function getDataHandler(appName: ObservabilityApp): DataHandler | undefined { - return dataHandlers[appName]; +export function getDataHandler(appName: T) { + const dataHandler = dataHandlers[appName]; + if (dataHandler) { + return dataHandler as DataHandler; + } } diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index ade347c79728d2..fcb569f535d763 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -5,15 +5,15 @@ */ import { PluginInitializerContext, PluginInitializer } from 'kibana/public'; -import { Plugin, ObservabilityPluginSetup, ObservabilityPluginStart } from './plugin'; +import { Plugin, ObservabilityPluginSetup } from './plugin'; -export const plugin: PluginInitializer = ( +export const plugin: PluginInitializer = ( context: PluginInitializerContext ) => { return new Plugin(context); }; -export { ObservabilityPluginSetup, ObservabilityPluginStart }; +export { ObservabilityPluginSetup }; export * from './components/action_menu'; diff --git a/x-pack/plugins/observability/public/pages/home/index.tsx b/x-pack/plugins/observability/public/pages/home/index.tsx index 696361393ef82d..91e7e2759b8244 100644 --- a/x-pack/plugins/observability/public/pages/home/index.tsx +++ b/x-pack/plugins/observability/public/pages/home/index.tsx @@ -92,7 +92,7 @@ export const Home = () => {

{i18n.translate('xpack.observability.home.sectionTitle', { - defaultMessage: 'Observability built on the Elastic Stack', + defaultMessage: 'Unified visibility across your entire ecosystem', })}

@@ -100,7 +100,7 @@ export const Home = () => { {i18n.translate('xpack.observability.home.sectionsubtitle', { defaultMessage: - 'Bring your logs, metrics, and APM traces together at scale in a single stack so you can monitor and react to events happening anywhere in your environment.', + 'Monitor, analyze, and react to events happening anywhere in your environment by bringing logs, metrics, and traces together at scale in a single stack.', })} diff --git a/x-pack/plugins/observability/public/pages/home/section.ts b/x-pack/plugins/observability/public/pages/home/section.ts index a2b82c31bf2ab0..d33571a16ccb75 100644 --- a/x-pack/plugins/observability/public/pages/home/section.ts +++ b/x-pack/plugins/observability/public/pages/home/section.ts @@ -23,7 +23,7 @@ export const appsSection: ISection[] = [ icon: 'logoLogging', description: i18n.translate('xpack.observability.section.apps.logs.description', { defaultMessage: - 'The Elastic Stack (sometimes known as the ELK Stack) is the most popular open source logging platform.', + 'Centralize logs from any source. Search, tail, automate anomaly detection, and visualize trends so you can take action quicker.', }), }, { @@ -34,7 +34,7 @@ export const appsSection: ISection[] = [ icon: 'logoAPM', description: i18n.translate('xpack.observability.section.apps.apm.description', { defaultMessage: - 'See exactly where your application is spending time so you can quickly fix issues and feel good about the code you push.', + 'Trace transactions through a distributed architecture and map your services’ interactions to easily spot performance bottlenecks.', }), }, { @@ -45,7 +45,7 @@ export const appsSection: ISection[] = [ icon: 'logoMetrics', description: i18n.translate('xpack.observability.section.apps.metrics.description', { defaultMessage: - 'Already using the Elastic Stack for logs? Add metrics in just a few steps and correlate metrics and logs in one place.', + 'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.', }), }, { @@ -56,7 +56,7 @@ export const appsSection: ISection[] = [ icon: 'logoUptime', description: i18n.translate('xpack.observability.section.apps.uptime.description', { defaultMessage: - 'React to availability issues across your apps and services before they affect users.', + 'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users’ experience.', }), }, ]; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 16adf88d152c52..c20e8c7b75d49d 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -10,15 +10,13 @@ import { Plugin as PluginClass, PluginInitializerContext, } from '../../../../src/core/public'; -import { RegisterDataHandler, registerDataHandler } from './data_handler'; +import { registerDataHandler } from './data_handler'; export interface ObservabilityPluginSetup { - dashboard: { register: RegisterDataHandler }; + dashboard: { register: typeof registerDataHandler }; } -export type ObservabilityPluginStart = void; - -export class Plugin implements PluginClass { +export class Plugin implements PluginClass { constructor(context: PluginInitializerContext) {} public setup(core: CoreSetup) { diff --git a/x-pack/plugins/observability/public/typings/data_handler/index.d.ts b/x-pack/plugins/observability/public/typings/data_handler/index.d.ts deleted file mode 100644 index a208e4e7c223d5..00000000000000 --- a/x-pack/plugins/observability/public/typings/data_handler/index.d.ts +++ /dev/null @@ -1,42 +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. - */ - -interface Stat { - label: string; - value: string; - color?: string; -} - -export interface Coordinates { - x: number; - y?: number; -} - -interface Series { - label: string; - coordinates: Coordinates[]; - color?: string; - key?: string; -} - -interface FetchDataResponse { - title: string; - appLink: string; - stats: Stat[]; - series: Series[]; -} -interface FetchDataParams { - // The start timestamp in milliseconds of the queried time interval - startTime: string; - // The end timestamp in milliseconds of the queried time interval - endTime: string; - // The aggregation bucket size in milliseconds if applicable to the data source - bucketSize: string; -} - -export type FetchData = (fetchDataParams: FetchDataParams) => Promise; - -export type HasData = () => Promise; diff --git a/x-pack/plugins/observability/public/typings/fetch_data_response/index.d.ts b/x-pack/plugins/observability/public/typings/fetch_data_response/index.d.ts new file mode 100644 index 00000000000000..06e86d1096cfc4 --- /dev/null +++ b/x-pack/plugins/observability/public/typings/fetch_data_response/index.d.ts @@ -0,0 +1,77 @@ +/* + * 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. + */ + +interface Stat { + type: 'number' | 'percent' | 'bytesPerSecond'; + label: string; + value: number; + color?: string; +} + +export interface Coordinates { + x: number; + y?: number; +} + +interface Series { + label: string; + coordinates: Coordinates[]; + color?: string; +} + +export interface FetchDataResponse { + title: string; + appLink: string; +} + +export interface LogsFetchDataResponse extends FetchDataResponse { + stats: Record; + series: Record; +} + +export interface MetricsFetchDataResponse extends FetchDataResponse { + stats: { + hosts: Stat; + cpu: Stat; + memory: Stat; + disk: Stat; + inboundTraffic: Stat; + outboundTraffic: Stat; + }; + series: { + inboundTraffic: Series; + outboundTraffic: Series; + }; +} + +export interface UptimeFetchDataResponse extends FetchDataResponse { + stats: { + monitors: Stat; + up: Stat; + down: Stat; + }; + series: { + up: Series; + down: Series; + }; +} + +export interface ApmFetchDataResponse extends FetchDataResponse { + stats: { + services: Stat; + transactions: Stat; + }; + series: { + transactions: Series; + }; +} + +export interface ObservabilityFetchDataResponse { + apm: ApmFetchDataResponse; + infra_metrics: MetricsFetchDataResponse; + infra_logs: LogsFetchDataResponse; + uptime: UptimeFetchDataResponse; +} diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 41285c2bfa133a..ddd5491b661bc4 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -112,6 +112,8 @@ describe('Reporting Config Schema', () => { .encryptionKey ).toBe('qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); + expect(ConfigSchema.validate({ encryptionKey: 'weaksauce' }).encryptionKey).toBe('weaksauce'); + // disableSandbox expect( ConfigSchema.validate({ capture: { browser: { chromium: { disableSandbox: true } } } }) diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index b1234a6ddf0b66..2f77aff0020d53 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -136,8 +136,8 @@ const CsvSchema = schema.object({ const EncryptionKeySchema = schema.conditional( schema.contextRef('dist'), true, - schema.maybe(schema.string({ minLength: 32 })), // default value is dynamic in createConfig$ - schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) + schema.maybe(schema.string()), // default value is dynamic in createConfig$ + schema.string({ defaultValue: 'a'.repeat(32) }) ); const RolesSchema = schema.object({ diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 9acd359fa0db4e..eccd6c7db16989 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -24,6 +24,7 @@ import { screenshotsObservableFactory } from './export_types/common/lib/screensh import { checkLicense, getExportTypesRegistry } from './lib'; import { ESQueueInstance } from './lib/create_queue'; import { EnqueueJobFn } from './lib/enqueue_job'; +import { ReportingStore } from './lib/store'; export interface ReportingInternalSetup { elasticsearch: ElasticsearchServiceSetup; @@ -37,6 +38,7 @@ export interface ReportingInternalStart { browserDriverFactory: HeadlessChromiumDriverFactory; enqueueJob: EnqueueJobFn; esqueue: ESQueueInstance; + store: ReportingStore; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; } diff --git a/x-pack/plugins/reporting/server/export_types/common/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/export_types/common/lib/screenshots/observable.test.ts index 2ddb4a5d5b9943..b00233137943de 100644 --- a/x-pack/plugins/reporting/server/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/lib/screenshots/observable.test.ts @@ -17,7 +17,7 @@ jest.mock('../../../../browsers/chromium/puppeteer', () => ({ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; import { HeadlessChromiumDriver } from '../../../../browsers'; import { LevelLogger } from '../../../../lib'; import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; @@ -28,7 +28,7 @@ import { screenshotsObservableFactory } from './observable'; /* * Mocks */ -const mockLogger = jest.fn(loggingServiceMock.create); +const mockLogger = jest.fn(loggingSystemMock.create); const logger = new LevelLogger(mockLogger()); const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; diff --git a/x-pack/plugins/reporting/server/lib/create_queue.ts b/x-pack/plugins/reporting/server/lib/create_queue.ts index 5d09af312a41b4..a8dcb92c55b2de 100644 --- a/x-pack/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/plugins/reporting/server/lib/create_queue.ts @@ -8,17 +8,16 @@ import { ReportingCore } from '../core'; import { JobSource, TaskRunResult } from '../types'; import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed import { createWorkerFactory } from './create_worker'; -import { Job } from './enqueue_job'; // @ts-ignore import { Esqueue } from './esqueue'; import { LevelLogger } from './level_logger'; +import { ReportingStore } from './store'; interface ESQueueWorker { on: (event: string, handler: any) => void; } export interface ESQueueInstance { - addJob: (type: string, payload: unknown, options: object) => Job; registerWorker: ( pluginId: string, workerFn: GenericWorkerFn, @@ -37,26 +36,25 @@ type GenericWorkerFn = ( ...workerRestArgs: any[] ) => void | Promise; -export async function createQueueFactory( +export async function createQueueFactory( reporting: ReportingCore, + store: ReportingStore, logger: LevelLogger ): Promise { const config = reporting.getConfig(); - const queueIndexInterval = config.get('queue', 'indexInterval'); + + // esqueue-related const queueTimeout = config.get('queue', 'timeout'); - const queueIndex = config.get('index'); const isPollingEnabled = config.get('queue', 'pollEnabled'); - const elasticsearch = await reporting.getElasticsearchService(); + const elasticsearch = reporting.getElasticsearchService(); const queueOptions = { - interval: queueIndexInterval, timeout: queueTimeout, - dateSeparator: '.', client: elasticsearch.legacy.client, logger: createTaggedLogger(logger, ['esqueue', 'queue-worker']), }; - const queue: ESQueueInstance = new Esqueue(queueIndex, queueOptions); + const queue: ESQueueInstance = new Esqueue(store, queueOptions); if (isPollingEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index 625da90f3b4f23..d1554a03b9389a 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -4,39 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EventEmitter } from 'events'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/server'; import { ESQueueCreateJobFn } from '../../server/types'; import { ReportingCore } from '../core'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; -import { LevelLogger } from './level_logger'; +import { LevelLogger } from './'; +import { ReportingStore, Report } from './store'; -interface ConfirmedJob { - id: string; - index: string; - _seq_no: number; - _primary_term: number; -} - -export type Job = EventEmitter & { - id: string; - toJSON: () => { - id: string; - }; -}; - -export type EnqueueJobFn = ( +export type EnqueueJobFn = ( exportTypeId: string, - jobParams: JobParamsType, + jobParams: unknown, user: AuthenticatedUser | null, context: RequestHandlerContext, request: KibanaRequest -) => Promise; +) => Promise; export function enqueueJobFactory( reporting: ReportingCore, + store: ReportingStore, parentLogger: LevelLogger ): EnqueueJobFn { const config = reporting.getConfig(); @@ -45,16 +30,16 @@ export function enqueueJobFactory( const maxAttempts = config.get('capture', 'maxAttempts'); const logger = parentLogger.clone(['queue-job']); - return async function enqueueJob( + return async function enqueueJob( exportTypeId: string, - jobParams: JobParamsType, + jobParams: unknown, user: AuthenticatedUser | null, context: RequestHandlerContext, request: KibanaRequest - ): Promise { - type ScheduleTaskFnType = ESQueueCreateJobFn; + ) { + type ScheduleTaskFnType = ESQueueCreateJobFn; + const username = user ? user.username : false; - const esqueue = await reporting.getEsqueue(); const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); if (exportType == null) { @@ -71,16 +56,6 @@ export function enqueueJobFactory( max_attempts: maxAttempts, }; - return new Promise((resolve, reject) => { - const job = esqueue.addJob(exportType.jobType, payload, options); - - job.on(esqueueEvents.EVENT_JOB_CREATED, (createdJob: ConfirmedJob) => { - if (createdJob.id === job.id) { - logger.info(`Successfully queued job: ${createdJob.id}`); - resolve(job); - } - }); - job.on(esqueueEvents.EVENT_JOB_CREATE_ERROR, reject); - }); + return await store.addReport(exportType.jobType, payload, options); }; } diff --git a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/create_index.js b/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/create_index.js deleted file mode 100644 index 691bd4f618a1c8..00000000000000 --- a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/create_index.js +++ /dev/null @@ -1,100 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { createIndex } from '../../helpers/create_index'; -import { ClientMock } from '../fixtures/legacy_elasticsearch'; -import { constants } from '../../constants'; - -describe('Create Index', function () { - describe('Does not exist', function () { - let client; - let createSpy; - - beforeEach(function () { - client = new ClientMock(); - createSpy = sinon.spy(client, 'callAsInternalUser').withArgs('indices.create'); - }); - - it('should return true', function () { - const indexName = 'test-index'; - const result = createIndex(client, indexName); - - return result.then((exists) => expect(exists).to.be(true)); - }); - - it('should create the index with mappings and default settings', function () { - const indexName = 'test-index'; - const settings = constants.DEFAULT_SETTING_INDEX_SETTINGS; - const result = createIndex(client, indexName); - - return result.then(function () { - const payload = createSpy.getCall(0).args[1]; - sinon.assert.callCount(createSpy, 1); - expect(payload).to.have.property('index', indexName); - expect(payload).to.have.property('body'); - expect(payload.body).to.have.property('settings'); - expect(payload.body.settings).to.eql(settings); - expect(payload.body).to.have.property('mappings'); - expect(payload.body.mappings).to.have.property('properties'); - }); - }); - - it('should create the index with custom settings', function () { - const indexName = 'test-index'; - const settings = { - ...constants.DEFAULT_SETTING_INDEX_SETTINGS, - auto_expand_replicas: false, - number_of_shards: 3000, - number_of_replicas: 1, - format: '3000', - }; - const result = createIndex(client, indexName, settings); - - return result.then(function () { - const payload = createSpy.getCall(0).args[1]; - sinon.assert.callCount(createSpy, 1); - expect(payload).to.have.property('index', indexName); - expect(payload).to.have.property('body'); - expect(payload.body).to.have.property('settings'); - expect(payload.body.settings).to.eql(settings); - expect(payload.body).to.have.property('mappings'); - expect(payload.body.mappings).to.have.property('properties'); - }); - }); - }); - - describe('Does exist', function () { - let client; - let createSpy; - - beforeEach(function () { - client = new ClientMock(); - sinon - .stub(client, 'callAsInternalUser') - .withArgs('indices.exists') - .callsFake(() => Promise.resolve(true)); - createSpy = client.callAsInternalUser.withArgs('indices.create'); - }); - - it('should return true', function () { - const indexName = 'test-index'; - const result = createIndex(client, indexName); - - return result.then((exists) => expect(exists).to.be(true)); - }); - - it('should not create the index', function () { - const indexName = 'test-index'; - const result = createIndex(client, indexName); - - return result.then(function () { - sinon.assert.callCount(createSpy, 0); - }); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/index_timestamp.js b/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/index_timestamp.js deleted file mode 100644 index 71dc8a363e4298..00000000000000 --- a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/index_timestamp.js +++ /dev/null @@ -1,93 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import moment from 'moment'; -import { constants } from '../../constants'; -import { indexTimestamp } from '../../helpers/index_timestamp'; - -const anchor = '2016-04-02T01:02:03.456'; // saturday - -describe('Index timestamp interval', function () { - describe('construction', function () { - it('should throw given an invalid interval', function () { - const init = () => indexTimestamp('bananas'); - expect(init).to.throwException(/invalid.+interval/i); - }); - }); - - describe('timestamps', function () { - let clock; - let separator; - - beforeEach(function () { - separator = constants.DEFAULT_SETTING_DATE_SEPARATOR; - clock = sinon.useFakeTimers(moment(anchor).valueOf()); - }); - - afterEach(function () { - clock.restore(); - }); - - describe('formats', function () { - it('should return the year', function () { - const timestamp = indexTimestamp('year'); - const str = `2016`; - expect(timestamp).to.equal(str); - }); - - it('should return the year and month', function () { - const timestamp = indexTimestamp('month'); - const str = `2016${separator}04`; - expect(timestamp).to.equal(str); - }); - - it('should return the year, month, and first day of the week', function () { - const timestamp = indexTimestamp('week'); - const str = `2016${separator}03${separator}27`; - expect(timestamp).to.equal(str); - }); - - it('should return the year, month, and day of the week', function () { - const timestamp = indexTimestamp('day'); - const str = `2016${separator}04${separator}02`; - expect(timestamp).to.equal(str); - }); - - it('should return the year, month, day and hour', function () { - const timestamp = indexTimestamp('hour'); - const str = `2016${separator}04${separator}02${separator}01`; - expect(timestamp).to.equal(str); - }); - - it('should return the year, month, day, hour and minute', function () { - const timestamp = indexTimestamp('minute'); - const str = `2016${separator}04${separator}02${separator}01${separator}02`; - expect(timestamp).to.equal(str); - }); - }); - - describe('date separator', function () { - it('should be customizable', function () { - const separators = ['-', '.', '_']; - separators.forEach((customSep) => { - const str = `2016${customSep}04${customSep}02${customSep}01${customSep}02`; - const timestamp = indexTimestamp('minute', customSep); - expect(timestamp).to.equal(str); - }); - }); - - it('should throw if a letter is used', function () { - const separators = ['a', 'B', 'YYYY']; - separators.forEach((customSep) => { - const fn = () => indexTimestamp('minute', customSep); - expect(fn).to.throwException(); - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/job.js b/x-pack/plugins/reporting/server/lib/esqueue/__tests__/job.js deleted file mode 100644 index 955eed8d657226..00000000000000 --- a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/job.js +++ /dev/null @@ -1,420 +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 events from 'events'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import proxyquire from 'proxyquire'; -import { QueueMock } from './fixtures/queue'; -import { ClientMock } from './fixtures/legacy_elasticsearch'; -import { constants } from '../constants'; - -const createIndexMock = sinon.stub(); -const { Job } = proxyquire.noPreserveCache()('../job', { - './helpers/create_index': { createIndex: createIndexMock }, -}); - -const maxPriority = 20; -const minPriority = -20; -const defaultPriority = 10; -const defaultCreatedBy = false; - -function validateDoc(spy) { - sinon.assert.callCount(spy, 1); - const spyCall = spy.getCall(0); - return spyCall.args[1]; -} - -describe('Job Class', function () { - let mockQueue; - let client; - let index; - - let type; - let payload; - let options; - - beforeEach(function () { - createIndexMock.resetHistory(); - createIndexMock.returns(Promise.resolve('mock')); - index = 'test'; - - client = new ClientMock(); - mockQueue = new QueueMock(); - mockQueue.setClient(client); - }); - - it('should be an event emitter', function () { - const job = new Job(mockQueue, index, 'test', {}); - expect(job).to.be.an(events.EventEmitter); - }); - - describe('invalid construction', function () { - it('should throw with a missing type', function () { - const init = () => new Job(mockQueue, index); - expect(init).to.throwException(/type.+string/i); - }); - - it('should throw with an invalid type', function () { - const init = () => new Job(mockQueue, index, { 'not a string': true }); - expect(init).to.throwException(/type.+string/i); - }); - - it('should throw with an invalid payload', function () { - const init = () => new Job(mockQueue, index, 'type1', [1, 2, 3]); - expect(init).to.throwException(/plain.+object/i); - }); - - it(`should throw error if invalid maxAttempts`, function () { - const init = () => new Job(mockQueue, index, 'type1', { id: '123' }, { max_attempts: -1 }); - expect(init).to.throwException(/invalid.+max_attempts/i); - }); - }); - - describe('construction', function () { - let indexSpy; - beforeEach(function () { - type = 'type1'; - payload = { id: '123' }; - indexSpy = sinon.spy(client, 'callAsInternalUser').withArgs('index'); - }); - - it('should create the target index', function () { - const job = new Job(mockQueue, index, type, payload, options); - return job.ready.then(() => { - sinon.assert.calledOnce(createIndexMock); - const args = createIndexMock.getCall(0).args; - expect(args[0]).to.equal(client); - expect(args[1]).to.equal(index); - }); - }); - - it('should index the payload', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs).to.have.property('index', index); - expect(indexArgs).to.have.property('body'); - expect(indexArgs.body).to.have.property('payload', payload); - }); - }); - - it('should index the job type', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs).to.have.property('index', index); - expect(indexArgs).to.have.property('body'); - expect(indexArgs.body).to.have.property('jobtype', type); - }); - }); - - it('should set event creation time', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('created_at'); - }); - }); - - it('should refresh the index', function () { - const refreshSpy = client.callAsInternalUser.withArgs('indices.refresh'); - - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - sinon.assert.calledOnce(refreshSpy); - const spyCall = refreshSpy.getCall(0); - expect(spyCall.args[1]).to.have.property('index', index); - }); - }); - - it('should emit the job information on success', function (done) { - const job = new Job(mockQueue, index, type, payload); - job.once(constants.EVENT_JOB_CREATED, (jobDoc) => { - try { - expect(jobDoc).to.have.property('id'); - expect(jobDoc).to.have.property('index'); - expect(jobDoc).to.have.property('_seq_no'); - expect(jobDoc).to.have.property('_primary_term'); - done(); - } catch (e) { - done(e); - } - }); - }); - - it('should emit error on index creation failure', function (done) { - const errMsg = 'test index creation failure'; - - createIndexMock.returns(Promise.reject(new Error(errMsg))); - const job = new Job(mockQueue, index, type, payload); - - job.once(constants.EVENT_JOB_CREATE_ERROR, (err) => { - try { - expect(err.message).to.equal(errMsg); - done(); - } catch (e) { - done(e); - } - }); - }); - - it('should emit error on client index failure', function (done) { - const errMsg = 'test document index failure'; - - client.callAsInternalUser.restore(); - sinon - .stub(client, 'callAsInternalUser') - .withArgs('index') - .callsFake(() => Promise.reject(new Error(errMsg))); - const job = new Job(mockQueue, index, type, payload); - - job.once(constants.EVENT_JOB_CREATE_ERROR, (err) => { - try { - expect(err.message).to.equal(errMsg); - done(); - } catch (e) { - done(e); - } - }); - }); - }); - - describe('event emitting', function () { - it('should trigger events on the queue instance', function (done) { - const eventName = 'test event'; - const payload1 = { - test: true, - deep: { object: 'ok' }, - }; - const payload2 = 'two'; - const payload3 = new Error('test error'); - - const job = new Job(mockQueue, index, type, payload, options); - - mockQueue.on(eventName, (...args) => { - try { - expect(args[0]).to.equal(payload1); - expect(args[1]).to.equal(payload2); - expect(args[2]).to.equal(payload3); - done(); - } catch (e) { - done(e); - } - }); - - job.emit(eventName, payload1, payload2, payload3); - }); - }); - - describe('default values', function () { - let indexSpy; - beforeEach(function () { - type = 'type1'; - payload = { id: '123' }; - indexSpy = sinon.spy(client, 'callAsInternalUser').withArgs('index'); - }); - - it('should set attempt count to 0', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('attempts', 0); - }); - }); - - it('should index default created_by value', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('created_by', defaultCreatedBy); - }); - }); - - it('should set an expired process_expiration time', function () { - const now = new Date().getTime(); - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('process_expiration'); - expect(indexArgs.body.process_expiration.getTime()).to.be.lessThan(now); - }); - }); - - it('should set status as pending', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('status', constants.JOB_STATUS_PENDING); - }); - }); - - it('should have a default priority of 10', function () { - const job = new Job(mockQueue, index, type, payload, options); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('priority', defaultPriority); - }); - }); - - it('should set a browser type', function () { - const job = new Job(mockQueue, index, type, payload); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('browser_type'); - }); - }); - }); - - describe('option passing', function () { - let indexSpy; - beforeEach(function () { - type = 'type1'; - payload = { id: '123' }; - options = { - timeout: 4567, - max_attempts: 9, - headers: { - authorization: 'Basic cXdlcnR5', - }, - }; - indexSpy = sinon.spy(client, 'callAsInternalUser').withArgs('index'); - }); - - it('should index the created_by value', function () { - const createdBy = 'user_identifier'; - const job = new Job(mockQueue, index, type, payload, { - created_by: createdBy, - ...options, - }); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('created_by', createdBy); - }); - }); - - it('should index timeout value from options', function () { - const job = new Job(mockQueue, index, type, payload, options); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('timeout', options.timeout); - }); - }); - - it('should set max attempt count', function () { - const job = new Job(mockQueue, index, type, payload, options); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('max_attempts', options.max_attempts); - }); - }); - - it('should add headers to the request params', function () { - const job = new Job(mockQueue, index, type, payload, options); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs).to.have.property('headers', options.headers); - }); - }); - - it(`should use upper priority of ${maxPriority}`, function () { - const job = new Job(mockQueue, index, type, payload, { priority: maxPriority * 2 }); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('priority', maxPriority); - }); - }); - - it(`should use lower priority of ${minPriority}`, function () { - const job = new Job(mockQueue, index, type, payload, { priority: minPriority * 2 }); - return job.ready.then(() => { - const indexArgs = validateDoc(indexSpy); - expect(indexArgs.body).to.have.property('priority', minPriority); - }); - }); - }); - - describe('get method', function () { - beforeEach(function () { - type = 'type2'; - payload = { id: '123' }; - }); - - it('should return the job document', function () { - const job = new Job(mockQueue, index, type, payload); - - return job.get().then((doc) => { - const jobDoc = job.document; // document should be resolved - expect(doc).to.have.property('index', index); - expect(doc).to.have.property('id', jobDoc.id); - expect(doc).to.have.property('_seq_no', jobDoc._seq_no); - expect(doc).to.have.property('_primary_term', jobDoc._primary_term); - expect(doc).to.have.property('created_by', defaultCreatedBy); - - expect(doc).to.have.property('payload'); - expect(doc).to.have.property('jobtype'); - expect(doc).to.have.property('priority'); - expect(doc).to.have.property('timeout'); - }); - }); - - it('should contain optional data', function () { - const optionals = { - created_by: 'some_ident', - }; - - const job = new Job(mockQueue, index, type, payload, optionals); - return Promise.resolve(client.callAsInternalUser('get', {}, optionals)) - .then((doc) => { - sinon.stub(client, 'callAsInternalUser').withArgs('get').returns(Promise.resolve(doc)); - }) - .then(() => { - return job.get().then((doc) => { - expect(doc).to.have.property('created_by', optionals.created_by); - }); - }); - }); - }); - - describe('toJSON method', function () { - beforeEach(function () { - type = 'type2'; - payload = { id: '123' }; - options = { - timeout: 4567, - max_attempts: 9, - priority: 8, - }; - }); - - it('should return the static information about the job', function () { - const job = new Job(mockQueue, index, type, payload, options); - - // toJSON is sync, should work before doc is written to elasticsearch - expect(job.document).to.be(undefined); - - const doc = job.toJSON(); - expect(doc).to.have.property('index', index); - expect(doc).to.have.property('jobtype', type); - expect(doc).to.have.property('created_by', defaultCreatedBy); - expect(doc).to.have.property('timeout', options.timeout); - expect(doc).to.have.property('max_attempts', options.max_attempts); - expect(doc).to.have.property('priority', options.priority); - expect(doc).to.have.property('id'); - expect(doc).to.not.have.property('version'); - }); - - it('should contain optional data', function () { - const optionals = { - created_by: 'some_ident', - }; - - const job = new Job(mockQueue, index, type, payload, optionals); - const doc = job.toJSON(); - expect(doc).to.have.property('created_by', optionals.created_by); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/esqueue/index.js b/x-pack/plugins/reporting/server/lib/esqueue/index.js index 735d19f8f6c473..0fbcb54c673dd3 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/index.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/index.js @@ -5,20 +5,17 @@ */ import { EventEmitter } from 'events'; -import { Job } from './job'; import { Worker } from './worker'; import { constants } from './constants'; -import { indexTimestamp } from './helpers/index_timestamp'; import { omit } from 'lodash'; export { events } from './constants/events'; export class Esqueue extends EventEmitter { - constructor(index, options = {}) { - if (!index) throw new Error('Must specify an index to write to'); - + constructor(store, options = {}) { super(); - this.index = index; + this.store = store; // for updating jobs in ES + this.index = this.store.indexPrefix; // for polling for pending jobs this.settings = { interval: constants.DEFAULT_SETTING_INTERVAL, timeout: constants.DEFAULT_SETTING_TIMEOUT, @@ -40,21 +37,6 @@ export class Esqueue extends EventEmitter { }); } - addJob(jobtype, payload, opts = {}) { - const timestamp = indexTimestamp(this.settings.interval, this.settings.dateSeparator); - const index = `${this.index}-${timestamp}`; - const defaults = { - timeout: this.settings.timeout, - }; - - const options = Object.assign(defaults, opts, { - indexSettings: this.settings.indexSettings, - logger: this._logger, - }); - - return new Job(this, index, jobtype, payload, options); - } - registerWorker(type, workerFn, opts) { const worker = new Worker(this, type, workerFn, { ...opts, logger: this._logger }); this._workers.push(worker); diff --git a/x-pack/plugins/reporting/server/lib/esqueue/job.js b/x-pack/plugins/reporting/server/lib/esqueue/job.js deleted file mode 100644 index 6ab78eeb1b86ba..00000000000000 --- a/x-pack/plugins/reporting/server/lib/esqueue/job.js +++ /dev/null @@ -1,142 +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 events from 'events'; -import Puid from 'puid'; -import { constants } from './constants'; -import { createIndex } from './helpers/create_index'; -import { isPlainObject } from 'lodash'; - -const puid = new Puid(); - -export class Job extends events.EventEmitter { - constructor(queue, index, jobtype, payload, options = {}) { - if (typeof jobtype !== 'string') throw new Error('Jobtype must be a string'); - if (!isPlainObject(payload)) throw new Error('Payload must be a plain object'); - - super(); - - this.queue = queue; - this._client = this.queue.client; - this.id = puid.generate(); - this.index = index; - this.jobtype = jobtype; - this.payload = payload; - this.created_by = options.created_by || false; - this.timeout = options.timeout || 10000; - this.maxAttempts = options.max_attempts || 3; - this.priority = Math.max(Math.min(options.priority || 10, 20), -20); - this.indexSettings = options.indexSettings || {}; - this.browser_type = options.browser_type; - - if (typeof this.maxAttempts !== 'number' || this.maxAttempts < 1) { - throw new Error(`Invalid max_attempts: ${this.maxAttempts}`); - } - - this.debug = (msg, err) => { - const logger = options.logger || function () {}; - const message = `${this.id} - ${msg}`; - const tags = ['debug']; - - if (err) { - logger(`${message}: ${err}`, tags); - return; - } - - logger(message, tags); - }; - - const indexParams = { - index: this.index, - id: this.id, - body: { - jobtype: this.jobtype, - meta: { - // We are copying these values out of payload because these fields are indexed and can be aggregated on - // for tracking stats, while payload contents are not. - objectType: payload.objectType, - layout: payload.layout ? payload.layout.id : 'none', - }, - payload: this.payload, - priority: this.priority, - created_by: this.created_by, - timeout: this.timeout, - process_expiration: new Date(0), // use epoch so the job query works - created_at: new Date(), - attempts: 0, - max_attempts: this.maxAttempts, - status: constants.JOB_STATUS_PENDING, - browser_type: this.browser_type, - }, - }; - - if (options.headers) { - indexParams.headers = options.headers; - } - - this.ready = createIndex(this._client, this.index, this.indexSettings) - .then(() => this._client.callAsInternalUser('index', indexParams)) - .then((doc) => { - this.document = { - id: doc._id, - index: doc._index, - _seq_no: doc._seq_no, - _primary_term: doc._primary_term, - }; - this.debug(`Job created in index ${this.index}`); - - return this._client - .callAsInternalUser('indices.refresh', { - index: this.index, - }) - .then(() => { - this.debug(`Job index refreshed ${this.index}`); - this.emit(constants.EVENT_JOB_CREATED, this.document); - }); - }) - .catch((err) => { - this.debug('Job creation failed', err); - this.emit(constants.EVENT_JOB_CREATE_ERROR, err); - }); - } - - emit(name, ...args) { - super.emit(name, ...args); - this.queue.emit(name, ...args); - } - - get() { - return this.ready - .then(() => { - return this._client.callAsInternalUser('get', { - index: this.index, - id: this.id, - }); - }) - .then((doc) => { - return Object.assign(doc._source, { - index: doc._index, - id: doc._id, - _seq_no: doc._seq_no, - _primary_term: doc._primary_term, - }); - }); - } - - toJSON() { - return { - id: this.id, - index: this.index, - jobtype: this.jobtype, - created_by: this.created_by, - payload: this.payload, - timeout: this.timeout, - max_attempts: this.maxAttempts, - priority: this.priority, - browser_type: this.browser_type, - }; - } -} diff --git a/x-pack/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/plugins/reporting/server/lib/esqueue/worker.js index b26ed731c68315..469bafd6946122 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/worker.js @@ -158,8 +158,8 @@ export class Worker extends events.EventEmitter { kibana_name: this.kibanaName, }; - return this._client - .callAsInternalUser('update', { + return this.queue.store + .updateReport({ index: job._index, id: job._id, if_seq_no: job._seq_no, @@ -197,8 +197,8 @@ export class Worker extends events.EventEmitter { output: docOutput, }); - return this._client - .callAsInternalUser('update', { + return this.queue.store + .updateReport({ index: job._index, id: job._id, if_seq_no: job._seq_no, @@ -294,8 +294,8 @@ export class Worker extends events.EventEmitter { output: docOutput, }; - return this._client - .callAsInternalUser('update', { + return this.queue.store + .updateReport({ index: job._index, id: job._id, if_seq_no: job._seq_no, diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index 0e9c49b1708877..f5a50fca28b7a6 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -12,3 +12,4 @@ export { enqueueJobFactory } from './enqueue_job'; export { getExportTypesRegistry } from './export_types_registry'; export { runValidations } from './validate'; export { startTrace } from './trace'; +export { ReportingStore } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts new file mode 100644 index 00000000000000..a88d36d3fdf9ae --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Report } from './report'; +export { ReportingStore } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts similarity index 80% rename from x-pack/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js rename to x-pack/plugins/reporting/server/lib/store/index_timestamp.ts index ceb4ef43b2d9df..71ce0b1e572f84 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts @@ -4,19 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; +import moment, { unitOfTime } from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; // TODO: This helper function can be removed by using `schema.duration` objects in the reporting config schema -export function indexTimestamp(intervalStr, separator = '-') { +export function indexTimestamp(intervalStr: string, separator = '-') { + const startOf = intervalStr as unitOfTime.StartOf; if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); const index = intervals.indexOf(intervalStr); - if (index === -1) throw new Error('Invalid index interval: ', intervalStr); + if (index === -1) throw new Error('Invalid index interval: ' + intervalStr); const m = moment(); - m.startOf(intervalStr); + m.startOf(startOf); let dateString; switch (intervalStr) { diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts new file mode 100644 index 00000000000000..a819923e2f1054 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mapping = { + meta: { + // We are indexing these properties with both text and keyword fields because that's what will be auto generated + // when an index already exists. This schema is only used when a reporting index doesn't exist. This way existing + // reporting indexes and new reporting indexes will look the same and the data can be queried in the same + // manner. + properties: { + /** + * Type of object that is triggering this report. Should be either search, visualization or dashboard. + * Used for job listing and telemetry stats only. + */ + objectType: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + /** + * Can be either preserve_layout, print or none (in the case of csv export). + * Used for phone home stats only. + */ + layout: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + browser_type: { type: 'keyword' }, + jobtype: { type: 'keyword' }, + payload: { type: 'object', enabled: false }, + priority: { type: 'byte' }, + timeout: { type: 'long' }, + process_expiration: { type: 'date' }, + created_by: { type: 'keyword' }, + created_at: { type: 'date' }, + started_at: { type: 'date' }, + completed_at: { type: 'date' }, + attempts: { type: 'short' }, + max_attempts: { type: 'short' }, + kibana_name: { type: 'keyword' }, + kibana_id: { type: 'keyword' }, + status: { type: 'keyword' }, + output: { + type: 'object', + properties: { + content_type: { type: 'keyword' }, + size: { type: 'long' }, + content: { type: 'object', enabled: false }, + }, + }, +}; diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts new file mode 100644 index 00000000000000..83444494e61d33 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { Report } from './report'; + +describe('Class Report', () => { + it('constructs Report instance', () => { + const opts = { + index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { payload_test_field: 1 }, + timeout: 30000, + priority: 1, + }; + const report = new Report(opts); + expect(report.toJSON()).toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + browser_type: 'browser_type_test_string', + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + payload: { + payload_test_field: 1, + }, + priority: 1, + timeout: 30000, + }); + + expect(report.id).toBeDefined(); + }); + + it('updateWithDoc method syncs takes fields to sync ES metadata', () => { + const opts = { + index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { payload_test_field: 1 }, + timeout: 30000, + priority: 1, + }; + const report = new Report(opts); + + const metadata = { + _index: '.reporting-test-update', + _id: '12342p9o387549o2345', + _primary_term: 77, + _seq_no: 99, + }; + report.updateWithDoc(metadata); + + expect(report.toJSON()).toMatchObject({ + index: '.reporting-test-update', + _primary_term: 77, + _seq_no: 99, + browser_type: 'browser_type_test_string', + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + payload: { + payload_test_field: 1, + }, + priority: 1, + timeout: 30000, + }); + + expect(report._id).toBe('12342p9o387549o2345'); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts new file mode 100644 index 00000000000000..cc9967e64b6ebc --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -0,0 +1,85 @@ +/* + * 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. + */ + +// @ts-ignore no module definition +import Puid from 'puid'; + +interface Payload { + id?: string; + index: string; + jobtype: string; + created_by: string | boolean; + payload: unknown; + browser_type: string; + priority: number; + max_attempts: number; + timeout: number; +} + +const puid = new Puid(); + +export class Report { + public readonly jobtype: string; + public readonly created_by: string | boolean; + public readonly payload: unknown; + public readonly browser_type: string; + public readonly id: string; + + public readonly priority: number; + // queue stuff, to be removed with Task Manager integration + public readonly max_attempts: number; + public readonly timeout: number; + + public _index: string; + public _id?: string; // set by ES + public _primary_term?: unknown; // set by ES + public _seq_no: unknown; // set by ES + + /* + * Create an unsaved report + */ + constructor(opts: Payload) { + this.jobtype = opts.jobtype; + this.created_by = opts.created_by; + this.payload = opts.payload; + this.browser_type = opts.browser_type; + this.priority = opts.priority; + this.max_attempts = opts.max_attempts; + this.timeout = opts.timeout; + this.id = puid.generate(); + + this._index = opts.index; + } + + /* + * Update the report with "live" storage metadata + */ + updateWithDoc(doc: Partial) { + if (doc._index) { + this._index = doc._index; // can not be undefined + } + + this._id = doc._id; + this._primary_term = doc._primary_term; + this._seq_no = doc._seq_no; + } + + toJSON() { + return { + id: this.id, + index: this._index, + _seq_no: this._seq_no, + _primary_term: this._primary_term, + jobtype: this.jobtype, + created_by: this.created_by, + payload: this.payload, + timeout: this.timeout, + max_attempts: this.max_attempts, + priority: this.priority, + browser_type: this.browser_type, + }; + } +} diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts new file mode 100644 index 00000000000000..4868a1dfdd8f3a --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -0,0 +1,166 @@ +/* + * 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 sinon from 'sinon'; +import { ReportingConfig, ReportingCore } from '../..'; +import { createMockReportingCore } from '../../test_helpers'; +import { createMockLevelLogger } from '../../test_helpers/create_mock_levellogger'; +import { ReportingStore } from './store'; +import { ElasticsearchServiceSetup } from 'src/core/server'; + +const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, +}); + +describe('ReportingStore', () => { + const mockLogger = createMockLevelLogger(); + let mockConfig: ReportingConfig; + let mockCore: ReportingCore; + + const callClusterStub = sinon.stub(); + const mockElasticsearch = { legacy: { client: { callAsInternalUser: callClusterStub } } }; + + beforeEach(async () => { + const mockConfigGet = sinon.stub(); + mockConfigGet.withArgs('index').returns('.reporting-test'); + mockConfigGet.withArgs('queue', 'indexInterval').returns('week'); + mockConfig = getMockConfig(mockConfigGet); + mockCore = await createMockReportingCore(mockConfig); + + callClusterStub.withArgs('indices.exists').resolves({}); + callClusterStub.withArgs('indices.create').resolves({}); + callClusterStub.withArgs('index').resolves({}); + callClusterStub.withArgs('indices.refresh').resolves({}); + callClusterStub.withArgs('update').resolves({}); + + mockCore.getElasticsearchService = () => + (mockElasticsearch as unknown) as ElasticsearchServiceSetup; + }); + + describe('addReport', () => { + it('returns Report object', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = {}; + const reportOptions = { + timeout: 10000, + created_by: 'created_by_string', + browser_type: 'browser_type_string', + max_attempts: 1, + }; + await expect( + store.addReport(reportType, reportPayload, reportOptions) + ).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + browser_type: 'browser_type_string', + created_by: 'created_by_string', + jobtype: 'unknowntype', + max_attempts: 1, + payload: {}, + priority: 10, + timeout: 10000, + }); + }); + + it('throws if options has invalid indexInterval', async () => { + const mockConfigGet = sinon.stub(); + mockConfigGet.withArgs('index').returns('.reporting-test'); + mockConfigGet.withArgs('queue', 'indexInterval').returns('centurially'); + mockConfig = getMockConfig(mockConfigGet); + mockCore = await createMockReportingCore(mockConfig); + + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = {}; + const reportOptions = { + timeout: 10000, + created_by: 'created_by_string', + browser_type: 'browser_type_string', + max_attempts: 1, + }; + expect( + store.addReport(reportType, reportPayload, reportOptions) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid index interval: centurially]`); + }); + + it('handles error creating the index', async () => { + // setup + callClusterStub.withArgs('indices.exists').resolves(false); + callClusterStub.withArgs('indices.create').rejects(new Error('error')); + + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = {}; + const reportOptions = { + timeout: 10000, + created_by: 'created_by_string', + browser_type: 'browser_type_string', + max_attempts: 1, + }; + await expect( + store.addReport(reportType, reportPayload, reportOptions) + ).rejects.toMatchInlineSnapshot(`[Error: error]`); + }); + + /* Creating the index will fail, if there were multiple jobs staged in + * parallel and creation completed from another Kibana instance. Only the + * first request in line can successfully create it. + * In spite of that race condition, adding the new job in Elasticsearch is + * fine. + */ + it('ignores index creation error if the index already exists and continues adding the report', async () => { + // setup + callClusterStub.withArgs('indices.exists').resolves(false); + callClusterStub.withArgs('indices.create').rejects(new Error('error')); + + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = {}; + const reportOptions = { + timeout: 10000, + created_by: 'created_by_string', + browser_type: 'browser_type_string', + max_attempts: 1, + }; + await expect( + store.addReport(reportType, reportPayload, reportOptions) + ).rejects.toMatchInlineSnapshot(`[Error: error]`); + }); + + it('skips creating the index if already exists', async () => { + // setup + callClusterStub.withArgs('indices.exists').resolves(false); + callClusterStub + .withArgs('indices.create') + .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored + + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = {}; + const reportOptions = { + timeout: 10000, + created_by: 'created_by_string', + browser_type: 'browser_type_string', + max_attempts: 1, + }; + await expect( + store.addReport(reportType, reportPayload, reportOptions) + ).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + browser_type: 'browser_type_string', + created_by: 'created_by_string', + jobtype: 'unknowntype', + max_attempts: 1, + payload: {}, + priority: 10, + timeout: 10000, + }); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts new file mode 100644 index 00000000000000..1cb964a7bbfac2 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -0,0 +1,169 @@ +/* + * 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 { ElasticsearchServiceSetup } from 'src/core/server'; +import { LevelLogger } from '../'; +import { ReportingCore } from '../../'; +import { LayoutInstance } from '../../export_types/common/layouts'; +import { indexTimestamp } from './index_timestamp'; +import { mapping } from './mapping'; +import { Report } from './report'; + +export const statuses = { + JOB_STATUS_PENDING: 'pending', + JOB_STATUS_PROCESSING: 'processing', + JOB_STATUS_COMPLETED: 'completed', + JOB_STATUS_WARNINGS: 'completed_with_warnings', + JOB_STATUS_FAILED: 'failed', + JOB_STATUS_CANCELLED: 'cancelled', +}; + +interface AddReportOpts { + timeout: number; + created_by: string | boolean; + browser_type: string; + max_attempts: number; +} + +interface UpdateQuery { + index: string; + id: string; + if_seq_no: unknown; + if_primary_term: unknown; + body: { doc: Partial }; +} + +/* + * A class to give an interface to historical reports in the reporting.index + * - track the state: pending, processing, completed, etc + * - handle updates and deletes to the reporting document + * - interface for downloading the report + */ +export class ReportingStore { + public readonly indexPrefix: string; + public readonly indexInterval: string; + + private client: ElasticsearchServiceSetup['legacy']['client']; + private logger: LevelLogger; + + constructor(reporting: ReportingCore, logger: LevelLogger) { + const config = reporting.getConfig(); + const elasticsearch = reporting.getElasticsearchService(); + + this.client = elasticsearch.legacy.client; + this.indexPrefix = config.get('index'); + this.indexInterval = config.get('queue', 'indexInterval'); + + this.logger = logger; + } + + private async createIndex(indexName: string) { + return this.client + .callAsInternalUser('indices.exists', { + index: indexName, + }) + .then((exists) => { + if (exists) { + return exists; + } + + const indexSettings = { + number_of_shards: 1, + auto_expand_replicas: '0-1', + }; + const body = { + settings: indexSettings, + mappings: { + properties: mapping, + }, + }; + + return this.client + .callAsInternalUser('indices.create', { + index: indexName, + body, + }) + .then(() => true) + .catch((err: Error) => { + const isIndexExistsError = err.message.match(/resource_already_exists_exception/); + if (isIndexExistsError) { + // Do not fail a job if the job runner hits the race condition. + this.logger.warn(`Automatic index creation failed: index already exists: ${err}`); + return; + } + + throw err; + }); + }); + } + + private async saveReport(report: Report) { + const payload = report.payload as { objectType: string; layout: LayoutInstance }; + + const indexParams = { + index: report._index, + id: report.id, + body: { + jobtype: report.jobtype, + meta: { + // We are copying these values out of payload because these fields are indexed and can be aggregated on + // for tracking stats, while payload contents are not. + objectType: payload.objectType, + layout: payload.layout ? payload.layout.id : 'none', + }, + payload: report.payload, + created_by: report.created_by, + timeout: report.timeout, + process_expiration: new Date(0), // use epoch so the job query works + created_at: new Date(), + attempts: 0, + max_attempts: report.max_attempts, + status: statuses.JOB_STATUS_PENDING, + browser_type: report.browser_type, + }, + }; + return this.client.callAsInternalUser('index', indexParams); + } + + private async refreshIndex(index: string) { + return this.client.callAsInternalUser('indices.refresh', { index }); + } + + public async addReport(type: string, payload: unknown, options: AddReportOpts): Promise { + const timestamp = indexTimestamp(this.indexInterval); + const index = `${this.indexPrefix}-${timestamp}`; + await this.createIndex(index); + + const report = new Report({ + index, + payload, + jobtype: type, + created_by: options.created_by, + browser_type: options.browser_type, + max_attempts: options.max_attempts, + timeout: options.timeout, + priority: 10, // unused + }); + + const doc = await this.saveReport(report); + report.updateWithDoc(doc); + + await this.refreshIndex(index); + this.logger.info(`Successfully queued pending job: ${report._index}/${report.id}`); + + return report; + } + + public async updateReport(query: UpdateQuery): Promise { + return this.client.callAsInternalUser('update', { + index: query.index, + id: query.id, + if_seq_no: query.if_seq_no, + if_primary_term: query.if_primary_term, + body: { doc: query.body.doc }, + }); + } +} diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 693b0917603fcd..cedc9dc14a2376 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -8,7 +8,13 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, ReportingConfigType } from './config'; -import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } from './lib'; +import { + createQueueFactory, + enqueueJobFactory, + LevelLogger, + runValidations, + ReportingStore, +} from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; @@ -86,9 +92,9 @@ export class ReportingPlugin const config = reportingCore.getConfig(); const browserDriverFactory = await initializeBrowserDriverFactory(config, logger); - - const esqueue = await createQueueFactory(reportingCore, logger); // starts polling for pending jobs - const enqueueJob = enqueueJobFactory(reportingCore, logger); // called from generation routes + const store = new ReportingStore(reportingCore, logger); + const esqueue = await createQueueFactory(reportingCore, store, logger); // starts polling for pending jobs + const enqueueJob = enqueueJobFactory(reportingCore, store, logger); // called from generation routes reportingCore.pluginStart({ browserDriverFactory, @@ -96,6 +102,7 @@ export class ReportingPlugin uiSettings: core.uiSettings, esqueue, enqueueJob, + store, }); // run self-check validations diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts new file mode 100644 index 00000000000000..f5e9a44281cb69 --- /dev/null +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_levellogger.ts @@ -0,0 +1,23 @@ +/* + * 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 { LevelLogger } from '../lib'; + +export function createMockLevelLogger() { + // eslint-disable-next-line no-console + const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); + const innerLogger = { + get: () => innerLogger, + debug: consoleLogger('debug'), + info: consoleLogger('info'), + warn: consoleLogger('warn'), + trace: consoleLogger('trace'), + error: consoleLogger('error'), + fatal: consoleLogger('fatal'), + log: consoleLogger('log'), + }; + return new LevelLogger(innerLogger); +} diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 579035a46f615e..427a6362a72581 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -20,6 +20,8 @@ import { } from '../browsers'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStartDeps } from '../types'; +import { ReportingStore } from '../lib'; +import { createMockLevelLogger } from './create_mock_levellogger'; (initializeBrowserDriverFactory as jest.Mock< Promise @@ -37,13 +39,19 @@ const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup => { }; }; -const createMockPluginStart = (startMock?: any): ReportingInternalStart => { +const createMockPluginStart = ( + mockReportingCore: ReportingCore, + startMock?: any +): ReportingInternalStart => { + const logger = createMockLevelLogger(); + const store = new ReportingStore(mockReportingCore, logger); return { browserDriverFactory: startMock.browserDriverFactory, enqueueJob: startMock.enqueueJob, esqueue: startMock.esqueue, savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, + store, }; }; @@ -60,9 +68,22 @@ export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ export const createMockReportingCore = async ( config: ReportingConfig, - setupDepsMock: ReportingInternalSetup | undefined = createMockPluginSetup({}), - startDepsMock: ReportingInternalStart | undefined = createMockPluginStart({}) + setupDepsMock: ReportingInternalSetup | undefined = undefined, + startDepsMock: ReportingInternalStart | undefined = undefined ) => { + if (!setupDepsMock) { + setupDepsMock = createMockPluginSetup({}); + } + + const mockReportingCore = { + getConfig: () => config, + getElasticsearchService: () => setupDepsMock?.elasticsearch, + } as ReportingCore; + + if (!startDepsMock) { + startDepsMock = createMockPluginStart(mockReportingCore, {}); + } + config = config || {}; const core = new ReportingCore(); diff --git a/x-pack/plugins/security/common/is_internal_url.test.ts b/x-pack/plugins/security/common/is_internal_url.test.ts new file mode 100644 index 00000000000000..7e9f63f069fd05 --- /dev/null +++ b/x-pack/plugins/security/common/is_internal_url.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { isInternalURL } from './is_internal_url'; + +describe('isInternalURL', () => { + describe('with basePath defined', () => { + const basePath = '/iqf'; + + it('should return `true `if URL includes hash fragment', () => { + const href = `${basePath}/app/kibana#/discover/New-Saved-Search`; + expect(isInternalURL(href, basePath)).toBe(true); + }); + + it('should return `false` if URL includes a protocol/hostname', () => { + const href = `https://example.com${basePath}/app/kibana`; + expect(isInternalURL(href, basePath)).toBe(false); + }); + + it('should return `false` if URL includes a port', () => { + const href = `http://localhost:5601${basePath}/app/kibana`; + expect(isInternalURL(href, basePath)).toBe(false); + }); + + it('should return `false` if URL does not specify protocol', () => { + const hrefWithTwoSlashes = `/${basePath}/app/kibana`; + expect(isInternalURL(hrefWithTwoSlashes)).toBe(false); + + const hrefWithThreeSlashes = `//${basePath}/app/kibana`; + expect(isInternalURL(hrefWithThreeSlashes)).toBe(false); + }); + + it('should return `true` if URL starts with a basepath', () => { + for (const href of [basePath, `${basePath}/`, `${basePath}/login`, `${basePath}/login/`]) { + expect(isInternalURL(href, basePath)).toBe(true); + } + }); + + it('should return `false` if URL does not start with basePath', () => { + for (const href of [ + '/notbasepath/app/kibana', + `${basePath}_/login`, + basePath.slice(1), + `${basePath.slice(1)}/app/kibana`, + ]) { + expect(isInternalURL(href, basePath)).toBe(false); + } + }); + + it('should return `true` if relative path does not escape base path', () => { + const href = `${basePath}/app/kibana/../../management`; + expect(isInternalURL(href, basePath)).toBe(true); + }); + + it('should return `false` if relative path escapes base path', () => { + const href = `${basePath}/app/kibana/../../../management`; + expect(isInternalURL(href, basePath)).toBe(false); + }); + }); + + describe('without basePath defined', () => { + it('should return `true `if URL includes hash fragment', () => { + const href = '/app/kibana#/discover/New-Saved-Search'; + expect(isInternalURL(href)).toBe(true); + }); + + it('should return `false` if URL includes a protocol/hostname', () => { + const href = 'https://example.com/app/kibana'; + expect(isInternalURL(href)).toBe(false); + }); + + it('should return `false` if URL includes a port', () => { + const href = 'http://localhost:5601/app/kibana'; + expect(isInternalURL(href)).toBe(false); + }); + + it('should return `false` if URL does not specify protocol', () => { + const hrefWithTwoSlashes = `//app/kibana`; + expect(isInternalURL(hrefWithTwoSlashes)).toBe(false); + + const hrefWithThreeSlashes = `///app/kibana`; + expect(isInternalURL(hrefWithThreeSlashes)).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/security/common/is_internal_url.ts b/x-pack/plugins/security/common/is_internal_url.ts new file mode 100644 index 00000000000000..e83839bf7d34bc --- /dev/null +++ b/x-pack/plugins/security/common/is_internal_url.ts @@ -0,0 +1,38 @@ +/* + * 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 { parse } from 'url'; + +export function isInternalURL(url: string, basePath = '') { + const { protocol, hostname, port, pathname } = parse( + url, + false /* parseQueryString */, + true /* slashesDenoteHost */ + ); + + // We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not + // detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but + // browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser + // hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`) + // and the first slash that belongs to path. + if (protocol !== null || hostname !== null || port !== null) { + return false; + } + + if (basePath) { + // Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected + // base path. We can rely on `URL` with a localhost to automatically "normalize" the URL. + const normalizedPathname = new URL(String(pathname), 'https://localhost').pathname; + return ( + // Normalized pathname can add a leading slash, but we should also make sure it's included in + // the original URL too + pathname?.startsWith('/') && + (normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) + ); + } + + return true; +} diff --git a/x-pack/plugins/security/common/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts index 7cbe335825a5a7..7ce0de05ad526d 100644 --- a/x-pack/plugins/security/common/parse_next.ts +++ b/x-pack/plugins/security/common/parse_next.ts @@ -5,6 +5,7 @@ */ import { parse } from 'url'; +import { isInternalURL } from './is_internal_url'; export function parseNext(href: string, basePath = '') { const { query, hash } = parse(href, true); @@ -20,23 +21,8 @@ export function parseNext(href: string, basePath = '') { } // validate that `next` is not attempting a redirect to somewhere - // outside of this Kibana install - const { protocol, hostname, port, pathname } = parse( - next, - false /* parseQueryString */, - true /* slashesDenoteHost */ - ); - - // We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not - // detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but - // browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser - // hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`) - // and the first slash that belongs to path. - if (protocol !== null || hostname !== null || port !== null) { - return `${basePath}/`; - } - - if (!String(pathname).startsWith(basePath)) { + // outside of this Kibana install. + if (!isInternalURL(next, basePath)) { return `${basePath}/`; } diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 94a2ada8df1da9..b2d866d07ff891 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { AuditService } from './audit_service'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { ConfigSchema, ConfigType } from '../config'; import { SecurityLicenseFeatures } from '../../common/licensing'; @@ -20,7 +20,7 @@ const config = createConfig({ describe('#setup', () => { it('returns the expected contract', () => { - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const auditService = new AuditService(logger); const license = licenseMock.create(); expect(auditService.setup({ license, config })).toMatchInlineSnapshot(` @@ -34,7 +34,7 @@ describe('#setup', () => { test(`calls the underlying logger with the provided message and requisite tags`, () => { const pluginId = 'foo'; - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const license = licenseMock.create(); license.features$ = new BehaviorSubject({ allowAuditLogging: true, @@ -58,7 +58,7 @@ test(`calls the underlying logger with the provided message and requisite tags`, test(`calls the underlying logger with the provided metadata`, () => { const pluginId = 'foo'; - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const license = licenseMock.create(); license.features$ = new BehaviorSubject({ allowAuditLogging: true, @@ -90,7 +90,7 @@ test(`calls the underlying logger with the provided metadata`, () => { test(`does not call the underlying logger if license does not support audit logging`, () => { const pluginId = 'foo'; - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const license = licenseMock.create(); license.features$ = new BehaviorSubject({ allowAuditLogging: false, @@ -110,7 +110,7 @@ test(`does not call the underlying logger if license does not support audit logg test(`does not call the underlying logger if security audit logging is not enabled`, () => { const pluginId = 'foo'; - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const license = licenseMock.create(); license.features$ = new BehaviorSubject({ allowAuditLogging: true, @@ -135,7 +135,7 @@ test(`does not call the underlying logger if security audit logging is not enabl test(`calls the underlying logger after license upgrade`, () => { const pluginId = 'foo'; - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const license = licenseMock.create(); const features$ = new BehaviorSubject({ diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 9f2a628b575d57..ad55f15545bd9f 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -10,7 +10,7 @@ import { APIKeys } from './api_keys'; import { httpServerMock, - loggingServiceMock, + loggingSystemMock, elasticsearchServiceMock, } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; @@ -35,7 +35,7 @@ describe('API Keys', () => { apiKeys = new APIKeys({ clusterClient: mockClusterClient, - logger: loggingServiceMock.create().get('api-keys'), + logger: loggingSystemMock.create().get('api-keys'), license: mockLicense, }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 60d0521a2947e7..3b77ea32481731 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -14,7 +14,7 @@ import { duration, Duration } from 'moment'; import { SessionStorage } from '../../../../../src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, elasticsearchServiceMock, @@ -48,10 +48,10 @@ function getMockOptions({ clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, license: licenseMock.create(), - loggers: loggingServiceMock.create(), + loggers: loggingSystemMock.create(), config: createConfig( ConfigSchema.validate({ session, authc: { selector, providers, http } }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: false } ), sessionStorageFactory: sessionStorageMock.createFactory(), @@ -112,6 +112,33 @@ describe('Authenticator', () => { ).toThrowError('Provider name "__http__" is reserved.'); }); + it('properly sets `loggedOut` URL.', () => { + const basicAuthenticationProviderMock = jest.requireMock('./providers/basic') + .BasicAuthenticationProvider; + + basicAuthenticationProviderMock.mockClear(); + new Authenticator(getMockOptions()); + expect(basicAuthenticationProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + urls: { + loggedOut: '/mock-server-basepath/security/logged_out', + }, + }), + expect.anything() + ); + + basicAuthenticationProviderMock.mockClear(); + new Authenticator(getMockOptions({ selector: { enabled: true } })); + expect(basicAuthenticationProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + urls: { + loggedOut: `/mock-server-basepath/login?msg=LOGGED_OUT`, + }, + }), + expect.anything() + ); + }); + describe('HTTP authentication provider', () => { beforeEach(() => { jest diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index ac5c2a72b9667c..70f4063878aa89 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -242,6 +242,11 @@ export class Authenticator { client: this.options.clusterClient, logger: this.options.loggers.get('tokens'), }), + urls: { + loggedOut: options.config.authc.selector.enabled + ? `${options.basePath.serverBasePath}/login?msg=LOGGED_OUT` + : `${options.basePath.serverBasePath}/security/logged_out`, + }, }; this.providers = new Map( diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index c7323509c00d68..0acd4fa7fae406 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -12,7 +12,7 @@ jest.mock('./authenticator'); import Boom from 'boom'; import { - loggingServiceMock, + loggingSystemMock, coreMock, httpServerMock, httpServiceMock, @@ -66,12 +66,12 @@ describe('setupAuthentication()', () => { secureCookies: true, cookieName: 'my-sid-cookie', }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: false } ), clusterClient: elasticsearchServiceMock.createClusterClient(), license: licenseMock.create(), - loggers: loggingServiceMock.create(), + loggers: loggingSystemMock.create(), getFeatureUsageService: jest .fn() .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), @@ -221,7 +221,7 @@ describe('setupAuthentication()', () => { expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - expect(loggingServiceMock.collect(mockSetupAuthenticationParams.loggers).error) + expect(loggingSystemMock.collect(mockSetupAuthenticationParams.loggers).error) .toMatchInlineSnapshot(` Array [ Array [ diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 1dcd2885f66dc9..7c71348bb8ca0f 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -5,7 +5,7 @@ */ import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, elasticsearchServiceMock, } from '../../../../../../src/core/server/mocks'; @@ -15,14 +15,14 @@ export type MockAuthenticationProviderOptions = ReturnType< >; export function mockAuthenticationProviderOptions(options?: { name: string }) { - const basePath = httpServiceMock.createSetupContract().basePath; - basePath.get.mockReturnValue('/base-path'); - return { client: elasticsearchServiceMock.createClusterClient(), - logger: loggingServiceMock.create().get(), - basePath, + logger: loggingSystemMock.create().get(), + basePath: httpServiceMock.createBasePath(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, name: options?.name ?? 'basic1', + urls: { + loggedOut: '/mock-server-basepath/security/logged_out', + }, }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index d2d2e82951a3e8..32ea41802d31bb 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -26,6 +26,9 @@ export interface AuthenticationProviderOptions { client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; + urls: { + loggedOut: string; + }; } /** diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 97ca4e46d3eb59..ee6a12e36df05f 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -107,7 +107,7 @@ describe('BasicAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo( - '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' ) ); }); @@ -186,7 +186,7 @@ describe('BasicAuthenticationProvider', () => { it('always redirects to the login page.', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); }); @@ -199,7 +199,9 @@ describe('BasicAuthenticationProvider', () => { {} ) ).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED') + DeauthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' + ) ); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index ca80761ee140c5..ebf1341127e5f0 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -518,7 +518,7 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); - it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { + it('redirects to `loggedOut` URL if tokens are invalidated successfully.', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'some-valid-token', @@ -528,7 +528,7 @@ describe('KerberosAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 2540c21210bd50..66a0ce22d3d19f 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -114,9 +114,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.failed(err); } - return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/logged_out` - ); + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 2d42d90ab60b8e..74344e8ae3edad 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -353,7 +353,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/s/foo/some-path', + nextURL: '/mock-server-basepath/s/foo/some-path', realm: 'oidc1', }, } @@ -575,7 +575,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/s/foo/some-path', + nextURL: '/mock-server-basepath/s/foo/some-path', realm: 'oidc1', }, } @@ -702,7 +702,7 @@ describe('OIDCAuthenticationProvider', () => { }); }); - it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => { + it('redirects to `loggedOut` URL if `redirect` field in OpenID Connect logout response is null.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; @@ -711,9 +711,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) - ).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') - ); + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index f8e6ac0f9b5d08..ac7374401f99a1 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -433,9 +433,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } - return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/logged_out` - ); + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } catch (err) { this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 28db64edd9e328..a1279c9b9ca7f8 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -547,14 +547,14 @@ describe('PKIAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); }); - it('redirects to `/logged_out` page if access token is invalidated successfully.', async () => { + it('redirects to `loggedOut` URL if access token is invalidated successfully.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { accessToken: 'foo', peerCertificateFingerprint256: '2A:7A:C2:DD' }; mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, state)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 243e5415ad2c2c..164a9516f06958 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -119,9 +119,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.failed(err); } - return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/logged_out` - ); + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 461ad3e38eca51..f7adaa24e9dbbd 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -110,6 +110,51 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: true, + }); + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-app', + realm: 'test-realm', + } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { + state: { + username: 'user', + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + realm: 'test-realm', + }, + }) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } + ); + }); + it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -163,7 +208,46 @@ describe('SAMLAuthenticationProvider', () => { { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } ) ).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/', { + AuthenticationResult.redirectTo('/mock-server-basepath/', { + state: { + accessToken: 'user-initiated-login-token', + refreshToken: 'user-initiated-login-refresh-token', + realm: 'test-realm', + }, + }) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } + ); + }); + + it('redirects to the default location if state contains empty redirect URL ignoring Relay State.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: true, + }); + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }, + { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/mock-server-basepath/', { state: { accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', @@ -192,7 +276,7 @@ describe('SAMLAuthenticationProvider', () => { samlResponse: 'saml-response-xml', }) ).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/', { + AuthenticationResult.redirectTo('/mock-server-basepath/', { state: { accessToken: 'idp-initiated-login-token', refreshToken: 'idp-initiated-login-refresh-token', @@ -231,6 +315,133 @@ describe('SAMLAuthenticationProvider', () => { ); }); + describe('IdP initiated login', () => { + beforeEach(() => { + mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => + Promise.resolve(mockAuthenticatedUser()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: true, + }); + }); + + it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => { + provider = new SAMLAuthenticationProvider(mockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: false, + }); + + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + state: { + username: 'user', + accessToken: 'valid-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + }) + ); + }); + + it('redirects to the home page if `relayState` is not specified.', async () => { + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + state: { + username: 'user', + accessToken: 'valid-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + }) + ); + }); + + it('redirects to the home page if `relayState` includes external URL', async () => { + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `https://evil.com${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + state: { + username: 'user', + accessToken: 'valid-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + }) + ); + }); + + it('redirects to the home page if `relayState` includes URL that starts with double slashes', async () => { + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `//${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { + state: { + username: 'user', + accessToken: 'valid-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + }) + ); + }); + + it('redirects to the URL from the relay state.', async () => { + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, + { + state: { + username: 'user', + accessToken: 'valid-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + } + ) + ); + }); + }); + describe('IdP initiated login with existing session', () => { it('returns `notHandled` if new SAML Response is rejected.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -351,7 +562,72 @@ describe('SAMLAuthenticationProvider', () => { state ) ).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/', { + AuthenticationResult.redirectTo('/mock-server-basepath/', { + state: { + username: 'user', + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', + }, + }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + } + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }); + }); + + it(`redirects to the URL from relay state if new SAML Response is for the same user if ${description}.`, async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const state = { + username: 'user', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + }); + + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + provider = new SAMLAuthenticationProvider(mockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: true, + }); + + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response-xml', + relayState: '/mock-server-basepath/app/some-app#some-deep-link', + }, + state + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', { state: { username: 'user', accessToken: 'new-valid-token', @@ -723,7 +999,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } + { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -753,7 +1029,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' + 'Max URL path size should not exceed 100b but it was 118b. URL is not captured.' ); }); @@ -998,7 +1274,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } + { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -1054,7 +1330,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' + 'Max URL path size should not exceed 100b but it was 118b. URL is not captured.' ); }); @@ -1124,7 +1400,7 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /security/logged_out if `redirect` field in SAML logout response is null.', async () => { + it('redirects to `loggedOut` URL if `redirect` field in SAML logout response is null.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -1138,9 +1414,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, realm: 'test-realm', }) - ).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') - ); + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -1148,7 +1422,7 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /security/logged_out if `redirect` field in SAML logout response is not defined.', async () => { + it('redirects to `loggedOut` URL if `redirect` field in SAML logout response is not defined.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -1162,9 +1436,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, realm: 'test-realm', }) - ).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') - ); + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -1174,7 +1446,7 @@ describe('SAMLAuthenticationProvider', () => { it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => { const request = httpServerMock.createKibanaRequest({ - query: { Whatever: 'something unrelated' }, + query: { Whatever: 'something unrelated', SAMLResponse: 'xxx yyy' }, }); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -1188,9 +1460,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, realm: 'test-realm', }) - ).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') - ); + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -1210,9 +1480,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'x-saml-refresh-token', realm: 'test-realm', }) - ).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') - ); + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { @@ -1220,13 +1488,13 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /security/logged_out if `redirect` field in SAML invalidate response is null.', async () => { + it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is null.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -1235,13 +1503,13 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /security/logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { + it('redirects to `loggedOut` URL if `redirect` field in SAML invalidate response is not defined.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -1250,6 +1518,16 @@ describe('SAMLAuthenticationProvider', () => { }); }); + it('redirects to `loggedOut` URL if SAML logout response is received.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { SAMLResponse: 'xxx yyy' } }); + + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 3161144023c1f3..d121cd4979aa73 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { isInternalURL } from '../../../common/is_internal_url'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; @@ -59,7 +60,7 @@ export enum SAMLLogin { */ type ProviderLoginAttempt = | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } - | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string }; + | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string; relayState?: string }; /** * Checks whether request query includes SAML request from IdP. @@ -69,6 +70,14 @@ function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } +/** + * Checks whether request query includes SAML response from IdP. + * @param query Parsed HTTP request query. + */ +function isSAMLResponseQuery(query: any): query is { SAMLResponse: string } { + return query && query.SAMLResponse; +} + /** * Checks whether current request can initiate new session. * @param request Request instance. @@ -98,9 +107,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private readonly maxRedirectURLSize: ByteSizeValue; + /** + * Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect + * user to after successful IdP initiated login. `RelayState` is ignored for SP initiated login. + */ + private readonly useRelayStateDeepLink: boolean; + constructor( protected readonly options: Readonly, - samlOptions?: Readonly<{ realm?: string; maxRedirectURLSize?: ByteSizeValue }> + samlOptions?: Readonly<{ + realm?: string; + maxRedirectURLSize?: ByteSizeValue; + useRelayStateDeepLink?: boolean; + }> ) { super(options); @@ -114,6 +133,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.realm = samlOptions.realm; this.maxRedirectURLSize = samlOptions.maxRedirectURLSize; + this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false; } /** @@ -148,14 +168,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); } - const { samlResponse } = attempt; + const { samlResponse, relayState } = attempt; const authenticationResult = state ? await this.authenticateViaState(request, state) : AuthenticationResult.notHandled(); // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. if (authenticationResult.notHandled()) { - return await this.loginWithSAMLResponse(request, samlResponse, state); + return await this.loginWithSAMLResponse(request, samlResponse, relayState, state); } // If user has been authenticated via session or failed to do so because of expired access token, @@ -169,6 +189,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return await this.loginWithNewSAMLResponse( request, samlResponse, + relayState, (authenticationResult.state || state) as ProviderState ); } @@ -234,22 +255,36 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug(`Trying to log user out via ${request.url.path}.`); // Normally when there is no active session in Kibana, `logout` method shouldn't do anything - // and user will eventually be redirected to the home page to log in. But when SAML is enabled - // there is a special case when logout is initiated by the IdP or another SP, then IdP will - // request _every_ SP associated with the current user session to do the logout. So if Kibana, - // without an active session, receives such request it shouldn't redirect user to the home page, - // but rather redirect back to IdP with correct logout response and only Elasticsearch knows how - // to do that. - const isIdPInitiatedSLO = isSAMLRequestQuery(request.query); - if (!state?.accessToken && !isIdPInitiatedSLO) { + // and user will eventually be redirected to the home page to log in. But when SAML SLO is + // supported there are two special cases that we need to handle even if there is no active + // Kibana session: + // + // 1. When IdP or another SP initiates logout, then IdP will request _every_ SP associated with + // the current user session to do the logout. So if Kibana receives such request it shouldn't + // redirect user to the home page, but rather redirect back to IdP with correct logout response + // and only Elasticsearch knows how to do that. + // + // 2. When Kibana initiates logout, then IdP may eventually respond with the logout response. So + // if Kibana receives such response it shouldn't redirect user to the home page, but rather + // redirect to the `loggedOut` URL instead. + const isIdPInitiatedSLORequest = isSAMLRequestQuery(request.query); + const isSPInitiatedSLOResponse = isSAMLResponseQuery(request.query); + if (!state?.accessToken && !isIdPInitiatedSLORequest && !isSPInitiatedSLOResponse) { this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } try { - const redirect = isIdPInitiatedSLO + // It may _theoretically_ (highly unlikely in practice though) happen that when user receives + // logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true + // and state !== undefined). In this case case it'd be safer to trigger SP initiated logout + // for the new session as well. + const redirect = isIdPInitiatedSLORequest ? await this.performIdPInitiatedSingleLogout(request) - : await this.performUserInitiatedSingleLogout(state?.accessToken!, state?.refreshToken!); + : state + ? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!) + : // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901 + null; // Having non-null `redirect` field within logout response means that IdP // supports SAML Single Logout and we should redirect user to the specified @@ -259,9 +294,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } - return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/logged_out` - ); + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } catch (err) { this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); @@ -290,11 +323,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * initiated login. * @param request Request instance. * @param samlResponse SAMLResponse payload string. + * @param relayState RelayState payload string. * @param [state] Optional state object associated with the provider. */ private async loginWithSAMLResponse( request: KibanaRequest, samlResponse: string, + relayState?: string, state?: ProviderState | null ) { this.logger.debug('Trying to log in with SAML response payload.'); @@ -334,9 +369,29 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }, }); + // IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and + // depending on the configuration we may need to redirect user to this URL. + let redirectURLFromRelayState; + if (isIdPInitiatedLogin && relayState) { + if (!this.useRelayStateDeepLink) { + this.options.logger.debug( + `"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.` + ); + } else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) { + this.options.logger.debug( + `"RelayState" is provided, but it is not a valid Kibana internal URL.` + ); + } else { + this.options.logger.debug( + `User will be redirected to the Kibana internal URL specified in "RelayState".` + ); + redirectURLFromRelayState = relayState; + } + } + this.logger.debug('Login has been performed with SAML response.'); return AuthenticationResult.redirectTo( - stateRedirectURL || `${this.options.basePath.get(request)}/`, + redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, { state: { username, accessToken, refreshToken, realm: this.realm } } ); } catch (err) { @@ -361,17 +416,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * we'll forward user to a page with the respective warning. * @param request Request instance. * @param samlResponse SAMLResponse payload string. + * @param relayState RelayState payload string. * @param existingState State existing user session is based on. */ private async loginWithNewSAMLResponse( request: KibanaRequest, samlResponse: string, + relayState: string | undefined, existingState: ProviderState ) { this.logger.debug('Trying to log in with SAML response payload and existing valid session.'); // First let's try to authenticate via SAML Response payload. - const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); + const payloadAuthenticationResult = await this.loginWithSAMLResponse( + request, + samlResponse, + relayState + ); if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) { return payloadAuthenticationResult; } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 92cea424e575da..84ff7d1f5a1ef7 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -179,7 +179,7 @@ describe('TokenAuthenticationProvider', () => { ) ).resolves.toEqual( AuthenticationResult.redirectTo( - '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' ) ); }); @@ -309,9 +309,10 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/login?next=%2Fbase-path%2Fsome-path', { - state: null, - }) + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fsome-path', + { state: null } + ) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -455,7 +456,7 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); @@ -469,7 +470,7 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?yep=nope') + DeauthenticationResult.redirectTo('/mock-server-basepath/login?yep=nope') ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 57366183050d7e..b42018b93e73fd 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -6,7 +6,7 @@ import { errors } from 'elasticsearch'; -import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { IClusterClient, ElasticsearchErrorHelpers } from '../../../../../src/core/server'; import { Tokens } from './tokens'; @@ -19,7 +19,7 @@ describe('Tokens', () => { const tokensOptions = { client: mockClusterClient, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), }; tokens = new Tokens(tokensOptions); diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index 183a36274142c8..75aa27c3c88c6a 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -10,7 +10,7 @@ import { coreMock, httpServerMock, httpServiceMock, - loggingServiceMock, + loggingSystemMock, } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; @@ -18,7 +18,7 @@ describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns false continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create(); - initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -42,7 +42,7 @@ describe('initAPIAuthorization', () => { test(`unprotected route when "mode.useRbacForRequest()" returns true continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create(); - initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -66,7 +66,7 @@ describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); - initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -101,7 +101,7 @@ describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { const mockHTTPSetup = coreMock.createSetup().http; const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); - initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index 1dc56161d63631..2d3a981fb32472 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -8,7 +8,7 @@ import { PluginSetupContract as FeaturesSetupContract } from '../../../features/ import { initAppAuthorization } from './app_authorization'; import { - loggingServiceMock, + loggingSystemMock, coreMock, httpServerMock, httpServiceMock, @@ -27,7 +27,7 @@ describe('initAppAuthorization', () => { initAppAuthorization( mockHTTPSetup, authorizationMock.create(), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), createFeaturesSetupContractMock() ); @@ -49,7 +49,7 @@ describe('initAppAuthorization', () => { initAppAuthorization( mockHTTPSetup, mockAuthz, - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), createFeaturesSetupContractMock() ); @@ -74,7 +74,7 @@ describe('initAppAuthorization', () => { initAppAuthorization( mockHTTPSetup, mockAuthz, - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), createFeaturesSetupContractMock() ); @@ -100,7 +100,7 @@ describe('initAppAuthorization', () => { initAppAuthorization( mockHTTPSetup, mockAuthz, - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), createFeaturesSetupContractMock() ); @@ -140,7 +140,7 @@ describe('initAppAuthorization', () => { initAppAuthorization( mockHTTPSetup, mockAuthz, - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), createFeaturesSetupContractMock() ); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index 978c985cfe820d..4d0ab1c964741d 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -25,7 +25,7 @@ import { AuthorizationService } from '.'; import { coreMock, elasticsearchServiceMock, - loggingServiceMock, + loggingSystemMock, } from '../../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../../features/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; @@ -71,7 +71,7 @@ it(`#setup returns exposed services`, () => { status: mockCoreSetup.status, clusterClient: mockClusterClient, license: mockLicense, - loggers: loggingServiceMock.create(), + loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', features: mockFeaturesSetup, @@ -140,7 +140,7 @@ describe('#start', () => { status: mockCoreSetup.status, clusterClient: mockClusterClient, license: mockLicense, - loggers: loggingServiceMock.create(), + loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', features: featuresPluginMock.createSetup(), @@ -241,7 +241,7 @@ it('#stop unsubscribes from license and ES updates.', () => { status: mockCoreSetup.status, clusterClient: mockClusterClient, license: mockLicense, - loggers: loggingServiceMock.create(), + loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', features: featuresPluginMock.createSetup(), diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 082484d5fa6b49..a1bedea9f7debe 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -7,7 +7,7 @@ import { Actions } from '.'; import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; -import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { httpServerMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; import { Feature } from '../../../features/server'; @@ -42,7 +42,7 @@ describe('usingPrivileges', () => { const mockAuthz = createMockAuthz({ rejectCheckPrivileges: { statusCode: 401, message: 'super informative message' }, }); - const mockLoggers = loggingServiceMock.create(); + const mockLoggers = loggingSystemMock.create(); const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, @@ -103,7 +103,7 @@ describe('usingPrivileges', () => { }, }); - expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` Array [ Array [ "Disabling all uiCapabilities because we received a 401: super informative message", @@ -116,7 +116,7 @@ describe('usingPrivileges', () => { const mockAuthz = createMockAuthz({ rejectCheckPrivileges: { statusCode: 403, message: 'even more super informative message' }, }); - const mockLoggers = loggingServiceMock.create(); + const mockLoggers = loggingSystemMock.create(); const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, @@ -176,7 +176,7 @@ describe('usingPrivileges', () => { bar: false, }, }); - expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` Array [ Array [ "Disabling all uiCapabilities because we received a 403: even more super informative message", @@ -189,7 +189,7 @@ describe('usingPrivileges', () => { const mockAuthz = createMockAuthz({ rejectCheckPrivileges: new Error('something else entirely'), }); - const mockLoggers = loggingServiceMock.create(); + const mockLoggers = loggingSystemMock.create(); const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, @@ -212,7 +212,7 @@ describe('usingPrivileges', () => { catalogue: {}, }) ).rejects.toThrowErrorMatchingSnapshot(); - expect(loggingServiceMock.collect(mockLoggers)).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(mockLoggers)).toMatchInlineSnapshot(` Object { "debug": Array [], "error": Array [], @@ -261,7 +261,7 @@ describe('usingPrivileges', () => { privileges: null, }), ], - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), mockAuthz ); @@ -347,7 +347,7 @@ describe('usingPrivileges', () => { privileges: null, }), ], - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), mockAuthz ); @@ -412,7 +412,7 @@ describe('all', () => { privileges: null, }), ], - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), mockAuthz ); diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts index e21203e60b887e..8604e02b632766 100644 --- a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts @@ -8,7 +8,7 @@ import { IClusterClient, Logger } from 'kibana/server'; import { RawKibanaPrivileges } from '../../common/model'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; const application = 'default-application'; const registerPrivilegesWithClusterTest = ( @@ -130,7 +130,7 @@ const registerPrivilegesWithClusterTest = ( } } }); - const mockLogger = loggingServiceMock.create().get() as jest.Mocked; + const mockLogger = loggingSystemMock.create().get() as jest.Mocked; let error; try { diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 0e1e2e2afeb13d..6ba33b2cccb7cb 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -6,7 +6,7 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); -import { loggingServiceMock } from '../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; import { createConfig, ConfigSchema } from './config'; describe('config schema', () => { @@ -655,6 +655,7 @@ describe('config schema', () => { saml: { saml1: { order: 0, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' }, + saml3: { order: 2, realm: 'saml3', useRelayStateDeepLink: true }, }, }, }, @@ -670,6 +671,7 @@ describe('config schema', () => { "order": 0, "realm": "saml1", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml2": Object { "enabled": true, @@ -679,6 +681,17 @@ describe('config schema', () => { "order": 1, "realm": "saml2", "showInSelector": true, + "useRelayStateDeepLink": false, + }, + "saml3": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 2, + "realm": "saml3", + "showInSelector": true, + "useRelayStateDeepLink": true, }, }, } @@ -767,6 +780,7 @@ describe('config schema', () => { "order": 3, "realm": "saml3", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml1": Object { "enabled": true, @@ -776,6 +790,7 @@ describe('config schema', () => { "order": 1, "realm": "saml1", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml2": Object { "enabled": true, @@ -785,6 +800,7 @@ describe('config schema', () => { "order": 2, "realm": "saml2", "showInSelector": true, + "useRelayStateDeepLink": false, }, }, } @@ -798,13 +814,13 @@ describe('createConfig()', () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); - const logger = loggingServiceMock.create().get(); + const logger = loggingSystemMock.create().get(); const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger, { isTLSEnabled: true, }); expect(config.encryptionKey).toEqual('ab'.repeat(16)); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", @@ -814,11 +830,11 @@ describe('createConfig()', () => { }); it('should log a warning if SSL is not configured', async () => { - const logger = loggingServiceMock.create().get(); + const logger = loggingSystemMock.create().get(); const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: false }); expect(config.secureCookies).toEqual(false); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Session cookies will be transmitted over insecure connections. This is not recommended.", @@ -828,13 +844,13 @@ describe('createConfig()', () => { }); it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { - const logger = loggingServiceMock.create().get(); + const logger = loggingSystemMock.create().get(); const config = createConfig(ConfigSchema.validate({ secureCookies: true }), logger, { isTLSEnabled: false, }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", @@ -844,15 +860,15 @@ describe('createConfig()', () => { }); it('should set xpack.security.secureCookies if SSL is configured', async () => { - const logger = loggingServiceMock.create().get(); + const logger = loggingSystemMock.create().get(); const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: true }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(logger).warn).toEqual([]); + expect(loggingSystemMock.collect(logger).warn).toEqual([]); }); it('transforms legacy `authc.providers` into new format', () => { - const logger = loggingServiceMock.create().get(); + const logger = loggingSystemMock.create().get(); expect( createConfig( @@ -919,7 +935,7 @@ describe('createConfig()', () => { ConfigSchema.validate({ authc: { providers: ['saml', 'basic'], saml: { realm: 'saml-realm' } }, }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: true } ).authc.selector.enabled ).toBe(false); @@ -934,7 +950,7 @@ describe('createConfig()', () => { saml: { realm: 'saml-realm' }, }, }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: true } ).authc.selector.enabled ).toBe(true); @@ -954,7 +970,7 @@ describe('createConfig()', () => { }, }, }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: true } ).authc.selector.enabled ).toBe(false); @@ -971,7 +987,7 @@ describe('createConfig()', () => { }, }, }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: true } ).authc.selector.enabled ).toBe(true); @@ -989,7 +1005,7 @@ describe('createConfig()', () => { }, }, }), - loggingServiceMock.create().get(), + loggingSystemMock.create().get(), { isTLSEnabled: true } ).authc.sortedProviders ).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 8a7865fa17efcd..051a3d2ab13422 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -97,6 +97,7 @@ const providersConfigSchema = schema.object( ...getCommonProviderSchemaProperties(), realm: schema.string(), maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + useRelayStateDeepLink: schema.boolean({ defaultValue: false }), }) ) ), diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 91783140539a5b..ad38a158af2b93 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -31,7 +31,7 @@ export function defineCommonRoutes({ { path, // Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any - // set of query string parameters (e.g. SAML/OIDC logout request parameters). + // set of query string parameters (e.g. SAML/OIDC logout request/response parameters). validate: { query: schema.object({}, { unknowns: 'allow' }) }, options: { authRequired: false }, }, diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index af63dfa2f44718..5f5161126f215d 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -62,9 +62,9 @@ describe('SAML authentication routes', () => { `"[SAMLResponse]: expected value of type [string] but got [undefined]"` ); - expect(() => - bodyValidator.validate({ SAMLResponse: 'saml-response', UnknownArg: 'arg' }) - ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + expect(bodyValidator.validate({ SAMLResponse: 'saml-response', UnknownArg: 'arg' })).toEqual({ + SAMLResponse: 'saml-response', + }); }); it('returns 500 if authentication throws unhandled exception.', async () => { @@ -174,5 +174,34 @@ describe('SAML authentication routes', () => { headers: { location: 'http://redirect-to/path' }, }); }); + + it('passes `RelayState` within login attempt.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const redirectResponse = Symbol('error'); + const responseFactory = httpServerMock.createResponseFactory(); + responseFactory.redirected.mockReturnValue(redirectResponse as any); + + const request = httpServerMock.createKibanaRequest({ + body: { SAMLResponse: 'saml-response', RelayState: '/app/kibana' }, + }); + + await expect(routeHandler({} as any, request, responseFactory)).resolves.toBe( + redirectResponse + ); + + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { type: 'saml' }, + value: { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: 'saml-response', + relayState: '/app/kibana', + }, + }); + + expect(responseFactory.redirected).toHaveBeenCalledWith({ + headers: { location: 'http://redirect-to/path' }, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 30e1f6f336bdd3..ce7516c2c9d880 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -89,10 +89,10 @@ export function defineSAMLRoutes({ { path: '/api/security/saml/callback', validate: { - body: schema.object({ - SAMLResponse: schema.string(), - RelayState: schema.maybe(schema.string()), - }), + body: schema.object( + { SAMLResponse: schema.string(), RelayState: schema.maybe(schema.string()) }, + { unknowns: 'ignore' } + ), }, options: { authRequired: false, xsrfRequired: false }, }, @@ -101,7 +101,11 @@ export function defineSAMLRoutes({ // When authenticating using SAML we _expect_ to redirect to the Kibana target location. const authenticationResult = await authc.login(request, { provider: { type: SAMLAuthenticationProvider.type }, - value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse }, + value: { + type: SAMLLogin.LoginWithSAMLResponse, + samlResponse: request.body.SAMLResponse, + relayState: request.body.RelayState, + }, }); if (authenticationResult.redirected()) { diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 1a93d6701e257d..c7ff2a1e68b027 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -7,7 +7,7 @@ import { elasticsearchServiceMock, httpServiceMock, - loggingServiceMock, + loggingSystemMock, httpResourcesMock, } from '../../../../../src/core/server/mocks'; import { authenticationMock } from '../authentication/index.mock'; @@ -20,9 +20,9 @@ export const routeDefinitionParamsMock = { router: httpServiceMock.createRouter(), basePath: httpServiceMock.createBasePath(), csp: httpServiceMock.createSetupContract().csp, - logger: loggingServiceMock.create().get(), + logger: loggingSystemMock.create().get(), clusterClient: elasticsearchServiceMock.createClusterClient(), - config: createConfig(ConfigSchema.validate(config), loggingServiceMock.create().get(), { + config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get(), { isTLSEnabled: false, }), authc: authenticationMock.create(), diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 0d162c068376fa..58431e405ea8b6 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -42,6 +42,9 @@ export const APP_TIMELINES_PATH = `${APP_PATH}/timelines`; export const APP_CASES_PATH = `${APP_PATH}/cases`; export const APP_MANAGEMENT_PATH = `${APP_PATH}/management`; +export const SHOW_ENDPOINT_ALERTS_NAV = true; +export const APP_ENDPOINT_ALERTS_PATH = `${APP_PATH}/endpoint-alerts`; + /** The comma-delimited list of Elasticsearch indices from which the SIEM app collects events */ export const DEFAULT_INDEX_PATTERN = [ 'apm-*-transaction*', diff --git a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts b/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts new file mode 100644 index 00000000000000..a8b177f587a487 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EntriesArray, namespaceType } from '../../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 0c7bcdefd360df..f6b732cd1f64e5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -341,40 +341,3 @@ export type Note = t.TypeOf; export const noteOrUndefined = t.union([note, t.undefined]); export type NoteOrUndefined = t.TypeOf; - -// NOTE: Experimental list support not being shipped currently and behind a feature flag -// TODO: Remove this comment once we lists have passed testing and is ready for the release -export const list_field = t.string; -export const list_values_operator = t.keyof({ included: null, excluded: null }); -export const list_values_type = t.keyof({ match: null, match_all: null, list: null, exists: null }); -export const list_values = t.exact( - t.intersection([ - t.type({ - name: t.string, - }), - t.partial({ - id: t.string, - description: t.string, - created_at, - }), - ]) -); -export const list = t.exact( - t.intersection([ - t.type({ - field: t.string, - values_operator: list_values_operator, - values_type: list_values_type, - }), - t.partial({ values: t.array(list_values) }), - ]) -); -export const list_and = t.intersection([ - list, - t.partial({ - and: t.array(list), - }), -]); - -export const listAndOrUndefined = t.union([t.array(list_and), t.undefined]); -export type ListAndOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 3e7e7e5409c9cc..43000f6d36f467 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -40,16 +40,19 @@ import { } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -import { DefaultStringArray } from '../types/default_string_array'; -import { DefaultActionsArray } from '../types/default_actions_array'; -import { DefaultBooleanFalse } from '../types/default_boolean_false'; -import { DefaultFromString } from '../types/default_from_string'; -import { DefaultIntervalString } from '../types/default_interval_string'; -import { DefaultMaxSignalsNumber } from '../types/default_max_signals_number'; -import { DefaultToString } from '../types/default_to_string'; -import { DefaultThreatArray } from '../types/default_threat_array'; -import { DefaultThrottleNull } from '../types/default_throttle_null'; -import { ListsDefaultArray, ListsDefaultArraySchema } from '../types/lists_default_array'; +import { + DefaultStringArray, + DefaultActionsArray, + DefaultBooleanFalse, + DefaultFromString, + DefaultIntervalString, + DefaultMaxSignalsNumber, + DefaultToString, + DefaultThreatArray, + DefaultThrottleNull, + DefaultListArray, + ListArray, +} from '../types'; /** * Big differences between this schema and the createRulesSchema @@ -96,7 +99,7 @@ export const addPrepackagedRulesSchema = t.intersection([ throttle: DefaultThrottleNull, // defaults to "null" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode note, // defaults to "undefined" if not set during decode - exceptions_list: ListsDefaultArray, // defaults to empty array if not set during decode + exceptions_list: DefaultListArray, // defaults to empty array if not set during decode }) ), ]); @@ -130,5 +133,5 @@ export type AddPrepackagedRulesSchemaDecoded = Omit< to: To; threat: Threat; throttle: ThrottleOrNull; - exceptions_list: ListsDefaultArraySchema; + exceptions_list: ListArray; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index f946b3ad3b39bf..47a98166927b41 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -19,6 +19,7 @@ import { getAddPrepackagedRulesSchemaDecodedMock, } from './add_prepackaged_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; +import { getListArrayMock } from '../types/lists.mock'; describe('add prepackaged rules schema', () => { test('empty objects do not validate', () => { @@ -1379,14 +1380,189 @@ describe('add prepackaged rules schema', () => { }); }); - // TODO: The exception_list tests are skipped and empty until we re-integrate it from the lists plugin - describe.skip('exception_list', () => { - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and exceptions_list] does validate', () => {}); + describe('exception_list', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, version, and exceptions_list] does validate', () => { + const payload: AddPrepackagedRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + version: 1, + exceptions_list: getListArrayMock(), + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => {}); + const decoded = addPrepackagedRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: AddPrepackagedRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: false, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + filters: [], + exceptions_list: [ + { + id: 'some_uuid', + namespace_type: 'single', + }, + { + id: 'some_uuid', + namespace_type: 'agnostic', + }, + ], + }; + expect(message.schema).toEqual(expected); + }); - test('rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid exceptions_list] does NOT validate', () => {}); + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, version, and empty exceptions_list] does validate', () => { + const payload: AddPrepackagedRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + version: 1, + note: '# some markdown', + exceptions_list: [], + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => {}); + const decoded = addPrepackagedRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: AddPrepackagedRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: false, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + filters: [], + exceptions_list: [], + }; + expect(message.schema).toEqual(expected); + }); + + test('rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, version, and invalid exceptions_list] does NOT validate', () => { + const payload: Omit & { + exceptions_list: Array<{ id: string; namespace_type: string }>; + } = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + version: 1, + note: '# some markdown', + exceptions_list: [{ id: 'uuid_here', namespace_type: 'not a namespace type' }], + }; + + const decoded = addPrepackagedRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, version, and non-existent exceptions_list] does validate with empty exceptions_list', () => { + const payload: AddPrepackagedRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + version: 1, + note: '# some markdown', + }; + + const decoded = addPrepackagedRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: AddPrepackagedRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: false, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + exceptions_list: [], + filters: [], + }; + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts index a126b833ba4615..1648044f5305a3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts @@ -18,6 +18,7 @@ import { getCreateRulesSchemaDecodedMock, } from './create_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; +import { getListArrayMock } from '../types/lists.mock'; describe('create rules schema', () => { test('empty objects do not validate', () => { @@ -1435,14 +1436,185 @@ describe('create rules schema', () => { ); }); - // TODO: The exception_list tests are skipped and empty until we re-integrate it from the lists plugin - describe.skip('exception_list', () => { - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and exceptions_list] does validate', () => {}); + describe('exception_list', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and exceptions_list] does validate', () => { + const payload: CreateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: getListArrayMock(), + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => {}); + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: CreateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + filters: [], + exceptions_list: [ + { + id: 'some_uuid', + namespace_type: 'single', + }, + { + id: 'some_uuid', + namespace_type: 'agnostic', + }, + ], + }; + expect(message.schema).toEqual(expected); + }); - test('rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid exceptions_list] does NOT validate', () => {}); + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => { + const payload: CreateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [], + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => {}); + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: CreateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + filters: [], + exceptions_list: [], + }; + expect(message.schema).toEqual(expected); + }); + + test('rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and invalid exceptions_list] does NOT validate', () => { + const payload: Omit & { + exceptions_list: Array<{ id: string; namespace_type: string }>; + } = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [{ id: 'uuid_here', namespace_type: 'not a namespace type' }], + }; + + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => { + const payload: CreateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + }; + + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: CreateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + exceptions_list: [], + filters: [], + }; + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts index 4e60201b8030e1..d623cff8f1fc31 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts @@ -41,18 +41,21 @@ import { } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -import { DefaultStringArray } from '../types/default_string_array'; -import { DefaultActionsArray } from '../types/default_actions_array'; -import { DefaultBooleanTrue } from '../types/default_boolean_true'; -import { DefaultFromString } from '../types/default_from_string'; -import { DefaultIntervalString } from '../types/default_interval_string'; -import { DefaultMaxSignalsNumber } from '../types/default_max_signals_number'; -import { DefaultToString } from '../types/default_to_string'; -import { DefaultThreatArray } from '../types/default_threat_array'; -import { DefaultThrottleNull } from '../types/default_throttle_null'; -import { DefaultVersionNumber } from '../types/default_version_number'; -import { ListsDefaultArray, ListsDefaultArraySchema } from '../types/lists_default_array'; -import { DefaultUuid } from '../types/default_uuid'; +import { + DefaultStringArray, + DefaultActionsArray, + DefaultBooleanTrue, + DefaultFromString, + DefaultIntervalString, + DefaultMaxSignalsNumber, + DefaultToString, + DefaultThreatArray, + DefaultThrottleNull, + DefaultVersionNumber, + DefaultListArray, + ListArray, + DefaultUuid, +} from '../types'; export const createRulesSchema = t.intersection([ t.exact( @@ -92,7 +95,7 @@ export const createRulesSchema = t.intersection([ references: DefaultStringArray, // defaults to empty array of strings if not set during decode note, // defaults to "undefined" if not set during decode version: DefaultVersionNumber, // defaults to 1 if not set during decode - exceptions_list: ListsDefaultArray, // defaults to empty array if not set during decode + exceptions_list: DefaultListArray, // defaults to empty array if not set during decode }) ), ]); @@ -129,6 +132,6 @@ export type CreateRulesSchemaDecoded = Omit< threat: Threat; throttle: ThrottleOrNull; version: Version; - exceptions_list: ListsDefaultArraySchema; + exceptions_list: ListArray; rule_id: RuleId; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index 9fe3e95a206217..12a13ab1a5ed1b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -22,6 +22,7 @@ import { getImportRulesSchemaDecodedMock, } from './import_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; +import { getListArrayMock } from '../types/lists.mock'; describe('import rules schema', () => { test('empty objects do not validate', () => { @@ -1569,14 +1570,188 @@ describe('import rules schema', () => { }); }); - // TODO: The exception_list tests are skipped and empty until we re-integrate it from the lists plugin - describe.skip('exception_list', () => { - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and exceptions_list] does validate', () => {}); + describe('exception_list', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and exceptions_list] does validate', () => { + const payload: ImportRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: getListArrayMock(), + }; + + const decoded = importRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: ImportRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + filters: [], + immutable: false, + exceptions_list: [ + { + id: 'some_uuid', + namespace_type: 'single', + }, + { + id: 'some_uuid', + namespace_type: 'agnostic', + }, + ], + }; + expect(message.schema).toEqual(expected); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => { + const payload: ImportRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [], + }; + + const decoded = importRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: ImportRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + immutable: false, + filters: [], + exceptions_list: [], + }; + expect(message.schema).toEqual(expected); + }); - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => {}); + test('rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and invalid exceptions_list] does NOT validate', () => { + const payload: Omit & { + exceptions_list: Array<{ id: string; namespace_type: string }>; + } = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [{ id: 'uuid_here', namespace_type: 'not a namespace type' }], + }; - test('rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid exceptions_list] does NOT validate', () => {}); + const decoded = importRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => {}); + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => { + const payload: ImportRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + }; + + const decoded = importRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: ImportRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + version: 1, + immutable: false, + exceptions_list: [], + filters: [], + }; + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index a2110263e8e513..7d79861aacf38b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -47,19 +47,22 @@ import { } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -import { DefaultStringArray } from '../types/default_string_array'; -import { DefaultActionsArray } from '../types/default_actions_array'; -import { DefaultBooleanTrue } from '../types/default_boolean_true'; -import { DefaultFromString } from '../types/default_from_string'; -import { DefaultIntervalString } from '../types/default_interval_string'; -import { DefaultMaxSignalsNumber } from '../types/default_max_signals_number'; -import { DefaultToString } from '../types/default_to_string'; -import { DefaultThreatArray } from '../types/default_threat_array'; -import { DefaultThrottleNull } from '../types/default_throttle_null'; -import { DefaultVersionNumber } from '../types/default_version_number'; -import { ListsDefaultArray, ListsDefaultArraySchema } from '../types/lists_default_array'; -import { OnlyFalseAllowed } from '../types/only_false_allowed'; -import { DefaultStringBooleanFalse } from '../types/default_string_boolean_false'; +import { + DefaultStringArray, + DefaultActionsArray, + DefaultBooleanTrue, + DefaultFromString, + DefaultIntervalString, + DefaultMaxSignalsNumber, + DefaultToString, + DefaultThreatArray, + DefaultThrottleNull, + DefaultVersionNumber, + OnlyFalseAllowed, + DefaultStringBooleanFalse, + DefaultListArray, + ListArray, +} from '../types'; /** * Differences from this and the createRulesSchema are @@ -111,7 +114,7 @@ export const importRulesSchema = t.intersection([ references: DefaultStringArray, // defaults to empty array of strings if not set during decode note, // defaults to "undefined" if not set during decode version: DefaultVersionNumber, // defaults to 1 if not set during decode - exceptions_list: ListsDefaultArray, // defaults to empty array if not set during decode + exceptions_list: DefaultListArray, // defaults to empty array if not set during decode created_at, // defaults "undefined" if not set during decode updated_at, // defaults "undefined" if not set during decode created_by, // defaults "undefined" if not set during decode @@ -153,7 +156,7 @@ export type ImportRulesSchemaDecoded = Omit< threat: Threat; throttle: ThrottleOrNull; version: Version; - exceptions_list: ListsDefaultArraySchema; + exceptions_list: ListArray; rule_id: RuleId; immutable: false; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts index 55363ffb183075..81a17df43daf63 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts @@ -10,6 +10,7 @@ import { exactCheck } from '../../../exact_check'; import { pipe } from 'fp-ts/lib/pipeable'; import { foldLeftRight, getPaths } from '../../../test_utils'; import { left } from 'fp-ts/lib/Either'; +import { getListArrayMock } from '../types/lists.mock'; describe('patch_rules_schema', () => { test('made up values do not validate', () => { @@ -1139,14 +1140,156 @@ describe('patch_rules_schema', () => { expect(message.schema).toEqual({}); }); - // TODO: The exception_list tests are skipped and empty until we re-integrate it from the lists plugin - describe.skip('exception_list', () => { - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and exceptions_list] does validate', () => {}); + describe('exception_list', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, note, and exceptions_list] does validate', () => { + const payload: PatchRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + note: '# some documentation markdown', + exceptions_list: getListArrayMock(), + }; + + const decoded = patchRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: PatchRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + note: '# some documentation markdown', + exceptions_list: [ + { + id: 'some_uuid', + namespace_type: 'single', + }, + { + id: 'some_uuid', + namespace_type: 'agnostic', + }, + ], + }; + expect(message.schema).toEqual(expected); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => { + const payload: PatchRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [], + }; + + const decoded = patchRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: PatchRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [], + }; + expect(message.schema).toEqual(expected); + }); + + test('rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and invalid exceptions_list] does NOT validate', () => { + const payload: Omit & { + exceptions_list: Array<{ id: string; namespace_type: string }>; + } = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + exceptions_list: [{ id: 'uuid_here', namespace_type: 'not a namespace type' }], + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => {}); + const decoded = patchRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', + 'Invalid value "[{"id":"uuid_here","namespace_type":"not a namespace type"}]" supplied to "exceptions_list"', + ]); + expect(message.schema).toEqual({}); + }); - test('rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid exceptions_list] does NOT validate', () => {}); + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => { + const payload: PatchRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => {}); + const decoded = patchRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: PatchRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + filters: [], + risk_score: 50, + note: '# some markdown', + }; + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 605e0272bbb4cd..29d5467071a3d0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -37,10 +37,10 @@ import { references, to, language, - listAndOrUndefined, query, id, } from '../common/schemas'; +import { listArrayOrUndefined } from '../types/lists'; /* eslint-enable @typescript-eslint/camelcase */ /** @@ -80,7 +80,7 @@ export const patchRulesSchema = t.exact( references, note, version, - exceptions_list: listAndOrUndefined, + exceptions_list: listArrayOrUndefined, }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts index 1ff38f1351f591..02f8e7bbeb59b2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts @@ -18,6 +18,7 @@ import { getUpdateRulesSchemaDecodedMock, } from './update_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; +import { getListArrayMock } from '../types/lists.mock'; describe('update rules schema', () => { test('empty objects do not validate', () => { @@ -1377,14 +1378,182 @@ describe('update rules schema', () => { }); }); - // TODO: The exception_list tests are skipped and empty until we re-integrate it from the lists plugin - describe.skip('exception_list', () => { - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and exceptions_list] does validate', () => {}); + describe('exception_list', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and exceptions_list] does validate', () => { + const payload: UpdateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + filters: [], + note: '# some markdown', + exceptions_list: getListArrayMock(), + }; + + const decoded = updateRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: UpdateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + filters: [], + exceptions_list: [ + { + id: 'some_uuid', + namespace_type: 'single', + }, + { + id: 'some_uuid', + namespace_type: 'agnostic', + }, + ], + }; + expect(message.schema).toEqual(expected); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => { + const payload: UpdateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + filters: [], + note: '# some markdown', + exceptions_list: [], + }; + + const decoded = updateRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: UpdateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + filters: [], + exceptions_list: [], + }; + expect(message.schema).toEqual(expected); + }); - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty exceptions_list] does validate', () => {}); + test('rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and invalid exceptions_list] does NOT validate', () => { + const payload: Omit & { + exceptions_list: Array<{ id: string; namespace_type: string }>; + } = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + filters: [], + note: '# some markdown', + exceptions_list: [{ id: 'uuid_here', namespace_type: 'not a namespace type' }], + }; + + const decoded = updateRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); - test('rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid exceptions_list] does NOT validate', () => {}); + test('[rule_id, description, from, to, index, name, severity, interval, type, filters, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => { + const payload: UpdateRulesSchema = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + filters: [], + note: '# some markdown', + }; - test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent exceptions_list] does validate with empty exceptions_list', () => {}); + const decoded = updateRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: UpdateRulesSchemaDecoded = { + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + references: [], + actions: [], + enabled: true, + false_positives: [], + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + threat: [], + throttle: null, + exceptions_list: [], + filters: [], + }; + expect(message.schema).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts index 504233f95986f6..73078e617efc6f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts @@ -43,16 +43,19 @@ import { } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -import { DefaultStringArray } from '../types/default_string_array'; -import { DefaultActionsArray } from '../types/default_actions_array'; -import { DefaultBooleanTrue } from '../types/default_boolean_true'; -import { DefaultFromString } from '../types/default_from_string'; -import { DefaultIntervalString } from '../types/default_interval_string'; -import { DefaultMaxSignalsNumber } from '../types/default_max_signals_number'; -import { DefaultToString } from '../types/default_to_string'; -import { DefaultThreatArray } from '../types/default_threat_array'; -import { DefaultThrottleNull } from '../types/default_throttle_null'; -import { ListsDefaultArray, ListsDefaultArraySchema } from '../types/lists_default_array'; +import { + DefaultStringArray, + DefaultActionsArray, + DefaultBooleanTrue, + DefaultFromString, + DefaultIntervalString, + DefaultMaxSignalsNumber, + DefaultToString, + DefaultThreatArray, + DefaultThrottleNull, + DefaultListArray, + ListArray, +} from '../types'; /** * This almost identical to the create_rules_schema except for a few details. @@ -100,7 +103,7 @@ export const updateRulesSchema = t.intersection([ references: DefaultStringArray, // defaults to empty array of strings if not set during decode note, // defaults to "undefined" if not set during decode version, // defaults to "undefined" if not set during decode - exceptions_list: ListsDefaultArray, // defaults to empty array if not set during decode + exceptions_list: DefaultListArray, // defaults to empty array if not set during decode }) ), ]); @@ -135,6 +138,6 @@ export type UpdateRulesSchemaDecoded = Omit< to: To; threat: Threat; throttle: ThrottleOrNull; - exceptions_list: ListsDefaultArraySchema; + exceptions_list: ListArray; rule_id: RuleId; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index ecbf0321cdc670..e63a7ad981e120 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -3,6 +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 { getListArrayMock } from '../types/lists.mock'; import { RulesSchema } from './rules_schema'; @@ -64,38 +65,7 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchem language: 'kuery', rule_id: 'query-rule-id', interval: '5m', - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }); export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchema => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts index 90aef656db3695..b3f9096b514834 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts @@ -22,6 +22,7 @@ import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; import { TypeAndTimelineOnly } from './type_timeline_only_schema'; import { getRulesSchemaMock, getRulesMlSchemaMock } from './rules_schema.mocks'; +import { ListArray } from '../types/lists'; export const ANCHOR_DATE = '2020-02-20T03:57:54.037Z'; @@ -650,4 +651,47 @@ describe('rules_schema', () => { expect(fields.length).toEqual(2); }); }); + + describe('exceptions_list', () => { + test('it should validate an empty array for "exceptions_list"', () => { + const payload = getRulesSchemaMock(); + payload.exceptions_list = []; + const decoded = rulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getRulesSchemaMock(); + expected.exceptions_list = []; + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + + test('it should NOT validate when "exceptions_list" is not expected type', () => { + const payload: Omit & { + exceptions_list?: string; + } = { ...getRulesSchemaMock(), exceptions_list: 'invalid_data' }; + + const decoded = rulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid_data" supplied to "exceptions_list"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should default to empty array if "exceptions_list" is undefined ', () => { + const payload: Omit & { + exceptions_list?: ListArray; + } = getRulesSchemaMock(); + payload.exceptions_list = undefined; + + const decoded = rulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...payload, exceptions_list: [] }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index a7a31ec9e1b59e..9803a80f57857e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -56,7 +56,7 @@ import { meta, note, } from '../common/schemas'; -import { ListsDefaultArray } from '../types/lists_default_array'; +import { DefaultListArray } from '../types/lists_default_array'; /** * This is the required fields for the rules schema response. Put all required properties on @@ -87,7 +87,7 @@ export const requiredRulesSchema = t.type({ updated_at, created_by, version, - exceptions_list: ListsDefaultArray, + exceptions_list: DefaultListArray, }); export type RequiredRulesSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts new file mode 100644 index 00000000000000..368dd4922eec48 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts @@ -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. + */ + +export * from './default_actions_array'; +export * from './default_boolean_false'; +export * from './default_boolean_true'; +export * from './default_empty_string'; +export * from './default_export_file_name'; +export * from './default_from_string'; +export * from './default_interval_string'; +export * from './default_language_string'; +export * from './default_max_signals_number'; +export * from './default_page'; +export * from './default_per_page'; +export * from './default_string_array'; +export * from './default_string_boolean_false'; +export * from './default_threat_array'; +export * from './default_throttle_null'; +export * from './default_to_string'; +export * from './default_uuid'; +export * from './default_version_number'; +export * from './iso_date_string'; +export * from './lists'; +export * from './lists_default_array'; +export * from './non_empty_string'; +export * from './only_false_allowed'; +export * from './positive_integer'; +export * from './positive_integer_greater_than_zero'; +export * from './references_default_array'; +export * from './risk_score'; +export * from './uuid'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts new file mode 100644 index 00000000000000..d76e2ac78f3d32 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts @@ -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 { List, ListArray } from './lists'; + +export const getListMock = (): List => ({ + id: 'some_uuid', + namespace_type: 'single', +}); + +export const getListAgnosticMock = (): List => ({ + id: 'some_uuid', + namespace_type: 'agnostic', +}); + +export const getListArrayMock = (): ListArray => [getListMock(), getListAgnosticMock()]; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts new file mode 100644 index 00000000000000..657a4b479f1640 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts @@ -0,0 +1,131 @@ +/* + * 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 '../../../test_utils'; + +import { getListAgnosticMock, getListMock, getListArrayMock } from './lists.mock'; +import { + List, + ListArray, + ListArrayOrUndefined, + list, + listArray, + listArrayOrUndefined, +} from './lists'; + +describe('Lists', () => { + describe('list', () => { + test('it should validate a list', () => { + const payload = getListMock(); + const decoded = list.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate a list with "namespace_type" of"agnostic"', () => { + const payload = getListAgnosticMock(); + const decoded = list.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate a list without an "id"', () => { + const payload = getListMock(); + delete payload.id; + const decoded = list.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a list without "namespace_type"', () => { + const payload = getListMock(); + delete payload.namespace_type; + const decoded = list.decode(payload); + const message = pipe(decoded, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: List & { + extraKey?: string; + } = getListMock(); + payload.extraKey = 'some value'; + const decoded = list.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getListMock()); + }); + }); + + describe('listArray', () => { + test('it should validate an array of lists', () => { + const payload = getListArrayMock(); + const decoded = listArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when unexpected type found in array', () => { + const payload = ([1] as unknown) as ListArray; + const decoded = listArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| id: string, namespace_type: "agnostic" | "single" |}>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('listArrayOrUndefined', () => { + test('it should validate an array of lists', () => { + const payload = getListArrayMock(); + const decoded = listArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = listArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not allow an item that is not of type "list" in array', () => { + const payload = ([1] as unknown) as ListArrayOrUndefined; + const decoded = listArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "(Array<{| id: string, namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "[1]" supplied to "(Array<{| id: string, namespace_type: "agnostic" | "single" |}> | undefined)"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts new file mode 100644 index 00000000000000..07be038ff35263 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts @@ -0,0 +1,22 @@ +/* + * 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 * as t from 'io-ts'; + +import { namespaceType } from '../../lists_common_deps'; + +export const list = t.exact( + t.type({ + id: t.string, + namespace_type: namespaceType, + }) +); + +export type List = t.TypeOf; +export const listArray = t.array(list); +export type ListArray = t.TypeOf; +export const listArrayOrUndefined = t.union([listArray, t.undefined]); +export type ListArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts index 9eb55c22756faf..2268e47bd1149c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts @@ -4,187 +4,60 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ListsDefaultArray } from './lists_default_array'; import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; -import { foldLeftRight, getPaths } from '../../../test_utils'; - -describe('lists_default_array', () => { - test('it should validate an empty array', () => { - const payload: string[] = []; - const decoded = ListsDefaultArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - test('it should validate an array of lists', () => { - const payload = [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ]; - const decoded = ListsDefaultArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); +import { foldLeftRight, getPaths } from '../../../test_utils'; - test('it should not validate an array of lists that includes a values_operator other than included or excluded', () => { - const payload = [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'exists', - }, - { - field: 'host.hostname', - values_operator: 'jibber jabber', - values_type: 'exists', - }, - ]; - const decoded = ListsDefaultArray.decode(payload); - const message = pipe(decoded, foldLeftRight); +import { DefaultListArray, DefaultListArrayC } from './lists_default_array'; +import { getListArrayMock } from './lists.mock'; - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "jibber jabber" supplied to "values_operator"', - ]); - expect(message.schema).toEqual({}); - }); - - // TODO - this scenario should never come up, as the values key is forbidden when values_type is "exists" in the incoming schema - need to find a good way to do this in io-ts - test('it will validate an array of lists that includes "values" when "values_type" is "exists"', () => { - const payload = [ - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'exists', - values: [ - { - name: '127.0.0.1', - }, - ], - }, - ]; - const decoded = ListsDefaultArray.decode(payload); +describe('lists_default_array', () => { + test('it should return a default array when null', () => { + const payload = null; + const decoded = DefaultListArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + expect(message.schema).toEqual([]); }); - // TODO - this scenario should never come up, as the values key is required when values_type is "match" in the incoming schema - need to find a good way to do this in io-ts - test('it will validate an array of lists that does not include "values" when "values_type" is "match"', () => { - const payload = [ - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - }, - ]; - const decoded = ListsDefaultArray.decode(payload); + test('it should return a default array when undefined', () => { + const payload = undefined; + const decoded = DefaultListArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + expect(message.schema).toEqual([]); }); - // TODO - this scenario should never come up, as the values key is required when values_type is "match_all" in the incoming schema - need to find a good way to do this in io-ts - test('it will validate an array of lists that does not include "values" when "values_type" is "match_all"', () => { - const payload = [ - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match_all', - }, - ]; - const decoded = ListsDefaultArray.decode(payload); + test('it should validate an empty array', () => { + const payload: string[] = []; + const decoded = DefaultListArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - // TODO - this scenario should never come up, as the values key is required when values_type is "list" in the incoming schema - need to find a good way to do this in io-ts - test('it should not validate an array of lists that does not include "values" when "values_type" is "list"', () => { - const payload = [ - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'list', - }, - ]; - const decoded = ListsDefaultArray.decode(payload); + test('it should validate an array of lists', () => { + const payload = getListArrayMock(); + const decoded = DefaultListArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should not validate an array with a number', () => { - const payload = [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - values: [ - { - name: '127.0.0.1', - }, - ], - }, - 5, - ]; - const decoded = ListsDefaultArray.decode(payload); + test('it should not validate an array of non accepted types', () => { + // Terrible casting for purpose of tests + const payload = ([1] as unknown) as DefaultListArrayC; + const decoded = DefaultListArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to "listsWithDefaultArray"', - 'Invalid value "5" supplied to "listsWithDefaultArray"', + 'Invalid value "1" supplied to "DefaultListArray"', ]); expect(message.schema).toEqual({}); }); - - test('it should return a default array entry', () => { - const payload = null; - const decoded = ListsDefaultArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual([]); - }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts index 7fe98cdc300eff..ac5666cad23a78 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts @@ -7,28 +7,18 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; -import { - list_and as listAnd, - list_values as listValues, - list_values_operator as listOperator, -} from '../common/schemas'; +import { ListArray, list } from './lists'; -export type List = t.TypeOf; -export type ListValues = t.TypeOf; -export type ListOperator = t.TypeOf; +export type DefaultListArrayC = t.Type; /** - * Types the ListsDefaultArray as: - * - If null or undefined, then a default array will be set for the list + * Types the DefaultListArray as: + * - If null or undefined, then a default array of type list will be set */ -export const ListsDefaultArray = new t.Type( - 'listsWithDefaultArray', - t.array(listAnd).is, - (input, context): Either => - input == null ? t.success([]) : t.array(listAnd).validate(input, context), +export const DefaultListArray: DefaultListArrayC = new t.Type( + 'DefaultListArray', + t.array(list).is, + (input, context): Either => + input == null ? t.success([]) : t.array(list).validate(input, context), t.identity ); - -export type ListsDefaultArrayC = typeof ListsDefaultArray; - -export type ListsDefaultArraySchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index bb00321567e80b..984cd7d2506a97 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const eventsIndexPattern = 'events-endpoint-*'; +export const eventsIndexPattern = 'logs-endpoint.events.*'; +export const alertsIndexPattern = 'logs-endpoint.alerts-*'; export const metadataIndexPattern = 'metrics-endpoint.metadata-*'; +export const metadataMirrorIndexPattern = 'metrics-endpoint.metadata_mirror-*'; export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index ef9e8376827a0c..5af34b6a694e83 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -14,6 +14,7 @@ import { HostPolicyResponse, HostPolicyResponseActionStatus, PolicyData, + EndpointStatus, } from './types'; import { factory as policyFactory } from './models/policy_config'; @@ -209,6 +210,7 @@ interface HostInfo { }; host: Host; Endpoint: { + status: EndpointStatus; policy: { applied: { id: string; @@ -305,7 +307,7 @@ export class EndpointDocGenerator { * Creates new random policy id for the host to simulate new policy application */ public updatePolicyId() { - this.commonInfo.Endpoint.policy.applied = this.randomChoice(APPLIED_POLICIES); + this.commonInfo.Endpoint.policy.applied.id = this.randomChoice(APPLIED_POLICIES).id; this.commonInfo.Endpoint.policy.applied.status = this.randomChoice([ HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.failure, @@ -333,6 +335,7 @@ export class EndpointDocGenerator { os: this.randomChoice(OS), }, Endpoint: { + status: EndpointStatus.enrolled, policy: { applied: this.randomChoice(APPLIED_POLICIES), }, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index d868ba63b1edcd..00b8f0b057afd7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -16,6 +16,7 @@ export async function indexHostsAndAlerts( metadataIndex: string, policyIndex: string, eventIndex: string, + alertIndex: string, alertsPerHost: number, options: TreeOptions = {} ) { @@ -23,7 +24,7 @@ export async function indexHostsAndAlerts( for (let i = 0; i < numHosts; i++) { const generator = new EndpointDocGenerator(random); await indexHostDocs(numDocs, client, metadataIndex, policyIndex, generator); - await indexAlerts(client, eventIndex, generator, alertsPerHost, options); + await indexAlerts(client, eventIndex, alertIndex, generator, alertsPerHost, options); } await client.indices.refresh({ index: eventIndex, @@ -65,7 +66,8 @@ async function indexHostDocs( async function indexAlerts( client: Client, - index: string, + eventIndex: string, + alertIndex: string, generator: EndpointDocGenerator, numAlerts: number, options: TreeOptions = {} @@ -82,9 +84,14 @@ async function indexAlerts( } const body = resolverDocs.reduce( // eslint-disable-next-line @typescript-eslint/no-explicit-any - (array: Array>, doc) => ( - array.push({ create: { _index: index } }, doc), array - ), + (array: Array>, doc) => { + let index = eventIndex; + if (doc.event.kind === 'alert') { + index = alertIndex; + } + array.push({ create: { _index: index } }, doc); + return array; + }, [] ); await client.bulk({ body, refresh: 'true' }); diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index f8cfb8f7c3bbce..42f5f4b220da95 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -74,7 +74,7 @@ export interface ResolverNodeStats { /** * A child node can also have additional children so we need to provide a pagination cursor. */ -export interface ChildNode extends LifecycleNode { +export interface ResolverChildNode extends ResolverLifecycleNode { /** * A child node's pagination cursor can be null for a couple reasons: * 1. At the time of querying it could have no children in ES, in which case it will be marked as @@ -89,7 +89,7 @@ export interface ChildNode extends LifecycleNode { * has an array of lifecycle events. */ export interface ResolverChildren { - childNodes: ChildNode[]; + childNodes: ResolverChildNode[]; /** * This is the children cursor for the origin of a tree. */ @@ -116,7 +116,7 @@ export interface ResolverTree { /** * The lifecycle events (start, end etc) for a node. */ -export interface LifecycleNode { +export interface ResolverLifecycleNode { entityID: string; lifecycle: ResolverEvent[]; /** @@ -132,7 +132,7 @@ export interface ResolverAncestry { /** * An array of ancestors with the lifecycle events grouped together */ - ancestors: LifecycleNode[]; + ancestors: ResolverLifecycleNode[]; /** * A cursor for retrieving additional ancestors for a particular node. `null` indicates that there were no additional * ancestors when the request returned. More could have been ingested by ES after the fact though. @@ -350,7 +350,23 @@ export interface AlertEvent { } /** - * The status of the host + * The status of the Endpoint Agent as reported by the Agent or the + * Security Solution app using events from Fleet. + */ +export enum EndpointStatus { + /** + * Agent is enrolled with Fleet + */ + enrolled = 'enrolled', + + /** + * Agent is unenrrolled from Fleet + */ + unenrolled = 'unenrolled', +} + +/** + * The status of the host, which is mapped to the Elastic Agent status in Fleet */ export enum HostStatus { /** @@ -386,6 +402,7 @@ export type HostMetadata = Immutable<{ }; }; Endpoint: { + status: EndpointStatus; policy: { applied: { id: string; diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index bb2dffe8ddd7de..efd9ece8aec566 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -44,7 +44,7 @@ import { backToCases, createNewCase } from '../tasks/create_new_case'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; -import { CASES } from '../urls/navigation'; +import { CASES_URL } from '../urls/navigation'; describe('Cases', () => { before(() => { @@ -56,7 +56,7 @@ describe('Cases', () => { }); it('Creates a new case with timeline and opens the timeline', () => { - loginAndWaitForPageWithoutDateRange(CASES); + loginAndWaitForPageWithoutDateRange(CASES_URL); goToCreateNewCase(); createNewCase(case1); backToCases(); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts index 266d183ea1b858..ed885ad653e5de 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts @@ -15,7 +15,7 @@ import { } from '../tasks/configure_cases'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { CASES } from '../urls/navigation'; +import { CASES_URL } from '../urls/navigation'; describe('Cases connectors', () => { before(() => { @@ -25,7 +25,7 @@ describe('Cases connectors', () => { }); it('Configures a new connector', () => { - loginAndWaitForPageWithoutDateRange(CASES); + loginAndWaitForPageWithoutDateRange(CASES_URL); goToEditExternalConnection(); openAddNewConnectorOption(); addServiceNowConnector(serviceNowConnector); diff --git a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts index b2d35f3f0c3361..84ca1e20e95766 100644 --- a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts @@ -33,7 +33,7 @@ import { } from '../tasks/hosts/events'; import { clearSearchBar, kqlSearch } from '../tasks/security_header'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; import { resetFields } from '../tasks/timeline'; const defaultHeadersInDefaultEcsCategory = [ @@ -49,7 +49,7 @@ const defaultHeadersInDefaultEcsCategory = [ describe('Events Viewer', () => { context('Fields rendering', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); }); @@ -75,7 +75,7 @@ describe('Events Viewer', () => { context('Events viewer query modal', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); }); @@ -93,7 +93,7 @@ describe('Events Viewer', () => { context('Events viewer fields behaviour', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); }); @@ -124,7 +124,7 @@ describe('Events Viewer', () => { context('Events behaviour', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); waitsForEventsToBeLoaded(); }); @@ -153,9 +153,9 @@ describe('Events Viewer', () => { }); }); - context.skip('Events columns', () => { + context('Events columns', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); waitsForEventsToBeLoaded(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts index b0dbf94c0efb92..6438a738580b78 100644 --- a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts @@ -31,7 +31,7 @@ import { loginAndWaitForPage } from '../tasks/login'; import { openTimeline } from '../tasks/security_main'; import { openTimelineFieldsBrowser, populateTimeline } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; const defaultHeaders = [ { id: '@timestamp' }, @@ -47,7 +47,7 @@ const defaultHeaders = [ describe('Fields Browser', () => { context('Fields Browser rendering', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openTimeline(); populateTimeline(); openTimelineFieldsBrowser(); @@ -110,7 +110,7 @@ describe('Fields Browser', () => { context('Editing the timeline', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openTimeline(); populateTimeline(); openTimelineFieldsBrowser(); diff --git a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts index 0529c797ee07a5..53ddff501db823 100644 --- a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts @@ -19,12 +19,12 @@ import { openTimelineSettings, } from '../tasks/timeline'; -import { HOSTS_PAGE, NETWORK_PAGE } from '../urls/navigation'; +import { HOSTS_URL, NETWORK_URL } from '../urls/navigation'; describe('Inspect', () => { context('Hosts stats and tables', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); }); afterEach(() => { closesModal(); @@ -40,7 +40,7 @@ describe('Inspect', () => { context('Network stats and tables', () => { before(() => { - loginAndWaitForPage(NETWORK_PAGE); + loginAndWaitForPage(NETWORK_URL); }); afterEach(() => { closesModal(); @@ -57,7 +57,7 @@ describe('Inspect', () => { context('Timeline', () => { it('inspects the timeline', () => { const hostExistsQuery = 'host.name: *'; - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openTimeline(); executeTimelineKQL(hostExistsQuery); openTimelineSettings(); diff --git a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts index 67b72982f44e04..ea3a78c77152a8 100644 --- a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts @@ -3,39 +3,120 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ALERTS, HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/security_header'; +import { + ALERTS, + CASES, + HOSTS, + MANAGEMENT, + NETWORK, + OVERVIEW, + TIMELINES, +} from '../screens/security_header'; import { loginAndWaitForPage } from '../tasks/login'; import { navigateFromHeaderTo } from '../tasks/security_header'; -import { TIMELINES_PAGE } from '../urls/navigation'; +import { + ALERTS_URL, + CASES_URL, + HOSTS_URL, + KIBANA_HOME, + MANAGEMENT_URL, + NETWORK_URL, + OVERVIEW_URL, + TIMELINES_URL, +} from '../urls/navigation'; +import { openKibanaNavigation, navigateFromKibanaCollapsibleTo } from '../tasks/kibana_navigation'; +import { + ALERTS_PAGE, + CASES_PAGE, + HOSTS_PAGE, + MANAGEMENT_PAGE, + NETWORK_PAGE, + OVERVIEW_PAGE, + TIMELINES_PAGE, +} from '../screens/kibana_navigation'; describe('top-level navigation common to all pages in the Security app', () => { before(() => { - loginAndWaitForPage(TIMELINES_PAGE); + loginAndWaitForPage(TIMELINES_URL); }); + it('navigates to the Overview page', () => { navigateFromHeaderTo(OVERVIEW); - cy.url().should('include', '/security/overview'); + cy.url().should('include', OVERVIEW_URL); + }); + + it('navigates to the Alerts page', () => { + navigateFromHeaderTo(ALERTS); + cy.url().should('include', ALERTS_URL); }); it('navigates to the Hosts page', () => { navigateFromHeaderTo(HOSTS); - cy.url().should('include', '/security/hosts'); + cy.url().should('include', HOSTS_URL); }); it('navigates to the Network page', () => { navigateFromHeaderTo(NETWORK); - cy.url().should('include', '/security/network'); + cy.url().should('include', NETWORK_URL); + }); + + it('navigates to the Timelines page', () => { + navigateFromHeaderTo(TIMELINES); + cy.url().should('include', TIMELINES_URL); + }); + + it('navigates to the Cases page', () => { + navigateFromHeaderTo(CASES); + cy.url().should('include', CASES_URL); + }); + + it('navigates to the Management page', () => { + navigateFromHeaderTo(MANAGEMENT); + cy.url().should('include', MANAGEMENT_URL); + }); +}); + +describe('Kibana navigation to all pages in the Security app ', () => { + before(() => { + loginAndWaitForPage(KIBANA_HOME); + }); + beforeEach(() => { + openKibanaNavigation(); + }); + it('navigates to the Overview page', () => { + navigateFromKibanaCollapsibleTo(OVERVIEW_PAGE); + cy.url().should('include', OVERVIEW_URL); }); it('navigates to the Alerts page', () => { - navigateFromHeaderTo(ALERTS); - cy.url().should('include', '/security/alerts'); + navigateFromKibanaCollapsibleTo(ALERTS_PAGE); + cy.url().should('include', ALERTS_URL); + }); + + it('navigates to the Hosts page', () => { + navigateFromKibanaCollapsibleTo(HOSTS_PAGE); + cy.url().should('include', HOSTS_URL); + }); + + it('navigates to the Network page', () => { + navigateFromKibanaCollapsibleTo(NETWORK_PAGE); + cy.url().should('include', NETWORK_URL); }); it('navigates to the Timelines page', () => { - navigateFromHeaderTo(TIMELINES); - cy.url().should('include', '/security/timelines'); + navigateFromKibanaCollapsibleTo(TIMELINES_PAGE); + cy.url().should('include', TIMELINES_URL); + }); + + it('navigates to the Cases page', () => { + navigateFromKibanaCollapsibleTo(CASES_PAGE); + cy.url().should('include', CASES_URL); + }); + + it('navigates to the Management page', () => { + navigateFromKibanaCollapsibleTo(MANAGEMENT_PAGE); + cy.url().should('include', MANAGEMENT_URL); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 284deb67e1386b..b799d487acd086 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -9,12 +9,12 @@ import { HOST_STATS, NETWORK_STATS } from '../screens/overview'; import { expandHostStats, expandNetworkStats } from '../tasks/overview'; import { loginAndWaitForPage } from '../tasks/login'; -import { OVERVIEW_PAGE } from '../urls/navigation'; +import { OVERVIEW_URL } from '../urls/navigation'; describe('Overview Page', () => { before(() => { cy.stubSecurityApi('overview'); - loginAndWaitForPage(OVERVIEW_PAGE); + loginAndWaitForPage(OVERVIEW_URL); }); it('Host stats render with correct values', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts index 6428a855c84852..10759cc7de6e91 100644 --- a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts @@ -9,13 +9,13 @@ import { openAddFilterPopover, fillAddFilterForm } from '../tasks/search_bar'; import { GLOBAL_SEARCH_BAR_FILTER_ITEM } from '../screens/search_bar'; import { hostIpFilter } from '../objects/filter'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; import { waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; // FAILING: https://github.com/elastic/kibana/issues/69595 describe.skip('SearchBar', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index 33394760c4da90..df0a26f3649c04 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -22,11 +22,11 @@ import { loginAndWaitForPage } from '../tasks/login'; import { openTimeline } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; describe('timeline data providers', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts index e462d6ade5dc44..87639f41d41097 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts @@ -11,11 +11,11 @@ import { loginAndWaitForPage } from '../tasks/login'; import { openTimeline, openTimelineIfClosed } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; describe('timeline flyout button', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts index a4352f58e6fc74..383ebe22205859 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts @@ -6,7 +6,7 @@ import { reload } from '../tasks/common'; import { loginAndWaitForPage } from '../tasks/login'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; import { openEvents } from '../tasks/hosts/main'; import { DRAGGABLE_HEADER } from '../screens/timeline'; import { TABLE_COLUMN_EVENTS_MESSAGE } from '../screens/hosts/external_events'; @@ -15,7 +15,7 @@ import { removeColumn, resetFields } from '../tasks/timeline'; describe('persistent timeline', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openEvents(); waitsForEventsToBeLoaded(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts index 00994f7a87a7bf..a2e2a72a17946b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts @@ -10,11 +10,11 @@ import { loginAndWaitForPage } from '../tasks/login'; import { openTimeline } from '../tasks/security_main'; import { executeTimelineKQL } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; describe('timeline search or filter KQL bar', () => { beforeEach(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); }); it('executes a KQL query', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts index 841d41782b3509..12e6f3db9b61e3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts @@ -22,11 +22,11 @@ import { uncheckTimestampToggleField, } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; describe('toggle column in timeline', () => { before(() => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); }); beforeEach(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index 1cefa7fe73d356..53460d1dfcbc6c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -38,7 +38,7 @@ import { executeTimelineKQL, } from '../tasks/timeline'; -import { HOSTS_PAGE } from '../urls/navigation'; +import { HOSTS_URL } from '../urls/navigation'; import { ABSOLUTE_DATE_RANGE } from '../urls/state'; const ABSOLUTE_DATE = { @@ -235,7 +235,7 @@ describe('url state', () => { }); it.skip('sets and reads the url state for timeline by id', () => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(HOSTS_URL); openTimeline(); executeTimelineKQL('host.name: *'); diff --git a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts new file mode 100644 index 00000000000000..2f7956ce370bc4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ALERTS_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Alerts"]'; + +export const CASES_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Cases"]'; + +export const HOSTS_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Hosts"]'; + +export const KIBANA_NAVIGATION_TOGGLE = '[data-test-subj="toggleNavButton"]'; + +export const MANAGEMENT_PAGE = + '[data-test-subj="collapsibleNavGroup-security"] [title="Management"]'; + +export const NETWORK_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Network"]'; + +export const OVERVIEW_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Overview"]'; + +export const TIMELINES_PAGE = '[data-test-subj="collapsibleNavGroup-security"] [title="Timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 89deeee0426d07..17d8aed1c2d21c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -8,10 +8,14 @@ export const ALERTS = '[data-test-subj="navigation-alerts"]'; export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a'; +export const CASES = '[data-test-subj="navigation-case"]'; + export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; +export const MANAGEMENT = '[data-test-subj="navigation-management"]'; + export const NETWORK = '[data-test-subj="navigation-network"]'; export const OVERVIEW = '[data-test-subj="navigation-overview"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts new file mode 100644 index 00000000000000..2d5b5d0de39d28 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts @@ -0,0 +1,15 @@ +/* + * 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 { KIBANA_NAVIGATION_TOGGLE } from '../screens/kibana_navigation'; + +export const navigateFromKibanaCollapsibleTo = (page: string) => { + cy.get(page).click(); +}; + +export const openKibanaNavigation = () => { + cy.get(KIBANA_NAVIGATION_TOGGLE).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index 7978aebfb413bd..9da9abf388e4d8 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -5,9 +5,9 @@ */ export const ALERTS_URL = 'app/security/alerts'; -export const CASES = '/app/security/cases'; +export const CASES_URL = '/app/security/cases'; export const DETECTIONS = '/app/siem#/detections'; -export const HOSTS_PAGE = '/app/security/hosts/allHosts'; +export const HOSTS_URL = '/app/security/hosts/allHosts'; export const HOSTS_PAGE_TAB_URLS = { allHosts: '/app/security/hosts/allHosts', anomalies: '/app/security/hosts/anomalies', @@ -15,6 +15,8 @@ export const HOSTS_PAGE_TAB_URLS = { events: '/app/security/hosts/events', uncommonProcesses: '/app/security/hosts/uncommonProcesses', }; -export const NETWORK_PAGE = '/app/security/network'; -export const OVERVIEW_PAGE = '/app/security/overview'; -export const TIMELINES_PAGE = '/app/security/timelines'; +export const KIBANA_HOME = '/app/home#/'; +export const MANAGEMENT_URL = '/app/security/management'; +export const NETWORK_URL = '/app/security/network'; +export const OVERVIEW_URL = '/app/security/overview'; +export const TIMELINES_URL = '/app/security/timelines'; diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.test.tsx index 62b942d03591c1..d033bc25e98013 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.test.tsx @@ -12,9 +12,10 @@ import '../../../common/mock/match_media'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { DetectionEnginePageComponent } from './detection_engine'; import { useUserInfo } from '../../components/user_info'; +import { useWithSource } from '../../../common/containers/source'; jest.mock('../../components/user_info'); -jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/containers/source'); jest.mock('../../../common/components/link_to'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -30,7 +31,12 @@ describe('DetectionEnginePageComponent', () => { beforeAll(() => { (useParams as jest.Mock).mockReturnValue({}); (useUserInfo as jest.Mock).mockReturnValue({}); + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); }); + it('renders correctly', () => { const wrapper = shallow( { /> ); - expect(wrapper.find('WithSource')).toHaveLength(1); + expect(wrapper.find('FiltersGlobal')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.tsx index 05a0b4441bb3a6..dc0b22c82af3ea 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine.tsx @@ -13,10 +13,7 @@ import { useHistory } from 'react-router-dom'; import { SecurityPageName } from '../../../app/types'; import { TimelineId } from '../../../../common/types/timeline'; import { GlobalTime } from '../../../common/containers/global_time'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../../common/containers/source'; +import { useWithSource } from '../../../common/containers/source'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { FiltersGlobal } from '../../../common/components/filters_global'; import { getRulesUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; @@ -82,6 +79,7 @@ export const DetectionEnginePageComponent: React.FC = ({ const indexToAdd = useMemo(() => (signalIndexName == null ? [] : [signalIndexName]), [ signalIndexName, ]); + const { indicesExist, indexPattern } = useWithSource('default', indexToAdd); if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -104,77 +102,73 @@ export const DetectionEnginePageComponent: React.FC = ({ <> {hasEncryptionKey != null && !hasEncryptionKey && } {hasIndexWrite != null && !hasIndexWrite && } - - {({ indicesExist, indexPattern }) => { - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - - - - {i18n.LAST_ALERT} - {': '} - {lastAlerts} - - ) - } - title={i18n.PAGE_TITLE} - > - - {i18n.BUTTON_MANAGE_RULES} - - + {indicesExist ? ( + + + + + + + {i18n.LAST_ALERT} + {': '} + {lastAlerts} + + ) + } + title={i18n.PAGE_TITLE} + > + + {i18n.BUTTON_MANAGE_RULES} + + - - {({ to, from, deleteQuery, setQuery }) => ( - <> - <> - - - - - - )} - - - - ) : ( - - - - - ); - }} - + + {({ to, from, deleteQuery, setQuery }) => ( + <> + <> + + + + + + )} + + + + ) : ( + + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.test.tsx index df6ea65ba52ba5..0acb18082379ae 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.test.tsx @@ -12,10 +12,12 @@ import { TestProviders } from '../../../../../common/mock'; import { RuleDetailsPageComponent } from './index'; import { setAbsoluteRangeDatePicker } from '../../../../../common/store/inputs/actions'; import { useUserInfo } from '../../../../components/user_info'; +import { useWithSource } from '../../../../../common/containers/source'; import { useParams } from 'react-router-dom'; jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); +jest.mock('../../../../../common/containers/source'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -30,6 +32,10 @@ describe('RuleDetailsPageComponent', () => { beforeAll(() => { (useUserInfo as jest.Mock).mockReturnValue({}); (useParams as jest.Mock).mockReturnValue({}); + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); }); it('renders correctly', () => { @@ -44,6 +50,6 @@ describe('RuleDetailsPageComponent', () => { } ); - expect(wrapper.find('WithSource')).toHaveLength(1); + expect(wrapper.find('GlobalTime')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx index 90fd4bb225ec5a..2ec603546983e4 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react-hooks/rules-of-hooks */ -/* eslint-disable complexity */ +/* eslint-disable react-hooks/rules-of-hooks, complexity */ // TODO: Disabling complexity is temporary till this component is refactored as part of lists UI integration import { @@ -36,10 +35,7 @@ import { SiemSearchBar } from '../../../../../common/components/search_bar'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { useRule } from '../../../../../alerts/containers/detection_engine/rules'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../../../../common/containers/source'; +import { useWithSource } from '../../../../../common/containers/source'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { StepAboutRuleToggleDetails } from '../../../../components/rules/step_about_rule_details'; @@ -255,6 +251,8 @@ export const RuleDetailsPageComponent: FC = ({ [history, ruleId] ); + const { indicesExist, indexPattern } = useWithSource('default', indexToAdd); + if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { history.replace(getDetectionEngineUrl()); return null; @@ -264,187 +262,185 @@ export const RuleDetailsPageComponent: FC = ({ <> {hasIndexWrite != null && !hasIndexWrite && } {userHasNoPermissions(canUserCRUD) && } - - {({ indicesExist, indexPattern }) => { - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - {({ to, from, deleteQuery, setQuery }) => ( - - - - - - - - {detectionI18n.LAST_ALERT} - {': '} - {lastAlerts} - , - ] - : []), - , - ]} - title={title} - > - + {indicesExist ? ( + + {({ to, from, deleteQuery, setQuery }) => ( + + + + + + + + {detectionI18n.LAST_ALERT} + {': '} + {lastAlerts} + , + ] + : []), + , + ]} + title={title} + > + + + + + + + + + - - - + {ruleI18n.EDIT_RULE_SETTINGS} + - - - - - {ruleI18n.EDIT_RULE_SETTINGS} - - - - - - + - - {ruleError} - - - - - + + + + {ruleError} + + + + + - - - - - {defineRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - - )} - - - + + + + + {defineRuleData != null && ( + + )} + + + + + + {scheduleRuleData != null && ( + + )} + + + + + {tabs} + + {ruleDetailTab === RuleDetailTabs.alerts && ( + <> + - {tabs} - - {ruleDetailTab === RuleDetailTabs.alerts && ( - <> - - - {ruleId != null && ( - - )} - - )} - {ruleDetailTab === RuleDetailTabs.exceptions && ( - )} - {ruleDetailTab === RuleDetailTabs.failures && } - - - )} - - ) : ( - - + + )} + {ruleDetailTab === RuleDetailTabs.exceptions && ( + + )} + {ruleDetailTab === RuleDetailTabs.failures && } + + + )} + + ) : ( + + - - - ); - }} - + + + )} ); }; +RuleDetailsPageComponent.displayName = 'RuleDetailsPageComponent'; + const makeMapStateToProps = () => { const getGlobalInputs = inputsSelectors.globalSelector(); return (state: State) => { @@ -467,3 +463,5 @@ const connector = connect(makeMapStateToProps, mapDispatchToProps); type PropsFromRedux = ConnectedProps; export const RuleDetailsPage = connector(memo(RuleDetailsPageComponent)); + +RuleDetailsPage.displayName = 'RuleDetailsPage'; diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx index 88e9d4179a9714..8839919af20604 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx @@ -15,6 +15,7 @@ import { APP_TIMELINES_PATH, APP_CASES_PATH, APP_MANAGEMENT_PATH, + APP_ENDPOINT_ALERTS_PATH, } from '../../../common/constants'; export const navTabs: SiemNavTab = { @@ -68,4 +69,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: SecurityPageName.management, }, + [SecurityPageName.endpointAlerts]: { + id: SecurityPageName.endpointAlerts, + name: 'Endpoint Alerts', // No Need of i18n since, it is just temporary + href: APP_ENDPOINT_ALERTS_PATH, + disabled: false, + urlKey: SecurityPageName.management, // Just to make type happy, this should go away soon + }, }; diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index d8bdbd6e7ef5fb..03e48282cb754d 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -14,10 +14,7 @@ import { HeaderGlobal } from '../../common/components/header_global'; import { HelpMenu } from '../../common/components/help_menu'; import { AutoSaveWarningMsg } from '../../timelines/components/timeline/auto_save_warning'; import { UseUrlState } from '../../common/components/url_state'; -import { - WithSource, - indicesExistOrDataTemporarilyUnavailable, -} from '../../common/containers/source'; +import { useWithSource } from '../../common/containers/source'; import { useShowTimeline } from '../../common/utils/timeline/use_show_timeline'; import { navTabs } from './home_navigations'; @@ -60,31 +57,28 @@ export const HomePage: React.FC = ({ children }) => { ); const [showTimeline] = useShowTimeline(); + const { browserFields, indexPattern, indicesExist } = useWithSource(); return (
- - {({ browserFields, indexPattern, indicesExist }) => ( - - - {indicesExistOrDataTemporarilyUnavailable(indicesExist) && showTimeline && ( - <> - - - - )} - - {children} - + + + {indicesExist && showTimeline && ( + <> + + + )} - + + {children} +
diff --git a/x-pack/plugins/security_solution/public/app/home/setup.tsx b/x-pack/plugins/security_solution/public/app/home/setup.tsx index 5b977a83302a97..bf7ce2ddf8b509 100644 --- a/x-pack/plugins/security_solution/public/app/home/setup.tsx +++ b/x-pack/plugins/security_solution/public/app/home/setup.tsx @@ -32,20 +32,7 @@ export const Setup: React.FunctionComponent<{ }); }; - const displayToast = () => { - notifications.toasts.addDanger({ - title, - text: defaultText, - }); - }; - - if (!ingestManager.success) { - if (ingestManager.error) { - displayToastWithModal(ingestManager.error.message); - } else { - displayToast(); - } - } + ingestManager.success.catch((error: Error) => displayToastWithModal(error.message)); }, [ingestManager, notifications.toasts]); return null; diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index 4bd888e87bbdc7..866a19b15771eb 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -27,6 +27,7 @@ export enum SecurityPageName { timelines = 'timelines', case = 'case', management = 'management', + endpointAlerts = 'endpointAlerts', } export interface SecuritySubPluginStore { initialState: Record; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 32f05e7c837a7c..3edc1d0d84b692 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -168,18 +168,6 @@ export const DragDropContextWrapper = connector(DragDropContextWrapperComponent) DragDropContextWrapper.displayName = 'DragDropContextWrapper'; const onBeforeCapture = (before: BeforeCapture) => { - const x = - window.pageXOffset !== undefined - ? window.pageXOffset - : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - - const y = - window.pageYOffset !== undefined - ? window.pageYOffset - : (document.documentElement || document.body.parentNode || document.body).scrollTop; - - window.onscroll = () => window.scrollTo(x, y); - if (!draggableIsField(before)) { document.body.classList.add(IS_DRAGGING_CLASS_NAME); } diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index e60d876617dcaa..ee1dc73b27fe2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -6,10 +6,9 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; -import { mocksSource } from '../../containers/source/mock'; -import { wait } from '../../lib/helpers'; +import { useWithSource } from '../../containers/source'; +import { mockBrowserFields } from '../../containers/source/mock'; import { useKibana } from '../../lib/kibana'; import { TestProviders } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; @@ -21,10 +20,19 @@ import { ManageGlobalTimeline, timelineDefaults, } from '../../../timelines/components/manage_timeline'; +import { TimelineId } from '../../../../common/types/timeline'; jest.mock('../link_to'); jest.mock('../../lib/kibana'); +jest.mock('../../containers/source', () => { + const original = jest.requireActual('../../containers/source'); + + return { + ...original, + useWithSource: jest.fn(), + }; +}); jest.mock('uuid', () => { return { @@ -34,9 +42,24 @@ jest.mock('uuid', () => { }); jest.mock('../../hooks/use_add_to_timeline'); +const mockAddFilters = jest.fn(); +const mockGetTimelineFilterManager = jest.fn().mockReturnValue({ + addFilters: mockAddFilters, +}); +jest.mock('../../../timelines/components/manage_timeline', () => { + const original = jest.requireActual('../../../timelines/components/manage_timeline'); + + return { + ...original, + useManageTimeline: () => ({ + getTimelineFilterManager: mockGetTimelineFilterManager, + isManagedTimeline: jest.fn().mockReturnValue(false), + }), + }; +}); const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; -const timelineId = 'cool-id'; +const timelineId = TimelineId.active; const field = 'process.name'; const value = 'nice'; const toggleTopN = jest.fn(); @@ -52,6 +75,9 @@ describe('DraggableWrapperHoverContent', () => { beforeAll(() => { // our mock implementation of the useAddToTimeline hook returns a mock startDragToTimeline function: (useAddToTimeline as jest.Mock).mockReturnValue(jest.fn()); + (useWithSource as jest.Mock).mockReturnValue({ + browserFields: mockBrowserFields, + }); }); // Suppress warnings about "react-beautiful-dnd" @@ -78,6 +104,9 @@ describe('DraggableWrapperHoverContent', () => { forOrOut.forEach((hoverAction) => { describe(`Filter ${hoverAction} value`, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test(`it renders the 'Filter ${hoverAction} value' button when showTopN is false`, () => { const wrapper = mount( @@ -101,21 +130,16 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().exists() ).toBe(false); }); - describe('when run in the context of a timeline', () => { - let filterManager: FilterManager; let wrapper: ReactWrapper; let onFilterAdded: () => void; beforeEach(() => { - filterManager = new FilterManager(mockUiSettingsForFilterManager); - filterManager.addFilters = jest.fn(); onFilterAdded = jest.fn(); const manageTimelineForTesting = { [timelineId]: { ...timelineDefaults, id: timelineId, - filterManager, }, }; @@ -131,7 +155,7 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().simulate('click'); wrapper.update(); - expect(filterManager.addFilters).toBeCalledWith({ + expect(mockAddFilters).toBeCalledWith({ meta: { alias: null, disabled: false, @@ -164,7 +188,9 @@ describe('DraggableWrapperHoverContent', () => { wrapper = mount( - + ); }); @@ -253,7 +279,7 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().simulate('click'); wrapper.update(); - expect(filterManager.addFilters).toBeCalledWith(expected); + expect(mockAddFilters).toBeCalledWith(expected); }); }); @@ -268,7 +294,14 @@ describe('DraggableWrapperHoverContent', () => { wrapper = mount( - + ); }); @@ -323,17 +356,15 @@ describe('DraggableWrapperHoverContent', () => { test(`it ${assertion} the 'Add to timeline investigation' button when showTopN is ${showTopN}, value is ${maybeValue}, and a draggableId is ${maybeDraggableId}`, () => { const wrapper = mount( - - - + ); @@ -348,15 +379,13 @@ describe('DraggableWrapperHoverContent', () => { test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', () => { const wrapper = mount( - - - + ); @@ -380,18 +409,15 @@ describe('DraggableWrapperHoverContent', () => { const aggregatableStringField = 'cloud.account.id'; const wrapper = mount( - - - + ); - await wait(); // https://github.com/apollographql/react-apollo/issues/1711 wrapper.update(); expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true); @@ -401,18 +427,15 @@ describe('DraggableWrapperHoverContent', () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true); @@ -422,18 +445,15 @@ describe('DraggableWrapperHoverContent', () => { const notKnownToBrowserFields = 'unknown.field'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false); @@ -443,18 +463,15 @@ describe('DraggableWrapperHoverContent', () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); wrapper.find('[data-test-subj="show-top-field"]').first().simulate('click'); @@ -467,18 +484,15 @@ describe('DraggableWrapperHoverContent', () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); expect(wrapper.find('[data-test-subj="eventsByDatasetOverviewPanel"]').first().exists()).toBe( @@ -490,19 +504,16 @@ describe('DraggableWrapperHoverContent', () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false); @@ -512,19 +523,16 @@ describe('DraggableWrapperHoverContent', () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( - - - + ); - await wait(); wrapper.update(); expect( @@ -559,4 +567,41 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find(`[data-test-subj="copy-to-clipboard"]`).first().exists()).toBe(false); }); }); + + describe('Filter Manager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('filter manager, not active timeline', () => { + mount( + + + + ); + + expect(mockGetTimelineFilterManager).not.toBeCalled(); + }); + test('filter manager, active timeline', () => { + mount( + + + + ); + + expect(mockGetTimelineFilterManager).toBeCalled(); + }); + test('filter manager, active timeline in draggableId', () => { + mount( + + + + ); + + expect(mockGetTimelineFilterManager).toBeCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index f916f42fe41cdc..4efdea5eee43b6 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -8,16 +8,17 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; -import { getAllFieldsByName, WithSource } from '../../containers/source'; +import { getAllFieldsByName, useWithSource } from '../../containers/source'; import { useAddToTimeline } from '../../hooks/use_add_to_timeline'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { useKibana } from '../../lib/kibana'; import { createFilter } from '../add_filter_to_global_search_bar'; -import { ACTIVE_TIMELINE_REDUX_ID, StatefulTopN } from '../top_n'; +import { StatefulTopN } from '../top_n'; import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { TimelineId } from '../../../../common/types/timeline'; interface Props { draggableId?: DraggableId; @@ -34,7 +35,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ field, onFilterAdded, showTopN, - timelineId = ACTIVE_TIMELINE_REDUX_ID, + timelineId, toggleTopN, value, }) => { @@ -44,11 +45,16 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ kibana.services.data.query.filterManager, ]); const { getTimelineFilterManager } = useManageTimeline(); - const filterManager = useMemo(() => getTimelineFilterManager(timelineId) ?? filterManagerBackup, [ - timelineId, - getTimelineFilterManager, - filterManagerBackup, - ]); + + const filterManager = useMemo( + () => + timelineId === TimelineId.active || + (draggableId != null && draggableId?.includes(TimelineId.active)) + ? getTimelineFilterManager(TimelineId.active) + : filterManagerBackup, + [draggableId, timelineId, getTimelineFilterManager, filterManagerBackup] + ); + const filterForValue = useCallback(() => { const filter = value?.length === 0 ? createFilter(field, undefined) : createFilter(field, value); @@ -79,6 +85,8 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [field, value, filterManager, onFilterAdded]); + const { browserFields } = useWithSource(); + return ( <> {!showTopN && value != null && ( @@ -117,40 +125,36 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ )} - - {({ browserFields }) => ( + <> + {allowTopN({ + browserField: getAllFieldsByName(browserFields)[field], + fieldName: field, + }) && ( <> - {allowTopN({ - browserField: getAllFieldsByName(browserFields)[field], - fieldName: field, - }) && ( - <> - {!showTopN && ( - - - - )} - - {showTopN && ( - - )} - + {!showTopN && ( + + + + )} + + {showTopN && ( + )} )} - + {!showTopN && ( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 2239de3764326f..244819080c93de 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -215,7 +215,7 @@ describe('Exception helpers', () => { fieldName: 'host.name', isNested: false, operator: 'is in list', - value: ['some host name'], + value: 'some-list-id', }, { fieldName: 'host.name', @@ -238,8 +238,8 @@ describe('Exception helpers', () => { { fieldName: 'host.name.host.name', isNested: true, - operator: 'exists', - value: null, + operator: 'is', + value: 'some host name', }, ]; expect(result).toEqual(expected); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index f8b9c39801ae5a..164940db619f9b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -19,6 +19,7 @@ import { OperatorTypeEnum, entriesNested, entriesExists, + entriesList, } from '../../../lists_plugin_deps'; /** @@ -87,6 +88,16 @@ export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] => return formattedEntries.flat(); }; +export const getEntryValue = (entry: Entry): string | string[] | null => { + if (entriesList.is(entry)) { + return entry.list.id; + } else if (entriesExists.is(entry)) { + return null; + } else { + return entry.value; + } +}; + /** * Helper method for `getFormattedEntries` */ @@ -100,7 +111,7 @@ export const formatEntry = ({ item: Entry; }): FormattedEntry => { const operator = getExceptionOperatorSelect(item); - const value = !entriesExists.is(item) ? item.value : null; + const value = getEntryValue(item); return { fieldName: isNested ? `${parent}.${item.field}` : item.field, diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index de19c1903586ae..17fdf2163b58ed 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -16,7 +16,7 @@ import { getAppOverviewUrl } from '../link_to'; import { MlPopover } from '../ml_popover/ml_popover'; import { SiemNavigation } from '../navigation'; import * as i18n from './translations'; -import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; +import { useWithSource } from '../../containers/source'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { useKibana } from '../../lib/kibana'; import { APP_ID, ADD_DATA_PATH, APP_ALERTS_PATH } from '../../../../common/constants'; @@ -41,6 +41,7 @@ interface HeaderGlobalProps { hideDetectionEngine?: boolean; } export const HeaderGlobal = React.memo(({ hideDetectionEngine = false }) => { + const { indicesExist } = useWithSource(); const search = useGetUrlSearch(navTabs.overview); const { navigateToApp } = useKibana().services.application; const goToOverview = useCallback( @@ -54,60 +55,55 @@ export const HeaderGlobal = React.memo(({ hideDetectionEngine return ( - - {({ indicesExist }) => ( - <> - - - - - - - + <> + + + + + + + - - {indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - key !== SecurityPageName.alerts, navTabs) - : navTabs - } - /> - ) : ( - key === SecurityPageName.overview, navTabs)} - /> - )} - - + + {indicesExist ? ( + key !== SecurityPageName.alerts, navTabs) + : navTabs + } + /> + ) : ( + key === SecurityPageName.overview, navTabs)} + /> + )} + + - - - {indicesExistOrDataTemporarilyUnavailable(indicesExist) && - window.location.pathname.includes(APP_ALERTS_PATH) && ( - - - - )} + + + {indicesExist && window.location.pathname.includes(APP_ALERTS_PATH) && ( + + + + )} - - - {i18n.BUTTON_ADD_DATA} - - - + + + {i18n.BUTTON_ADD_DATA} + - - )} - + + + ); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index a99497090f8435..cab4ef8ead63f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -140,6 +140,13 @@ describe('SIEM Navigation', () => { name: 'Timelines', urlKey: 'timeline', }, + endpointAlerts: { + disabled: false, + href: '/app/security/endpoint-alerts', + id: 'endpointAlerts', + name: 'Endpoint Alerts', + urlKey: 'management', + }, }, pageName: 'hosts', pathName: '/', @@ -185,7 +192,7 @@ describe('SIEM Navigation', () => { wrapper.setProps({ pageName: 'network', pathName: '/', - tabName: undefined, + tabName: 'authentications', }); wrapper.update(); expect(setBreadcrumbs).toHaveBeenNthCalledWith( @@ -209,7 +216,13 @@ describe('SIEM Navigation', () => { name: 'Cases', urlKey: 'case', }, - + endpointAlerts: { + disabled: false, + href: '/app/security/endpoint-alerts', + id: 'endpointAlerts', + name: 'Endpoint Alerts', + urlKey: 'management', + }, hosts: { disabled: false, href: '/app/security/hosts', @@ -252,7 +265,7 @@ describe('SIEM Navigation', () => { savedQuery: undefined, search: '', state: undefined, - tabName: undefined, + tabName: 'authentications', timeline: { id: '', isOpen: false }, timerange: { global: { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 80302be18355c4..a870c790527b75 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -48,7 +48,8 @@ export type SiemNavTabKey = | SecurityPageName.alerts | SecurityPageName.timelines | SecurityPageName.case - | SecurityPageName.management; + | SecurityPageName.management + | SecurityPageName.endpointAlerts; export type SiemNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx index c28f5ab8aa44f0..09da027569c61a 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GlobalTime } from '../../containers/global_time'; -import { BrowserFields, WithSource } from '../../containers/source'; +import { BrowserFields, useWithSource } from '../../containers/source'; import { useKibana } from '../../lib/kibana'; import { esQuery, Filter, Query } from '../../../../../../../src/plugins/data/public'; import { inputsModel, inputsSelectors, State } from '../../store'; @@ -99,7 +99,7 @@ const StatefulTopNComponent: React.FC = ({ // * `id` (`timelineId`) may only be populated when we are rendered in the // context of the active timeline. // * `indexToAdd`, which enables the alerts index to be appended to - // the `indexPattern` returned by `WithSource`, may only be populated when + // the `indexPattern` returned by `useWithSource`, may only be populated when // this component is rendered in the context of the active timeline. This // behavior enables the 'All events' view by appending the alerts index // to the index pattern. @@ -117,54 +117,50 @@ const StatefulTopNComponent: React.FC = ({ timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineEventType : undefined ); + const { indexPattern } = useWithSource('default', indexToAdd); + return ( {({ from, deleteQuery, setQuery, to }) => ( - - {({ indexPattern }) => ( - - )} - + )} ); diff --git a/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx b/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx index 9b9b5c5d815b99..9c9778c7074ee1 100644 --- a/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/global_time/index.tsx @@ -94,3 +94,5 @@ export const connector = connect(mapStateToProps, mapDispatchToProps); type PropsFromRedux = ConnectedProps; export const GlobalTime = connector(React.memo(GlobalTimeComponent)); + +GlobalTime.displayName = 'GlobalTime'; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx index d1a183a402e371..c30c3668638a3f 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.test.tsx @@ -4,55 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash/fp'; -import { mount } from 'enzyme'; -import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; -import { wait } from '../../lib/helpers'; - -import { WithSource, indicesExistOrDataTemporarilyUnavailable } from '.'; +import { useWithSource, indicesExistOrDataTemporarilyUnavailable } from '.'; import { mockBrowserFields, mockIndexFields, mocksSource } from './mock'; jest.mock('../../lib/kibana'); +jest.mock('../../utils/apollo_context', () => ({ + useApolloClient: jest.fn().mockReturnValue({ + query: jest.fn().mockImplementation(() => Promise.resolve(mocksSource[0].result)), + }), +})); describe('Index Fields & Browser Fields', () => { - test('Index Fields', async () => { - mount( - - - {({ indexPattern }) => { - if (!isEqual(indexPattern.fields, [])) { - expect(indexPattern.fields).toEqual(mockIndexFields); - } + test('returns memoized value', async () => { + const { result, waitForNextUpdate, rerender } = renderHook(() => useWithSource()); + await waitForNextUpdate(); - return null; - }} - - - ); + const result1 = result.current; + act(() => rerender()); + const result2 = result.current; - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await wait(); + return expect(result1).toBe(result2); }); - test('Browser Fields', async () => { - mount( - - - {({ browserFields }) => { - if (!isEqual(browserFields, {})) { - expect(browserFields).toEqual(mockBrowserFields); - } + test('Index Fields', async () => { + const { result, waitForNextUpdate } = renderHook(() => useWithSource()); - return null; - }} - - - ); + await waitForNextUpdate(); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await wait(); + return expect(result).toEqual({ + current: { + indicesExist: true, + browserFields: mockBrowserFields, + indexPattern: { + fields: mockIndexFields, + title: 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,packetbeat-*,winlogbeat-*', + }, + loading: false, + errorMessage: null, + }, + error: undefined, + }); }); describe('indicesExistOrDataTemporarilyUnavailable', () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index ad480ad2c496bf..34ac5f8f5d94fa 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -6,8 +6,7 @@ import { isUndefined } from 'lodash'; import { get, keyBy, pick, set, isEmpty } from 'lodash/fp'; -import { Query } from 'react-apollo'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import memoizeOne from 'memoize-one'; import { IIndexPattern } from 'src/plugins/data/public'; @@ -50,18 +49,6 @@ export const getAllFieldsByName = ( ): { [fieldName: string]: Partial } => keyBy('name', getAllBrowserFields(browserFields)); -interface WithSourceArgs { - indicesExist: boolean; - browserFields: BrowserFields; - indexPattern: IIndexPattern; -} - -interface WithSourceProps { - children: (args: WithSourceArgs) => React.ReactNode; - indexToAdd?: string[] | null; - sourceId: string; -} - export const getIndexFields = memoizeOne( (title: string, fields: IndexField[]): IIndexPattern => fields && fields.length > 0 @@ -71,7 +58,8 @@ export const getIndexFields = memoizeOne( ), title, } - : { fields: [], title } + : { fields: [], title }, + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length ); export const getBrowserFields = memoizeOne( @@ -82,10 +70,26 @@ export const getBrowserFields = memoizeOne( set([field.category, 'fields', field.name], field, accumulator), {} ) - : {} + : {}, + // Update the value only if _title has changed + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] ); -export const WithSource = React.memo(({ children, indexToAdd, sourceId }) => { +export const indicesExistOrDataTemporarilyUnavailable = ( + indicesExist: boolean | null | undefined +) => indicesExist || isUndefined(indicesExist); + +const EMPTY_BROWSER_FIELDS = {}; + +interface UseWithSourceState { + browserFields: BrowserFields; + errorMessage: string | null; + indexPattern: IIndexPattern; + indicesExist: boolean | undefined | null; + loading: boolean; +} + +export const useWithSource = (sourceId = 'default', indexToAdd?: string[] | null) => { const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const defaultIndex = useMemo(() => { if (indexToAdd != null && !isEmpty(indexToAdd)) { @@ -94,87 +98,84 @@ export const WithSource = React.memo(({ children, indexToAdd, s return configIndex; }, [configIndex, indexToAdd]); - return ( - - query={sourceQuery} - fetchPolicy="cache-first" - notifyOnNetworkStatusChange - variables={{ - sourceId, - defaultIndex, - }} - > - {({ data }) => - children({ - indicesExist: get('source.status.indicesExist', data), - browserFields: getBrowserFields( - defaultIndex.join(), - get('source.status.indexFields', data) - ), - indexPattern: getIndexFields(defaultIndex.join(), get('source.status.indexFields', data)), - }) - } - - ); -}); + const [state, setState] = useState({ + browserFields: EMPTY_BROWSER_FIELDS, + errorMessage: null, + indexPattern: getIndexFields(defaultIndex.join(), []), + indicesExist: undefined, + loading: false, + }); -WithSource.displayName = 'WithSource'; + const apolloClient = useApolloClient(); -export const indicesExistOrDataTemporarilyUnavailable = (indicesExist: boolean | undefined) => - indicesExist || isUndefined(indicesExist); + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); -export const useWithSource = (sourceId: string, indices: string[]) => { - const [loading, updateLoading] = useState(false); - const [indicesExist, setIndicesExist] = useState(undefined); - const [browserFields, setBrowserFields] = useState(null); - const [indexPattern, setIndexPattern] = useState(null); - const [errorMessage, updateErrorMessage] = useState(null); + async function fetchSource() { + if (!apolloClient) return; - const apolloClient = useApolloClient(); - async function fetchSource(signal: AbortSignal) { - updateLoading(true); - if (apolloClient) { - apolloClient - .query({ + setState((prevState) => ({ ...prevState, loading: true })); + + try { + const result = await apolloClient.query({ query: sourceQuery, fetchPolicy: 'cache-first', variables: { sourceId, - defaultIndex: indices, + defaultIndex, }, context: { fetchOptions: { - signal, + signal: abortCtrl.signal, }, }, - }) - .then( - (result) => { - updateLoading(false); - updateErrorMessage(null); - setIndicesExist(get('data.source.status.indicesExist', result)); - setBrowserFields( - getBrowserFields(indices.join(), get('data.source.status.indexFields', result)) - ); - setIndexPattern( - getIndexFields(indices.join(), get('data.source.status.indexFields', result)) - ); - }, - (error) => { - updateLoading(false); - updateErrorMessage(error.message); - } - ); + }); + if (!isSubscribed) { + return setState((prevState) => ({ + ...prevState, + loading: false, + })); + } + + setState({ + loading: false, + indicesExist: indicesExistOrDataTemporarilyUnavailable( + get('data.source.status.indicesExist', result) + ), + browserFields: getBrowserFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + indexPattern: getIndexFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + errorMessage: null, + }); + } catch (error) { + if (!isSubscribed) { + return setState((prevState) => ({ + ...prevState, + loading: false, + })); + } + + setState((prevState) => ({ + ...prevState, + loading: false, + errorMessage: error.message, + })); + } } - } - useEffect(() => { - const abortCtrl = new AbortController(); - const signal = abortCtrl.signal; - fetchSource(signal); - return () => abortCtrl.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [apolloClient, sourceId, indices]); + fetchSource(); + + return () => { + isSubscribed = false; + return abortCtrl.abort(); + }; + }, [apolloClient, sourceId, defaultIndex]); - return { indicesExist, browserFields, indexPattern, loading, errorMessage }; + return state; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx index c9953fdb30e027..0737db3cd08eb3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx @@ -63,7 +63,7 @@ const JiraConnectorForm: React.FC> fullWidth error={errors.email} isInvalid={isEmailInvalid} - label={i18n.EMAIL_LABEL} + label={i18n.JIRA_EMAIL_LABEL} > > fullWidth error={errors.apiToken} isInvalid={isApiTokenInvalid} - label={i18n.API_TOKEN_LABEL} + label={i18n.JIRA_API_TOKEN_LABEL} > { } if (!action.secrets.email) { - errors.email = [...errors.email, i18n.EMAIL_REQUIRED]; + errors.email = [...errors.email, i18n.JIRA_EMAIL_REQUIRED]; } if (!action.secrets.apiToken) { - errors.apiToken = [...errors.apiToken, i18n.API_TOKEN_REQUIRED]; + errors.apiToken = [...errors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; } return { errors }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts index 286f81842411bd..bcb2c49a0de74a 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts @@ -36,6 +36,34 @@ export const JIRA_PROJECT_KEY_REQUIRED = i18n.translate( } ); +export const JIRA_EMAIL_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.jira.emailTextFieldLabel', + { + defaultMessage: 'Email or Username', + } +); + +export const JIRA_EMAIL_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.jira.requiredEmailTextField', + { + defaultMessage: 'Email or Username is required', + } +); + +export const JIRA_API_TOKEN_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.jira.apiTokenTextFieldLabel', + { + defaultMessage: 'API token or Password', + } +); + +export const JIRA_API_TOKEN_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.jira.requiredApiTokenTextField', + { + defaultMessage: 'API token or Password is required', + } +); + export const MAPPING_FIELD_SUMMARY = i18n.translate( 'xpack.securitySolution.case.configureCases.mappingFieldSummary', { diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/translations.ts index 40848ea7690088..6dd1247d40fcb6 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/translations.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/translations.ts @@ -58,14 +58,14 @@ export const PASSWORD_REQUIRED = i18n.translate( export const API_TOKEN_LABEL = i18n.translate( 'xpack.securitySolution.case.connectors.common.apiTokenTextFieldLabel', { - defaultMessage: 'Api token', + defaultMessage: 'API token', } ); export const API_TOKEN_REQUIRED = i18n.translate( 'xpack.securitySolution.case.connectors.common.requiredApiTokenTextField', { - defaultMessage: 'Api token is required', + defaultMessage: 'API token is required', } ); diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts index 759ec45c7e54be..f2e8d045eccf9f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts @@ -56,6 +56,6 @@ export const depsStartMock: () => DepsStartMock = () => { return { data: dataMock, - ingestManager: { success: true, registerDatasource }, + ingestManager: { success: Promise.resolve(true), registerDatasource }, }; }; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/routes.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/routes.tsx index acc6a82e29a2cf..1c92919aa982fe 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/routes.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/routes.tsx @@ -11,7 +11,7 @@ import { AlertIndex } from './view'; export const EndpointAlertsRoutes: React.FC = () => ( - + diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts index ae5d36ce0d1ffa..8fabce4c4fec24 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { eventsIndexPattern } from '../../../common/endpoint/constants'; +import { alertsIndexPattern } from '../../../common/endpoint/constants'; import { IIndexPattern } from '../../../../../../src/plugins/data/public'; import { AlertResultList, @@ -49,10 +49,10 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory async function fetchIndexPatterns(): Promise { const { indexPatterns } = depsStart.data; const fields = await indexPatterns.getFieldsForWildcard({ - pattern: eventsIndexPattern, + pattern: alertsIndexPattern, }); const indexPattern: IIndexPattern = { - title: eventsIndexPattern, + title: alertsIndexPattern, fields, }; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/store/selectors.ts b/x-pack/plugins/security_solution/public/endpoint_alerts/store/selectors.ts index ab0e4165a25771..878c5f4fd2bb85 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/store/selectors.ts @@ -44,7 +44,10 @@ export const alertListPagination = createStructuredSelector({ * Returns a boolean based on whether or not the user is on the alerts page */ export const isOnAlertPage = (state: Immutable): boolean => { - return state.location ? state.location.pathname === '/endpoint-alerts' : false; + return state.location + ? state.location.pathname === '/endpoint-alerts' || + window.location.pathname.includes('/endpoint-alerts') + : false; }; /** diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx index 936789625a4dde..e520facf285c2b 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { IIndexPattern } from 'src/plugins/data/public'; import { MemoryRouter } from 'react-router-dom'; import useResizeObserver from 'use-resize-observer/polyfilled'; @@ -19,12 +18,7 @@ import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { getHostDetailsPageFilters } from './helpers'; jest.mock('../../../common/containers/source', () => ({ - indicesExistOrDataTemporarilyUnavailable: () => true, - WithSource: ({ - children, - }: { - children: (args: { indicesExist: boolean; indexPattern: IIndexPattern }) => React.ReactNode; - }) => children({ indicesExist: true, indexPattern: mockIndexPattern }), + useWithSource: jest.fn().mockReturnValue({ indicesExist: true, indexPattern: mockIndexPattern }), })); // Test will fail because we will to need to mock some core services to make the test work diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index e3f00a377d2724..1c66a9edc19475 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -27,10 +27,7 @@ import { SiemSearchBar } from '../../../common/components/search_bar'; import { WrapperPage } from '../../../common/components/wrapper_page'; import { HostOverviewByNameQuery } from '../../containers/hosts/overview'; import { KpiHostDetailsQuery } from '../../containers/kpi_host_details'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../../common/containers/source'; +import { useWithSource } from '../../../common/containers/source'; import { LastEventIndexKey } from '../../../graphql/types'; import { useKibana } from '../../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; @@ -83,132 +80,126 @@ const HostDetailsComponent = React.memo( }, [setAbsoluteRangeDatePicker] ); + const { indicesExist, indexPattern } = useWithSource(); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters: getFilters(), + }); return ( <> - - {({ indicesExist, indexPattern }) => { - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters: getFilters(), - }); - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - - - - - } - title={detailName} - /> - - + + + + + + + } + title={detailName} + /> + + + {({ hostOverview, loading, id, inspect, refetch }) => ( + - {({ hostOverview, loading, id, inspect, refetch }) => ( - - {({ isLoadingAnomaliesData, anomaliesData }) => ( - { - const fromTo = scoreIntervalToDateTime(score, interval); - setAbsoluteRangeDatePicker({ - id: 'global', - from: fromTo.from, - to: fromTo.to, - }); - }} - /> - )} - - )} - - - - - - {({ kpiHostDetails, id, inspect, loading, refetch }) => ( - ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} /> )} - - - - - - - - - + )} + + + + + + {({ kpiHostDetails, id, inspect, loading, refetch }) => ( + - - - ) : ( - - - - - - ); - }} - + )} + + + + + + + + + + + + ) : ( + + + + + + )} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index 85db3b4e159f12..ea0b32137eb395 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -5,15 +5,12 @@ */ import { mount } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; import React from 'react'; import { Router } from 'react-router-dom'; -import { MockedProvider } from 'react-apollo/test-utils'; import { Filter } from '../../../../../../src/plugins/data/common/es_query'; import '../../common/mock/match_media'; -import { mocksSource } from '../../common/containers/source/mock'; -import { wait } from '../../common/lib/helpers'; +import { useWithSource } from '../../common/containers/source'; import { apolloClientObservable, TestProviders, @@ -28,6 +25,8 @@ import { HostsComponentProps } from './types'; import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; +jest.mock('../../common/containers/source'); + // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar jest.mock('../../common/components/search_bar', () => ({ @@ -37,19 +36,6 @@ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); -let localSource: Array<{ - request: {}; - result: { - data: { - source: { - status: { - indicesExist: boolean; - }; - }; - }; - }; -}>; - type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -84,57 +70,49 @@ describe('Hosts - rendering', () => { hostsPagePath: '', }; - beforeEach(() => { - localSource = cloneDeep(mocksSource); - }); - test('it renders the Setup Instructions text when no index is available', async () => { - localSource[0].result.data.source.status.indicesExist = false; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: false, + }); + const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); test('it should render tab navigation', async () => { - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); + const wrapper = mount( - - - - - + + + ); - await wait(); - wrapper.update(); expect(wrapper.find(SiemNavigation).exists()).toBe(true); }); @@ -170,22 +148,21 @@ describe('Hosts - rendering', () => { }, }, ]; - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: { fields: [], title: 'title' }, + }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const wrapper = mount( - - - - - + + + ); - await wait(); wrapper.update(); - myStore.dispatch(inputsActions.setSearchBarFilter({ id: 'global', filters: newFilters })); wrapper.update(); expect(wrapper.find(HostsTabs).props().filterQuery).toEqual( diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index f6429544f855e0..f5cc651a30443d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -22,10 +22,7 @@ import { manageQuery } from '../../common/components/page/manage_query'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { KpiHostsQuery } from '../containers/kpi_hosts'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../common/containers/source'; +import { useWithSource } from '../../common/containers/source'; import { LastEventIndexKey } from '../../graphql/types'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; @@ -77,87 +74,84 @@ export const HostsComponent = React.memo( }, [setAbsoluteRangeDatePicker] ); + const { indicesExist, indexPattern } = useWithSource(); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters, + }); + const tabsFilterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters: tabsFilters, + }); return ( <> - - {({ indicesExist, indexPattern }) => { - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters, - }); - const tabsFilterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters: tabsFilters, - }); - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - - - - } - title={i18n.PAGE_TITLE} - /> - - - {({ kpiHosts, loading, id, inspect, refetch }) => ( - - )} - - - - - - - - - + + + + + + } + title={i18n.PAGE_TITLE} + /> + + + {({ kpiHosts, loading, id, inspect, refetch }) => ( + - - - ) : ( - - - - - - ); - }} - + )} + + + + + + + + + + + + ) : ( + + + + + + )} diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index 22732c86bd9a9e..575ff26330a460 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -27,4 +27,5 @@ export { OperatorTypeEnum, entriesNested, entriesExists, + entriesList, } from '../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts index ec0c526482b453..899f85ecdea306 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts @@ -17,7 +17,6 @@ import { sendPutDatasource, } from '../policy_list/services/ingest'; import { NewPolicyData, PolicyData } from '../../../../../../common/endpoint/types'; -import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; import { ImmutableMiddlewareFactory } from '../../../../../common/store'; export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory = ( @@ -43,23 +42,6 @@ export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory { page_size: 50, }); }); + + it('should load package information only if not already in state', async () => { + dispatchUserChangedUrl('?page_size=10&page_index=10'); + await waitForAction('serverReturnedEndpointPackageInfo'); + expect(endpointPackageVersion(store.getState())).toEqual('0.5.0'); + fakeCoreStart.http.get.mockClear(); + dispatchUserChangedUrl('?page_size=10&page_index=11'); + expect(fakeCoreStart.http.get).toHaveBeenCalledWith(INGEST_API_DATASOURCES, { + query: { + kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.package.name: endpoint`, + page: 12, + perPage: 10, + }, + }); + expect(endpointPackageVersion(store.getState())).toEqual('0.5.0'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts index 39c685da3ec460..7d8620a5831d0d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts @@ -9,8 +9,9 @@ import { sendGetEndpointSpecificDatasources, sendDeleteDatasource, sendGetFleetAgentStatusForConfig, + sendGetEndpointSecurityPackage, } from './services/ingest'; -import { isOnPolicyListPage, urlSearchParams } from './selectors'; +import { endpointPackageInfo, isOnPolicyListPage, urlSearchParams } from './selectors'; import { ImmutableMiddlewareFactory } from '../../../../../common/store'; import { initialPolicyListState } from './reducer'; import { @@ -32,6 +33,25 @@ export const policyListMiddlewareFactory: ImmutableMiddlewareFactory { + dispatch({ + type: 'serverReturnedEndpointPackageInfo', + payload: packageInfo, + }); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + } + const { page_index: pageIndex, page_size: pageSize } = urlSearchParams(state); let response: GetPolicyListResponse; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/reducer.ts index a8a2ad3e7cc268..52bed8d850ef42 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/reducer.ts @@ -16,6 +16,7 @@ import { PolicyListState } from '../../types'; */ export const initialPolicyListState: () => Immutable = () => ({ policyItems: [], + endpointPackageInfo: undefined, isLoading: false, isDeleting: false, deleteStatus: undefined, @@ -95,6 +96,13 @@ export const policyListReducer: ImmutableReducer = ( }; } + if (action.type === 'serverReturnedEndpointPackageInfo') { + return { + ...state, + endpointPackageInfo: action.payload, + }; + } + if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/selectors.ts index 089c97b5520a20..ce57d238d7581b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/selectors.ts @@ -84,3 +84,17 @@ export const urlSearchParams: ( return searchParams; }); + +/** + * Returns package information for Endpoint + * @param state + */ +export const endpointPackageInfo = (state: Immutable) => state.endpointPackageInfo; + +/** + * Returns the version number for the endpoint package. + */ +export const endpointPackageVersion = createSelector( + endpointPackageInfo, + (info) => info?.version ?? undefined +); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts index cbbc5c3c6fdbe8..2270c65fb149fc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sendGetDatasource, sendGetEndpointSpecificDatasources } from './ingest'; +import { + sendGetDatasource, + sendGetEndpointSecurityPackage, + sendGetEndpointSpecificDatasources, +} from './ingest'; import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../../../../../ingest_manager/common'; @@ -37,6 +41,7 @@ describe('ingest service', () => { }); }); }); + describe('sendGetDatasource()', () => { it('builds correct API path', async () => { await sendGetDatasource(http, '123'); @@ -51,4 +56,90 @@ describe('ingest service', () => { }); }); }); + + describe('sendGetEndpointSecurityPackage()', () => { + it('should query EPM with category=security', async () => { + http.get.mockResolvedValue({ + response: [ + { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + description: 'This is the Elastic Endpoint package.', + type: 'solution', + download: '/epr/endpoint/endpoint-0.5.0.tar.gz', + path: '/package/endpoint/0.5.0', + icons: [ + { + src: '/package/endpoint/0.5.0/img/logo-endpoint-64-color.svg', + size: '16x16', + type: 'image/svg+xml', + }, + ], + status: 'installed', + savedObject: { + type: 'epm-packages', + id: 'endpoint', + attributes: { + installed: [ + { id: '826759f0-7074-11ea-9bc8-6b38f4d29a16', type: 'dashboard' }, + { id: '1cfceda0-728b-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '1e525190-7074-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '55387750-729c-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '92b1edc0-706a-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: 'a3a3bd10-706b-11ea-9bc8-6b38f4d29a16', type: 'map' }, + { id: 'logs-endpoint.alerts', type: 'index-template' }, + { id: 'events-endpoint', type: 'index-template' }, + { id: 'logs-endpoint.events.file', type: 'index-template' }, + { id: 'logs-endpoint.events.library', type: 'index-template' }, + { id: 'metrics-endpoint.metadata', type: 'index-template' }, + { id: 'metrics-endpoint.metadata_mirror', type: 'index-template' }, + { id: 'logs-endpoint.events.network', type: 'index-template' }, + { id: 'metrics-endpoint.policy', type: 'index-template' }, + { id: 'logs-endpoint.events.process', type: 'index-template' }, + { id: 'logs-endpoint.events.registry', type: 'index-template' }, + { id: 'logs-endpoint.events.security', type: 'index-template' }, + { id: 'metrics-endpoint.telemetry', type: 'index-template' }, + ], + es_index_patterns: { + alerts: 'logs-endpoint.alerts-*', + events: 'events-endpoint-*', + file: 'logs-endpoint.events.file-*', + library: 'logs-endpoint.events.library-*', + metadata: 'metrics-endpoint.metadata-*', + metadata_mirror: 'metrics-endpoint.metadata_mirror-*', + network: 'logs-endpoint.events.network-*', + policy: 'metrics-endpoint.policy-*', + process: 'logs-endpoint.events.process-*', + registry: 'logs-endpoint.events.registry-*', + security: 'logs-endpoint.events.security-*', + telemetry: 'metrics-endpoint.telemetry-*', + }, + name: 'endpoint', + version: '0.5.0', + internal: false, + removable: false, + }, + references: [], + updated_at: '2020-06-24T14:41:23.098Z', + version: 'Wzc0LDFd', + score: 0, + }, + }, + ], + success: true, + }); + await sendGetEndpointSecurityPackage(http); + expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/epm/packages', { + query: { category: 'security' }, + }); + }); + + it('should throw if package is not found', async () => { + http.get.mockResolvedValue({ response: [], success: true }); + await expect(async () => { + await sendGetEndpointSecurityPackage(http); + }).rejects.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts index 66e98aa51601e7..cbdd67261739f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -20,7 +20,7 @@ const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; -const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; +export const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; const INGEST_API_DELETE_DATASOURCE = `${INGEST_API_DATASOURCES}/delete`; /** diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts index 2c495202dc75b0..0f0d1cb1b559d8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts @@ -5,9 +5,14 @@ */ import { HttpStart } from 'kibana/public'; -import { INGEST_API_DATASOURCES } from './services/ingest'; +import { INGEST_API_DATASOURCES, INGEST_API_EPM_PACKAGES } from './services/ingest'; import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; import { GetPolicyListResponse } from '../../types'; +import { + AssetReference, + GetPackagesResponse, + InstallationStatus, +} from '../../../../../../../ingest_manager/common'; const generator = new EndpointDocGenerator('policy-list'); @@ -32,6 +37,78 @@ export const setPolicyListApiMockImplementation = ( success: true, }); } + + if (path === INGEST_API_EPM_PACKAGES) { + return Promise.resolve({ + response: [ + { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + description: 'This is the Elastic Endpoint package.', + type: 'solution', + download: '/epr/endpoint/endpoint-0.5.0.tar.gz', + path: '/package/endpoint/0.5.0', + icons: [ + { + src: '/package/endpoint/0.5.0/img/logo-endpoint-64-color.svg', + size: '16x16', + type: 'image/svg+xml', + }, + ], + status: 'installed' as InstallationStatus, + savedObject: { + type: 'epm-packages', + id: 'endpoint', + attributes: { + installed: [ + { id: '826759f0-7074-11ea-9bc8-6b38f4d29a16', type: 'dashboard' }, + { id: '1cfceda0-728b-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '1e525190-7074-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '55387750-729c-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '92b1edc0-706a-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: 'a3a3bd10-706b-11ea-9bc8-6b38f4d29a16', type: 'map' }, + { id: 'logs-endpoint.alerts', type: 'index-template' }, + { id: 'events-endpoint', type: 'index-template' }, + { id: 'logs-endpoint.events.file', type: 'index-template' }, + { id: 'logs-endpoint.events.library', type: 'index-template' }, + { id: 'metrics-endpoint.metadata', type: 'index-template' }, + { id: 'metrics-endpoint.metadata_mirror', type: 'index-template' }, + { id: 'logs-endpoint.events.network', type: 'index-template' }, + { id: 'metrics-endpoint.policy', type: 'index-template' }, + { id: 'logs-endpoint.events.process', type: 'index-template' }, + { id: 'logs-endpoint.events.registry', type: 'index-template' }, + { id: 'logs-endpoint.events.security', type: 'index-template' }, + { id: 'metrics-endpoint.telemetry', type: 'index-template' }, + ] as AssetReference[], + es_index_patterns: { + alerts: 'logs-endpoint.alerts-*', + events: 'events-endpoint-*', + file: 'logs-endpoint.events.file-*', + library: 'logs-endpoint.events.library-*', + metadata: 'metrics-endpoint.metadata-*', + metadata_mirror: 'metrics-endpoint.metadata_mirror-*', + network: 'logs-endpoint.events.network-*', + policy: 'metrics-endpoint.policy-*', + process: 'logs-endpoint.events.process-*', + registry: 'logs-endpoint.events.registry-*', + security: 'logs-endpoint.events.security-*', + telemetry: 'metrics-endpoint.telemetry-*', + }, + name: 'endpoint', + version: '0.5.0', + internal: false, + removable: false, + }, + references: [], + updated_at: '2020-06-24T14:41:23.098Z', + version: 'Wzc0LDFd', + }, + }, + ], + success: true, + }); + } } return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`)); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index 4d798d3717ce4d..a3a0983331ac38 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -16,6 +16,7 @@ import { GetAgentStatusResponse, GetDatasourcesResponse, GetOneDatasourceResponse, + GetPackagesResponse, UpdateDatasourceResponse, } from '../../../../../ingest_manager/common'; @@ -25,6 +26,8 @@ import { export interface PolicyListState { /** Array of policy items */ policyItems: PolicyData[]; + /** Information about the latest endpoint package */ + endpointPackageInfo?: GetPackagesResponse['response'][0]; /** API error if loading data failed */ apiError?: ServerApiError; /** total number of policies */ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts deleted file mode 100644 index 75e1556ff0bb08..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts +++ /dev/null @@ -1,44 +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 { useEffect, useState } from 'react'; -import { Immutable } from '../../../../../common/endpoint/types'; -import { GetPackagesResponse } from '../../../../../../ingest_manager/common/types/rest_spec'; -import { sendGetEndpointSecurityPackage } from '../store/policy_list/services/ingest'; -import { useKibana } from '../../../../common/lib/kibana'; - -type UseEndpointPackageInfo = [ - /** The Package Info. will be undefined while it is being fetched */ - Immutable | undefined, - /** Boolean indicating if fetching is underway */ - boolean, - /** Any error encountered during fetch */ - Error | undefined -]; - -/** - * Hook that fetches the endpoint package info - * - * @example - * const [packageInfo, isFetching, fetchError] = useEndpointPackageInfo(); - */ -export const useEndpointPackageInfo = (): UseEndpointPackageInfo => { - const { - services: { http }, - } = useKibana(); - const [endpointPackage, setEndpointPackage] = useState(); - const [isFetching, setIsFetching] = useState(true); - const [error, setError] = useState(); - - useEffect(() => { - sendGetEndpointSecurityPackage(http) - .then((packageInfo) => setEndpointPackage(packageInfo)) - .catch((apiError) => setError(apiError)) - .finally(() => setIsFetching(false)); - }, [http]); - - return [endpointPackage, isFetching, error]; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx index 20346cb720acbc..7b4dc36def1335 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx @@ -35,9 +35,13 @@ export const ConfigureEndpointDatasource = memo {from === 'edit' ? ( { const [policyIdToDelete, setPolicyIdToDelete] = useState(''); const dispatch = useDispatch<(action: PolicyListAction) => void>(); - const [packageInfo, isFetchingPackageInfo] = useEndpointPackageInfo(); const { selectPolicyItems: policyItems, selectPageIndex: pageIndex, @@ -146,6 +144,7 @@ export const PolicyList = React.memo(() => { selectIsDeleting: isDeleting, selectDeleteStatus: deleteStatus, selectAgentStatusSummary: agentStatusSummary, + endpointPackageVersion, } = usePolicyListSelector(selector); const handleCreatePolicyClick = useNavigateToAppEventHandler( @@ -156,7 +155,9 @@ export const PolicyList = React.memo(() => { // Also, // We pass along soem state information so that the Ingest page can change the behaviour // of the cancel and submit buttons and redirect the user back to endpoint policy - path: `#/integrations${packageInfo ? `/endpoint-${packageInfo.version}/add-datasource` : ''}`, + path: `#/integrations${ + endpointPackageVersion ? `/endpoint-${endpointPackageVersion}/add-datasource` : '' + }`, state: { onCancelNavigateTo: ['securitySolution:management', { path: getPoliciesPath() }], onCancelUrl: formatUrl(getPoliciesPath()), @@ -401,7 +402,6 @@ export const PolicyList = React.memo(() => { { )} @@ -449,7 +449,6 @@ export const PolicyList = React.memo(() => { }, [ policyItems, loading, - isFetchingPackageInfo, columns, handleCreatePolicyClick, handleTableChange, diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts index bc1de567b60ae7..6f8c3e11238546 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__mocks__/mock.ts @@ -475,3 +475,12 @@ export const mockGlobIndexPattern: IndexPatternSavedObject = { title: '*', }, }; + +export const mockCCSGlobIndexPattern: IndexPatternSavedObject = { + id: '*:*', + type: 'index-pattern', + _version: 'abc', + attributes: { + title: '*:*', + }, +}; diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.test.tsx index d42ac919e9af07..50170f4f6ae9e4 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.test.tsx @@ -14,6 +14,7 @@ import { mockAuditbeatIndexPattern, mockFilebeatIndexPattern, mockGlobIndexPattern, + mockCCSGlobIndexPattern, } from './__mocks__/mock'; const mockEmbeddable = embeddablePluginMock.createStartContract(); @@ -106,12 +107,20 @@ describe('embedded_map_helpers', () => { ]); }); - test('finds glob-only index patterns ', () => { + test('excludes glob-only index patterns', () => { const matchingIndexPatterns = findMatchingIndexPatterns({ kibanaIndexPatterns: [mockGlobIndexPattern, mockFilebeatIndexPattern], siemDefaultIndices, }); - expect(matchingIndexPatterns).toEqual([mockGlobIndexPattern, mockFilebeatIndexPattern]); + expect(matchingIndexPatterns).toEqual([mockFilebeatIndexPattern]); + }); + + test('excludes glob-only CCS index patterns', () => { + const matchingIndexPatterns = findMatchingIndexPatterns({ + kibanaIndexPatterns: [mockCCSGlobIndexPattern, mockFilebeatIndexPattern], + siemDefaultIndices, + }); + expect(matchingIndexPatterns).toEqual([mockFilebeatIndexPattern]); }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.tsx index e50dcd7a8c8d83..b0f8e2cc024033 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map_helpers.tsx @@ -128,6 +128,9 @@ export const createEmbeddable = async ( return embeddableObject; }; +// These patterns are overly greedy and must be excluded when matching against Security indexes. +const ignoredIndexPatterns = ['*', '*:*']; + /** * Returns kibanaIndexPatterns that wildcard match at least one of siemDefaultIndices * @@ -142,9 +145,13 @@ export const findMatchingIndexPatterns = ({ siemDefaultIndices: string[]; }): IndexPatternSavedObject[] => { try { - return kibanaIndexPatterns.filter((kip) => - siemDefaultIndices.some((sdi) => minimatch(sdi, kip.attributes.title)) - ); + return kibanaIndexPatterns.filter((kip) => { + const pattern = kip.attributes.title; + return ( + !ignoredIndexPatterns.includes(pattern) && + siemDefaultIndices.some((sdi) => minimatch(sdi, pattern)) + ); + }); } catch { return []; } diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/pages/ip_details/__snapshots__/index.test.tsx.snap index 6e76ff00a8141c..d7af8d6910f45c 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/__snapshots__/index.test.tsx.snap @@ -1,15 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Ip Details it matches the snapshot 1`] = ` - - - - +
+ + + + - +
`; diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx index bbb964ae17b9f0..a87eb3d0574479 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx @@ -5,15 +5,13 @@ */ import { shallow } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; import React from 'react'; import { Router } from 'react-router-dom'; -import { MockedProvider } from 'react-apollo/test-utils'; import { ActionCreator } from 'typescript-fsa'; import '../../../common/mock/match_media'; -import { mocksSource } from '../../../common/containers/source/mock'; +import { useWithSource } from '../../../common/containers/source'; import { FlowTarget } from '../../../graphql/types'; import { apolloClientObservable, @@ -32,6 +30,9 @@ const pop: Action = 'POP'; type GlobalWithFetch = NodeJS.Global & { fetch: jest.Mock }; +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/containers/source'); + // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar jest.mock('../../../common/components/search_bar', () => ({ @@ -41,19 +42,6 @@ jest.mock('../../../common/components/query_bar', () => ({ QueryBar: () => null, })); -let localSource: Array<{ - request: {}; - result: { - data: { - source: { - status: { - indicesExist: boolean; - }; - }; - }; - }; -}>; - const getMockHistory = (ip: string) => ({ length: 2, location: { @@ -104,6 +92,10 @@ describe('Ip Details', () => { const mount = useMountAppended(); beforeAll(() => { + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: false, + indexPattern: {}, + }); (global as GlobalWithFetch).fetch = jest.fn().mockImplementationOnce(() => Promise.resolve({ ok: true, @@ -124,7 +116,6 @@ describe('Ip Details', () => { beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); - localSource = cloneDeep(mocksSource); }); test('it renders', () => { @@ -138,20 +129,18 @@ describe('Ip Details', () => { }); test('it renders ipv6 headline', async () => { - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); const ip = 'fe80--24ce-f7ff-fede-a571'; const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect( wrapper .find('[data-test-subj="ip-details-headline"] [data-test-subj="header-page-title"]') diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.tsx index face3f8904794e..162b3a7c158d5e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.tsx @@ -22,10 +22,7 @@ import { IpOverview } from '../../components/ip_overview'; import { SiemSearchBar } from '../../../common/components/search_bar'; import { WrapperPage } from '../../../common/components/wrapper_page'; import { IpOverviewQuery } from '../../containers/ip_overview'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../../common/containers/source'; +import { useWithSource } from '../../../common/containers/source'; import { FlowTargetSourceDest, LastEventIndexKey } from '../../../graphql/types'; import { useKibana } from '../../../common/lib/kibana'; import { decodeIpv6 } from '../../../common/lib/helpers'; @@ -74,208 +71,207 @@ export const IPDetailsComponent: React.FC { setIpDetailsTablesActivePageToZero(); }, [detailName, setIpDetailsTablesActivePageToZero]); - return ( - <> - - {({ indicesExist, indexPattern }) => { - const ip = decodeIpv6(detailName); - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters, - }); + const { indicesExist, indexPattern } = useWithSource(); + const ip = decodeIpv6(detailName); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }); - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - + return ( +
+ {indicesExist ? ( + + + + - - } - title={ip} - > - - + + } + title={ip} + > + + - + {({ id, inspect, ipOverviewData, loading, refetch }) => ( + - {({ id, inspect, ipOverviewData, loading, refetch }) => ( - - {({ isLoadingAnomaliesData, anomaliesData }) => ( - - )} - - )} - - - - - - - ( + - - - - - - - - - - - - - - - - - - + )} + + )} + - + - + + + - - - + + + - + - + + + - - - + - - - ) : ( - - + + + + + + + + + + + + + + + + + + + + + ) : ( + + - - - ); - }} - + + + )} - +
); }; IPDetailsComponent.displayName = 'IPDetailsComponent'; diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index e1078dee3eb0d7..7cdfdbf0af69a6 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -5,14 +5,12 @@ */ import { mount } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; import React from 'react'; import { Router } from 'react-router-dom'; -import { MockedProvider } from 'react-apollo/test-utils'; import '../../common/mock/match_media'; import { Filter } from '../../../../../../src/plugins/data/common/es_query'; -import { mocksSource } from '../../common/containers/source/mock'; +import { useWithSource } from '../../common/containers/source'; import { TestProviders, mockGlobalState, @@ -26,6 +24,8 @@ import { inputsActions } from '../../common/store/inputs'; import { Network } from './network'; import { NetworkRoutes } from './navigation'; +jest.mock('../../common/containers/source'); + // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar jest.mock('../../common/components/search_bar', () => ({ @@ -35,19 +35,6 @@ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); -let localSource: Array<{ - request: {}; - result: { - data: { - source: { - status: { - indicesExist: boolean; - }; - }; - }; - }; -}>; - type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -84,41 +71,33 @@ const getMockProps = () => ({ }); describe('rendering - rendering', () => { - beforeEach(() => { - localSource = cloneDeep(mocksSource); - }); - test('it renders the Setup Instructions text when no index is available', async () => { - localSource[0].result.data.source.status.indicesExist = false; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: false, + }); + const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); @@ -154,20 +133,20 @@ describe('rendering - rendering', () => { }, }, ]; - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: { fields: [], title: 'title' }, + }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const wrapper = mount( - - - - - + + + ); - await new Promise((resolve) => setTimeout(resolve)); wrapper.update(); myStore.dispatch(inputsActions.setSearchBarFilter({ id: 'global', filters: newFilters })); diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index 845a6bbd95dd6d..4275c1641f5176 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -23,10 +23,7 @@ import { KpiNetworkComponent } from '..//components/kpi_network'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { KpiNetworkQuery } from '../../network/containers/kpi_network'; -import { - indicesExistOrDataTemporarilyUnavailable, - WithSource, -} from '../../common/containers/source'; +import { useWithSource } from '../../common/containers/source'; import { LastEventIndexKey } from '../../graphql/types'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; @@ -78,103 +75,100 @@ const NetworkComponent = React.memo( [setAbsoluteRangeDatePicker] ); + const { indicesExist, indexPattern } = useWithSource(sourceId); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters, + }); + const tabsFilterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters: tabsFilters, + }); + return ( <> - - {({ indicesExist, indexPattern }) => { - const filterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters, - }); - const tabsFilterQuery = convertToBuildEsQuery({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - indexPattern, - queries: [query], - filters: tabsFilters, - }); - - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - - - - } - title={i18n.PAGE_TITLE} - /> - - + + + + + + } + title={i18n.PAGE_TITLE} + /> + + + + + + + {({ kpiNetwork, loading, id, inspect, refetch }) => ( + + )} + + {capabilitiesFetched && !isInitializing ? ( + <> - - {({ kpiNetwork, loading, id, inspect, refetch }) => ( - - )} - - - {capabilitiesFetched && !isInitializing ? ( - <> - - - - - - - - - ) : ( - - )} + - - - ) : ( - - - - - ); - }} - + + + + ) : ( + + )} + + + +
+ ) : ( + + + + + )} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index a2010f1f64b718..d6e8fb984ac0ff 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -5,17 +5,16 @@ */ import { mount } from 'enzyme'; -import { cloneDeep } from 'lodash/fp'; import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; import { MemoryRouter } from 'react-router-dom'; import '../../common/mock/match_media'; import { TestProviders } from '../../common/mock'; -import { mocksSource } from '../../common/containers/source/mock'; +import { useWithSource } from '../../common/containers/source'; import { Overview } from './index'; jest.mock('../../common/lib/kibana'); +jest.mock('../../common/containers/source'); // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -26,56 +25,36 @@ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); -let localSource: Array<{ - request: {}; - result: { - data: { - source: { - status: { - indicesExist: boolean; - }; - }; - }; - }; -}>; - describe('Overview', () => { describe('rendering', () => { - beforeEach(() => { - localSource = cloneDeep(mocksSource); - }); - test('it renders the Setup Instructions text when no index is available', async () => { - localSource[0].result.data.source.status.indicesExist = false; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: false, + }); + const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); + expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); test('it DOES NOT render the Getting started text when an index is available', async () => { - localSource[0].result.data.source.status.indicesExist = true; + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); const wrapper = mount( - - - - - + + + ); - // Why => https://github.com/apollographql/react-apollo/issues/1711 - await new Promise((resolve) => setTimeout(resolve)); - wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 543dafd50c8e03..53cb32a16a9de1 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -16,10 +16,7 @@ import { FiltersGlobal } from '../../common/components/filters_global'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { GlobalTime } from '../../common/containers/global_time'; -import { - WithSource, - indicesExistOrDataTemporarilyUnavailable, -} from '../../common/containers/source'; +import { useWithSource } from '../../common/containers/source'; import { EventsByDataset } from '../components/events_by_dataset'; import { EventCounts } from '../components/event_counts'; import { OverviewEmpty } from '../components/overview_empty'; @@ -41,89 +38,89 @@ const OverviewComponent: React.FC = ({ filters = NO_FILTERS, query = DEFAULT_QUERY, setAbsoluteRangeDatePicker, -}) => ( - <> - - {({ indicesExist, indexPattern }) => - indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - - - - - - - - - - - - - {({ from, deleteQuery, setQuery, to }) => ( - - - - - - - - - - - - - - - - - - - )} - - - - - - ) : ( - - ) - } - - - - -); +}) => { + const { indicesExist, indexPattern } = useWithSource(); + + return ( + <> + {indicesExist ? ( + + + + + + + + + + + + + + {({ from, deleteQuery, setQuery, to }) => ( + + + + + + + + + + + + + + + + + + + )} + + + + + + ) : ( + + )} + + + + ); +}; const makeMapStateToProps = () => { const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 58f0a0ddb749e9..360c81abadc810 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -33,6 +33,8 @@ import { APP_TIMELINES_PATH, APP_MANAGEMENT_PATH, APP_CASES_PATH, + SHOW_ENDPOINT_ALERTS_NAV, + APP_ENDPOINT_ALERTS_PATH, } from '../common/constants'; import { ConfigureEndpointDatasource } from './management/pages/policy/view/ingest_manager_integration/configure_datasource'; @@ -290,6 +292,35 @@ export class Plugin implements IPlugin { + const [ + { coreStart, startPlugins, store, services }, + { renderApp, composeLibs }, + { endpointAlertsSubPlugin }, + ] = await Promise.all([ + mountSecurityFactory(), + this.downloadAssets(), + this.downloadSubPlugins(), + ]); + return renderApp({ + ...composeLibs(coreStart), + ...params, + services, + store, + SubPluginRoutes: endpointAlertsSubPlugin.start(coreStart, startPlugins).SubPluginRoutes, + }); + }, + }); + } + core.application.register({ id: 'siem', appRoute: 'app/siem', diff --git a/x-pack/plugins/security_solution/public/resolver/embeddable.tsx b/x-pack/plugins/security_solution/public/resolver/embeddable.tsx deleted file mode 100644 index 5ec71e6b3041e0..00000000000000 --- a/x-pack/plugins/security_solution/public/resolver/embeddable.tsx +++ /dev/null @@ -1,41 +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 ReactDOM from 'react-dom'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Resolver } from './view'; -import { storeFactory } from './store'; -import { Embeddable } from '../../../../../src/plugins/embeddable/public'; - -export class ResolverEmbeddable extends Embeddable { - public readonly type = 'resolver'; - private lastRenderTarget?: Element; - - public render(node: HTMLElement) { - if (this.lastRenderTarget !== undefined) { - ReactDOM.unmountComponentAtNode(this.lastRenderTarget); - } - this.lastRenderTarget = node; - const { store } = storeFactory(); - ReactDOM.render( - - - , - node - ); - } - - public reload(): void { - throw new Error('Method not implemented.'); - } - - public destroy(): void { - if (this.lastRenderTarget !== undefined) { - ReactDOM.unmountComponentAtNode(this.lastRenderTarget); - } - } -} diff --git a/x-pack/plugins/security_solution/public/resolver/factory.ts b/x-pack/plugins/security_solution/public/resolver/factory.ts deleted file mode 100644 index 5168d2771e7235..00000000000000 --- a/x-pack/plugins/security_solution/public/resolver/factory.ts +++ /dev/null @@ -1,31 +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 { i18n } from '@kbn/i18n'; -import { - IContainer, - EmbeddableInput, - EmbeddableFactoryDefinition, -} from '../../../../../src/plugins/embeddable/public'; -import { ResolverEmbeddable } from './embeddable'; - -export class ResolverEmbeddableFactory implements EmbeddableFactoryDefinition { - public readonly type = 'resolver'; - - public async isEditable() { - return true; - } - - public async create(initialInput: EmbeddableInput, parent?: IContainer) { - return new ResolverEmbeddable(initialInput, {}, parent); - } - - public getDisplayName() { - return i18n.translate('xpack.securitySolution.endpoint.resolver.displayNameTitle', { - defaultMessage: 'Resolver', - }); - } -} diff --git a/x-pack/plugins/security_solution/public/resolver/index.ts b/x-pack/plugins/security_solution/public/resolver/index.ts deleted file mode 100644 index e4f3cc90ae30aa..00000000000000 --- a/x-pack/plugins/security_solution/public/resolver/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { ResolverEmbeddableFactory } from './factory'; -export { ResolverEmbeddable } from './embeddable'; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index fbeeefe1ab9f26..3de6f08f5e0154 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -12,8 +12,11 @@ import { interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; - readonly events: ResolverEvent[]; - readonly stats: Map; + readonly payload: { + readonly events: Readonly; + readonly stats: Readonly>; + readonly lineageLimits: { readonly children: string | null; readonly ancestors: string | null }; + }; } interface ServerFailedToReturnResolverData { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts index d120adb72cd81b..163846e0414dbf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/graphing.test.ts @@ -9,8 +9,13 @@ import { DataAction } from './action'; import { dataReducer } from './reducer'; import { DataState } from '../../types'; import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; -import { graphableProcesses, processNodePositionsAndEdgeLineSegments } from './selectors'; +import { + graphableProcesses, + processNodePositionsAndEdgeLineSegments, + limitsReached, +} from './selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; describe('resolver graph layout', () => { let processA: LegacyEndpointEvent; @@ -114,7 +119,10 @@ describe('resolver graph layout', () => { describe('when rendering no nodes', () => { beforeEach(() => { const events: ResolverEvent[] = []; - const action: DataAction = { type: 'serverReturnedResolverData', events, stats: new Map() }; + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, + }; store.dispatch(action); }); it('the graphableProcesses list should only include nothing', () => { @@ -128,7 +136,10 @@ describe('resolver graph layout', () => { describe('when rendering one node', () => { beforeEach(() => { const events = [processA]; - const action: DataAction = { type: 'serverReturnedResolverData', events, stats: new Map() }; + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, + }; store.dispatch(action); }); it('the graphableProcesses list should only include nothing', () => { @@ -142,7 +153,10 @@ describe('resolver graph layout', () => { describe('when rendering two nodes, one being the parent of the other', () => { beforeEach(() => { const events = [processA, processB]; - const action: DataAction = { type: 'serverReturnedResolverData', events, stats: new Map() }; + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, + }; store.dispatch(action); }); it('the graphableProcesses list should only include nothing', () => { @@ -166,7 +180,10 @@ describe('resolver graph layout', () => { processH, processI, ]; - const action: DataAction = { type: 'serverReturnedResolverData', events, stats: new Map() }; + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { events, stats: new Map(), lineageLimits: { children: null, ancestors: null } }, + }; store.dispatch(action); }); it("the graphableProcesses list should only include events with 'processCreated' an 'processRan' eventType", () => { @@ -187,3 +204,48 @@ describe('resolver graph layout', () => { }); }); }); + +describe('resolver graph with too much lineage', () => { + let generator: EndpointDocGenerator; + let store: Store; + let allEvents: ResolverEvent[]; + let childrenCursor: string; + let ancestorCursor: string; + + beforeEach(() => { + generator = new EndpointDocGenerator('seed'); + allEvents = generator.generateTree({ ancestors: 1, generations: 2, children: 2 }).allEvents; + childrenCursor = 'aValidChildursor'; + ancestorCursor = 'aValidAncestorCursor'; + store = createStore(dataReducer, undefined); + }); + + describe('should select from state properly', () => { + it('should indicate there are too many ancestors', () => { + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { + events: allEvents, + stats: new Map(), + lineageLimits: { children: childrenCursor, ancestors: ancestorCursor }, + }, + }; + store.dispatch(action); + const { ancestors } = limitsReached(store.getState()); + expect(ancestors).toEqual(true); + }); + it('should indicate there are too many children', () => { + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { + events: allEvents, + stats: new Map(), + lineageLimits: { children: childrenCursor, ancestors: ancestorCursor }, + }, + }; + store.dispatch(action); + const { children } = limitsReached(store.getState()); + expect(children).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 3e897a91a74c67..a36d43b70b87d7 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -13,6 +13,7 @@ function initialState(): DataState { relatedEventsStats: new Map(), relatedEvents: new Map(), relatedEventsReady: new Map(), + lineageLimits: { children: null, ancestors: null }, isLoading: false, hasError: false, }; @@ -22,8 +23,9 @@ export const dataReducer: Reducer = (state = initialS if (action.type === 'serverReturnedResolverData') { return { ...state, - results: action.events, - relatedEventsStats: action.stats, + results: action.payload.events, + relatedEventsStats: action.payload.stats, + lineageLimits: action.payload.lineageLimits, isLoading: false, hasError: false, }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/sample.ts b/x-pack/plugins/security_solution/public/resolver/store/data/sample.ts deleted file mode 100644 index b0ed9f3554c9bb..00000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/data/sample.ts +++ /dev/null @@ -1,1608 +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 { ProcessEvent } from '../../types'; - -interface ProcessEventSampleData { - data: { - result: { - search_results: ProcessEvent[]; - }; - }; -} - -const rawData = { - data: { - code: 200, - result: { - alert_id: 'a9834bf5-42c1-4039-83be-08c3ad3232b3', - bulk_task_id: null, - correlation_id: '7022e509-087e-493d-b02c-d88a206cd993', - created_at: '2019-09-24T03:17:36Z', - endpoint: { - ad_distinguished_name: - 'CN=ENDPOINT-W-1-07,OU=Desktops,OU=Workstations,OU=Computers_DEMO,DC=demo,DC=endgamelabs,DC=net', - ad_hostname: 'demo.endgamelabs.net', - display_operating_system: 'Windows 7 (SP1)', - hostname: 'ENDPOINT-W-1-07', - id: '39153006-0064-424b-99e9-4e21dcc00c2e', - ip_address: '172.31.27.17', - mac_address: '00:50:56:b1:b7:7b', - name: 'ENDPOINT-W-1-07', - operating_system: 'Windows 6.1 Service Pack 1', - status: 'monitored', - updated_at: '2019-09-24T01:48:47.960649+00:00', - }, - event_logging_search_request_count: 3, - family: 'collection', - investigation_id: null, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - message_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - metadata: { - chunk_id: 0, - correlation_id: '7022e509-087e-493d-b02c-d88a206cd993', - final: true, - message_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - origination_task_id: '2bed7d8b-72b1-4650-882c-5167a2fe1735', - os_type: 'windows', - priority: 50, - result: { - local_code: 0, - local_msg: 'Success', - }, - semantic_version: '3.52.8', - task_id: '2bed7d8b-72b1-4650-882c-5167a2fe1735', - type: 'collection', - }, - origination_task_id: '2bed7d8b-72b1-4650-882c-5167a2fe1735', - pagination: { - backwards: false, - eof: false, - page_number: 3, - page_offset: 31666, - params: - 'eyJhbGVydF9pZCI6ICJhOTgzNGJmNS00MmMxLTQwMzktODNiZS0wOGMzYWQzMjMyYjMiLCAidGVtcGxhdGVfZmlsZSI6ICJwcm9jZXNzLWNvbnRleHQubHVhIiwgImNyaXRlcmlhIjogeyJwaWQiOiAxODA4LCAidW5pcXVlX3BpZCI6IDE4OTQzfX0=', - remaining_events: 0, - }, - pending_event_logging_search_request: false, - results_count: 807, - search_results: [ - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 6, - command_line: '', - depth: -5, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'already_running', - event_type_full: 'process_event', - integrity_level: 'system', - node_id: 1002, - opcode: 3, - pid: 4, - ppid: 0, - process_name: '', - process_path: '', - serial_event_id: 1002, - timestamp: 132137632670000000, - timestamp_utc: '2019-09-24 01:47:47Z', - unique_pid: 1002, - unique_ppid: 1001, - user_domain: 'NT AUTHORITY', - user_name: 'SYSTEM', - user_sid: 'S-1-5-18', - }, - event_timestamp: 132137632670000000, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 5, - command_line: '\\SystemRoot\\System32\\smss.exe', - depth: -4, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'already_running', - event_type_full: 'process_event', - integrity_level: 'system', - md5: '1911a3356fa3f77ccc825ccbac038c2a', - node_id: 1003, - opcode: 3, - original_file_name: 'smss.exe', - pid: 244, - ppid: 4, - process_name: 'smss.exe', - process_path: 'C:\\Windows\\System32\\smss.exe', - serial_event_id: 1003, - sha1: '706473ad489e5365af1e3431c4f8fe80a9139bc2', - sha256: '6ed135b792c81d78b33a57f0f4770db6105c9ed3e2193629cb3ec38bfd5b7e1b', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 1002, - timestamp: 132137632670000000, - timestamp_utc: '2019-09-24 01:47:47Z', - unique_pid: 1003, - unique_ppid: 1002, - user_domain: 'NT AUTHORITY', - user_name: 'SYSTEM', - user_sid: 'S-1-5-18', - }, - event_timestamp: 132137632670000000, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 4, - authentication_id: 999, - command_line: '\\SystemRoot\\System32\\smss.exe 00000000 00000048 ', - depth: -3, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'system', - md5: '1911a3356fa3f77ccc825ccbac038c2a', - node_id: 18643, - opcode: 1, - original_file_name: 'smss.exe', - parent_process_name: 'smss.exe', - parent_process_path: 'C:\\Windows\\System32\\smss.exe', - pid: 2364, - ppid: 244, - process_name: 'smss.exe', - process_path: 'C:\\Windows\\System32\\smss.exe', - serial_event_id: 18643, - sha1: '706473ad489e5365af1e3431c4f8fe80a9139bc2', - sha256: '6ed135b792c81d78b33a57f0f4770db6105c9ed3e2193629cb3ec38bfd5b7e1b', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 1003, - timestamp: 132137681960227504, - timestamp_utc: '2019-09-24 03:09:56Z', - unique_pid: 18643, - unique_ppid: 1003, - user_domain: 'NT AUTHORITY', - user_name: 'SYSTEM', - user_sid: 'S-1-5-18', - }, - event_timestamp: 132137681960227504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 3, - authentication_id: 999, - command_line: 'winlogon.exe', - depth: -2, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'system', - md5: '1151b1baa6f350b1db6598e0fea7c457', - node_id: 18645, - opcode: 1, - original_file_name: 'WINLOGON.EXE', - parent_process_name: 'smss.exe', - parent_process_path: 'C:\\Windows\\System32\\smss.exe', - pid: 3108, - ppid: 2364, - process_name: 'winlogon.exe', - process_path: 'C:\\Windows\\System32\\winlogon.exe', - serial_event_id: 18645, - sha1: '434856b834baf163c5ea4d26434eeae775a507fb', - sha256: 'b1506e0a7e826eff0f5252ef5026070c46e2235438403a9a24d73ee69c0b8a49', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18643, - timestamp: 132137681961163504, - timestamp_utc: '2019-09-24 03:09:56Z', - unique_pid: 18645, - unique_ppid: 18643, - user_domain: 'NT AUTHORITY', - user_name: 'SYSTEM', - user_sid: 'S-1-5-18', - }, - event_timestamp: 132137681961163504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - depth: -2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: '1911a3356fa3f77ccc825ccbac038c2a', - node_id: 18646, - opcode: 2, - original_file_name: 'smss.exe', - parent_process_name: 'smss.exe', - parent_process_path: 'C:\\Windows\\System32\\smss.exe', - pid: 2364, - ppid: 244, - process_name: 'smss.exe', - process_path: 'C:\\Windows\\System32\\smss.exe', - serial_event_id: 18646, - sha1: '706473ad489e5365af1e3431c4f8fe80a9139bc2', - sha256: '6ed135b792c81d78b33a57f0f4770db6105c9ed3e2193629cb3ec38bfd5b7e1b', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18643, - timestamp: 132137681961787504, - timestamp_utc: '2019-09-24 03:09:56Z', - unique_pid: 18643, - unique_ppid: 1003, - user_domain: 'NT AUTHORITY', - user_name: 'SYSTEM', - user_sid: 'S-1-5-18', - }, - event_timestamp: 132137681961787504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 1, - authentication_id: 4904488, - command_line: 'C:\\Windows\\system32\\userinit.exe', - depth: -1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'bafe84e637bf7388c96ef48d4d3fdd53', - node_id: 18833, - opcode: 1, - original_file_name: 'USERINIT.EXE', - parent_process_name: 'winlogon.exe', - parent_process_path: 'C:\\Windows\\System32\\winlogon.exe', - pid: 3560, - ppid: 3108, - process_name: 'userinit.exe', - process_path: 'C:\\Windows\\System32\\userinit.exe', - serial_event_id: 18833, - sha1: '47267f943f060e36604d56c8895a6eece063d9a1', - sha256: '11c194d9adce90027272c627d7fbf3ba5025ff0f7b26a8333f764e11e1382cf9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18645, - timestamp: 132137681981287504, - timestamp_utc: '2019-09-24 03:09:58Z', - unique_pid: 18833, - unique_ppid: 18645, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137681981287504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - _descendant_count: 0, - authentication_id: 4904488, - command_line: 'C:\\Windows\\Explorer.EXE', - depth: 0, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 18943, - opcode: 1, - origin: true, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'userinit.exe', - parent_process_path: 'C:\\Windows\\System32\\userinit.exe', - pid: 1808, - ppid: 3560, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 18943, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18833, - timestamp: 132137681985655504, - timestamp_utc: '2019-09-24 03:09:58Z', - unique_pid: 18943, - unique_ppid: 18833, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137681985655504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Program Files\\VMware\\VMware Tools\\vmtoolsd.exe" -n vmusr', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '8dc5ad50587b936f7f616738112bfd2a', - node_id: 19545, - opcode: 1, - original_file_name: 'vmtoolsd.exe', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3596, - ppid: 1808, - process_name: 'vmtoolsd.exe', - process_path: 'C:\\Program Files\\VMware\\VMware Tools\\vmtoolsd.exe', - serial_event_id: 19545, - sha1: '04479ea30943ec471a6a5ca4c0dc74b5ff496e9f', - sha256: 'd6d9f041da6f724bf69f48bbee3bf41295a0ed4dca715b1908c5f35bc8034d53', - signature_signer: 'VMware, Inc.', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137681999539504, - timestamp_utc: '2019-09-24 03:09:59Z', - unique_pid: 19545, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137681999539504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - depth: 0, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'bafe84e637bf7388c96ef48d4d3fdd53', - node_id: 20261, - opcode: 2, - original_file_name: 'USERINIT.EXE', - parent_process_name: 'winlogon.exe', - parent_process_path: 'C:\\Windows\\System32\\winlogon.exe', - pid: 3560, - ppid: 3108, - process_name: 'userinit.exe', - process_path: 'C:\\Windows\\System32\\userinit.exe', - serial_event_id: 20261, - sha1: '47267f943f060e36604d56c8895a6eece063d9a1', - sha256: '11c194d9adce90027272c627d7fbf3ba5025ff0f7b26a8333f764e11e1382cf9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18833, - timestamp: 132137682277819504, - timestamp_utc: '2019-09-24 03:10:27Z', - unique_pid: 18833, - unique_ppid: 18645, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682277819504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\explorer.exe" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 20303, - opcode: 1, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3124, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 20303, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137682603979504, - timestamp_utc: '2019-09-24 03:11:00Z', - unique_pid: 20303, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682603979504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: '310624aa-bb2b-442b-a6c9-3284148b0ae3', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 1, - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 20310, - opcode: 2, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3124, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 20310, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 20303, - timestamp: 132137682604229504, - timestamp_utc: '2019-09-24 03:11:00Z', - unique_pid: 20303, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682604229504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\explorer.exe" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 20455, - opcode: 1, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3084, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 20455, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137682773669504, - timestamp_utc: '2019-09-24 03:11:17Z', - unique_pid: 20455, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682773669504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 1, - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 20462, - opcode: 2, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3084, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 20462, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 20455, - timestamp: 132137682774259504, - timestamp_utc: '2019-09-24 03:11:17Z', - unique_pid: 20455, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682774259504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\System32\\cmd.exe" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '5746bd7e255dd6a8afa06f7c42c1ba41', - node_id: 21120, - opcode: 1, - original_file_name: 'Cmd.Exe', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3280, - ppid: 1808, - process_name: 'cmd.exe', - process_path: 'C:\\Windows\\System32\\cmd.exe', - serial_event_id: 21120, - sha1: '0f3c4ff28f354aede202d54e9d1c5529a3bf87d8', - sha256: 'db06c3534964e3fc79d2763144ba53742d7fa250ca336f4a0fe724b75aaff386', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137682997939504, - timestamp_utc: '2019-09-24 03:11:39Z', - unique_pid: 21120, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137682997939504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\explorer.exe" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 21166, - opcode: 1, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3548, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 21166, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137683166079504, - timestamp_utc: '2019-09-24 03:11:56Z', - unique_pid: 21166, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683166079504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 1, - md5: 'ac4c51eb24aa95b77f705ab159189e24', - node_id: 21173, - opcode: 2, - original_file_name: 'EXPLORER.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3548, - ppid: 1808, - process_name: 'explorer.exe', - process_path: 'C:\\Windows\\explorer.exe', - serial_event_id: 21173, - sha1: '4583daf9442880204730fb2c8a060430640494b1', - sha256: '6a671b92a69755de6fd063fcbe4ba926d83b49f78c42dbaeed8cdb6bbc57576a', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 21166, - timestamp: 132137683166729504, - timestamp_utc: '2019-09-24 03:11:56Z', - unique_pid: 21166, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683166729504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Python27\\python.exe" "C:\\tmp\\dns.py" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '743b91619fbfee3c3e173ba5a17b1290', - node_id: 21480, - opcode: 1, - original_file_name: '', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 4060, - ppid: 1808, - process_name: 'python.exe', - process_path: 'C:\\Python27\\python.exe', - serial_event_id: 21480, - sha1: 'edabcf58d55a5e462f7a368d99616e3ac051c620', - sha256: '45b9384b852d850327e194ac86d84aed8916a3c13fc8f49ca54fddcbca4f7e32', - signature_signer: '', - signature_status: 'noSignature', - source_id: 18943, - timestamp: 132137683493349504, - timestamp_utc: '2019-09-24 03:12:29Z', - unique_pid: 21480, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683493349504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: '743b91619fbfee3c3e173ba5a17b1290', - node_id: 21500, - opcode: 2, - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 4060, - ppid: 1808, - process_name: 'python.exe', - process_path: 'C:\\Python27\\python.exe', - serial_event_id: 21500, - sha1: 'edabcf58d55a5e462f7a368d99616e3ac051c620', - sha256: '45b9384b852d850327e194ac86d84aed8916a3c13fc8f49ca54fddcbca4f7e32', - signature_status: 'noSignature', - source_id: 21480, - timestamp: 132137683493889504, - timestamp_utc: '2019-09-24 03:12:29Z', - unique_pid: 21480, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683493889504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Python27\\python.exe" "C:\\tmp\\dns.py" ', - depth: 2, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '743b91619fbfee3c3e173ba5a17b1290', - node_id: 21539, - opcode: 1, - original_file_name: '', - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 2888, - ppid: 3280, - process_name: 'python.exe', - process_path: 'C:\\Python27\\python.exe', - serial_event_id: 21539, - sha1: 'edabcf58d55a5e462f7a368d99616e3ac051c620', - sha256: '45b9384b852d850327e194ac86d84aed8916a3c13fc8f49ca54fddcbca4f7e32', - signature_signer: '', - signature_status: 'noSignature', - source_id: 21120, - timestamp: 132137683555889504, - timestamp_utc: '2019-09-24 03:12:35Z', - unique_pid: 21539, - unique_ppid: 21120, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683555889504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 3, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: '743b91619fbfee3c3e173ba5a17b1290', - node_id: 21540, - opcode: 2, - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 2888, - ppid: 3280, - process_name: 'python.exe', - process_path: 'C:\\Python27\\python.exe', - serial_event_id: 21540, - sha1: 'edabcf58d55a5e462f7a368d99616e3ac051c620', - sha256: '45b9384b852d850327e194ac86d84aed8916a3c13fc8f49ca54fddcbca4f7e32', - signature_status: 'noSignature', - source_id: 21539, - timestamp: 132137683556159504, - timestamp_utc: '2019-09-24 03:12:35Z', - unique_pid: 21539, - unique_ppid: 21120, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683556159504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - depth: 2, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21634, - opcode: 1, - original_file_name: '', - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 3996, - ppid: 3280, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21634, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_signer: '', - signature_status: 'noSignature', - source_id: 21120, - timestamp: 132137683921669504, - timestamp_utc: '2019-09-24 03:13:12Z', - unique_pid: 21634, - unique_ppid: 21120, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683921669504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - depth: 3, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21669, - opcode: 1, - original_file_name: '', - parent_process_name: 'fakenet.exe', - parent_process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - pid: 184, - ppid: 3996, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21669, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_signer: '', - signature_status: 'noSignature', - source_id: 21634, - timestamp: 132137683923819504, - timestamp_utc: '2019-09-24 03:13:12Z', - unique_pid: 21669, - unique_ppid: 21634, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683923819504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 4, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 1, - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21679, - opcode: 2, - parent_process_name: 'fakenet.exe', - parent_process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - pid: 184, - ppid: 3996, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21679, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_status: 'noSignature', - source_id: 21669, - timestamp: 132137683931089504, - timestamp_utc: '2019-09-24 03:13:13Z', - unique_pid: 21669, - unique_ppid: 21634, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683931089504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 3, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 1, - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21694, - opcode: 2, - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 3996, - ppid: 3280, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21694, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_status: 'noSignature', - source_id: 21634, - timestamp: 132137683931569504, - timestamp_utc: '2019-09-24 03:13:13Z', - unique_pid: 21634, - unique_ppid: 21120, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137683931569504, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: - '"C:\\Windows\\system32\\NOTEPAD.EXE" C:\\tmp\\fakenet1.4.3\\configs\\default.ini', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'f2c7bb8acc97f92e987a2d4087d021b1', - node_id: 21769, - opcode: 1, - original_file_name: 'NOTEPAD.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 2492, - ppid: 1808, - process_name: 'notepad.exe', - process_path: 'C:\\Windows\\System32\\notepad.exe', - serial_event_id: 21769, - sha1: '7eb0139d2175739b3ccb0d1110067820be6abd29', - sha256: '142e1d688ef0568370c37187fd9f2351d7ddeda574f8bfa9b0fa4ef42db85aa2', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137684112851830, - timestamp_utc: '2019-09-24 03:13:31Z', - unique_pid: 21769, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137684112851830, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'f2c7bb8acc97f92e987a2d4087d021b1', - node_id: 21794, - opcode: 2, - original_file_name: 'NOTEPAD.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 2492, - ppid: 1808, - process_name: 'notepad.exe', - process_path: 'C:\\Windows\\System32\\notepad.exe', - serial_event_id: 21794, - sha1: '7eb0139d2175739b3ccb0d1110067820be6abd29', - sha256: '142e1d688ef0568370c37187fd9f2351d7ddeda574f8bfa9b0fa4ef42db85aa2', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 21769, - timestamp: 132137684131573702, - timestamp_utc: '2019-09-24 03:13:33Z', - unique_pid: 21769, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137684131573702, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: 'fakenet.exe', - depth: 2, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21890, - opcode: 1, - original_file_name: '', - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 1060, - ppid: 3280, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21890, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_signer: '', - signature_status: 'noSignature', - source_id: 21120, - timestamp: 132137684579848525, - timestamp_utc: '2019-09-24 03:14:17Z', - unique_pid: 21890, - unique_ppid: 21120, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137684579848525, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: 'fakenet.exe', - depth: 3, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'c29675ce0750f73225bf05d03080dfb2', - node_id: 21924, - opcode: 1, - original_file_name: '', - parent_process_name: 'fakenet.exe', - parent_process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - pid: 4024, - ppid: 1060, - process_name: 'fakenet.exe', - process_path: 'C:\\tmp\\fakenet1.4.3\\fakenet.exe', - serial_event_id: 21924, - sha1: 'b14763ef982450551bcb09f6e0ecc75d2b9684fb', - sha256: '948f1c024118e434b6867ea593bb180212d35f9d2a9401892903ef22841fb303', - signature_signer: '', - signature_status: 'noSignature', - source_id: 21890, - timestamp: 132137684580468587, - timestamp_utc: '2019-09-24 03:14:18Z', - unique_pid: 21924, - unique_ppid: 21890, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137684580468587, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\System32\\cmd.exe" ', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '5746bd7e255dd6a8afa06f7c42c1ba41', - node_id: 22238, - opcode: 1, - original_file_name: 'Cmd.Exe', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 3328, - ppid: 1808, - process_name: 'cmd.exe', - process_path: 'C:\\Windows\\System32\\cmd.exe', - serial_event_id: 22238, - sha1: '0f3c4ff28f354aede202d54e9d1c5529a3bf87d8', - sha256: 'db06c3534964e3fc79d2763144ba53742d7fa250ca336f4a0fe724b75aaff386', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137684944024939, - timestamp_utc: '2019-09-24 03:14:54Z', - unique_pid: 22238, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137684944024939, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - attack_references: [ - { - tactics: ['Privilege Escalation', 'Execution', 'Persistence'], - technique_id: 'T1053', - technique_name: 'Scheduled Task', - }, - ], - authentication_id: 4904488, - command_line: 'SCHTASKS /CREATE /SC MINUTE /TN "Windiws" /TR "C:\\tmp\\scheduler.bat"', - depth: 2, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: '97e0ec3d6d99e8cc2b17ef2d3760e8fc', - node_id: 22376, - opcode: 1, - original_file_name: 'sctasks.exe', - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 2864, - ppid: 3328, - process_name: 'schtasks.exe', - process_path: 'C:\\Windows\\System32\\schtasks.exe', - serial_event_id: 22376, - sha1: 'bd9dceffbcbbc82bee5f2109bd73a57477fe1f92', - sha256: '6dce7d58ebb0d705fcb4179349c441b45e160c94e43934c5ed8fa1964e2cd031', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 22238, - timestamp: 132137685249385472, - timestamp_utc: '2019-09-24 03:15:24Z', - unique_pid: 22376, - unique_ppid: 22238, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137685249385472, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 3, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: '97e0ec3d6d99e8cc2b17ef2d3760e8fc', - node_id: 22384, - opcode: 2, - original_file_name: 'sctasks.exe', - parent_process_name: 'cmd.exe', - parent_process_path: 'C:\\Windows\\System32\\cmd.exe', - pid: 2864, - ppid: 3328, - process_name: 'schtasks.exe', - process_path: 'C:\\Windows\\System32\\schtasks.exe', - serial_event_id: 22384, - sha1: 'bd9dceffbcbbc82bee5f2109bd73a57477fe1f92', - sha256: '6dce7d58ebb0d705fcb4179349c441b45e160c94e43934c5ed8fa1964e2cd031', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 22376, - timestamp: 132137685251515685, - timestamp_utc: '2019-09-24 03:15:25Z', - unique_pid: 22376, - unique_ppid: 22238, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137685251515685, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - authentication_id: 4904488, - command_line: '"C:\\Windows\\System32\\NOTEPAD.EXE" C:\\tmp\\scheduler.bat', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'f2c7bb8acc97f92e987a2d4087d021b1', - node_id: 22448, - opcode: 1, - original_file_name: 'NOTEPAD.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 4048, - ppid: 1808, - process_name: 'notepad.exe', - process_path: 'C:\\Windows\\System32\\notepad.exe', - serial_event_id: 22448, - sha1: '7eb0139d2175739b3ccb0d1110067820be6abd29', - sha256: '142e1d688ef0568370c37187fd9f2351d7ddeda574f8bfa9b0fa4ef42db85aa2', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137685448755407, - timestamp_utc: '2019-09-24 03:15:44Z', - unique_pid: 22448, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137685448755407, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'f2c7bb8acc97f92e987a2d4087d021b1', - node_id: 22464, - opcode: 2, - original_file_name: 'NOTEPAD.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 4048, - ppid: 1808, - process_name: 'notepad.exe', - process_path: 'C:\\Windows\\System32\\notepad.exe', - serial_event_id: 22464, - sha1: '7eb0139d2175739b3ccb0d1110067820be6abd29', - sha256: '142e1d688ef0568370c37187fd9f2351d7ddeda574f8bfa9b0fa4ef42db85aa2', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 22448, - timestamp: 132137685516752206, - timestamp_utc: '2019-09-24 03:15:51Z', - unique_pid: 22448, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137685516752206, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - attack_references: [ - { - tactics: ['Execution'], - technique_id: 'T1085', - technique_name: 'Rundll32', - }, - ], - authentication_id: 4904488, - command_line: - '"C:\\Windows\\system32\\rundll32.exe" C:\\Windows\\system32\\shell32.dll,OpenAs_RunDLL C:\\tmp\\XLS_no_email_Upcoming Events February 2018.xls\\cb85072e6ca66a29cb0b73659a0fe5ba2456d9ba0b52e3a4c89e86549bc6e2c7.xls', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 22799, - opcode: 1, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 2864, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 22799, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137686572217742, - timestamp_utc: '2019-09-24 03:17:37Z', - unique_pid: 22799, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686572217742, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 22805, - opcode: 2, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 2864, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 22805, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 22799, - timestamp: 132137686585839104, - timestamp_utc: '2019-09-24 03:17:38Z', - unique_pid: 22799, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686585839104, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - attack_references: [ - { - tactics: ['Execution'], - technique_id: 'T1085', - technique_name: 'Rundll32', - }, - ], - authentication_id: 4904488, - command_line: - '"C:\\Windows\\system32\\rundll32.exe" C:\\Windows\\system32\\shell32.dll,OpenAs_RunDLL C:\\tmp\\Upcoming Defense events February 2018.eml', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 22933, - opcode: 1, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 1864, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 22933, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137686702740793, - timestamp_utc: '2019-09-24 03:17:50Z', - unique_pid: 22933, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686702740793, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 22945, - opcode: 2, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 1864, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 22945, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 22933, - timestamp: 132137686718432362, - timestamp_utc: '2019-09-24 03:17:51Z', - unique_pid: 22933, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686718432362, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - attack_references: [ - { - tactics: ['Execution'], - technique_id: 'T1085', - technique_name: 'Rundll32', - }, - ], - authentication_id: 4904488, - command_line: - '"C:\\Windows\\system32\\rundll32.exe" C:\\Windows\\system32\\shell32.dll,OpenAs_RunDLL C:\\Users\\Administrator\\AppData\\Roaming\\Microsoft\\Windows\\SendTo\\Mail Recipient.MAPIMail', - depth: 1, - elevated: true, - elevation_type: 'default', - event_subtype_full: 'creation_event', - event_type_full: 'process_event', - integrity_level: 'high', - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 27050, - opcode: 1, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 568, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 27050, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 18943, - timestamp: 132137686926723189, - timestamp_utc: '2019-09-24 03:18:12Z', - unique_pid: 27050, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686926723189, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - { - collection_id: 'f287067d-3ec7-4d5a-b6de-ea1415bc5e56', - data_buffer: { - depth: 2, - event_subtype_full: 'termination_event', - event_type_full: 'process_event', - exit_code: 0, - md5: 'dd81d91ff3b0763c392422865c9ac12e', - node_id: 27053, - opcode: 2, - original_file_name: 'RUNDLL32.EXE', - parent_process_name: 'explorer.exe', - parent_process_path: 'C:\\Windows\\explorer.exe', - pid: 568, - ppid: 1808, - process_name: 'rundll32.exe', - process_path: 'C:\\Windows\\System32\\rundll32.exe', - serial_event_id: 27053, - sha1: '963b55acc8c566876364716d5aafa353995812a8', - sha256: 'f5691b8f200e3196e6808e932630e862f8f26f31cd949981373f23c9d87db8b9', - signature_signer: 'Microsoft Windows', - signature_status: 'trusted', - source_id: 27050, - timestamp: 132137686939784495, - timestamp_utc: '2019-09-24 03:18:13Z', - unique_pid: 27050, - unique_ppid: 18943, - user_domain: 'ENDPOINT-W-1-07', - user_name: 'vagrant', - user_sid: 'S-1-5-21-3883902650-1642591343-2485142877-1001', - }, - event_timestamp: 132137686939784495, - event_type: 4, - machine_id: '7f1660dc-2c12-ce99-71b8-1ef862aeec34', - serial_event_id: 0, - }, - ], - status: 'success', - task_id: '2bed7d8b-72b1-4650-882c-5167a2fe1735', - total_events_searched: 7730, - type: 'eventLoggingSearchResponse', - }, - }, - metadata: { - count: 39, - next: null, - next_url: null, - per_page: '4000', - previous_url: null, - timestamp: '2019-12-18T19:31:27.565110', - }, -}; - -export const sampleData: ProcessEventSampleData = rawData as ProcessEventSampleData; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 2873993cc645f7..ba415e6d83c8d7 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -529,3 +529,15 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( }; } ); + +/** + * Returns the `children` and `ancestors` limits for the current graph, if any. + * + * @param state {DataState} the DataState from the reducer + */ +export const limitsReached = (state: DataState): { children: boolean; ancestors: boolean } => { + return { + children: state.lineageLimits.children !== null, + ancestors: state.lineageLimits.ancestors !== null, + }; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware.ts index 7f6f58dac7158c..343b4e1a14478c 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware.ts @@ -12,7 +12,7 @@ import { ResolverEvent, ResolverChildren, ResolverAncestry, - LifecycleNode, + ResolverLifecycleNode, ResolverNodeStats, ResolverRelatedEvents, } from '../../../common/endpoint/types'; @@ -25,10 +25,10 @@ type MiddlewareFactory = ( ) => (next: Dispatch) => (action: ResolverAction) => unknown; function getLifecycleEventsAndStats( - nodes: LifecycleNode[], + nodes: ResolverLifecycleNode[], stats: Map ): ResolverEvent[] { - return nodes.reduce((flattenedEvents: ResolverEvent[], currentNode: LifecycleNode) => { + return nodes.reduce((flattenedEvents: ResolverEvent[], currentNode: ResolverLifecycleNode) => { if (currentNode.lifecycle && currentNode.lifecycle.length > 0) { flattenedEvents.push(...currentNode.lifecycle); } @@ -77,6 +77,8 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { } const nodeStats: Map = new Map(); nodeStats.set(entityId, stats); + const lineageLimits = { children: children.nextChild, ancestors: ancestry.nextAncestor }; + const events = [ ...lifecycle, ...getLifecycleEventsAndStats(children.childNodes, nodeStats), @@ -84,8 +86,11 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { ]; api.dispatch({ type: 'serverReturnedResolverData', - events, - stats: nodeStats, + payload: { + events, + stats: nodeStats, + lineageLimits, + }, }); } catch (error) { api.dispatch({ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index bff30c62864f2e..3a5c48009e5bb0 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -152,6 +152,15 @@ export const graphableProcesses = composeSelectors( dataSelectors.graphableProcesses ); +/** + * Select the `ancestors` and `children` limits that were reached or exceeded + * during the request for the current tree. + */ +export const lineageLimitsReached = composeSelectors( + dataStateSelector, + dataSelectors.limitsReached +); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index a48f3b59b0f6dc..f0e401dd2e8930 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -147,9 +147,10 @@ export type CameraState = { */ export interface DataState { readonly results: readonly ResolverEvent[]; - readonly relatedEventsStats: Map; + readonly relatedEventsStats: Readonly>; readonly relatedEvents: Map; readonly relatedEventsReady: Map; + readonly lineageLimits: Readonly<{ children: string | null; ancestors: string | null }>; isLoading: boolean; hasError: boolean; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 8ed9f00d51af8b..dc7cb9a2ab1991 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -176,8 +176,11 @@ describe('useCamera on an unpainted element', () => { } const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - events, - stats: new Map(), + payload: { + events, + stats: new Map(), + lineageLimits: { children: null, ancestors: null }, + }, }; act(() => { store.dispatch(serverResponseAction); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx index ae05d99b58ee08..a1392ad8b82707 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx @@ -10,7 +10,7 @@ import { rgba } from 'polished'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { WithSource } from '../../../../common/containers/source'; +import { useWithSource } from '../../../../common/containers/source'; import { IS_DRAGGING_CLASS_NAME } from '../../../../common/components/drag_and_drop/helpers'; import { DataProvider } from '../../timeline/data_providers/data_provider'; import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers'; @@ -84,6 +84,7 @@ interface FlyoutButtonProps { export const FlyoutButton = React.memo( ({ onOpen, show, dataProviders, timelineId }) => { const badgeCount = useMemo(() => getBadgeCount(dataProviders), [dataProviders]); + const { browserFields } = useWithSource(); if (!show) { return null; @@ -121,19 +122,15 @@ export const FlyoutButton = React.memo( - - {({ browserFields }) => ( - - )} - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 51cfe8ae33b05e..df76eb350ace7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -9,7 +9,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { NO_ALERT_INDEX } from '../../../../common/constants'; -import { WithSource } from '../../../common/containers/source'; +import { useWithSource } from '../../../common/containers/source'; import { useSignalIndex } from '../../../alerts/containers/detection_engine/alerts/use_signal_index'; import { inputsModel, inputsSelectors, State } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../store/timeline'; @@ -158,40 +158,38 @@ const StatefulTimelineComponent = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const { indexPattern, browserFields } = useWithSource('default', indexToAdd); + return ( - - {({ indexPattern, browserFields }) => ( - - )} - + ); }, (prevProps, nextProps) => { diff --git a/x-pack/plugins/security_solution/scripts/endpoint/README.md b/x-pack/plugins/security_solution/scripts/endpoint/README.md index 0c36a47307232e..bd9502f2f59e03 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/README.md +++ b/x-pack/plugins/security_solution/scripts/endpoint/README.md @@ -13,52 +13,10 @@ Example command sequence to get ES and kibana running with sample data after ins `yarn es snapshot` -> starts ES -`npx yarn start --xpack.securitySolution.enabled=true --no-base-path` -> starts kibana +`npx yarn start --no-base-path` -> starts kibana. Note: you may need other configurations steps to start the security solution with endpoint support. -`cd ~/path/to/kibana/x-pack/plugins/endpoint` +`cd x-pack/plugins/security_solution/scripts/endpoint` -`yarn test:generate --auth elastic:changeme` -> run the resolver_generator.ts script +`yarn test:generate` -> run the resolver_generator.ts script -Resolver generator CLI options: - -```bash -Options: - --help Show help [boolean] - --seed, -s random seed to use for document generator - [string] - --node, -n elasticsearch node url - [string] [default: "http://elastic:changeme@localhost:9200"] - --kibana, -k kibana url - [string] [default: "http://elastic:changeme@localhost:5601"] - --eventIndex, --ei index to store events in - [string] [default: "events-endpoint-1"] - --metadataIndex, --mi index to store host metadata in - [string] [default: "metrics-endpoint.metadata-default-1"] - --policyIndex, --pi index to store host policy in - [string] [default: "metrics-endpoint.policy-default-1"] - --ancestors, --anc number of ancestors of origin to create - [number] [default: 3] - --generations, --gen number of child generations to create - [number] [default: 3] - --children, --ch maximum number of children per node - [number] [default: 3] - --relatedEvents, --related number of related events to create for each - process event [number] [default: 5] - --relatedAlerts, --relAlerts number of related alerts to create for each - process event [number] [default: 5] - --percentWithRelated, --pr percent of process events to add related events - and related alerts to [number] [default: 30] - --percentTerminated, --pt percent of process events to add termination - event for [number] [default: 30] - --maxChildrenPerNode, --maxCh always generate the max number of children per - node instead of it being random up to the max - children [boolean] [default: false] - --numHosts, --ne number of different hosts to generate alerts - for [number] [default: 1] - --numDocs, --nd number of metadata and policy response doc to - generate per host [number] [default: 5] - --alertsPerHost, --ape number of resolver trees to make for each host - [number] [default: 1] - --delete, -d delete indices and remake them - [boolean] [default: false] -``` +To see Resolver generator CLI options, run `yarn test:generate --help`. diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts index 542991a927f7ab..53fa59060550f8 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts @@ -95,19 +95,25 @@ async function main() { eventIndex: { alias: 'ei', describe: 'index to store events in', - default: 'events-endpoint-1', + default: 'logs-endpoint.events.process-default', + type: 'string', + }, + alertIndex: { + alias: 'ai', + describe: 'index to store alerts in', + default: 'logs-endpoint.alerts-default', type: 'string', }, metadataIndex: { alias: 'mi', describe: 'index to store host metadata in', - default: 'metrics-endpoint.metadata-default-1', + default: 'metrics-endpoint.metadata-default', type: 'string', }, policyIndex: { alias: 'pi', describe: 'index to store host policy in', - default: 'metrics-endpoint.policy-default-1', + default: 'metrics-endpoint.policy-default', type: 'string', }, ancestors: { @@ -192,7 +198,10 @@ async function main() { const client = new Client(clientOptions); if (argv.delete) { - await deleteIndices([argv.eventIndex, argv.metadataIndex, argv.policyIndex], client); + await deleteIndices( + [argv.eventIndex, argv.metadataIndex, argv.policyIndex, argv.alertIndex], + client + ); } let seed = argv.seed; @@ -209,6 +218,7 @@ async function main() { argv.metadataIndex, argv.policyIndex, argv.eventIndex, + argv.alertIndex, argv.alertsPerHost, { ancestors: argv.ancestors, diff --git a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json index dcce9746086e00..ea7a11b89dab2b 100644 --- a/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json +++ b/x-pack/plugins/security_solution/scripts/optimize_tsconfig/tsconfig.json @@ -1,6 +1,7 @@ { "include": [ "typings/**/*", + "plugins/lists/**/*", "plugins/security_solution/**/*", "plugins/apm/typings/numeral.d.ts", "plugins/canvas/types/webpack.d.ts", diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts index fd785bca4aa246..6c0ff9fcdc66f7 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts @@ -7,11 +7,11 @@ import { IClusterClient, IRouter, IScopedClusterClient } from 'kibana/server'; import { elasticsearchServiceMock, httpServiceMock, - loggingServiceMock, + loggingSystemMock, } from '../../../../../../../src/core/server/mocks'; import { registerAlertRoutes } from '../routes'; import { alertingIndexGetQuerySchema } from '../../../../common/endpoint_alerts/schema/alert_index'; -import { createMockAgentService } from '../../mocks'; +import { createMockEndpointAppContextServiceStartContract } from '../../mocks'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; @@ -28,12 +28,10 @@ describe('test alerts route', () => { routerMock = httpServiceMock.createRouter(); endpointAppContextService = new EndpointAppContextService(); - endpointAppContextService.start({ - agentService: createMockAgentService(), - }); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); registerAlertRoutes(routerMock, { - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: endpointAppContextService, config: () => Promise.resolve(createMockConfig()), }); diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts index 5bb3f969807df3..6433705290936b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts @@ -5,7 +5,7 @@ */ import { GetResponse } from 'elasticsearch'; import { KibanaRequest, RequestHandler } from 'kibana/server'; -import { eventsIndexPattern } from '../../../../../common/endpoint/constants'; +import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; import { AlertEvent } from '../../../../../common/endpoint/types'; import { EndpointAppContext } from '../../../types'; import { AlertDetailsRequestParams } from '../../../../../common/endpoint_alerts/types'; @@ -34,7 +34,7 @@ export const alertDetailsHandlerWrapper = function ( ctx, req.params, response, - eventsIndexPattern + alertsIndexPattern ); const currentHostInfo = await getHostData( diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts index 5122dd89bba165..24b940bf80ba5f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { RequestHandler } from 'kibana/server'; -import { eventsIndexPattern } from '../../../../../common/endpoint/constants'; +import { alertsIndexPattern } from '../../../../../common/endpoint/constants'; import { EndpointAppContext } from '../../../types'; import { searchESForAlerts } from '../lib'; import { getRequestData, mapToAlertResultList } from './lib'; @@ -23,7 +23,7 @@ export const alertListHandlerWrapper = function ( const response = await searchESForAlerts( ctx.core.elasticsearch.legacy.client, reqData, - eventsIndexPattern + alertsIndexPattern ); const mappedBody = await mapToAlertResultList(ctx, endpointAppContext, reqData, response); return res.ok({ body: mappedBody }); diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index cb8c913a73b8e8..7b8a368b6c9757 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -3,7 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { AgentService } from '../../../ingest_manager/server'; +import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server'; +import { handleDatasourceCreate } from './ingest_integration'; + +export type EndpointAppContextServiceStartContract = Pick< + IngestManagerStartContract, + 'agentService' +> & { + registerIngestCallback: IngestManagerStartContract['registerExternalCallback']; +}; /** * A singleton that holds shared services that are initialized during the start up phase @@ -12,8 +20,9 @@ import { AgentService } from '../../../ingest_manager/server'; export class EndpointAppContextService { private agentService: AgentService | undefined; - public start(dependencies: { agentService: AgentService }) { + public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; + dependencies.registerIngestCallback('datasourceCreate', handleDatasourceCreate); } public stop() {} diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts new file mode 100644 index 00000000000000..6ff0949311587f --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -0,0 +1,49 @@ +/* + * 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 { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; +import { NewPolicyData } from '../../common/endpoint/types'; +import { NewDatasource } from '../../../ingest_manager/common/types/models'; + +/** + * Callback to handle creation of Datasources in Ingest Manager + * @param newDatasource + */ +export const handleDatasourceCreate = async ( + newDatasource: NewDatasource +): Promise => { + // We only care about Endpoint datasources + if (newDatasource.package?.name !== 'endpoint') { + return newDatasource; + } + + // We cast the type here so that any changes to the Endpoint specific data + // follow the types/schema expected + let updatedDatasource = newDatasource as NewPolicyData; + + // Until we get the Default Policy Configuration in the Endpoint package, + // we will add it here manually at creation time. + // @ts-ignore + if (newDatasource.inputs.length === 0) { + updatedDatasource = { + ...newDatasource, + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: policyConfigFactory(), + }, + }, + }, + ], + }; + } + + return updatedDatasource; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index b10e9e4dc90e77..5435eff4ef1507 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -6,7 +6,28 @@ import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import { xpackMocks } from '../../../../mocks'; -import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server'; +import { + AgentService, + IngestManagerStartContract, + ExternalCallback, +} from '../../../ingest_manager/server'; +import { EndpointAppContextServiceStartContract } from './endpoint_app_context_services'; +import { createDatasourceServiceMock } from '../../../ingest_manager/server/mocks'; + +/** + * Crates a mocked input contract for the `EndpointAppContextService#start()` method + */ +export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< + EndpointAppContextServiceStartContract +> => { + return { + agentService: createMockAgentService(), + registerIngestCallback: jest.fn< + ReturnType, + Parameters + >(), + }; +}; /** * Creates a mock AgentService @@ -32,6 +53,8 @@ export const createMockIngestManagerStartContract = ( getESIndexPattern: jest.fn().mockResolvedValue(indexPattern), }, agentService: createMockAgentService(), + registerExternalCallback: jest.fn((...args: ExternalCallback) => {}), + datasourceService: createDatasourceServiceMock(), }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 4037f1a7cbc464..7c50a10846f9a0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -8,6 +8,7 @@ import { IRouter, Logger, RequestHandlerContext } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; import { metadataIndexPattern } from '../../../../common/endpoint/constants'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; import { @@ -18,6 +19,7 @@ import { } from '../../../../common/endpoint/types'; import { EndpointAppContext } from '../../types'; import { AgentStatus } from '../../../../../ingest_manager/common/types/models'; +import { findAllUnenrolledHostIds, findUnenrolledHostByHostId, HostId } from './support/unenroll'; interface HitSource { _source: HostMetadata; @@ -68,10 +70,17 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp }, async (context, req, res) => { try { + const unenrolledHostIds = await findAllUnenrolledHostIds( + context.core.elasticsearch.legacy.client + ); + const queryParams = await kibanaRequestToMetadataListESQuery( req, endpointAppContext, - metadataIndexPattern + metadataIndexPattern, + { + unenrolledHostIds: unenrolledHostIds.map((host: HostId) => host.host.id), + } ); const response = (await context.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', @@ -113,6 +122,12 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp return res.notFound({ body: 'Endpoint Not Found' }); } catch (err) { logger.warn(JSON.stringify(err, null, 2)); + if (err.isBoom) { + return res.customError({ + statusCode: err.output.statusCode, + body: { message: err.message }, + }); + } return res.internalError({ body: err }); } } @@ -123,6 +138,13 @@ export async function getHostData( metadataRequestContext: MetadataRequestContext, id: string ): Promise { + const unenrolledHostId = await findUnenrolledHostByHostId( + metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client, + id + ); + if (unenrolledHostId) { + throw Boom.badRequest('the requested endpoint is unenrolled'); + } const query = getESQueryHostMetadataByID(id, metadataIndexPattern); const response = (await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 92835dc5329ce3..1ca205f669fa37 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -16,7 +16,7 @@ import { elasticsearchServiceMock, httpServerMock, httpServiceMock, - loggingServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../../../src/core/server/mocks'; import { @@ -27,12 +27,15 @@ import { } from '../../../../common/endpoint/types'; import { SearchResponse } from 'elasticsearch'; import { registerEndpointRoutes } from './index'; -import { createMockAgentService, createRouteHandlerContext } from '../../mocks'; -import { AgentService } from '../../../../../ingest_manager/server'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostId } from './support/unenroll'; describe('test endpoint route', () => { let routerMock: jest.Mocked; @@ -44,8 +47,16 @@ describe('test endpoint route', () => { let routeHandler: RequestHandler; // eslint-disable-next-line @typescript-eslint/no-explicit-any let routeConfig: RouteConfig; - let mockAgentService: jest.Mocked; + let mockAgentService: ReturnType< + typeof createMockEndpointAppContextServiceStartContract + >['agentService']; let endpointAppContextService: EndpointAppContextService; + const noUnenrolledEndpoint = () => + Promise.resolve(({ + hits: { + hits: [], + }, + } as unknown) as SearchResponse); beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createClusterClient() as jest.Mocked< @@ -56,14 +67,13 @@ describe('test endpoint route', () => { mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); mockResponse = httpServerMock.createResponseFactory(); - mockAgentService = createMockAgentService(); endpointAppContextService = new EndpointAppContextService(); - endpointAppContextService.start({ - agentService: mockAgentService, - }); + const startContract = createMockEndpointAppContextServiceStartContract(); + endpointAppContextService.start(startContract); + mockAgentService = startContract.agentService; registerEndpointRoutes(routerMock, { - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: endpointAppContextService, config: () => Promise.resolve(createMockConfig()), }); @@ -74,7 +84,9 @@ describe('test endpoint route', () => { it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -85,7 +97,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; @@ -110,9 +122,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -123,8 +137,8 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); + expect(mockScopedClient.callAsCurrentUser.mock.calls[1][1]?.body?.query).toEqual({ match_all: {}, }); expect(routeConfig.options).toEqual({ authRequired: true }); @@ -153,9 +167,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -167,20 +183,26 @@ describe('test endpoint route', () => { ); expect(mockScopedClient.callAsCurrentUser).toBeCalled(); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + expect(mockScopedClient.callAsCurrentUser.mock.calls[1][1]?.body?.query).toEqual({ bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.ip': '10.140.73.246', + must: [ + { + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], }, }, - ], + }, }, - }, + ], }, }); expect(routeConfig.options).toEqual({ authRequired: true }); @@ -196,9 +218,10 @@ describe('test endpoint route', () => { it('should return 404 on no results', async () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse()) - ); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(createSearchResponse())); + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') @@ -209,7 +232,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.notFound).toBeCalled(); const message = mockResponse.notFound.mock.calls[0][0]?.body; @@ -221,8 +244,12 @@ describe('test endpoint route', () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: response.hits.hits[0]._id }, }); + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('online'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -233,7 +260,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; @@ -251,7 +278,11 @@ describe('test endpoint route', () => { mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { throw Boom.notFound('Agent not found'); }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -262,7 +293,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; @@ -277,7 +308,11 @@ describe('test endpoint route', () => { }); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('warning'); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(noUnenrolledEndpoint) + .mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith('/api/endpoint/metadata') )!; @@ -288,12 +323,50 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; expect(result.host_status).toEqual(HostStatus.ERROR); }); + + it('should throw error when endpoint is unenrolled', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: 'hostId' }, + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(({ + hits: { + hits: [ + { + _index: 'metrics-endpoint.metadata_mirror-default', + _id: 'S5M1yHIBLSMVtiLw6Wpr', + _score: 0.0, + _source: { + host: { + id: 'hostId', + }, + }, + }, + ], + }, + } as unknown) as SearchResponse) + ); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith('/api/endpoint/metadata') + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + }); }); }); @@ -316,7 +389,7 @@ function createSearchResponse(hostMetadata?: HostMetadata): SearchResponse { const query = await kibanaRequestToMetadataListESQuery( mockRequest, { - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, @@ -58,6 +58,69 @@ describe('query builder', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); + + it( + 'test default query params for all endpoints metadata when no params or body is provided ' + + 'with unenrolled host ids excluded', + async () => { + const unenrolledHostId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: {}, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataIndexPattern, + { + unenrolledHostIds: [unenrolledHostId], + } + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must_not: { + terms: { + 'host.id': ['1fdca33f-799f-49f4-939c-ea4383c77672'], + }, + }, + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); }); describe('test query builder with kql filter', () => { @@ -70,28 +133,35 @@ describe('query builder', () => { const query = await kibanaRequestToMetadataListESQuery( mockRequest, { - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, metadataIndexPattern ); + expect(query).toEqual({ body: { query: { bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.ip': '10.140.73.246', + must: [ + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, }, }, - ], + }, }, - }, + ], }, }, collapse: { @@ -123,6 +193,93 @@ describe('query builder', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); + + it( + 'test default query params for all endpoints endpoint metadata excluding unerolled endpoint ' + + 'and when body filter is provided', + async () => { + const unenrolledHostId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + filter: 'not host.ip:10.140.73.246', + }, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataIndexPattern, + { + unenrolledHostIds: [unenrolledHostId], + } + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must: [ + { + bool: { + must_not: { + terms: { + 'host.id': [unenrolledHostId], + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); }); describe('MetadataGetQuery', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index 075e4377f0b2a2..b6ec91675f2483 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -7,17 +7,22 @@ import { KibanaRequest } from 'kibana/server'; import { esKuery } from '../../../../../../../src/plugins/data/server'; import { EndpointAppContext } from '../../types'; -export const kibanaRequestToMetadataListESQuery = async ( +export interface QueryBuilderOptions { + unenrolledHostIds?: string[]; +} + +export async function kibanaRequestToMetadataListESQuery( // eslint-disable-next-line @typescript-eslint/no-explicit-any request: KibanaRequest, endpointAppContext: EndpointAppContext, - index: string + index: string, + queryBuilderOptions?: QueryBuilderOptions // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise> => { +): Promise> { const pagingProperties = await getPagingProperties(request, endpointAppContext); return { body: { - query: buildQueryBody(request), + query: buildQueryBody(request, queryBuilderOptions?.unenrolledHostIds!), collapse: { field: 'host.id', inner_hits: { @@ -45,7 +50,7 @@ export const kibanaRequestToMetadataListESQuery = async ( size: pagingProperties.pageSize, index, }; -}; +} async function getPagingProperties( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -68,14 +73,53 @@ async function getPagingProperties( }; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function buildQueryBody(request: KibanaRequest): Record { +function buildQueryBody( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: KibanaRequest, + unerolledHostIds: string[] | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Record { + const filterUnenrolledHosts = unerolledHostIds && unerolledHostIds.length > 0; if (typeof request?.body?.filter === 'string') { - return esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(request.body.filter)); + const kqlQuery = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(request.body.filter)); + return { + bool: { + must: filterUnenrolledHosts + ? [ + { + bool: { + must_not: { + terms: { + 'host.id': unerolledHostIds, + }, + }, + }, + }, + { + ...kqlQuery, + }, + ] + : [ + { + ...kqlQuery, + }, + ], + }, + }; } - return { - match_all: {}, - }; + return filterUnenrolledHosts + ? { + bool: { + must_not: { + terms: { + 'host.id': unerolledHostIds, + }, + }, + }, + } + : { + match_all: {}, + }; } export function getESQueryHostMetadataByID(hostID: string, index: string) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts new file mode 100644 index 00000000000000..2e6bb2c976fef1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { IScopedClusterClient } from 'kibana/server'; +import { + findAllUnenrolledHostIds, + fetchAllUnenrolledHostIdsWithScroll, + HostId, + findUnenrolledHostByHostId, +} from './unenroll'; +import { elasticsearchServiceMock } from '../../../../../../../../src/core/server/mocks'; +import { SearchResponse } from 'elasticsearch'; +import { metadataMirrorIndexPattern } from '../../../../../common/endpoint/constants'; +import { EndpointStatus } from '../../../../../common/endpoint/types'; + +const noUnenrolledEndpoint = () => + Promise.resolve(({ + hits: { + hits: [], + }, + } as unknown) as SearchResponse); + +describe('test find all unenrolled HostId', () => { + let mockScopedClient: jest.Mocked; + + it('can find all hits with scroll', async () => { + const firstHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + const secondHostId = '2fdca33f-799f-49f4-939c-ea4383c77672'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(() => Promise.resolve(createSearchResponse(secondHostId, 'scrollId'))) + .mockImplementationOnce(noUnenrolledEndpoint); + + const initialResponse = createSearchResponse(firstHostId, 'initialScrollId'); + const hostIds = await fetchAllUnenrolledHostIdsWithScroll( + initialResponse, + mockScopedClient.callAsCurrentUser + ); + + expect(hostIds).toEqual([{ host: { id: firstHostId } }, { host: { id: secondHostId } }]); + }); + + it('can find all unerolled endpoint host ids', async () => { + const firstEndpointHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + const secondEndpointHostId = '2fdca33f-799f-49f4-939c-ea4383c77672'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(firstEndpointHostId, 'initialScrollId')) + ) + .mockImplementationOnce(() => + Promise.resolve(createSearchResponse(secondEndpointHostId, 'scrollId')) + ) + .mockImplementationOnce(noUnenrolledEndpoint); + const hostIds = await findAllUnenrolledHostIds(mockScopedClient); + + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]).toEqual({ + index: metadataMirrorIndexPattern, + scroll: '30s', + body: { + size: 1000, + _source: ['host.id'], + query: { + bool: { + filter: { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + }, + }, + }, + }); + expect(hostIds).toEqual([ + { host: { id: firstEndpointHostId } }, + { host: { id: secondEndpointHostId } }, + ]); + }); +}); + +describe('test find unenrolled endpoint host id by hostId', () => { + let mockScopedClient: jest.Mocked; + + it('can find unenrolled endpoint by the host id when unenrolled', async () => { + const firstEndpointHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createSearchResponse(firstEndpointHostId, 'initialScrollId')) + ); + const endpointHostId = await findUnenrolledHostByHostId(mockScopedClient, firstEndpointHostId); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.index).toEqual( + metadataMirrorIndexPattern + ); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body).toEqual({ + size: 1, + _source: ['host.id'], + query: { + bool: { + filter: [ + { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + { + term: { + 'host.id': firstEndpointHostId, + }, + }, + ], + }, + }, + }); + expect(endpointHostId).toEqual({ host: { id: firstEndpointHostId } }); + }); + + it('find unenrolled endpoint host by the host id return undefined when no unenrolled host', async () => { + const firstHostId = '1fdca33f-799f-49f4-939c-ea4383c77671'; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(noUnenrolledEndpoint); + const hostId = await findUnenrolledHostByHostId(mockScopedClient, firstHostId); + expect(hostId).toBeFalsy(); + }); +}); + +function createSearchResponse(hostId: string, scrollId: string): SearchResponse { + return ({ + hits: { + hits: [ + { + _index: metadataMirrorIndexPattern, + _id: 'S5M1yHIBLSMVtiLw6Wpr', + _score: 0.0, + _source: { + host: { + id: hostId, + }, + }, + }, + ], + }, + _scroll_id: scrollId, + } as unknown) as SearchResponse; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts new file mode 100644 index 00000000000000..ef6898fad2807d --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.ts @@ -0,0 +1,114 @@ +/* + * 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 { APICaller, IScopedClusterClient } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; +import { metadataMirrorIndexPattern } from '../../../../../common/endpoint/constants'; +import { EndpointStatus } from '../../../../../common/endpoint/types'; + +const KEEPALIVE = '30s'; +const SIZE = 1000; + +export interface HostId { + host: { + id: string; + }; +} + +interface HitSource { + _source: HostId; +} + +export async function findUnenrolledHostByHostId( + client: IScopedClusterClient, + hostId: string +): Promise { + const queryParams = { + index: metadataMirrorIndexPattern, + body: { + size: 1, + _source: ['host.id'], + query: { + bool: { + filter: [ + { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + { + term: { + 'host.id': hostId, + }, + }, + ], + }, + }, + }, + }; + + const response = (await client.callAsCurrentUser('search', queryParams)) as SearchResponse< + HostId + >; + const newHits = response.hits?.hits || []; + + if (newHits.length > 0) { + const hostIds = newHits.map((hitSource: HitSource) => hitSource._source); + return hostIds[0]; + } else { + return undefined; + } +} + +export async function findAllUnenrolledHostIds(client: IScopedClusterClient): Promise { + const queryParams = { + index: metadataMirrorIndexPattern, + scroll: KEEPALIVE, + body: { + size: SIZE, + _source: ['host.id'], + query: { + bool: { + filter: { + term: { + 'Endpoint.status': EndpointStatus.unenrolled, + }, + }, + }, + }, + }, + }; + const response = (await client.callAsCurrentUser('search', queryParams)) as SearchResponse< + HostId + >; + + return fetchAllUnenrolledHostIdsWithScroll(response, client.callAsCurrentUser); +} + +export async function fetchAllUnenrolledHostIdsWithScroll( + response: SearchResponse, + client: APICaller, + hits: HostId[] = [] +): Promise { + let newHits = response.hits?.hits || []; + let scrollId = response._scroll_id; + + while (newHits.length > 0) { + const hostIds: HostId[] = newHits.map((hitSource: HitSource) => hitSource._source); + hits.push(...hostIds); + + const innerResponse = await client('scroll', { + body: { + scroll: KEEPALIVE, + scroll_id: scrollId, + }, + }); + + newHits = innerResponse.hits?.hits || []; + scrollId = innerResponse._scroll_id; + } + return hits; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 2b94fe3576e2dc..16af3a95bc72da 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import { EndpointAppContextService } from '../../endpoint_app_context_services'; -import { createMockAgentService, createRouteHandlerContext } from '../../mocks'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; import { getHostPolicyResponseHandler } from './handlers'; import { IScopedClusterClient, @@ -14,10 +17,9 @@ import { import { elasticsearchServiceMock, httpServerMock, - loggingServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../../../src/core/server/mocks'; -import { AgentService } from '../../../../../ingest_manager/server/services'; import { SearchResponse } from 'elasticsearch'; import { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; @@ -28,17 +30,13 @@ describe('test policy response handler', () => { let mockScopedClient: jest.Mocked; let mockSavedObjectClient: jest.Mocked; let mockResponse: jest.Mocked; - let mockAgentService: jest.Mocked; beforeEach(() => { mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockResponse = httpServerMock.createResponseFactory(); endpointAppContextService = new EndpointAppContextService(); - mockAgentService = createMockAgentService(); - endpointAppContextService.start({ - agentService: mockAgentService, - }); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); }); afterEach(() => endpointAppContextService.stop()); @@ -46,7 +44,7 @@ describe('test policy response handler', () => { it('should return the latest policy response for a host', async () => { const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); const hostPolicyResponseHandler = getHostPolicyResponseHandler({ - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: endpointAppContextService, config: () => Promise.resolve(createMockConfig()), }); @@ -69,7 +67,7 @@ describe('test policy response handler', () => { it('should return not found when there is no response policy for host', async () => { const hostPolicyResponseHandler = getHostPolicyResponseHandler({ - logFactory: loggingServiceMock.create(), + logFactory: loggingSystemMock.create(), service: endpointAppContextService, config: () => Promise.resolve(createMockConfig()), }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts index 7bfe101b920fb3..830d92ef2efc0c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts @@ -7,7 +7,7 @@ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler, Logger } from 'kibana/server'; import { validateAlerts } from '../../../../common/endpoint/schema/resolver'; -import { eventsIndexPattern } from '../../../../common/endpoint/constants'; +import { alertsIndexPattern, eventsIndexPattern } from '../../../../common/endpoint/constants'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -23,7 +23,7 @@ export function handleAlerts( try { const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ body: await fetcher.alerts(alerts, afterAlert), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts index aa040638045b2c..cff80ec5010bd2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts @@ -6,7 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; -import { eventsIndexPattern } from '../../../../common/endpoint/constants'; +import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; import { validateAncestry } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -23,7 +23,7 @@ export function handleAncestry( try { const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); const ancestorInfo = await fetcher.ancestors(ancestors); return res.ok({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts index 83aed602c97a33..74448a324a4ecb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts @@ -6,7 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; -import { eventsIndexPattern } from '../../../../common/endpoint/constants'; +import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; import { validateChildren } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -22,7 +22,7 @@ export function handleChildren( } = req; try { const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ body: await fetcher.children(children, generations, afterChild), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts index 5018a265cd12d8..9e5c6be43f7289 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts @@ -6,7 +6,7 @@ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler, Logger } from 'kibana/server'; -import { eventsIndexPattern } from '../../../../common/endpoint/constants'; +import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; import { validateEvents } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -23,7 +23,7 @@ export function handleEvents( try { const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ body: await fetcher.events(events, afterEvent), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts index 013bc4302de2e6..95bc612c58a1b6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts @@ -15,7 +15,7 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com export class AlertsQuery extends ResolverQuery { constructor( private readonly pagination: PaginationBuilder, - indexPattern: string, + indexPattern: string | string[], endpointID?: string ) { super(indexPattern, endpointID); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/base.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/base.ts index debb455ac728ff..025d3a9420634c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/base.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/base.ts @@ -25,13 +25,16 @@ export abstract class ResolverQuery implements MSearchQuery { * we need `endpointID` for legacy data is because we don't have a cross endpoint unique identifier for process * events. Instead we use `unique_pid/ppid` and `endpointID` to uniquely identify a process event. */ - constructor(private readonly indexPattern: string, private readonly endpointID?: string) {} + constructor( + private readonly indexPattern: string | string[], + private readonly endpointID?: string + ) {} private static createIdsArray(ids: string | string[]): string[] { return Array.isArray(ids) ? ids : [ids]; } - private buildQuery(ids: string | string[]): { query: JsonObject; index: string } { + private buildQuery(ids: string | string[]): { query: JsonObject; index: string | string[] } { const idsArray = ResolverQuery.createIdsArray(ids); if (this.endpointID) { return { query: this.legacyQuery(this.endpointID, idsArray), index: legacyEventIndexPattern }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts index f357ac1a43d729..b7b1a16926a15d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts @@ -15,7 +15,7 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com export class ChildrenQuery extends ResolverQuery { constructor( private readonly pagination: PaginationBuilder, - indexPattern: string, + indexPattern: string | string[], endpointID?: string ) { super(indexPattern, endpointID); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts index 04202cfd007f9d..ec65e30d1d5d42 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts @@ -15,7 +15,7 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com export class EventsQuery extends ResolverQuery { constructor( private readonly pagination: PaginationBuilder, - indexPattern: string, + indexPattern: string | string[], endpointID?: string ) { super(indexPattern, endpointID); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts index cad6d948358084..181fb8c3df3f92 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts @@ -6,7 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; -import { eventsIndexPattern } from '../../../../common/endpoint/constants'; +import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; import { validateTree } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { Tree } from './utils/tree'; @@ -34,7 +34,7 @@ export function handleTree( try { const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); const [childrenNodes, ancestry, relatedEvents, relatedAlerts] = await Promise.all([ fetcher.children(children, generations, afterChild), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts index 7a3e1fc591e82e..e60e5087c30a9e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts @@ -9,7 +9,11 @@ import { parentEntityId, isProcessStart, } from '../../../../../common/endpoint/models/event'; -import { ChildNode, ResolverEvent, ResolverChildren } from '../../../../../common/endpoint/types'; +import { + ResolverChildNode, + ResolverEvent, + ResolverChildren, +} from '../../../../../common/endpoint/types'; import { PaginationBuilder } from './pagination'; import { createChild } from './node'; @@ -17,7 +21,7 @@ import { createChild } from './node'; * This class helps construct the children structure when building a resolver tree. */ export class ChildrenNodesHelper { - private readonly cache: Map = new Map(); + private readonly cache: Map = new Map(); constructor(private readonly rootID: string) { this.cache.set(rootID, createChild(rootID)); @@ -27,7 +31,7 @@ export class ChildrenNodesHelper { * Constructs a ResolverChildren response based on the children that were previously add. */ getNodes(): ResolverChildren { - const cacheCopy: Map = new Map(this.cache); + const cacheCopy: Map = new Map(this.cache); const rootNode = cacheCopy.get(this.rootID); let rootNextChild = null; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts index 8d7c3d7b731589..0af2fca7106bef 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts @@ -10,7 +10,7 @@ import { ResolverRelatedEvents, ResolverAncestry, ResolverRelatedAlerts, - LifecycleNode, + ResolverLifecycleNode, ResolverEvent, } from '../../../../../common/endpoint/types'; import { @@ -39,9 +39,13 @@ export class Fetcher { */ private readonly id: string, /** - * Index pattern for searching ES + * Index pattern for searching ES for events */ - private readonly indexPattern: string, + private readonly eventsIndexPattern: string, + /** + * Index pattern for searching ES for alerts + */ + private readonly alertsIndexPattern: string, /** * This is used for searching legacy events */ @@ -109,7 +113,7 @@ export class Fetcher { public async alerts(limit: number, after?: string): Promise { const query = new AlertsQuery( PaginationBuilder.createBuilder(limit, after), - this.indexPattern, + this.alertsIndexPattern, this.endpointID ); @@ -139,8 +143,8 @@ export class Fetcher { return tree; } - private async getNode(entityID: string): Promise { - const query = new LifecycleQuery(this.indexPattern, this.endpointID); + private async getNode(entityID: string): Promise { + const query = new LifecycleQuery(this.eventsIndexPattern, this.endpointID); const results = await query.search(this.client, entityID); if (results.length === 0) { return; @@ -172,7 +176,7 @@ export class Fetcher { return; } - const query = new LifecycleQuery(this.indexPattern, this.endpointID); + const query = new LifecycleQuery(this.eventsIndexPattern, this.endpointID); const results = await query.search(this.client, ancestors); if (results.length === 0) { @@ -182,7 +186,7 @@ export class Fetcher { // bucket the start and end events together for a single node const ancestryNodes = results.reduce( - (nodes: Map, ancestorEvent: ResolverEvent) => { + (nodes: Map, ancestorEvent: ResolverEvent) => { const nodeId = entityId(ancestorEvent); let node = nodes.get(nodeId); if (!node) { @@ -210,7 +214,7 @@ export class Fetcher { private async doEvents(limit: number, after?: string) { const query = new EventsQuery( PaginationBuilder.createBuilder(limit, after), - this.indexPattern, + this.eventsIndexPattern, this.endpointID ); @@ -243,10 +247,10 @@ export class Fetcher { const childrenQuery = new ChildrenQuery( PaginationBuilder.createBuilder(limit, after), - this.indexPattern, + this.eventsIndexPattern, this.endpointID ); - const lifecycleQuery = new LifecycleQuery(this.indexPattern, this.endpointID); + const lifecycleQuery = new LifecycleQuery(this.eventsIndexPattern, this.endpointID); const { totals, results } = await childrenQuery.search(this.client, ids); if (results.length === 0) { @@ -262,7 +266,10 @@ export class Fetcher { } private async doStats(tree: Tree) { - const statsQuery = new StatsQuery(this.indexPattern, this.endpointID); + const statsQuery = new StatsQuery( + [this.eventsIndexPattern, this.alertsIndexPattern], + this.endpointID + ); const ids = tree.ids(); const res = await statsQuery.search(this.client, ids); const alerts = res.alerts; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts index 58aa9efc1fc567..57a2ebfcc17929 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts @@ -7,10 +7,10 @@ import { ResolverEvent, ResolverAncestry, - LifecycleNode, + ResolverLifecycleNode, ResolverRelatedEvents, ResolverTree, - ChildNode, + ResolverChildNode, ResolverRelatedAlerts, } from '../../../../../common/endpoint/types'; @@ -49,7 +49,7 @@ export function createRelatedAlerts( * * @param entityID the entity_id of the child */ -export function createChild(entityID: string): ChildNode { +export function createChild(entityID: string): ResolverChildNode { const lifecycle = createLifecycle(entityID, []); return { ...lifecycle, @@ -70,7 +70,10 @@ export function createAncestry(): ResolverAncestry { * @param id the entity_id that these lifecycle nodes should have * @param lifecycle an array of lifecycle events */ -export function createLifecycle(entityID: string, lifecycle: ResolverEvent[]): LifecycleNode { +export function createLifecycle( + entityID: string, + lifecycle: ResolverEvent[] +): ResolverLifecycleNode { return { entityID, lifecycle }; } diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index 8a77137c20c115..06b35213b4713a 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -4,15 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { Plugin, PluginSetup, PluginStart } from './plugin'; import { configSchema, ConfigType } from './config'; +import { SIGNALS_INDEX_KEY } from '../common/constants'; export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; -export const config = { schema: configSchema }; +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ renameFromRoot }) => [ + renameFromRoot('xpack.siem.enabled', 'xpack.securitySolution.enabled'), + renameFromRoot( + 'xpack.siem.maxRuleImportExportSize', + 'xpack.securitySolution.maxRuleImportExportSize' + ), + renameFromRoot( + 'xpack.siem.maxRuleImportPayloadBytes', + 'xpack.securitySolution.maxRuleImportPayloadBytes' + ), + renameFromRoot( + 'xpack.siem.maxTimelineImportExportSize', + 'xpack.securitySolution.maxTimelineImportExportSize' + ), + renameFromRoot( + 'xpack.siem.maxTimelineImportPayloadBytes', + 'xpack.securitySolution.maxTimelineImportPayloadBytes' + ), + renameFromRoot( + `xpack.siem.${SIGNALS_INDEX_KEY}`, + `xpack.securitySolution.${SIGNALS_INDEX_KEY}` + ), + ], +}; export { ConfigType, Plugin, PluginSetup, PluginStart }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 47356679c8075e..3eefd3e665cd62 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { getResult } from '../routes/__mocks__/request_responses'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; import { buildSignalsSearchQuery } from './build_signals_query'; @@ -15,12 +15,12 @@ jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { let payload: NotificationExecutorOptions; let alert: ReturnType; - let logger: ReturnType; + let logger: ReturnType; let alertServices: AlertServicesMock; beforeEach(() => { alertServices = alertsMock.createAlertServices(); - logger = loggingServiceMock.createLogger(); + logger = loggingSystemMock.createLogger(); payload = { alertId: '1111', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts index 0c9ccf069b3b62..03d08625000eca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; import { isAlertTypes, isNotificationAlertExecutor } from './types'; import { rulesNotificationAlertType } from './rules_notification_alert_type'; @@ -21,7 +21,7 @@ describe('types', () => { it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { expect( isNotificationAlertExecutor( - rulesNotificationAlertType({ logger: loggingServiceMock.createLogger() }) + rulesNotificationAlertType({ logger: loggingSystemMock.createLogger() }) ) ).toEqual(true); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 9928ce4807da9a..581946f2300b41 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -27,6 +27,7 @@ import { RuleNotificationAlertType } from '../../notifications/types'; import { QuerySignalsSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/query_signals_index_schema'; import { SetSignalsStatusSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/set_signal_status_schema'; import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({ signal_ids: ['somefakeid1', 'somefakeid2'], @@ -390,38 +391,7 @@ export const getResult = (): RuleAlertType => ({ references: ['http://www.example.com', 'https://ww.example.com'], note: '# Investigative notes', version: 1, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptionsList: getListArrayMock(), }, createdAt: new Date('2019-12-13T16:40:33.400Z'), updatedAt: new Date('2019-12-13T16:40:33.400Z'), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 063c9dffd66dd6..7b7d3fbdea0bfd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -8,6 +8,7 @@ import { Readable } from 'stream'; import { HapiReadableStream } from '../../rules/types'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response/rules_schema'; +import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; /** * Given a string, builds a hapi stream as our @@ -76,38 +77,7 @@ export const getOutputRuleAlertForRest = (): Omit< ], }, ], - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), filters: [ { query: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 1f5442e23d8842..00656967126280 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -14,6 +14,7 @@ import { FindResult } from '../../../../../../alerts/server'; import { BulkError } from '../utils'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response/rules_schema'; +import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; export const ruleOutput: RulesSchema = { actions: [], @@ -68,38 +69,7 @@ export const ruleOutput: RulesSchema = { }, }, ], - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], meta: { someMeta: 'someField', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index ee21c335400245..7d4bbfdced4324 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -80,36 +80,8 @@ describe('getExportAll', () => { note: '# Investigative notes', version: 1, exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, + { id: 'some_uuid', namespace_type: 'single' }, + { id: 'some_uuid', namespace_type: 'agnostic' }, ], })}\n`, exportDetails: `${JSON.stringify({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index b00b7353a370f6..043e563a4c8b5c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -88,36 +88,8 @@ describe('get_export_by_object_ids', () => { note: '# Investigative notes', version: 1, exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, + { id: 'some_uuid', namespace_type: 'single' }, + { id: 'some_uuid', namespace_type: 'agnostic' }, ], })}\n`, exportDetails: `${JSON.stringify({ @@ -216,36 +188,8 @@ describe('get_export_by_object_ids', () => { note: '# Investigative notes', version: 1, exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, + { id: 'some_uuid', namespace_type: 'single' }, + { id: 'some_uuid', namespace_type: 'agnostic' }, ], }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 4b84057f6d7952..fc95f0cfeb78e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -14,7 +14,6 @@ import { SavedObjectsClientContract, } from 'kibana/server'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { ListsDefaultArraySchema } from '../../../../common/detection_engine/schemas/types/lists_default_array'; import { FalsePositives, From, @@ -62,7 +61,6 @@ import { ThreatOrUndefined, TypeOrUndefined, ReferencesOrUndefined, - ListAndOrUndefined, PerPageOrUndefined, PageOrUndefined, SortFieldOrUndefined, @@ -80,6 +78,7 @@ import { AlertsClient, PartialAlert } from '../../../../../alerts/server'; import { Alert, SanitizedAlert } from '../../../../../alerts/common'; import { SIGNALS_ID } from '../../../../common/constants'; import { RuleTypeParams, PartialFilter } from '../types'; +import { ListArrayOrUndefined, ListArray } from '../../../../common/detection_engine/schemas/types'; export interface RuleAlertType extends Alert { params: RuleTypeParams; @@ -194,7 +193,7 @@ export interface CreateRulesOptions { references: References; note: NoteOrUndefined; version: Version; - exceptionsList: ListsDefaultArraySchema; + exceptionsList: ListArray; actions: RuleAlertAction[]; } @@ -230,7 +229,7 @@ export interface UpdateRulesOptions { references: References; note: NoteOrUndefined; version: VersionOrUndefined; - exceptionsList: ListsDefaultArraySchema; + exceptionsList: ListArray; actions: RuleAlertAction[]; } @@ -264,7 +263,7 @@ export interface PatchRulesOptions { references: ReferencesOrUndefined; note: NoteOrUndefined; version: VersionOrUndefined; - exceptionsList: ListAndOrUndefined; + exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; rule: SanitizedAlert | null; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index d40cb5d96669bd..5c620a5df61f8e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -31,9 +31,9 @@ import { ThreatOrUndefined, TypeOrUndefined, ReferencesOrUndefined, - ListAndOrUndefined, } from '../../../../common/detection_engine/schemas/common/schemas'; import { PartialFilter } from '../types'; +import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types'; export const calculateInterval = ( interval: string | undefined, @@ -74,7 +74,7 @@ export interface UpdateProperties { references: ReferencesOrUndefined; note: NoteOrUndefined; version: VersionOrUndefined; - exceptionsList: ListAndOrUndefined; + exceptionsList: ListArrayOrUndefined; anomalyThreshold: AnomalyThresholdOrUndefined; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/update_list.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/update_list.json index 8d831f3a961d8d..6323597fc09461 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/update_list.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/patches/update_list.json @@ -2,31 +2,8 @@ "rule_id": "query-with-list", "exceptions_list": [ { - "field": "source.ip", - "values_operator": "excluded", - "values_type": "exists" - }, - { - "field": "host.name", - "values_operator": "included", - "values_type": "match", - "values": [ - { - "name": "rock01" - } - ], - "and": [ - { - "field": "host.id", - "values_operator": "included", - "values_type": "match_all", - "values": [ - { - "name": "123456" - } - ] - } - ] + "id": "some_updated_fake_id", + "namespace_type": "single" } ] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_and.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_and.json deleted file mode 100644 index 1575a712e2cbaa..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_and.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "List - and", - "description": "Query with a list that includes and. This rule should only produce signals when host.name exists and when both event.module is endgame and event.category is anything other than file", - "rule_id": "query-with-list-and", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "excluded", - "values_type": "match", - "values": [ - { - "name": "endgame" - } - ], - "and": [ - { - "field": "event.category", - "values_operator": "included", - "values_type": "match", - "values": [ - { - "name": "file" - } - ] - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_excluded.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_excluded.json deleted file mode 100644 index 4e6d9403a276f4..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_excluded.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "List - excluded", - "description": "Query with a list of values_operator excluded. This rule should only produce signals when host.name exists and event.module is suricata", - "rule_id": "query-with-list-excluded", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "excluded", - "values_type": "match", - "values": [ - { - "name": "suricata" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_exists.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_exists.json deleted file mode 100644 index 97beace37633fd..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_exists.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "List - exists", - "description": "Query with a list that includes exists. This rule should only produce signals when host.name exists and event.action does not exist", - "rule_id": "query-with-list-exists", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.action", - "values_operator": "included", - "values_type": "exists" - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list.json deleted file mode 100644 index ad0585b5a2ec50..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "Query with a list", - "description": "Query with a list. This rule should only produce signals when either host.name exists and event.module is system and user.name is zeek or gdm OR when host.name exists and event.module is not endgame or zeek or system.", - "rule_id": "query-with-list", - "risk_score": 2, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "excluded", - "values_type": "match", - "values": [ - { - "name": "system" - } - ], - "and": [ - { - "field": "user.name", - "values_operator": "excluded", - "values_type": "match_all", - "values": [ - { - "name": "zeek" - }, - { - "name": "gdm" - } - ] - } - ] - }, - { - "field": "event.module", - "values_operator": "included", - "values_type": "match_all", - "values": [ - { - "name": "endgame" - }, - { - "name": "zeek" - }, - { - "name": "system" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json deleted file mode 100644 index fa6fe6ac711173..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "Query with a list", - "description": "Query with a list only generate signals if source.ip is not in list", - "rule_id": "query-with-list", - "risk_score": 2, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "source.ip", - "values_operator": "excluded", - "values_type": "list", - "values": [ - { - "id": "ci-badguys.txt", - "name": "ip" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match.json deleted file mode 100644 index 6e6880cc28f24a..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "List - match", - "description": "Query with a list that includes match. This rule should only produce signals when host.name exists and event.module is not suricata", - "rule_id": "query-with-list-match", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "included", - "values_type": "match", - "values": [ - { - "name": "suricata" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match_all.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match_all.json deleted file mode 100644 index 44cc26ac3315e4..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_match_all.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "List - match_all", - "description": "Query with a list that includes match_all. This rule should only produce signals when host.name exists and event.module is not suricata or auditd", - "rule_id": "query-with-list-match-all", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "language": "kuery", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "included", - "values_type": "match_all", - "values": [ - { - "name": "suricata" - }, - { - "name": "auditd" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_or.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_or.json deleted file mode 100644 index 9c4eda559d5bc0..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/lists/query_with_or.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "List - or", - "description": "Query with a list that includes or. This rule should only produce signals when host.name exists and event.module is suricata OR when host.name exists and event.category is file", - "rule_id": "query-with-list-or", - "risk_score": 1, - "severity": "high", - "type": "query", - "query": "host.name: *", - "interval": "30s", - "exceptions_list": [ - { - "field": "event.module", - "values_operator": "excluded", - "values_type": "match", - "values": [ - { - "name": "suricata" - } - ] - }, - { - "field": "event.category", - "values_operator": "excluded", - "values_type": "match", - "values": [ - { - "name": "file" - } - ] - } - ] -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_list.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_list.json new file mode 100644 index 00000000000000..1cb4c144aa2935 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/queries/query_with_list.json @@ -0,0 +1,10 @@ +{ + "name": "Rule w exceptions", + "description": "Sample rule with exception list", + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "host.name: *", + "interval": "30s", + "exceptions_list": [{ "id": "endpoint_list", "namespace_type": "single" }] +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/updates/update_list.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/updates/update_list.json index df22dff5c046e9..f7359d586bd86a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/updates/update_list.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/updates/update_list.json @@ -6,33 +6,5 @@ "severity": "high", "type": "query", "query": "user.name: root or user.name: admin", - "exceptions_list": [ - { - "field": "source.ip", - "values_operator": "excluded", - "values_type": "exists" - }, - { - "field": "host.name", - "values_operator": "included", - "values_type": "match", - "values": [ - { - "name": "rock01" - } - ], - "and": [ - { - "field": "host.id", - "values_operator": "included", - "values_type": "match_all", - "values": [ - { - "name": "123456" - } - ] - } - ] - } - ] + "exceptions_list": [{ "id": "some_updated_fake_id", "namespace_type": "single" }] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 01ee41e3b877c9..50f6e7d9e9c10f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -10,10 +10,11 @@ import { SavedObject, SavedObjectsFindResponse, } from '../../../../../../../../src/core/server'; -import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../../../../src/core/server/mocks'; import { RuleTypeParams } from '../../types'; import { IRuleStatusAttributes } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; export const sampleRuleAlertParams = ( maxSignals?: number | undefined, @@ -44,38 +45,7 @@ export const sampleRuleAlertParams = ( meta: undefined, threat: undefined, version: 1, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptionsList: getListArrayMock(), }); export const sampleDocNoSortId = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ @@ -394,7 +364,7 @@ export const exampleFindRuleStatusResponse: ( saved_objects: mockStatuses.map((obj) => ({ ...obj, score: 1 })), }); -export const mockLogger: Logger = loggingServiceMock.createLogger(); +export const mockLogger: Logger = loggingSystemMock.createLogger(); export const sampleBulkErrorItem = ( { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 80c2441193a0ca..ad439328188364 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -12,6 +12,7 @@ import { } from './__mocks__/es_results'; import { buildBulkBody } from './build_bulk_body'; import { SignalHit } from './types'; +import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; describe('buildBulkBody', () => { beforeEach(() => { @@ -91,38 +92,7 @@ describe('buildBulkBody', () => { version: 1, created_at: fakeSignalSourceHit.signal.rule?.created_at, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }, }, }; @@ -218,38 +188,7 @@ describe('buildBulkBody', () => { updated_at: fakeSignalSourceHit.signal.rule?.updated_at, throttle: 'no_actions', threat: [], - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }, }, }; @@ -343,38 +282,7 @@ describe('buildBulkBody', () => { created_at: fakeSignalSourceHit.signal.rule?.created_at, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, throttle: 'no_actions', - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }, }, }; @@ -461,38 +369,7 @@ describe('buildBulkBody', () => { updated_at: fakeSignalSourceHit.signal.rule?.updated_at, created_at: fakeSignalSourceHit.signal.rule?.created_at, throttle: 'no_actions', - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.test.ts index 07adfde71c1a99..ce7cc50e81d671 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.test.ts @@ -6,16 +6,24 @@ import { buildQueryExceptions, - buildExceptions, + buildExceptionItemEntries, operatorBuilder, buildExists, buildMatch, - buildMatchAll, + buildMatchAny, evaluateValues, formatQuery, getLanguageBooleanOperator, + buildNested, } from './build_exceptions_query'; -import { List } from '../../../../common/detection_engine/schemas/types/lists_default_array'; +import { + EntriesArray, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, +} from '../../../../../lists/common/schemas'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('build_exceptions_query', () => { describe('getLanguageBooleanOperator', () => { @@ -34,30 +42,30 @@ describe('build_exceptions_query', () => { describe('operatorBuilder', () => { describe('kuery', () => { - test('it returns "not " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); + test('it returns "not " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); - expect(operator).toEqual(' and '); + expect(operator).toEqual('not '); }); - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); - expect(operator).toEqual(' and not '); + expect(operator).toEqual(''); }); }); describe('lucene', () => { - test('it returns "NOT " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); + test('it returns "NOT " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); - expect(operator).toEqual(' AND '); + expect(operator).toEqual('NOT '); }); - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); - expect(operator).toEqual(' AND NOT '); + expect(operator).toEqual(''); }); }); }); @@ -65,161 +73,117 @@ describe('build_exceptions_query', () => { describe('buildExists', () => { describe('kuery', () => { test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ operator: 'excluded', field: 'host.name', language: 'kuery' }); + const query = buildExists({ + item: { type: 'exists', operator: 'excluded', field: 'host.name' }, + language: 'kuery', + }); - expect(query).toEqual(' and host.name:*'); + expect(query).toEqual('host.name:*'); }); test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ operator: 'included', field: 'host.name', language: 'kuery' }); + const query = buildExists({ + item: { type: 'exists', operator: 'included', field: 'host.name' }, + language: 'kuery', + }); - expect(query).toEqual(' and not host.name:*'); + expect(query).toEqual('not host.name:*'); }); }); describe('lucene', () => { test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ operator: 'excluded', field: 'host.name', language: 'lucene' }); + const query = buildExists({ + item: { type: 'exists', operator: 'excluded', field: 'host.name' }, + language: 'lucene', + }); - expect(query).toEqual(' AND _exists_host.name'); + expect(query).toEqual('_exists_host.name'); }); test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ operator: 'included', field: 'host.name', language: 'lucene' }); + const query = buildExists({ + item: { type: 'exists', operator: 'included', field: 'host.name' }, + language: 'lucene', + }); - expect(query).toEqual(' AND NOT _exists_host.name'); + expect(query).toEqual('NOT _exists_host.name'); }); }); }); describe('buildMatch', () => { describe('kuery', () => { - test('it returns empty string if no items in "values"', () => { - const query = buildMatch({ - operator: 'included', - field: 'host.name', - values: [], - language: 'kuery', - }); - - expect(query).toEqual(''); - }); - test('it returns formatted string when operator is "included"', () => { - const values = [ - { - name: 'suricata', - }, - ]; const query = buildMatch({ - operator: 'included', - field: 'host.name', - values, + item: { + type: 'match', + operator: 'included', + field: 'host.name', + value: 'suricata', + }, language: 'kuery', }); - expect(query).toEqual(' and not host.name:suricata'); + expect(query).toEqual('not host.name:suricata'); }); test('it returns formatted string when operator is "excluded"', () => { - const values = [ - { - name: 'suricata', - }, - ]; const query = buildMatch({ - operator: 'excluded', - field: 'host.name', - values, - language: 'kuery', - }); - - expect(query).toEqual(' and host.name:suricata'); - }); - - // TODO: need to clean up types and maybe restrict values to one if type is 'match' - test('it returns formatted string when "values" includes more than one item', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + item: { + type: 'match', + operator: 'excluded', + field: 'host.name', + value: 'suricata', }, - ]; - const query = buildMatch({ - operator: 'included', - field: 'host.name', - values, language: 'kuery', }); - expect(query).toEqual(' and not host.name:suricata'); + expect(query).toEqual('host.name:suricata'); }); }); describe('lucene', () => { test('it returns formatted string when operator is "included"', () => { - const values = [ - { - name: 'suricata', - }, - ]; const query = buildMatch({ - operator: 'included', - field: 'host.name', - values, + item: { + type: 'match', + operator: 'included', + field: 'host.name', + value: 'suricata', + }, language: 'lucene', }); - expect(query).toEqual(' AND NOT host.name:suricata'); + expect(query).toEqual('NOT host.name:suricata'); }); test('it returns formatted string when operator is "excluded"', () => { - const values = [ - { - name: 'suricata', - }, - ]; const query = buildMatch({ - operator: 'excluded', - field: 'host.name', - values, - language: 'lucene', - }); - - expect(query).toEqual(' AND host.name:suricata'); - }); - - // TODO: need to clean up types and maybe restrict values to one if type is 'match' - test('it returns formatted string when "values" includes more than one item', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + item: { + type: 'match', + operator: 'excluded', + field: 'host.name', + value: 'suricata', }, - ]; - const query = buildMatch({ - operator: 'included', - field: 'host.name', - values, language: 'lucene', }); - expect(query).toEqual(' AND NOT host.name:suricata'); + expect(query).toEqual('host.name:suricata'); }); }); }); - describe('buildMatchAll', () => { + describe('buildMatchAny', () => { describe('kuery', () => { test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAll({ - operator: 'included', - field: 'host.name', - values: [], + const exceptionSegment = buildMatchAny({ + item: { + operator: 'included', + field: 'host.name', + value: [], + type: 'match_any', + }, language: 'kuery', }); @@ -227,113 +191,180 @@ describe('build_exceptions_query', () => { }); test('it returns formatted string when "values" includes only one item', () => { - const values = [ - { - name: 'suricata', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'included', + field: 'host.name', + value: ['suricata'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'included', - field: 'host.name', - values, language: 'kuery', }); - expect(exceptionSegment).toEqual(' and not host.name:suricata'); + expect(exceptionSegment).toEqual('not host.name:(suricata)'); }); test('it returns formatted string when operator is "included"', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'included', + field: 'host.name', + value: ['suricata', 'auditd'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'included', - field: 'host.name', - values, language: 'kuery', }); - expect(exceptionSegment).toEqual(' and not host.name:(suricata or auditd)'); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); }); test('it returns formatted string when operator is "excluded"', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'excluded', + field: 'host.name', + value: ['suricata', 'auditd'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'excluded', - field: 'host.name', - values, language: 'kuery', }); - expect(exceptionSegment).toEqual(' and host.name:(suricata or auditd)'); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); }); describe('lucene', () => { test('it returns formatted string when operator is "included"', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'included', + field: 'host.name', + value: ['suricata', 'auditd'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'included', - field: 'host.name', - values, language: 'lucene', }); - expect(exceptionSegment).toEqual(' AND NOT host.name:(suricata OR auditd)'); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); }); test('it returns formatted string when operator is "excluded"', () => { - const values = [ - { - name: 'suricata', - }, - { - name: 'auditd', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'excluded', + field: 'host.name', + value: ['suricata', 'auditd'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'excluded', - field: 'host.name', - values, language: 'lucene', }); - expect(exceptionSegment).toEqual(' AND host.name:(suricata OR auditd)'); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); }); test('it returns formatted string when "values" includes only one item', () => { - const values = [ - { - name: 'suricata', + const exceptionSegment = buildMatchAny({ + item: { + operator: 'included', + field: 'host.name', + value: ['suricata'], + type: 'match_any', }, - ]; - const exceptionSegment = buildMatchAll({ - operator: 'included', - field: 'host.name', - values, language: 'lucene', }); - expect(exceptionSegment).toEqual(' AND NOT host.name:suricata'); + expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); + }); + }); + }); + + describe('buildNested', () => { + describe('kuery', () => { + test('it returns formatted query when one item in nested entry', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', + }, + ], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ nestedField:value-3 }'); + }); + + test('it returns formatted query when multiple items in nested entry', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', + }, + { + field: 'nestedFieldB', + operator: 'excluded', + type: 'match', + value: 'value-4', + }, + ], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ nestedField:value-3 and nestedFieldB:value-4 }'); + }); + }); + + // TODO: Does lucene support nested query syntax? + describe.skip('lucene', () => { + test('it returns formatted query when one item in nested entry', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', + }, + ], + }; + const result = buildNested({ item, language: 'lucene' }); + + expect(result).toEqual('parent:{ nestedField:value-3 }'); + }); + + test('it returns formatted query when multiple items in nested entry', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', + }, + { + field: 'nestedFieldB', + operator: 'excluded', + type: 'match', + value: 'value-4', + }, + ], + }; + const result = buildNested({ item, language: 'lucene' }); + + expect(result).toEqual('parent:{ nestedField:value-3 AND nestedFieldB:value-4 }'); }); }); }); @@ -341,110 +372,96 @@ describe('build_exceptions_query', () => { describe('evaluateValues', () => { describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: List = { - values_operator: 'included', - values_type: 'exists', + const list: EntryExists = { + operator: 'included', + type: 'exists', field: 'host.name', }; const result = evaluateValues({ - list, + item: list, language: 'kuery', }); - expect(result).toEqual(' and not host.name:*'); + expect(result).toEqual('not host.name:*'); }); test('it returns formatted string when "type" is "match"', () => { - const list: List = { - values_operator: 'included', - values_type: 'match', + const list: EntryMatch = { + operator: 'included', + type: 'match', field: 'host.name', - values: [{ name: 'suricata' }], + value: 'suricata', }; const result = evaluateValues({ - list, + item: list, language: 'kuery', }); - expect(result).toEqual(' and not host.name:suricata'); + expect(result).toEqual('not host.name:suricata'); }); - test('it returns formatted string when "type" is "match_all"', () => { - const list: List = { - values_operator: 'included', - values_type: 'match_all', + test('it returns formatted string when "type" is "match_any"', () => { + const list: EntryMatchAny = { + operator: 'included', + type: 'match_any', field: 'host.name', - values: [ - { - name: 'suricata', - }, - { - name: 'auditd', - }, - ], + value: ['suricata', 'auditd'], }; const result = evaluateValues({ - list, + item: list, language: 'kuery', }); - expect(result).toEqual(' and not host.name:(suricata or auditd)'); + expect(result).toEqual('not host.name:(suricata or auditd)'); }); }); describe('lucene', () => { describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: List = { - values_operator: 'included', - values_type: 'exists', + const list: EntryExists = { + operator: 'included', + type: 'exists', field: 'host.name', }; const result = evaluateValues({ - list, + item: list, language: 'lucene', }); - expect(result).toEqual(' AND NOT _exists_host.name'); + expect(result).toEqual('NOT _exists_host.name'); }); test('it returns formatted string when "type" is "match"', () => { - const list: List = { - values_operator: 'included', - values_type: 'match', + const list: EntryMatch = { + operator: 'included', + type: 'match', field: 'host.name', - values: [{ name: 'suricata' }], + value: 'suricata', }; const result = evaluateValues({ - list, + item: list, language: 'lucene', }); - expect(result).toEqual(' AND NOT host.name:suricata'); + expect(result).toEqual('NOT host.name:suricata'); }); - test('it returns formatted string when "type" is "match_all"', () => { - const list: List = { - values_operator: 'included', - values_type: 'match_all', + test('it returns formatted string when "type" is "match_any"', () => { + const list: EntryMatchAny = { + operator: 'included', + type: 'match_any', field: 'host.name', - values: [ - { - name: 'suricata', - }, - { - name: 'auditd', - }, - ], + value: ['suricata', 'auditd'], }; const result = evaluateValues({ - list, + item: list, language: 'lucene', }); - expect(result).toEqual(' AND NOT host.name:(suricata OR auditd)'); + expect(result).toEqual('NOT host.name:(suricata OR auditd)'); }); }); }); @@ -459,7 +476,7 @@ describe('build_exceptions_query', () => { test('it returns expected query string when single exception in array', () => { const formattedQuery = formatQuery({ - exceptions: [' and b:(value-1 or value-2) and not c:*'], + exceptions: ['b:(value-1 or value-2) and not c:*'], query: 'a:*', language: 'kuery', }); @@ -469,7 +486,7 @@ describe('build_exceptions_query', () => { test('it returns expected query string when multiple exceptions in array', () => { const formattedQuery = formatQuery({ - exceptions: [' and b:(value-1 or value-2) and not c:*', ' and not d:*'], + exceptions: ['b:(value-1 or value-2) and not c:*', 'not d:*'], query: 'a:*', language: 'kuery', }); @@ -480,149 +497,70 @@ describe('build_exceptions_query', () => { }); }); - describe('buildExceptions', () => { - test('it returns empty array if empty lists array passed in', () => { - const query = buildExceptions({ - query: 'a:*', + describe('buildExceptionItemEntries', () => { + test('it returns empty string if empty lists array passed in', () => { + const query = buildExceptionItemEntries({ language: 'kuery', lists: [], }); - expect(query).toEqual([]); + expect(query).toEqual(''); }); test('it returns expected query when more than one item in list', () => { // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const payload: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value-1', - }, - { - name: 'value-2', - }, - ], + operator: 'included', + type: 'match_any', + value: ['value-1', 'value-2'], }, { field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value-3', - }, - ], - }, - ]; - const query = buildExceptions({ - query: 'a:*', - language: 'kuery', - lists, - }); - const expectedQuery = [' and not b:(value-1 or value-2)', ' and c:value-3']; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list item includes nested "and" value', () => { - // Equal to query && !(b || !c) -> (query AND NOT b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ - { - field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value-1', - }, - { - name: 'value-2', - }, - ], - and: [ - { - field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value-3', - }, - ], - }, - ], + operator: 'excluded', + type: 'match', + value: 'value-3', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', - lists, + lists: payload, }); - const expectedQuery = [' and not b:(value-1 or value-2) and c:value-3']; + const expectedQuery = 'not b:(value-1 or value-2) and c:value-3'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list item includes nested "and" value of empty array', () => { + test('it returns expected query when list item includes nested value', () => { // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value-1', - }, - { - name: 'value-2', - }, - ], - and: [], + operator: 'included', + type: 'match_any', + value: ['value-1', 'value-2'], }, - ]; - const query = buildExceptions({ - query: 'a:*', - language: 'kuery', - lists, - }); - const expectedQuery = [' and not b:(value-1 or value-2)']; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list item includes nested "and" value of null', () => { - // Equal to query && !(b || !c) -> (query AND NOT b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ { - field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ + field: 'parent', + type: 'nested', + entries: [ { - name: 'value-1', - }, - { - name: 'value-2', + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', }, ], - and: undefined, }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:(value-1 or value-2)']; + const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; expect(query).toEqual(expectedQuery); }); @@ -630,130 +568,112 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes multiple items and nested "and" values', () => { // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value-1', - }, - { - name: 'value-2', - }, - ], - and: [ + operator: 'included', + type: 'match_any', + value: ['value-1', 'value-2'], + }, + { + field: 'parent', + type: 'nested', + entries: [ { - field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value-3', - }, - ], + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', }, ], }, { field: 'd', - values_operator: 'included', - values_type: 'exists', + operator: 'included', + type: 'exists', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:(value-1 or value-2) and c:value-3', ' and not d:*']; - + const expectedQuery = + 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when language is "lucene"', () => { // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value-1', - }, - { - name: 'value-2', - }, - ], - and: [ + operator: 'included', + type: 'match_any', + value: ['value-1', 'value-2'], + }, + { + field: 'parent', + type: 'nested', + entries: [ { - field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value-3', - }, - ], + field: 'nestedField', + operator: 'excluded', + type: 'match', + value: 'value-3', }, ], }, { field: 'e', - values_operator: 'excluded', - values_type: 'exists', + operator: 'excluded', + type: 'exists', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'lucene', lists, }); - const expectedQuery = [' AND NOT b:(value-1 OR value-2) AND c:value-3', ' AND _exists_e']; - + const expectedQuery = + 'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e'; expect(query).toEqual(expectedQuery); }); describe('exists', () => { - test('it returns expected query when list includes single list item with values_operator of "included"', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'exists', + operator: 'included', + type: 'exists', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:*']; + const expectedQuery = 'not b:*'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with values_operator of "excluded"', () => { + test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'exists', + operator: 'excluded', + type: 'exists', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:*']; + const expectedQuery = 'b:*'; expect(query).toEqual(expectedQuery); }); @@ -761,26 +681,30 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes list item with "and" values', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'exists', - and: [ + operator: 'excluded', + type: 'exists', + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'exists', + operator: 'excluded', + type: 'match', + value: 'value-1', }, ], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:* and c:*']; + const expectedQuery = 'b:* and parent:{ c:value-1 }'; expect(query).toEqual(expectedQuery); }); @@ -788,88 +712,83 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes multiple items', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'exists', - and: [ + operator: 'included', + type: 'exists', + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'exists', + operator: 'excluded', + type: 'match', + value: 'value-1', }, { field: 'd', - values_operator: 'included', - values_type: 'exists', + operator: 'included', + type: 'match', + value: 'value-2', }, ], }, { field: 'e', - values_operator: 'included', - values_type: 'exists', + operator: 'included', + type: 'exists', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:* and c:* and not d:*', ' and not e:*']; + const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*'; expect(query).toEqual(expectedQuery); }); }); describe('match', () => { - test('it returns expected query when list includes single list item with values_operator of "included"', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match', - values: [ - { - name: 'value', - }, - ], + operator: 'included', + type: 'match', + value: 'value', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:value']; + const expectedQuery = 'not b:value'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with values_operator of "excluded"', () => { + test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value', - }, - ], + operator: 'excluded', + type: 'match', + value: 'value', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:value']; + const expectedQuery = 'b:value'; expect(query).toEqual(expectedQuery); }); @@ -877,36 +796,31 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes list item with "and" values', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'value', - }, - ], - and: [ + operator: 'excluded', + type: 'match', + value: 'value', + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'valueC', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, ], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:value and c:valueC']; + const expectedQuery = 'b:value and parent:{ c:valueC }'; expect(query).toEqual(expectedQuery); }); @@ -914,160 +828,117 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes multiple items', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match', - values: [ - { - name: 'value', - }, - ], - and: [ + operator: 'included', + type: 'match', + value: 'value', + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'valueC', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, { field: 'd', - values_operator: 'included', - values_type: 'match', - values: [ - { - name: 'valueC', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, ], }, { field: 'e', - values_operator: 'included', - values_type: 'match', - values: [ - { - name: 'valueC', - }, - ], + operator: 'included', + type: 'match', + value: 'valueC', }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [ - ' and not b:value and c:valueC and not d:valueC', - ' and not e:valueC', - ]; + const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueC } and not e:valueC'; expect(query).toEqual(expectedQuery); }); }); - describe('match_all', () => { - test('it returns expected query when list includes single list item with values_operator of "included"', () => { + describe('match_any', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], + operator: 'included', + type: 'match_any', + value: ['value', 'value-1'], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and not b:(value or value-1)']; + const expectedQuery = 'not b:(value or value-1)'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with values_operator of "excluded"', () => { + test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], + operator: 'excluded', + type: 'match_any', + value: ['value', 'value-1'], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:(value or value-1)']; + const expectedQuery = 'b:(value or value-1)'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes list item with "and" values', () => { + test('it returns expected query when list includes list item with nested values', () => { // Equal to query && !(!b || c) -> (query AND b AND NOT c) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'excluded', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], - and: [ + operator: 'excluded', + type: 'match_any', + value: ['value', 'value-1'], + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueC', - }, - { - name: 'value-2', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, ], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [' and b:(value or value-1) and not c:(valueC or value-2)']; + const expectedQuery = 'b:(value or value-1) and parent:{ c:valueC }'; expect(query).toEqual(expectedQuery); }); @@ -1075,71 +946,25 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes multiple items', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const lists: EntriesArray = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], - and: [ - { - field: 'c', - values_operator: 'excluded', - values_type: 'match_all', - values: [ - { - name: 'valueC', - }, - { - name: 'value-2', - }, - ], - }, - { - field: 'd', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueD', - }, - { - name: 'value-3', - }, - ], - }, - ], + operator: 'included', + type: 'match_any', + value: ['value', 'value-1'], }, { field: 'e', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueE', - }, - { - name: 'value-4', - }, - ], + operator: 'included', + type: 'match_any', + value: ['valueE', 'value-4'], }, ]; - const query = buildExceptions({ - query: 'a:*', + const query = buildExceptionItemEntries({ language: 'kuery', lists, }); - const expectedQuery = [ - ' and not b:(value or value-1) and c:(valueC or value-2) and not d:(valueD or value-3)', - ' and not e:(valueE or value-4)', - ]; + const expectedQuery = 'not b:(value or value-1) and not e:(valueE or value-4)'; expect(query).toEqual(expectedQuery); }); @@ -1157,65 +982,47 @@ describe('build_exceptions_query', () => { test('it returns expected query when lists exist and language is "kuery"', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], - and: [ + operator: 'included', + type: 'match_any', + value: ['value', 'value-1'], + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'match_all', - values: [ - { - name: 'valueC', - }, - { - name: 'value-2', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, { field: 'd', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueD', - }, - { - name: 'value-3', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueD', }, ], }, { field: 'e', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueE', - }, - { - name: 'value-4', - }, - ], + operator: 'included', + type: 'match_any', + value: ['valueE', 'value-4'], }, ]; - const query = buildQueryExceptions({ query: 'a:*', language: 'kuery', lists }); + const query = buildQueryExceptions({ + query: 'a:*', + language: 'kuery', + lists: [payload, payload2], + }); const expectedQuery = - '(a:* and not b:(value or value-1) and c:(valueC or value-2) and not d:(valueD or value-3)) or (a:* and not e:(valueE or value-4))'; + '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value or value-1) and parent:{ c:valueC and d:valueD } and not e:(valueE or value-4))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); @@ -1223,65 +1030,47 @@ describe('build_exceptions_query', () => { test('it returns expected query when lists exist and language is "lucene"', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator - const lists: List[] = [ + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ { field: 'b', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'value', - }, - { - name: 'value-1', - }, - ], - and: [ + operator: 'included', + type: 'match_any', + value: ['value', 'value-1'], + }, + { + field: 'parent', + type: 'nested', + entries: [ { field: 'c', - values_operator: 'excluded', - values_type: 'match_all', - values: [ - { - name: 'valueC', - }, - { - name: 'value-2', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueC', }, { field: 'd', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueD', - }, - { - name: 'value-3', - }, - ], + operator: 'excluded', + type: 'match', + value: 'valueD', }, ], }, { field: 'e', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: 'valueE', - }, - { - name: 'value-4', - }, - ], + operator: 'included', + type: 'match_any', + value: ['valueE', 'value-4'], }, ]; - const query = buildQueryExceptions({ query: 'a:*', language: 'lucene', lists }); + const query = buildQueryExceptions({ + query: 'a:*', + language: 'lucene', + lists: [payload, payload2], + }); const expectedQuery = - '(a:* AND NOT b:(value OR value-1) AND c:(valueC OR value-2) AND NOT d:(valueD OR value-3)) OR (a:* AND NOT e:(valueE OR value-4))'; + '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value OR value-1) AND parent:{ c:valueC AND d:valueD } AND NOT e:(valueE OR value-4))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.ts index 233b20792299b1..ba0d9dec7d1b02 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_exceptions_query.ts @@ -3,17 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - ListAndOrUndefined, - Language, - Query, -} from '../../../../common/detection_engine/schemas/common/schemas'; -import { - ListOperator, - ListValues, - List, -} from '../../../../common/detection_engine/schemas/types/lists_default_array'; +import { Language, Query } from '../../../../common/detection_engine/schemas/common/schemas'; import { Query as DataQuery } from '../../../../../../../src/plugins/data/server'; +import { + Entry, + ExceptionListItemSchema, + EntryMatch, + EntryMatchAny, + EntryNested, + EntryExists, + EntriesArray, + Operator, + entriesMatchAny, + entriesExists, + entriesMatch, + entriesNested, + entriesList, +} from '../../../../../lists/common/schemas'; type Operators = 'and' | 'or' | 'not'; type LuceneOperators = 'AND' | 'OR' | 'NOT'; @@ -41,37 +47,30 @@ export const operatorBuilder = ({ operator, language, }: { - operator: ListOperator; + operator: Operator; language: Language; }): string => { - const and = getLanguageBooleanOperator({ - language, - value: 'and', - }); - const or = getLanguageBooleanOperator({ + const not = getLanguageBooleanOperator({ language, value: 'not', }); switch (operator) { - case 'excluded': - return ` ${and} `; case 'included': - return ` ${and} ${or} `; + return `${not} `; default: return ''; } }; export const buildExists = ({ - operator, - field, + item, language, }: { - operator: ListOperator; - field: string; + item: EntryExists; language: Language; }): string => { + const { operator, field } = item; const exceptionOperator = operatorBuilder({ operator, language }); switch (language) { @@ -85,64 +84,70 @@ export const buildExists = ({ }; export const buildMatch = ({ - operator, - field, - values, + item, language, }: { - operator: ListOperator; - field: string; - values: ListValues[]; + item: EntryMatch; language: Language; }): string => { - if (values.length > 0) { - const exceptionOperator = operatorBuilder({ operator, language }); - const [exception] = values; + const { value, operator, field } = item; + const exceptionOperator = operatorBuilder({ operator, language }); - return `${exceptionOperator}${field}:${exception.name}`; - } else { - return ''; - } + return `${exceptionOperator}${field}:${value}`; }; -export const buildMatchAll = ({ - operator, - field, - values, +export const buildMatchAny = ({ + item, language, }: { - operator: ListOperator; - field: string; - values: ListValues[]; + item: EntryMatchAny; language: Language; }): string => { - switch (values.length) { + const { value, operator, field } = item; + + switch (value.length) { case 0: return ''; - case 1: - return buildMatch({ operator, field, values, language }); default: const or = getLanguageBooleanOperator({ language, value: 'or' }); const exceptionOperator = operatorBuilder({ operator, language }); - const matchAllValues = values.map((value) => { - return value.name; - }); + const matchAnyValues = value.map((v) => v); - return `${exceptionOperator}${field}:(${matchAllValues.join(` ${or} `)})`; + return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; } }; -export const evaluateValues = ({ list, language }: { list: List; language: Language }): string => { - const { values_operator: operator, values_type: type, field, values } = list; - switch (type) { - case 'exists': - return buildExists({ operator, field, language }); - case 'match': - return buildMatch({ operator, field, values: values ?? [], language }); - case 'match_all': - return buildMatchAll({ operator, field, values: values ?? [], language }); - default: - return ''; +export const buildNested = ({ + item, + language, +}: { + item: EntryNested; + language: Language; +}): string => { + const { field, entries } = item; + const and = getLanguageBooleanOperator({ language, value: 'and' }); + const values = entries.map((entry) => `${entry.field}:${entry.value}`); + + return `${field}:{ ${values.join(` ${and} `)} }`; +}; + +export const evaluateValues = ({ + item, + language, +}: { + item: Entry | EntryNested; + language: Language; +}): string => { + if (entriesExists.is(item)) { + return buildExists({ item, language }); + } else if (entriesMatch.is(item)) { + return buildMatch({ item, language }); + } else if (entriesMatchAny.is(item)) { + return buildMatchAny({ item, language }); + } else if (entriesNested.is(item)) { + return buildNested({ item, language }); + } else { + return ''; } }; @@ -157,8 +162,9 @@ export const formatQuery = ({ }): string => { if (exceptions.length > 0) { const or = getLanguageBooleanOperator({ language, value: 'or' }); + const and = getLanguageBooleanOperator({ language, value: 'and' }); const formattedExceptions = exceptions.map((exception) => { - return `(${query}${exception})`; + return `(${query} ${and} ${exception})`; }); return formattedExceptions.join(` ${or} `); @@ -167,23 +173,22 @@ export const formatQuery = ({ } }; -export const buildExceptions = ({ - query, +export const buildExceptionItemEntries = ({ lists, language, }: { - query: string; - lists: List[]; + lists: EntriesArray; language: Language; -}): string[] => { - return lists.reduce((accum, listItem) => { - const { and, ...exceptionDetails } = { ...listItem }; - const andExceptionsSegments = and ? buildExceptions({ query, lists: and, language }) : []; - const exceptionSegment = evaluateValues({ list: exceptionDetails, language }); - const exception = [...exceptionSegment, ...andExceptionsSegments]; - - return [...accum, exception.join('')]; - }, []); +}): string => { + const and = getLanguageBooleanOperator({ language, value: 'and' }); + const exceptionItem = lists + .filter((t) => !entriesList.is(t)) + .reduce((accum, listItem) => { + const exceptionSegment = evaluateValues({ item: listItem, language }); + return [...accum, exceptionSegment]; + }, []); + + return exceptionItem.join(` ${and} `); }; export const buildQueryExceptions = ({ @@ -193,12 +198,13 @@ export const buildQueryExceptions = ({ }: { query: Query; language: Language; - lists: ListAndOrUndefined; + lists: ExceptionListItemSchema[] | undefined; }): DataQuery[] => { if (lists && lists !== null) { - const exceptions = buildExceptions({ lists, language, query }); + const exceptions = lists.map((exceptionItem) => + buildExceptionItemEntries({ lists: exceptionItem.entries, language }) + ); const formattedQuery = formatQuery({ exceptions, language, query }); - return [ { query: formattedQuery, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts index eb87976a6fbab9..9aef5a370b86a4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts @@ -7,6 +7,7 @@ import { buildRule } from './build_rule'; import { sampleRuleAlertParams, sampleRuleGuid } from './__mocks__/es_results'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; +import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; describe('buildRule', () => { beforeEach(() => { @@ -80,38 +81,7 @@ describe('buildRule', () => { query: 'host.name: Braden', }, ], - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), version: 1, }; expect(rule).toEqual(expected); @@ -164,38 +134,7 @@ describe('buildRule', () => { updated_at: rule.updated_at, created_at: rule.created_at, throttle: 'no_actions', - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }; expect(rule).toEqual(expected); }); @@ -247,38 +186,7 @@ describe('buildRule', () => { updated_at: rule.updated_at, created_at: rule.created_at, throttle: 'no_actions', - exceptions_list: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'exists', - }, - { - field: 'host.name', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'rock01', - }, - ], - and: [ - { - field: 'host.id', - values_operator: 'included', - values_type: 'match_all', - values: [ - { - name: '123', - }, - { - name: '678', - }, - ], - }, - ], - }, - ], + exceptions_list: getListArrayMock(), }; expect(rule).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts index 4e9eb8587484fa..bb56926390af99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts @@ -8,6 +8,7 @@ import uuid from 'uuid'; import { filterEventsAgainstList } from './filter_events_with_list'; import { mockLogger, repeatedSearchResultsWithSortId } from './__mocks__/es_results'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getListItemResponseMock } from '../../../../../lists/common/schemas/response/list_item_schema.mock'; import { listMock } from '../../../../../lists/server/mocks'; @@ -36,92 +37,42 @@ describe('filterEventsAgainstList', () => { expect(res.hits.hits.length).toEqual(4); }); - it('should throw an error if malformed exception list present', async () => { - let message = ''; - try { - await filterEventsAgainstList({ - logger: mockLogger, - listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'excluded', - values_type: 'list', - values: undefined, + describe('operator_type is included', () => { + it('should respond with same list if no items match value list', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', }, - ], - eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', - '7.7.7.7', - ]), - }); - } catch (exc) { - message = exc.message; - } - expect(message).toEqual( - 'Failed to query lists index. Reason: Malformed exception list provided' - ); - }); + }, + ]; - it('should throw an error if unsupported exception type', async () => { - let message = ''; - try { - await filterEventsAgainstList({ - logger: mockLogger, - listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'excluded', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'unsupportedListPluginType', - }, - ], - }, - ], - eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', - '7.7.7.7', - ]), - }); - } catch (exc) { - message = exc.message; - } - expect(message).toEqual( - 'Failed to query lists index. Reason: Unsupported list type used, please use one of ip,keyword' - ); - }); - - describe('operator_type is includes', () => { - it('should respond with same list if no items match value list', async () => { const res = await filterEventsAgainstList({ logger: mockLogger, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), }); expect(res.hits.hits.length).toEqual(4); }); it('should respond with less items in the list if some values match', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; listClient.getListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ @@ -133,19 +84,7 @@ describe('filterEventsAgainstList', () => { const res = await filterEventsAgainstList({ logger: mockLogger, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', '2.2.2.2', @@ -162,27 +101,39 @@ describe('filterEventsAgainstList', () => { }); describe('operator type is excluded', () => { it('should respond with empty list if no items match value list', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; const res = await filterEventsAgainstList({ logger: mockLogger, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'excluded', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), }); expect(res.hits.hits.length).toEqual(0); }); it('should respond with less items in the list if some values match', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; listClient.getListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ @@ -194,19 +145,7 @@ describe('filterEventsAgainstList', () => { const res = await filterEventsAgainstList({ logger: mockLogger, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'excluded', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', '2.2.2.2', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts index 48b120d1b5806c..1a2f648eb85625 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -6,15 +6,17 @@ import { get } from 'lodash/fp'; import { Logger } from 'src/core/server'; -import { ListAndOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas'; -import { List } from '../../../../common/detection_engine/schemas/types/lists_default_array'; -import { type } from '../../../../../lists/common/schemas/common'; import { ListClient } from '../../../../../lists/server'; import { SignalSearchResponse, SearchTypes } from './types'; +import { + entriesList, + EntryList, + ExceptionListItemSchema, +} from '../../../../../lists/common/schemas'; interface FilterEventsAgainstList { listClient: ListClient; - exceptionsList: ListAndOrUndefined; + exceptionsList: ExceptionListItemSchema[]; logger: Logger; eventSearchResult: SignalSearchResponse; } @@ -34,63 +36,63 @@ export const filterEventsAgainstList = async ({ const isStringableType = (val: SearchTypes) => ['string', 'number', 'boolean'].includes(typeof val); // grab the signals with values found in the given exception lists. - const filteredHitsPromises = exceptionsList - .filter((exceptionItem: List) => exceptionItem.values_type === 'list') - .map(async (exceptionItem: List) => { - if (exceptionItem.values == null || exceptionItem.values.length === 0) { - throw new Error('Malformed exception list provided'); - } - if (!type.is(exceptionItem.values[0].name)) { - throw new Error( - `Unsupported list type used, please use one of ${Object.keys(type.keys).join()}` - ); - } - if (!exceptionItem.values[0].id) { - throw new Error(`Missing list id for exception on field ${exceptionItem.field}`); - } - // acquire the list values we are checking for. - const valuesOfGivenType = eventSearchResult.hits.hits.reduce((acc, searchResultItem) => { - const valueField = get(exceptionItem.field, searchResultItem._source); - if (valueField != null && isStringableType(valueField)) { - acc.add(valueField.toString()); - } - return acc; - }, new Set()); + const filteredHitsPromises = exceptionsList.map( + async (exceptionItem: ExceptionListItemSchema) => { + const { entries } = exceptionItem; - // matched will contain any list items that matched with the - // values passed in from the Set. - const matchedListItems = await listClient.getListItemByValues({ - listId: exceptionItem.values[0].id, - type: exceptionItem.values[0].name, - value: [...valuesOfGivenType], - }); + const filteredHitsEntries = entries + .filter((t): t is EntryList => entriesList.is(t)) + .map(async (entry) => { + // acquire the list values we are checking for. + const valuesOfGivenType = eventSearchResult.hits.hits.reduce( + (acc, searchResultItem) => { + const valueField = get(entry.field, searchResultItem._source); + if (valueField != null && isStringableType(valueField)) { + acc.add(valueField.toString()); + } + return acc; + }, + new Set() + ); - // create a set of list values that were a hit - easier to work with - const matchedListItemsSet = new Set( - matchedListItems.map((item) => item.value) - ); + // matched will contain any list items that matched with the + // values passed in from the Set. + const matchedListItems = await listClient.getListItemByValues({ + listId: entry.list.id, + type: entry.list.type, + value: [...valuesOfGivenType], + }); - // do a single search after with these values. - // painless script to do nested query in elasticsearch - // filter out the search results that match with the values found in the list. - const operator = exceptionItem.values_operator; - const filteredEvents = eventSearchResult.hits.hits.filter((item) => { - const eventItem = get(exceptionItem.field, item._source); - if (operator === 'included') { - if (eventItem != null) { - return !matchedListItemsSet.has(eventItem); - } - } else if (operator === 'excluded') { - if (eventItem != null) { - return matchedListItemsSet.has(eventItem); - } - } - return false; - }); - const diff = eventSearchResult.hits.hits.length - filteredEvents.length; - logger.debug(`Lists filtered out ${diff} events`); - return filteredEvents; - }); + // create a set of list values that were a hit - easier to work with + const matchedListItemsSet = new Set( + matchedListItems.map((item) => item.value) + ); + + // do a single search after with these values. + // painless script to do nested query in elasticsearch + // filter out the search results that match with the values found in the list. + const operator = entry.operator; + const filteredEvents = eventSearchResult.hits.hits.filter((item) => { + const eventItem = get(entry.field, item._source); + if (operator === 'included') { + if (eventItem != null) { + return !matchedListItemsSet.has(eventItem); + } + } else if (operator === 'excluded') { + if (eventItem != null) { + return matchedListItemsSet.has(eventItem); + } + } + return false; + }); + const diff = eventSearchResult.hits.hits.length - filteredEvents.length; + logger.debug(`Lists filtered out ${diff} events`); + return filteredEvents; + }); + + return (await Promise.all(filteredHitsEntries)).flat(); + } + ); const filteredHits = await Promise.all(filteredHitsPromises); const toReturn: SignalSearchResponse = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts index 61cd9cfedd94f1..9b3a446bc666dc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts @@ -7,6 +7,7 @@ import { getQueryFilter, getFilter } from './get_filter'; import { PartialFilter } from '../types'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('get_filter', () => { let servicesMock: AlertServicesMock; @@ -381,18 +382,7 @@ describe('get_filter', () => { 'kuery', [], ['auditbeat-*'], - [ - { - field: 'event.module', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'suricata', - }, - ], - }, - ] + [getExceptionListItemSchemaMock()] ); expect(esQuery).toEqual({ bool: { @@ -414,11 +404,39 @@ describe('get_filter', () => { }, { bool: { - minimum_should_match: 1, - should: [ + filter: [ { - match: { - 'event.module': 'suricata', + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, }, }, ], @@ -450,7 +468,7 @@ describe('get_filter', () => { }); test('it should work when lists has value undefined', () => { - const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], undefined); + const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], []); expect(esQuery).toEqual({ bool: { filter: [ @@ -529,7 +547,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: ['auditbeat-*'], - lists: undefined, + lists: [], }); expect(filter).toEqual({ bool: { @@ -564,7 +582,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: ['auditbeat-*'], - lists: undefined, + lists: [], }) ).rejects.toThrow('query, filters, and index parameter should be defined'); }); @@ -579,7 +597,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: ['auditbeat-*'], - lists: undefined, + lists: [], }) ).rejects.toThrow('query, filters, and index parameter should be defined'); }); @@ -594,7 +612,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: undefined, - lists: undefined, + lists: [], }) ).rejects.toThrow('query, filters, and index parameter should be defined'); }); @@ -608,7 +626,7 @@ describe('get_filter', () => { savedId: 'some-id', services: servicesMock, index: ['auditbeat-*'], - lists: undefined, + lists: [], }); expect(filter).toEqual({ bool: { @@ -632,7 +650,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: ['auditbeat-*'], - lists: undefined, + lists: [], }) ).rejects.toThrow('savedId parameter should be defined'); }); @@ -647,7 +665,7 @@ describe('get_filter', () => { savedId: 'some-id', services: servicesMock, index: undefined, - lists: undefined, + lists: [], }) ).rejects.toThrow('savedId parameter should be defined'); }); @@ -662,7 +680,7 @@ describe('get_filter', () => { savedId: 'some-id', services: servicesMock, index: undefined, - lists: undefined, + lists: [], }) ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); }); @@ -812,18 +830,7 @@ describe('get_filter', () => { savedId: undefined, services: servicesMock, index: ['auditbeat-*'], - lists: [ - { - field: 'event.module', - values_operator: 'excluded', - values_type: 'match', - values: [ - { - name: 'suricata', - }, - ], - }, - ], + lists: [getExceptionListItemSchemaMock()], }); expect(filter).toEqual({ bool: { @@ -845,11 +852,39 @@ describe('get_filter', () => { }, { bool: { - minimum_should_match: 1, - should: [ + filter: [ { - match: { - 'event.module': 'suricata', + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts index 3e9f79c67d8ca8..50ce01aaa6f74f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts @@ -10,11 +10,11 @@ import { Type, SavedIdOrUndefined, IndexOrUndefined, - ListAndOrUndefined, Language, Index, Query, } from '../../../../common/detection_engine/schemas/common/schemas'; +import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { AlertServices } from '../../../../../alerts/server'; import { assertUnreachable } from '../../../utils/build_query'; import { @@ -33,7 +33,7 @@ export const getQueryFilter = ( language: Language, filters: PartialFilter[], index: Index, - lists: ListAndOrUndefined + lists: ExceptionListItemSchema[] ) => { const indexPattern = { fields: [], @@ -64,7 +64,7 @@ interface GetFilterArgs { savedId: SavedIdOrUndefined; services: AlertServices; index: IndexOrUndefined; - lists: ListAndOrUndefined; + lists: ExceptionListItemSchema[]; } interface QueryAttributes { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 163ed76d0c6c3d..1923f43c47b92a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -17,6 +17,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mock import uuid from 'uuid'; import { getListItemResponseMock } from '../../../../../lists/common/schemas/response/list_item_schema.mock'; import { listMock } from '../../../../../lists/server/mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('searchAfterAndBulkCreate', () => { let mockService: AlertServicesMock; @@ -94,22 +95,23 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -168,22 +170,22 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -254,7 +256,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, listClient, - exceptionsList: undefined, + exceptionsList: [], services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -281,25 +283,25 @@ describe('searchAfterAndBulkCreate', () => { }); test('if unsuccessful first bulk create', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockRejectedValue(new Error('bulk failed')); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -327,6 +329,18 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with 0 total hits', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); listClient.getListItemByValues = jest.fn(({ value }) => @@ -339,19 +353,7 @@ describe('searchAfterAndBulkCreate', () => { ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -405,21 +407,21 @@ describe('searchAfterAndBulkCreate', () => { })) ) ); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, - exceptionsList: [ - { - field: 'source.ip', - values_operator: 'included', - values_type: 'list', - values: [ - { - id: 'ci-badguys.txt', - name: 'ip', - }, - ], - }, - ], + exceptionsList: [exceptionItem], ruleParams: sampleParams, services: mockService, logger: mockLogger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 65679dc23e64ff..74752571215521 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ListAndOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas'; import { AlertServices } from '../../../../../alerts/server'; import { ListClient } from '../../../../../lists/server'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; @@ -14,12 +13,13 @@ import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; import { SignalSearchResponse } from './types'; import { filterEventsAgainstList } from './filter_events_with_list'; +import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; interface SearchAfterAndBulkCreateParams { ruleParams: RuleTypeParams; services: AlertServices; listClient: ListClient | undefined; // TODO: undefined is for temporary development, remove before merged - exceptionsList: ListAndOrUndefined; + exceptionsList: ExceptionListItemSchema[]; logger: Logger; id: string; inputIndexPattern: string[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index a2dc33ba1c2bf3..5832b4075a40b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -5,12 +5,12 @@ */ import moment from 'moment'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; -import { getGapBetweenRuns } from './utils'; +import { getGapBetweenRuns, getListsClient, getExceptions, sortExceptionItems } from './utils'; import { RuleExecutorOptions } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; @@ -18,6 +18,9 @@ import { RuleAlertType } from '../rules/types'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; import { listMock } from '../../../../../lists/server/mocks'; +import { getListClientMock } from '../../../../../lists/server/services/lists/list_client.mock'; +import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -69,13 +72,13 @@ describe('rules_notification_alert_type', () => { }; let payload: jest.Mocked; let alert: ReturnType; - let logger: ReturnType; + let logger: ReturnType; let alertServices: AlertServicesMock; let ruleStatusService: Record; beforeEach(() => { alertServices = alertsMock.createAlertServices(); - logger = loggingServiceMock.createLogger(); + logger = loggingSystemMock.createLogger(); ruleStatusService = { success: jest.fn(), find: jest.fn(), @@ -84,6 +87,15 @@ describe('rules_notification_alert_type', () => { }; (ruleStatusServiceFactory as jest.Mock).mockReturnValue(ruleStatusService); (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(0)); + (getListsClient as jest.Mock).mockReturnValue({ + listClient: getListClientMock(), + exceptionsClient: getExceptionListClientMock(), + }); + (getExceptions as jest.Mock).mockReturnValue([getExceptionListItemSchemaMock()]); + (sortExceptionItems as jest.Mock).mockReturnValue({ + exceptionsWithoutValueLists: [getExceptionListItemSchemaMock()], + exceptionsWithValueLists: [], + }); (searchAfterAndBulkCreate as jest.Mock).mockClear(); (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({ success: true, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 728bd66b7d65c6..1bf27dc6e26b21 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -15,9 +15,6 @@ import { } from '../../../../common/constants'; import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; import { SetupPlugins } from '../../../plugin'; - -import { ListClient } from '../../../../../lists/server'; - import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate, @@ -25,7 +22,7 @@ import { } from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; -import { getGapBetweenRuns, parseScheduleDates } from './utils'; +import { getGapBetweenRuns, parseScheduleDates, getListsClient, getExceptions } from './utils'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; import { findMlSignals } from './find_ml_signals'; @@ -38,7 +35,6 @@ import { ruleStatusServiceFactory } from './rule_status_service'; import { buildRuleMessageFactory } from './rule_messages'; import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects_client'; import { getNotificationResultsLink } from '../notifications/utils'; -import { hasListsFeature } from '../feature_flags'; export const signalRulesAlertType = ({ logger, @@ -140,6 +136,18 @@ export const signalRulesAlertType = ({ await ruleStatusService.error(gapMessage, { gap: gapString }); } try { + const { listClient, exceptionsClient } = await getListsClient({ + services, + updatedByUser, + spaceId, + lists, + savedObjectClient: services.savedObjectsClient, + }); + const exceptionItems = await getExceptions({ + client: exceptionsClient, + lists: exceptionsList, + }); + if (isMlRule(type)) { if (ml == null) { throw new Error('ML plugin unavailable during rule execution'); @@ -214,18 +222,6 @@ export const signalRulesAlertType = ({ result.bulkCreateTimes.push(bulkCreateDuration); } } else { - let listClient: ListClient | undefined; - if (hasListsFeature()) { - if (lists == null) { - throw new Error('lists plugin unavailable during rule execution'); - } - listClient = await lists.getListClient( - services.callCluster, - spaceId, - updatedByUser ?? 'elastic' - ); - } - const inputIndex = await getInputIndex(services, version, index); const esFilter = await getFilter({ type, @@ -235,13 +231,12 @@ export const signalRulesAlertType = ({ savedId, services, index: inputIndex, - // temporary filter out list type - lists: exceptionsList?.filter((item) => item.values_type !== 'list'), + lists: exceptionItems ?? [], }); result = await searchAfterAndBulkCreate({ listClient, - exceptionsList, + exceptionsList: exceptionItems ?? [], ruleParams: params, services, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index f74694df613ce6..24c2d24ee972ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -7,6 +7,12 @@ import moment from 'moment'; import sinon from 'sinon'; +import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; +import { listMock } from '../../../../../lists/server/mocks'; +import { EntriesArray } from '../../../../common/detection_engine/lists_common_deps'; + +import * as featureFlags from '../feature_flags'; + import { generateId, parseInterval, @@ -14,10 +20,10 @@ import { getDriftTolerance, getGapBetweenRuns, errorAggregator, + getListsClient, + hasLargeValueList, } from './utils'; - import { BulkResponseErrorAggregation } from './types'; - import { sampleBulkResponse, sampleEmptyBulkResponse, @@ -529,4 +535,107 @@ describe('utils', () => { expect(aggregated).toEqual(expected); }); }); + + describe('#getListsClient', () => { + let alertServices: AlertServicesMock; + + beforeEach(() => { + alertServices = alertsMock.createAlertServices(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('it successfully returns list and exceptions list client', async () => { + jest.spyOn(featureFlags, 'hasListsFeature').mockReturnValue(true); + + const { listClient, exceptionsClient } = await getListsClient({ + services: alertServices, + savedObjectClient: alertServices.savedObjectsClient, + updatedByUser: 'some_user', + spaceId: '', + lists: listMock.createSetup(), + }); + + expect(listClient).toBeDefined(); + expect(exceptionsClient).toBeDefined(); + }); + + test('it returns list and exceptions client of "undefined" if lists feature flag is off', async () => { + jest.spyOn(featureFlags, 'hasListsFeature').mockReturnValue(false); + + const listsClient = await getListsClient({ + services: alertServices, + savedObjectClient: alertServices.savedObjectsClient, + updatedByUser: 'some_user', + spaceId: '', + lists: listMock.createSetup(), + }); + + expect(listsClient).toEqual({ listClient: undefined, exceptionsClient: undefined }); + }); + + test('it throws if "lists" is undefined', async () => { + jest.spyOn(featureFlags, 'hasListsFeature').mockReturnValue(true); + + await expect(() => + getListsClient({ + services: alertServices, + savedObjectClient: alertServices.savedObjectsClient, + updatedByUser: 'some_user', + spaceId: '', + lists: undefined, + }) + ).rejects.toThrowError('lists plugin unavailable during rule execution'); + }); + }); + + describe('#hasLargeValueList', () => { + test('it returns false if empty array', () => { + const hasLists = hasLargeValueList([]); + + expect(hasLists).toBeFalsy(); + }); + + test('it returns true if item of type EntryList exists', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'list', + operator: 'included', + list: { id: 'some id', type: 'ip' }, + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasLargeValueList(entries); + + expect(hasLists).toBeTruthy(); + }); + + test('it returns false if item of type EntryList does not exist', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: 'included', + value: 'Elastic, N.V.', + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasLargeValueList(entries); + + expect(hasLists).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index f0ca08b73fac6c..e431e65fad623c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -7,9 +7,125 @@ import { createHash } from 'crypto'; import moment from 'moment'; import dateMath from '@elastic/datemath'; -import { parseDuration } from '../../../../../alerts/server'; +import { SavedObjectsClientContract } from '../../../../../../../src/core/server'; +import { AlertServices, parseDuration } from '../../../../../alerts/server'; +import { ExceptionListClient, ListClient, ListPluginSetup } from '../../../../../lists/server'; +import { EntriesArray, ExceptionListItemSchema } from '../../../../../lists/common/schemas'; +import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; +import { hasListsFeature } from '../feature_flags'; import { BulkResponse, BulkResponseErrorAggregation } from './types'; +interface SortExceptionsReturn { + exceptionsWithValueLists: ExceptionListItemSchema[]; + exceptionsWithoutValueLists: ExceptionListItemSchema[]; +} + +export const getListsClient = async ({ + lists, + spaceId, + updatedByUser, + services, + savedObjectClient, +}: { + lists: ListPluginSetup | undefined; + spaceId: string; + updatedByUser: string | null; + services: AlertServices; + savedObjectClient: SavedObjectsClientContract; +}): Promise<{ + listClient: ListClient | undefined; + exceptionsClient: ExceptionListClient | undefined; +}> => { + // TODO Remove check once feature is no longer behind flag + if (hasListsFeature()) { + if (lists == null) { + throw new Error('lists plugin unavailable during rule execution'); + } + + const listClient = await lists.getListClient( + services.callCluster, + spaceId, + updatedByUser ?? 'elastic' + ); + const exceptionsClient = await lists.getExceptionListClient( + savedObjectClient, + updatedByUser ?? 'elastic' + ); + + return { listClient, exceptionsClient }; + } else { + return { listClient: undefined, exceptionsClient: undefined }; + } +}; + +export const hasLargeValueList = (entries: EntriesArray): boolean => { + const found = entries.filter(({ type }) => type === 'list'); + return found.length > 0; +}; + +export const getExceptions = async ({ + client, + lists, +}: { + client: ExceptionListClient | undefined; + lists: ListArrayOrUndefined; +}): Promise => { + // TODO Remove check once feature is no longer behind flag + if (hasListsFeature()) { + if (client == null) { + throw new Error('lists plugin unavailable during rule execution'); + } + + if (lists != null) { + try { + // Gather all exception items of all exception lists linked to rule + const exceptions = await Promise.all( + lists + .map(async (list) => { + const { id, namespace_type: namespaceType } = list; + const items = await client.findExceptionListItem({ + listId: id, + namespaceType, + page: 1, + perPage: 5000, + filter: undefined, + sortOrder: undefined, + sortField: undefined, + }); + return items != null ? items.data : []; + }) + .flat() + ); + return exceptions.flat(); + } catch { + return []; + } + } + } +}; + +export const sortExceptionItems = (exceptions: ExceptionListItemSchema[]): SortExceptionsReturn => { + return exceptions.reduce( + (acc, exception) => { + const { entries } = exception; + const { exceptionsWithValueLists, exceptionsWithoutValueLists } = acc; + + if (hasLargeValueList(entries)) { + return { + exceptionsWithValueLists: [...exceptionsWithValueLists, { ...exception }], + exceptionsWithoutValueLists, + }; + } else { + return { + exceptionsWithValueLists, + exceptionsWithoutValueLists: [...exceptionsWithoutValueLists, { ...exception }], + }; + } + }, + { exceptionsWithValueLists: [], exceptionsWithoutValueLists: [] } + ); +}; + export const generateId = ( docIndex: string, docId: string, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 6e284908e3358c..90484a46dc6d3e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -28,11 +28,11 @@ import { Version, MetaOrUndefined, RuleId, - ListAndOrUndefined, } from '../../../common/detection_engine/schemas/common/schemas'; import { CallAPIOptions } from '../../../../../../src/core/server'; import { Filter } from '../../../../../../src/plugins/data/server'; import { RuleType } from '../../../common/detection_engine/types'; +import { ListArrayOrUndefined } from '../../../common/detection_engine/schemas/types'; export type PartialFilter = Partial; @@ -62,7 +62,7 @@ export interface RuleTypeParams { type: RuleType; references: References; version: Version; - exceptionsList: ListAndOrUndefined; + exceptionsList: ListArrayOrUndefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9fe7307e8cb6da..879c132ddec54d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -219,7 +219,9 @@ export class Plugin implements IPlugin void; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx index 38bd3e04bc2404..d7ded819771fc4 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx @@ -8,8 +8,8 @@ import React, { Fragment } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiStat, EuiHorizontalRule } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ProcessedImportResponse } from '../../../../../../src/legacy/core_plugins/kibana/public'; import { ImportRetry } from '../types'; +import { ProcessedImportResponse } from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { copyInProgress: boolean; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index fc1810ba8f8193..255268d388eb8d 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -13,8 +13,10 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ProcessedImportResponse } from '../../../../../../src/legacy/core_plugins/kibana/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + SavedObjectsManagementRecord, +} from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { CopyOptions, ImportRetry } from '../types'; import { SpaceResult } from './space_result'; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index 65a0cabfeb7166..a8ecd7c7b9d9f5 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -5,7 +5,7 @@ */ import { summarizeCopyResult } from './summarize_copy_result'; -import { ProcessedImportResponse } from 'src/legacy/core_plugins/kibana/public'; +import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; const createSavedObjectsManagementRecord = () => ({ type: 'dashboard', diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 5c1fe6afcf0833..518e89df579a64 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ProcessedImportResponse } from 'src/legacy/core_plugins/kibana/public'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; +import { + SavedObjectsManagementRecord, + ProcessedImportResponse, +} from 'src/plugins/saved_objects_management/public'; export interface SummarizedSavedObjectResult { type: string; diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index b4489e57001591..1e01e04332f43d 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -8,7 +8,7 @@ import { Feature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; import { setupCapabilitiesSwitcher } from './capabilities_switcher'; import { Capabilities, CoreSetup } from 'src/core/server'; -import { coreMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; +import { coreMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { featuresPluginMock } from '../../../features/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { PluginsStart } from '../plugin'; @@ -109,7 +109,7 @@ const setup = (space: Space) => { const spacesService = spacesServiceMock.createSetupContract(); spacesService.getActiveSpace.mockResolvedValue(space); - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const switcher = setupCapabilitiesSwitcher( (coreSetup as unknown) as CoreSetup, diff --git a/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts b/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts index 80cc7428e28e74..f281cd8efaa9f6 100644 --- a/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts +++ b/x-pack/plugins/spaces/server/default_space/create_default_space.test.ts @@ -6,7 +6,7 @@ import { createDefaultSpace } from './create_default_space'; import { SavedObjectsErrorHelpers } from 'src/core/server'; -import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; interface MockServerSettings { defaultExists?: boolean; @@ -57,7 +57,7 @@ const createMockDeps = (settings: MockServerSettings = {}) => { }; }), }), - logger: loggingServiceMock.createLogger(), + logger: loggingSystemMock.createLogger(), }; }; diff --git a/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts b/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts index 2d677565164a20..311bedd0bf9e74 100644 --- a/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts +++ b/x-pack/plugins/spaces/server/default_space/default_space_service.test.ts @@ -16,7 +16,7 @@ import { SavedObjectsRepository, SavedObjectsErrorHelpers, } from '../../../../../src/core/server'; -import { coreMock, loggingServiceMock } from 'src/core/server/mocks'; +import { coreMock, loggingSystemMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../licensing/server/mocks'; import { SpacesLicenseService } from '../../common/licensing'; import { ILicense } from '../../../licensing/server'; @@ -59,7 +59,7 @@ const setup = ({ elasticsearchStatus, savedObjectsStatus, license }: SetupOpts) const license$ = new Rx.BehaviorSubject(license); - const logger = loggingServiceMock.createLogger(); + const logger = loggingSystemMock.createLogger(); const { license: spacesLicense } = new SpacesLicenseService().setup({ license$ }); diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index e596eade802fdc..17a1fbcca73bd2 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -17,7 +17,7 @@ import { } from '../../../../../../src/core/server'; import { elasticsearchServiceMock, - loggingServiceMock, + loggingSystemMock, coreMock, } from '../../../../../../src/core/server/mocks'; import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; @@ -121,7 +121,7 @@ describe.skip('onPostAuthInterceptor', () => { // Mock esNodesCompatibility$ to prevent `root.start()` from blocking on ES version check elasticsearch.esNodesCompatibility$ = elasticsearchServiceMock.createInternalSetup().esNodesCompatibility$; - const loggingMock = loggingServiceMock.create().asLoggerFactory().get('xpack', 'spaces'); + const loggingMock = loggingSystemMock.create().asLoggerFactory().get('xpack', 'spaces'); const featuresPlugin = { getFeatures: () => diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 0abf545fa7493c..8ec2e6f978d81e 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -9,12 +9,12 @@ import { DEFAULT_SPACE_ID } from '../../common/constants'; import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory'; import { SpacesService } from '../spaces_service'; import { SpacesAuditLogger } from './audit_logger'; -import { coreMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { coreMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { spacesConfig } from './__fixtures__'; import { securityMock } from '../../../security/server/mocks'; -const log = loggingServiceMock.createLogger(); +const log = loggingSystemMock.createLogger(); const service = new SpacesService(log); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 53f5a219dda5b9..b604554cbc59ac 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -16,7 +16,7 @@ import { } from '../__fixtures__'; import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, coreMock, @@ -68,7 +68,7 @@ describe('copy to space', () => { createResolveSavedObjectsImportErrorsMock() ); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const coreStart = coreMock.createStart(); coreStart.savedObjects = createMockSavedObjectsService(spaces); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index f31ef657642e74..5461aaf1e36ea8 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -18,7 +18,7 @@ import { SavedObjectsErrorHelpers, } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, coreMock, @@ -40,7 +40,7 @@ describe('Spaces Public API', () => { const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 55e153cf47f5bc..ac9a46ee9c3fac 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -13,7 +13,7 @@ import { import { initGetSpaceApi } from './get'; import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, coreMock, @@ -36,7 +36,7 @@ describe('GET space', () => { const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const service = new SpacesService(log); const spacesService = await service.setup({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index aabd4900c5469b..ec841808f771d2 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -12,7 +12,7 @@ import { } from '../__fixtures__'; import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, coreMock, @@ -36,7 +36,7 @@ describe('GET /spaces/space', () => { const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const service = new SpacesService(log); const spacesService = await service.setup({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 5e09308f07d312..6aa89b36b020ab 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -12,7 +12,7 @@ import { } from '../__fixtures__'; import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServerMock, httpServiceMock, coreMock, @@ -36,7 +36,7 @@ describe('Spaces Public API', () => { const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const service = new SpacesService(log); const spacesService = await service.setup({ diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index 7b068d37840438..ebdffa20a6c8e9 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -13,7 +13,7 @@ import { } from '../__fixtures__'; import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { - loggingServiceMock, + loggingSystemMock, httpServiceMock, httpServerMock, coreMock, @@ -37,7 +37,7 @@ describe('PUT /api/spaces/space', () => { const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingServiceMock.create().get('spaces'); + const log = loggingSystemMock.create().get('spaces'); const service = new SpacesService(log); const spacesService = await service.setup({ diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index 3e1a849a9bdfab..b341d76c86649a 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -5,7 +5,7 @@ */ import * as Rx from 'rxjs'; import { SpacesService } from './spaces_service'; -import { coreMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; +import { coreMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { SpacesAuditLogger } from '../lib/audit_logger'; import { KibanaRequest, @@ -18,7 +18,7 @@ import { getSpaceIdFromPath } from '../../common/lib/spaces_url_parser'; import { spacesConfig } from '../lib/__fixtures__'; import { securityMock } from '../../../security/server/mocks'; -const mockLogger = loggingServiceMock.createLogger(); +const mockLogger = loggingSystemMock.createLogger(); const createService = async (serverBasePath: string = '') => { const spacesService = new SpacesService(mockLogger); diff --git a/x-pack/plugins/transform/common/index.ts b/x-pack/plugins/transform/common/index.ts index 79ff6298a2ca28..08bb4022c7016e 100644 --- a/x-pack/plugins/transform/common/index.ts +++ b/x-pack/plugins/transform/common/index.ts @@ -43,6 +43,7 @@ export interface DeleteTransformEndpointRequest { transformsInfo: TransformEndpointRequest[]; deleteDestIndex?: boolean; deleteDestIndexPattern?: boolean; + forceDelete?: boolean; } export interface DeleteTransformStatus { diff --git a/x-pack/plugins/transform/common/utils/date_utils.ts b/x-pack/plugins/transform/common/utils/date_utils.ts deleted file mode 100644 index 2acde91413acaa..00000000000000 --- a/x-pack/plugins/transform/common/utils/date_utils.ts +++ /dev/null @@ -1,22 +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. - */ - -// utility functions for handling dates - -// @ts-ignore -import { formatDate } from '@elastic/eui/lib/services/format'; - -export function formatHumanReadableDate(ts: number) { - return formatDate(ts, 'MMMM Do YYYY'); -} - -export function formatHumanReadableDateTime(ts: number) { - return formatDate(ts, 'MMMM Do YYYY, HH:mm'); -} - -export function formatHumanReadableDateTimeSeconds(ts: number) { - return formatDate(ts, 'MMMM Do YYYY, HH:mm:ss'); -} diff --git a/x-pack/plugins/transform/public/app/common/transform.ts b/x-pack/plugins/transform/public/app/common/transform.ts index a7c53e76ec5f98..a02bed2fa65e74 100644 --- a/x-pack/plugins/transform/public/app/common/transform.ts +++ b/x-pack/plugins/transform/public/app/common/transform.ts @@ -54,6 +54,8 @@ export interface CreateRequestBody extends PreviewRequestBody { export interface TransformPivotConfig extends CreateRequestBody { id: TransformId; + create_time?: number; + version?: string; } export enum REFRESH_TRANSFORM_LIST_STATE { diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index 17d729a453a05d..a27fc0b3e0dbb3 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -10,8 +10,6 @@ import { TransformStats } from './transform_stats'; // Used to pass on attribute names to table columns export enum TRANSFORM_LIST_COLUMN { - CONFIG_DEST_INDEX = 'config.dest.index', - CONFIG_SOURCE_INDEX = 'config.source.index', DESCRIPTION = 'config.description', ID = 'id', } diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 5d7839cf5fba7d..56528370a3ab9a 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -47,14 +47,22 @@ export const useApi = () => { deleteTransforms( transformsInfo: TransformEndpointRequest[], deleteDestIndex: boolean | undefined, - deleteDestIndexPattern: boolean | undefined + deleteDestIndexPattern: boolean | undefined, + forceDelete: boolean ): Promise { return http.post(`${API_BASE_PATH}delete_transforms`, { - body: JSON.stringify({ transformsInfo, deleteDestIndex, deleteDestIndexPattern }), + body: JSON.stringify({ + transformsInfo, + deleteDestIndex, + deleteDestIndexPattern, + forceDelete, + }), }); }, getTransformsPreview(obj: PreviewRequestBody): Promise { - return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); + return http.post(`${API_BASE_PATH}transforms/_preview`, { + body: JSON.stringify(obj), + }); }, startTransforms( transformsInfo: TransformEndpointRequest[] diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index 1f395e67b7d31f..43c5ae6fad1b18 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -8,13 +8,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { - TransformEndpointRequest, DeleteTransformEndpointResult, DeleteTransformStatus, + TransformEndpointRequest, } from '../../../common'; -import { getErrorMessage, extractErrorMessage } from '../../shared_imports'; +import { extractErrorMessage, getErrorMessage } from '../../shared_imports'; import { useAppDependencies, useToastNotifications } from '../app_dependencies'; -import { TransformListRow, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE } from '../common'; +import { REFRESH_TRANSFORM_LIST_STATE, refreshTransformList$, TransformListRow } from '../common'; import { ToastNotificationText } from '../components'; import { useApi } from './use_api'; import { indexService } from '../services/es_index_service'; @@ -27,13 +27,13 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); const [indexPatternExists, setIndexPatternExists] = useState(false); + const toggleDeleteIndex = useCallback(() => setDeleteDestIndex(!deleteDestIndex), [ deleteDestIndex, ]); const toggleDeleteIndexPattern = useCallback(() => setDeleteIndexPattern(!deleteIndexPattern), [ deleteIndexPattern, ]); - const checkIndexPatternExists = useCallback( async (indexName: string) => { try { @@ -79,6 +79,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { useEffect(() => { checkUserIndexPermission(); + // if user only deleting one transform if (items.length === 1) { const config = items[0].config; const destinationIndex = Array.isArray(config.dest.index) @@ -110,7 +111,8 @@ export const useDeleteTransforms = () => { return async ( transforms: TransformListRow[], shouldDeleteDestIndex: boolean, - shouldDeleteDestIndexPattern: boolean + shouldDeleteDestIndexPattern: boolean, + shouldForceDelete = false ) => { const transformsInfo: TransformEndpointRequest[] = transforms.map((tf) => ({ id: tf.config.id, @@ -121,7 +123,8 @@ export const useDeleteTransforms = () => { const results: DeleteTransformEndpointResult = await api.deleteTransforms( transformsInfo, shouldDeleteDestIndex, - shouldDeleteDestIndexPattern + shouldDeleteDestIndexPattern, + shouldForceDelete ); const isBulk = Object.keys(results).length > 1; const successCount: Record = { diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index b073dace30340f..a9f34996b9b51e 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import { dictionaryToArray } from '../../../common/types/common'; -import { formatHumanReadableDateTimeSeconds } from '../../../common/utils/date_utils'; +import { formatHumanReadableDateTimeSeconds } from '../../shared_imports'; import { getNestedProperty } from '../../../common/utils/object_utils'; import { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap deleted file mode 100644 index 1f134cd39948b4..00000000000000 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap +++ /dev/null @@ -1,291 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Transform: Transform List Minimal initialization 1`] = ` -, - "data-test-subj": "transformDetailsTab", - "id": "transform-details-tab-fq_date_histogram_1m_1441", - "name": "Transform details", - } - } - onTabClick={[Function]} - size="s" - style={ - Object { - "width": "100%", - } - } - tabs={ - Array [ - Object { - "content": , - "data-test-subj": "transformDetailsTab", - "id": "transform-details-tab-fq_date_histogram_1m_1441", - "name": "Transform details", - }, - Object { - "content": , - "data-test-subj": "transformJsonTab", - "id": "transform-json-tab-fq_date_histogram_1m_1441", - "name": "JSON", - }, - Object { - "content": , - "data-test-subj": "transformMessagesTab", - "id": "transform-messages-tab-fq_date_histogram_1m_1441", - "name": "Messages", - }, - Object { - "content": , - "data-test-subj": "transformPreviewTab", - "id": "transform-preview-tab-fq_date_histogram_1m_1441", - "name": "Preview", - }, - ] - } -/> -`; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx index d7db55990d3338..19297eb25d0bd5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/action_delete.tsx @@ -4,25 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useState } from 'react'; +import React, { FC, Fragment, useContext, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { + EUI_MODAL_CONFIRM_BUTTON, EuiButtonEmpty, EuiConfirmModal, - EuiOverlayMask, - EuiToolTip, - EUI_MODAL_CONFIRM_BUTTON, EuiFlexGroup, EuiFlexItem, - EuiSwitch, + EuiOverlayMask, EuiSpacer, + EuiSwitch, + EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { TRANSFORM_STATE } from '../../../../../../common'; -import { useDeleteTransforms, useDeleteIndexAndTargetIndex } from '../../../../hooks'; +import { useDeleteIndexAndTargetIndex, useDeleteTransforms } from '../../../../hooks'; import { - createCapabilityFailureMessage, AuthorizationContext, + createCapabilityFailureMessage, } from '../../../../lib/authorization'; import { TransformListRow } from '../../../../common'; @@ -31,11 +31,17 @@ interface DeleteActionProps { forceDisable?: boolean; } +const transformCanNotBeDeleted = (i: TransformListRow) => + ![TRANSFORM_STATE.STOPPED, TRANSFORM_STATE.FAILED].includes(i.stats.state); + export const DeleteAction: FC = ({ items, forceDisable }) => { const isBulkAction = items.length > 1; - const disabled = items.some((i: TransformListRow) => i.stats.state !== TRANSFORM_STATE.STOPPED); - + const disabled = items.some(transformCanNotBeDeleted); + const shouldForceDelete = useMemo( + () => items.some((i: TransformListRow) => i.stats.state === TRANSFORM_STATE.FAILED), + [items] + ); const { canDeleteTransform } = useContext(AuthorizationContext).capabilities; const deleteTransforms = useDeleteTransforms(); const { @@ -56,7 +62,12 @@ export const DeleteAction: FC = ({ items, forceDisable }) => const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; const shouldDeleteDestIndexPattern = userCanDeleteIndex && indexPatternExists && deleteIndexPattern; - deleteTransforms(items, shouldDeleteDestIndex, shouldDeleteDestIndexPattern); + // if we are deleting multiple transforms, then force delete all if at least one item has failed + // else, force delete only when the item user picks has failed + const forceDelete = isBulkAction + ? shouldForceDelete + : items[0] && items[0] && items[0].stats.state === TRANSFORM_STATE.FAILED; + deleteTransforms(items, shouldDeleteDestIndex, shouldDeleteDestIndexPattern, forceDelete); }; const openModal = () => setModalVisible(true); @@ -89,11 +100,19 @@ export const DeleteAction: FC = ({ items, forceDisable }) => const bulkDeleteModalContent = ( <>

- + {shouldForceDelete ? ( + + ) : ( + + )}

@@ -134,10 +153,17 @@ export const DeleteAction: FC = ({ items, forceDisable }) => const deleteModalContent = ( <>

- + {items[0] && items[0] && items[0].stats.state === TRANSFORM_STATE.FAILED ? ( + + ) : ( + + )}

diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx index aac1d8b5a3f9c7..18d324c8767c72 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.test.tsx @@ -13,7 +13,6 @@ describe('Transform: Transform List Actions', () => { const actions = getActions({ forceDisable: false }); expect(actions).toHaveLength(4); - expect(actions[0].isPrimary).toBeTruthy(); expect(typeof actions[0].render).toBe('function'); expect(typeof actions[1].render).toBe('function'); expect(typeof actions[2].render).toBe('function'); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx index 820b9e0e0d0623..343b5e4db67e3e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/actions.tsx @@ -19,7 +19,6 @@ import { StopAction } from './action_stop'; export const getActions = ({ forceDisable }: { forceDisable: boolean }) => { return [ { - isPrimary: true, render: (item: TransformListRow) => { if (item.stats.state === TRANSFORM_STATE.STOPPED) { return ; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx index b4198ce3c7244d..3c75c33caf8407 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.test.tsx @@ -12,15 +12,13 @@ describe('Transform: Job List Columns', () => { test('getColumns()', () => { const columns = getColumns([], () => {}, []); - expect(columns).toHaveLength(9); + expect(columns).toHaveLength(7); expect(columns[0].isExpander).toBeTruthy(); expect(columns[1].name).toBe('ID'); expect(columns[2].name).toBe('Description'); - expect(columns[3].name).toBe('Source index'); - expect(columns[4].name).toBe('Destination index'); - expect(columns[5].name).toBe('Status'); - expect(columns[6].name).toBe('Mode'); - expect(columns[7].name).toBe('Progress'); - expect(columns[8].name).toBe('Actions'); + expect(columns[3].name).toBe('Status'); + expect(columns[4].name).toBe('Mode'); + expect(columns[5].name).toBe('Progress'); + expect(columns[6].name).toBe('Actions'); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx index 159833354b5efb..5ed2566e8a1940 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/columns.tsx @@ -88,8 +88,6 @@ export const getColumns = ( EuiTableComputedColumnType, EuiTableFieldDataColumnType, EuiTableFieldDataColumnType, - EuiTableFieldDataColumnType, - EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableComputedColumnType, EuiTableComputedColumnType, @@ -143,22 +141,6 @@ export const getColumns = ( sortable: true, truncateText: true, }, - { - field: TRANSFORM_LIST_COLUMN.CONFIG_SOURCE_INDEX, - 'data-test-subj': 'transformListColumnSourceIndex', - name: i18n.translate('xpack.transform.sourceIndex', { defaultMessage: 'Source index' }), - sortable: true, - truncateText: true, - }, - { - field: TRANSFORM_LIST_COLUMN.CONFIG_DEST_INDEX, - 'data-test-subj': 'transformListColumnDestinationIndex', - name: i18n.translate('xpack.transform.destinationIndex', { - defaultMessage: 'Destination index', - }), - sortable: true, - truncateText: true, - }, { name: i18n.translate('xpack.transform.status', { defaultMessage: 'Status' }), 'data-test-subj': 'transformListColumnStatus', @@ -242,7 +224,7 @@ export const getColumns = ( { name: i18n.translate('xpack.transform.tableActionLabel', { defaultMessage: 'Actions' }), actions, - width: '200px', + width: '80px', }, ]; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index 7fcaf5e6048f6b..846d8a8ccd2006 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import moment from 'moment-timezone'; - import { TransformListRow } from '../../../../common'; import { ExpandedRow } from './expanded_row'; import transformListRow from '../../../../common/__mocks__/transform_list_row.json'; +import { within } from '@testing-library/dom'; -jest.mock('../../../../../shared_imports'); - +jest.mock('../../../../../shared_imports', () => ({ + formatHumanReadableDateTimeSeconds: jest.fn(), +})); describe('Transform: Transform List ', () => { // Set timezone to US/Eastern for consistent test results. beforeEach(() => { @@ -25,11 +26,25 @@ describe('Transform: Transform List ', () => { moment.tz.setDefault('Browser'); }); - test('Minimal initialization', () => { + test('Minimal initialization', async () => { const item: TransformListRow = transformListRow; - const wrapper = shallow(); + const { getByText, getByTestId } = render(); + + expect(getByText('Details')).toBeInTheDocument(); + expect(getByText('Stats')).toBeInTheDocument(); + expect(getByText('JSON')).toBeInTheDocument(); + expect(getByText('Messages')).toBeInTheDocument(); + expect(getByText('Preview')).toBeInTheDocument(); + + const tabContent = getByTestId('transformDetailsTabContent'); + expect(tabContent).toBeInTheDocument(); + + expect(getByTestId('transformDetailsTab')).toHaveAttribute('aria-selected', 'true'); + expect(within(tabContent).getByText('General')).toBeInTheDocument(); - expect(wrapper).toMatchSnapshot(); + fireEvent.click(getByTestId('transformStatsTab')); + expect(getByTestId('transformStatsTab')).toHaveAttribute('aria-selected', 'true'); + expect(within(tabContent).getByText('Stats')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index 5d58db5b8871e3..311aed19e07062 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -10,8 +10,8 @@ import { EuiTabbedContent } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils'; - +import moment from 'moment-timezone'; +import { formatHumanReadableDateTimeSeconds } from '../../../../../shared_imports'; import { TransformListRow } from '../../../../common'; import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane'; import { ExpandedRowJsonPane } from './expanded_row_json_pane'; @@ -61,6 +61,44 @@ export const ExpandedRow: FC = ({ item }) => { const state: SectionConfig = { title: 'State', items: stateItems, + position: 'right', + }; + + const configItems: Item[] = [ + { + title: 'transform_id', + description: item.id, + }, + { + title: 'transform_version', + description: item.config.version, + }, + { + title: 'description', + description: item.config.description ?? '', + }, + { + title: 'create_time', + description: + formatHumanReadableDateTimeSeconds(moment(item.config.create_time).unix() * 1000) ?? '', + }, + { + title: 'source_index', + description: Array.isArray(item.config.source.index) + ? item.config.source.index[0] + : item.config.source.index, + }, + { + title: 'destination_index', + description: Array.isArray(item.config.dest.index) + ? item.config.dest.index[0] + : item.config.dest.index, + }, + ]; + + const general: SectionConfig = { + title: 'General', + items: configItems, position: 'left', }; @@ -108,7 +146,7 @@ export const ExpandedRow: FC = ({ item }) => { const checkpointing: SectionConfig = { title: 'Checkpointing', items: checkpointingItems, - position: 'left', + position: 'right', }; const stats: SectionConfig = { @@ -116,7 +154,7 @@ export const ExpandedRow: FC = ({ item }) => { items: Object.entries(item.stats.stats).map((s) => { return { title: s[0].toString(), description: getItemDescription(s[1]) }; }), - position: 'right', + position: 'left', }; const tabs = [ @@ -124,12 +162,23 @@ export const ExpandedRow: FC = ({ item }) => { id: `transform-details-tab-${item.id}`, 'data-test-subj': 'transformDetailsTab', name: i18n.translate( - 'xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel', + 'xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel', + { + defaultMessage: 'Details', + } + ), + content: , + }, + { + id: `transform-stats-tab-${item.id}`, + 'data-test-subj': 'transformStatsTab', + name: i18n.translate( + 'xpack.transform.transformList.transformDetails.tabs.transformStatsLabel', { - defaultMessage: 'Transform details', + defaultMessage: 'Stats', } ), - content: , + content: , }, { id: `transform-json-tab-${item.id}`, diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index f50ae9274fa4a9..e0bbcd0b5d9db7 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -17,6 +17,7 @@ export { fetchChartsData, getErrorMessage, extractErrorMessage, + formatHumanReadableDateTimeSeconds, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, diff --git a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts index 91c00f5eb5df20..a17eb1416408a1 100644 --- a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts +++ b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts @@ -83,11 +83,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) transform.deleteTransform = ca({ urls: [ { - fmt: '/_transform/<%=transformId%>', + fmt: '/_transform/<%=transformId%>?&force=<%=force%>', req: { transformId: { type: 'string', }, + force: { + type: 'boolean', + }, }, }, ], diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts index cf39f2e3829ea3..7da3f1ccfe55e3 100644 --- a/x-pack/plugins/transform/server/routes/api/schema.ts +++ b/x-pack/plugins/transform/server/routes/api/schema.ts @@ -27,4 +27,5 @@ export const deleteTransformSchema = schema.object({ ), deleteDestIndex: schema.maybe(schema.boolean()), deleteDestIndexPattern: schema.maybe(schema.boolean()), + forceDelete: schema.maybe(schema.boolean()), }); diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 93fda56d319adc..efbe813db5e670 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -190,6 +190,7 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { transformsInfo, deleteDestIndex, deleteDestIndexPattern, + forceDelete, } = req.body as DeleteTransformEndpointRequest; try { @@ -197,6 +198,7 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { transformsInfo, deleteDestIndex, deleteDestIndexPattern, + forceDelete, ctx, license, res @@ -295,39 +297,28 @@ async function deleteTransforms( transformsInfo: TransformEndpointRequest[], deleteDestIndex: boolean | undefined, deleteDestIndexPattern: boolean | undefined, + shouldForceDelete: boolean = false, ctx: RequestHandlerContext, license: RouteDependencies['license'], response: KibanaResponseFactory ) { - const tempResults: TransformEndpointResult = {}; const results: Record = {}; for (const transformInfo of transformsInfo) { let destinationIndex: string | undefined; + const transformDeleted: ResultData = { success: false }; const destIndexDeleted: ResultData = { success: false }; const destIndexPatternDeleted: ResultData = { success: false, }; const transformId = transformInfo.id; + // force delete only if the transform has failed + let needToForceDelete = false; + try { if (transformInfo.state === TRANSFORM_STATE.FAILED) { - try { - await ctx.transform!.dataClient.callAsCurrentUser('transform.stopTransform', { - transformId, - force: true, - waitForCompletion: true, - } as StopOptions); - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results: tempResults, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.DELETE, - }); - } - } + needToForceDelete = true; } // Grab destination index info to delete try { @@ -383,6 +374,7 @@ async function deleteTransforms( try { await ctx.transform!.dataClient.callAsCurrentUser('transform.deleteTransform', { transformId, + force: shouldForceDelete && needToForceDelete, }); transformDeleted.success = true; } catch (deleteTransformJobError) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fb8a4c3464d118..2a7517540e7085 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4296,21 +4296,6 @@ "xpack.apm.serviceDetails.alertsMenu.errorRate": "エラー率", "xpack.apm.serviceDetails.alertsMenu.transactionDuration": "トランザクション期間", "xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "現在 {serviceName} ({transactionType}) の実行中のジョブがあります。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "既存のジョブを表示", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "ジョブが既に存在します", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "トランザクション時間のグラフ", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "ジョブを作成", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "異常検知を有効にする", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText": "現在 {serviceName} ({transactionType}) の分析を実行中です。応答時間グラフに結果が追加されるまで少し時間がかかる場合があります。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText": "ジョブを表示", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle": "ジョブが作成されました", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText": "現在のライセンスでは機械学習ジョブの作成が許可されていないか、ジョブが既に存在する可能性があります。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle": "ジョブの作成に失敗", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription": "ジョブはそれぞれのサービス + トランザクションタイプの組み合わせに対して作成できます。ジョブの作成後、{mlJobsPageLink} で管理と詳細の確認ができます。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText": "機械学習ジョブの管理ページ", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注:ジョブが結果の計算を開始するまでに少し時間がかかる場合があります。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.selectTransactionTypeLabel": "このジョブのトランザクションタイプを選択してください", "xpack.apm.serviceDetails.enableErrorReportsPanel.actionsDescription": "レポートはメールで送信するか Slack チャンネルに投稿できます。各レポートにはオカランス別のトップ 10 のエラーが含まれます。", "xpack.apm.serviceDetails.enableErrorReportsPanel.actionsTitle": "アクション", "xpack.apm.serviceDetails.enableErrorReportsPanel.conditionTitle": "コンディション", @@ -4346,8 +4331,6 @@ "xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationText": "ユーザーにウォッチ作成のパーミッションがあることを確認してください。", "xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationTitle": "ウォッチの作成に失敗", "xpack.apm.serviceDetails.errorsTabLabel": "エラー", - "xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel": "ML 異常検知を有効にする", - "xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip": "このサービスの機械学習ジョブをセットアップします", "xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel": "ウォッチエラーレポートを有効にする", "xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel": "統合", "xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel": "既存のウォッチを表示", @@ -4357,9 +4340,6 @@ "xpack.apm.serviceDetails.metricsTabLabel": "メトリック", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", "xpack.apm.serviceDetails.transactionsTabLabel": "トランザクション", - "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "異常を表示", - "xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric": "スコア(最大)", - "xpack.apm.serviceMap.anomalyDetectionPopoverTitle": "異常検知", "xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU使用状況 (平均)", "xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "1分あたりのエラー(平均)", "xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "メモリー使用状況(平均)", @@ -7065,9 +7045,6 @@ "xpack.idxMgmt.summary.summaryTitle": "一般", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneDescription": "クローンを作成するテンプレートを読み込み中…", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneErrorMessage": "クローンを作成するテンプレートを読み込み中にエラーが発生", - "xpack.idxMgmt.templateDetails.aliasesTab.noAliasesTitle": "エイリアスが定義されていません。", - "xpack.idxMgmt.templateDetails.mappingsTab.noMappingsTitle": "マッピングが定義されていません。", - "xpack.idxMgmt.templateDetails.settingsTab.noSettingsTitle": "設定が定義されていません。", "xpack.idxMgmt.templateDetails.summaryTab.ilmPolicyDescriptionListTitle": "ILM ポリシー", "xpack.idxMgmt.templateDetails.summaryTab.indexPatternsDescriptionListTitle": "インデックス{numIndexPatterns, plural, one {パターン} other {パターン}}", "xpack.idxMgmt.templateDetails.summaryTab.noneDescriptionText": "なし", @@ -7082,12 +7059,6 @@ "xpack.idxMgmt.templateForm.createButtonLabel": "テンプレートを作成", "xpack.idxMgmt.templateForm.saveButtonLabel": "テンプレートを保存", "xpack.idxMgmt.templateForm.saveTemplateError": "テンプレートを作成できません", - "xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "エイリアスをセットアップして、インデックスに関連付けてください。", - "xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "JSON フォーマットを使用: {code}", - "xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "インデックステンプレートドキュメント", - "xpack.idxMgmt.templateForm.stepAliases.fieldAliasesAriaLabel": "エイリアスコードエディター", - "xpack.idxMgmt.templateForm.stepAliases.fieldAliasesLabel": "エイリアス", - "xpack.idxMgmt.templateForm.stepAliases.stepTitle": "エイリアス (任意)", "xpack.idxMgmt.templateForm.stepLogistics.docsButtonLabel": "インデックステンプレートドキュメント", "xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsHelpText": "スペースと {invalidCharactersList} は使用できません。", "xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsLabel": "インデックスパターン", @@ -7103,9 +7074,6 @@ "xpack.idxMgmt.templateForm.stepLogistics.stepTitle": "ロジスティクス", "xpack.idxMgmt.templateForm.stepLogistics.versionDescription": "テンプレートを外部管理システムで識別するための番号です。", "xpack.idxMgmt.templateForm.stepLogistics.versionTitle": "バージョン", - "xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel": "マッピングドキュメント", - "xpack.idxMgmt.templateForm.stepMappings.mappingsDescription": "ドキュメントの保存とインデックス方法を定義します。", - "xpack.idxMgmt.templateForm.stepMappings.stepTitle": "マッピング (任意)", "xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText": "このリクエストは次のインデックステンプレートを作成します。", "xpack.idxMgmt.templateForm.stepReview.requestTabTitle": "リクエスト", "xpack.idxMgmt.templateForm.stepReview.stepTitle": "「{templateName}」の詳細の確認", @@ -7127,12 +7095,6 @@ "xpack.idxMgmt.templateForm.steps.mappingsStepName": "マッピング", "xpack.idxMgmt.templateForm.steps.settingsStepName": "インデックス設定", "xpack.idxMgmt.templateForm.steps.summaryStepName": "テンプレートのレビュー", - "xpack.idxMgmt.templateForm.stepSettings.docsButtonLabel": "インデックス設定ドキュメント", - "xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsAriaLabel": "インデックス設定エディター", - "xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsLabel": "インデックス設定", - "xpack.idxMgmt.templateForm.stepSettings.settingsDescription": "インデックスの動作を定義します。", - "xpack.idxMgmt.templateForm.stepSettings.settingsEditorHelpText": "JSON フォーマットを使用: {code}", - "xpack.idxMgmt.templateForm.stepSettings.stepTitle": "インデックス設定 (任意)", "xpack.idxMgmt.templateList.table.ilmPolicyColumnDescription": "インデックスライフサイクルポリシー「{policyName}」", "xpack.idxMgmt.templateList.table.ilmPolicyColumnTitle": "ILM ポリシー", "xpack.idxMgmt.templateList.table.indexPatternsColumnTitle": "インデックスパターン", @@ -9039,8 +9001,6 @@ "xpack.maps.layerPanel.filterEditor.emptyState.description": "フィルターを追加してレイヤーデータを絞ります。", "xpack.maps.layerPanel.filterEditor.queryBarSubmitButtonLabel": "フィルターを設定", "xpack.maps.layerPanel.filterEditor.title": "フィルタリング", - "xpack.maps.layerPanel.fitToBoundsAriaLabel": "境界に合わせる", - "xpack.maps.layerPanel.fitToBoundsButtonLabel": "合わせる", "xpack.maps.layerPanel.footer.cancelButtonLabel": "キャンセル", "xpack.maps.layerPanel.footer.closeButtonLabel": "閉じる", "xpack.maps.layerPanel.footer.removeLayerButtonLabel": "レイヤーを削除", @@ -10709,7 +10669,6 @@ "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.startDateLabel": "開始日", "xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage": "バケットスパンを設定する必要があります", "xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage": "重複する検知器が検出されました。", - "xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage": "{value} は有効な時間間隔のフォーマット (例: {tenMinutes}、{oneHour}) ではありません。また、0 よりも大きい数字である必要があります。", "xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists": "グループ ID が既に存在します。グループ ID は既存のジョブやグループと同じにできません。", "xpack.ml.newJob.wizard.validateJob.jobGroupAllowedCharactersDescription": "ジョブグループ名にはアルファベットの小文字 (a-z と 0-9)、ハイフンまたはアンダーラインが使用でき、最初と最後を英数字にする必要があります", "xpack.ml.newJob.wizard.validateJob.jobGroupMaxLengthDescription": "ジョブグループ名は {maxLength, plural, one {# 文字} other {# 文字}} 以内でなければなりません。", @@ -10722,7 +10681,6 @@ "xpack.ml.newJob.wizard.validateJob.queryIsInvalidEsQuery": "データフィードクエリは有効な Elasticsearch クエリでなければなりません。", "xpack.ml.overview.analyticsList.createFirstJobMessage": "最初のデータフレーム分析ジョブを作成", "xpack.ml.overview.analyticsList.createJobButtonText": "ジョブを作成", - "xpack.ml.overview.analyticsList.emptyPromptText": "データフレーム分析は、様々なデータ分析を行い結果と共に注釈に追加することができます。ジョブは注釈付きデータと共に、ソースデータのコピーを新規インデックスに保存します。", "xpack.ml.overview.analyticsList.errorPromptTitle": "データフレーム分析リストの取得中にエラーが発生しました。", "xpack.ml.overview.analyticsList.id": "ID", "xpack.ml.overview.analyticsList.manageJobsButtonText": "ジョブの管理", @@ -11735,7 +11693,6 @@ "xpack.monitoring.metrics.apm.outputFailedEventsRate.failedDescription": "アウトプットにより処理されたイベントです (再試行を含む)", "xpack.monitoring.metrics.apm.outputFailedEventsRate.failedLabel": "失敗", "xpack.monitoring.metrics.apm.outputFailedEventsRateTitle": "アウトプットイベント失敗率", - "xpack.monitoring.metrics.apm.perMinuteUnitLabel": "/m", "xpack.monitoring.metrics.apm.processedEvents.transactionDescription": "処理されたトランザクションイベントです", "xpack.monitoring.metrics.apm.processedEvents.transactionLabel": "トランザクション", "xpack.monitoring.metrics.apm.processedEventsTitle": "処理済みのイベント", @@ -15812,7 +15769,6 @@ "xpack.transform.cloneTransform.breadcrumbTitle": "クローン変換", "xpack.transform.createTransform.breadcrumbTitle": "変換の作成", "xpack.transform.description": "説明", - "xpack.transform.destinationIndex": "デスティネーションインデックス", "xpack.transform.groupby.popoverForm.aggLabel": "集約", "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "別のグループ分けの構成が既にこの名前を使用しています。", "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "無効な名前です。「[」、「]」「>」は使用できず、名前の始めと終わりにはスペースを使用できません。", @@ -15847,7 +15803,6 @@ "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "プレビューリクエストはデータを返しませんでした。オプションのクエリがデータを返し、グループ分け基準により使用されるフィールドと集約フィールドに値が存在することを確認してください。", "xpack.transform.pivotPreview.PivotPreviewTitle": "ピボットプレビューを変換", "xpack.transform.progress": "進捗", - "xpack.transform.sourceIndex": "ソースインデックス", "xpack.transform.statsBar.batchTransformsLabel": "一斉", "xpack.transform.statsBar.continuousTransformsLabel": "連続", "xpack.transform.statsBar.failedTransformsLabel": "失敗", @@ -16008,7 +15963,6 @@ "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "時間", "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "メッセージ", "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "プレビュー", - "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "ジョブの詳細", "xpack.transform.transformList.transformDocsLinkText": "変換ドキュメント", "xpack.transform.transformList.transformTitle": "データフレームジョブ", "xpack.transform.transformsDescription": "変換を使用して、集約されたインデックスまたはエンティティ中心のインデックスに、既存のElasticsearchインデックスをインデックスします。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3fe58b62c00c33..9a55fee2b88981 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4299,21 +4299,6 @@ "xpack.apm.serviceDetails.alertsMenu.errorRate": "错误率", "xpack.apm.serviceDetails.alertsMenu.transactionDuration": "事务持续时间", "xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts": "查看活动的告警", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "当前有 {serviceName}({transactionType})的作业正在运行。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "查看现有作业", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "作业已存在", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "事务持续时间图表", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "创建作业", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "启用异常检测", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText": "现在正在运行对 {serviceName}({transactionType})的分析。可能要花费点时间,才会将结果添加响应时间图表。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText": "查看作业", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle": "作业已成功创建", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText": "您当前的许可可能不允许创建 Machine Learning 作业,或者此作业可能已存在。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle": "作业创建失败", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription": "可以创建每个服务 + 事务类型组合的作业。创建作业后,可以在 {mlJobsPageLink}中管理作业以及查看更多详细信息。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText": "Machine Learning 作业管理页面", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注意:可能要过几分钟后,作业才会开始计算结果。", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.selectTransactionTypeLabel": "为此作业选择事务类型", "xpack.apm.serviceDetails.enableErrorReportsPanel.actionsDescription": "可以通过电子邮件发送报告或将报告发布到 Slack 频道。每个报告将包括按发生次数排序的前 10 个错误。", "xpack.apm.serviceDetails.enableErrorReportsPanel.actionsTitle": "操作", "xpack.apm.serviceDetails.enableErrorReportsPanel.conditionTitle": "条件", @@ -4349,8 +4334,6 @@ "xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationText": "确保您的用户有权创建监视。", "xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationTitle": "监视创建失败", "xpack.apm.serviceDetails.errorsTabLabel": "错误", - "xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel": "启用 ML 异常检测", - "xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip": "为此服务设置 Machine Learning 作业", "xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel": "启用 Watcher 错误报告", "xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel": "集成", "xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel": "查看现有监视", @@ -4360,9 +4343,6 @@ "xpack.apm.serviceDetails.metricsTabLabel": "指标", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", "xpack.apm.serviceDetails.transactionsTabLabel": "事务", - "xpack.apm.serviceMap.anomalyDetectionPopoverLink": "查看异常", - "xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric": "分数(最大)", - "xpack.apm.serviceMap.anomalyDetectionPopoverTitle": "异常检测", "xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU 使用(平均)", "xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "每分钟错误数(平均)", "xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "内存使用(平均)", @@ -7069,9 +7049,6 @@ "xpack.idxMgmt.summary.summaryTitle": "常规", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneDescription": "正在加载要克隆的模板……", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneErrorMessage": "加载要克隆的模板时出错", - "xpack.idxMgmt.templateDetails.aliasesTab.noAliasesTitle": "未定义任何别名。", - "xpack.idxMgmt.templateDetails.mappingsTab.noMappingsTitle": "未定义任何映射。", - "xpack.idxMgmt.templateDetails.settingsTab.noSettingsTitle": "未定义任何设置。", "xpack.idxMgmt.templateDetails.summaryTab.ilmPolicyDescriptionListTitle": "ILM 策略", "xpack.idxMgmt.templateDetails.summaryTab.indexPatternsDescriptionListTitle": "索引{numIndexPatterns, plural, one {模式} other {模式}}", "xpack.idxMgmt.templateDetails.summaryTab.noneDescriptionText": "无", @@ -7086,12 +7063,6 @@ "xpack.idxMgmt.templateForm.createButtonLabel": "创建模板", "xpack.idxMgmt.templateForm.saveButtonLabel": "保存模板", "xpack.idxMgmt.templateForm.saveTemplateError": "无法创建模板", - "xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "设置要与索引关联的别名。", - "xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "使用 JSON 格式:{code}", - "xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "索引模板文档", - "xpack.idxMgmt.templateForm.stepAliases.fieldAliasesAriaLabel": "别名代码编辑器", - "xpack.idxMgmt.templateForm.stepAliases.fieldAliasesLabel": "别名", - "xpack.idxMgmt.templateForm.stepAliases.stepTitle": "别名(可选)", "xpack.idxMgmt.templateForm.stepLogistics.docsButtonLabel": "索引模板文档", "xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsHelpText": "不允许使用空格和字符 {invalidCharactersList}。", "xpack.idxMgmt.templateForm.stepLogistics.fieldIndexPatternsLabel": "索引模式", @@ -7107,9 +7078,6 @@ "xpack.idxMgmt.templateForm.stepLogistics.stepTitle": "运筹", "xpack.idxMgmt.templateForm.stepLogistics.versionDescription": "在外部管理系统中标识该模板的编号。", "xpack.idxMgmt.templateForm.stepLogistics.versionTitle": "版本", - "xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel": "映射文档", - "xpack.idxMgmt.templateForm.stepMappings.mappingsDescription": "定义如何存储和索引文档。", - "xpack.idxMgmt.templateForm.stepMappings.stepTitle": "映射(可选)", "xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText": "此请求将创建以下索引模板。", "xpack.idxMgmt.templateForm.stepReview.requestTabTitle": "请求", "xpack.idxMgmt.templateForm.stepReview.stepTitle": "查看 “{templateName}” 的详情", @@ -7131,12 +7099,6 @@ "xpack.idxMgmt.templateForm.steps.mappingsStepName": "映射", "xpack.idxMgmt.templateForm.steps.settingsStepName": "索引设置", "xpack.idxMgmt.templateForm.steps.summaryStepName": "复查模板", - "xpack.idxMgmt.templateForm.stepSettings.docsButtonLabel": "索引设置文档", - "xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsAriaLabel": "索引设置编辑器", - "xpack.idxMgmt.templateForm.stepSettings.fieldIndexSettingsLabel": "索引设置", - "xpack.idxMgmt.templateForm.stepSettings.settingsDescription": "定义索引的行为。", - "xpack.idxMgmt.templateForm.stepSettings.settingsEditorHelpText": "使用 JSON 格式:{code}", - "xpack.idxMgmt.templateForm.stepSettings.stepTitle": "索引设置(可选)", "xpack.idxMgmt.templateList.table.ilmPolicyColumnDescription": "“{policyName}”索引生命周期策略", "xpack.idxMgmt.templateList.table.ilmPolicyColumnTitle": "ILM 策略", "xpack.idxMgmt.templateList.table.indexPatternsColumnTitle": "索引模式", @@ -9043,8 +9005,6 @@ "xpack.maps.layerPanel.filterEditor.emptyState.description": "添加筛选以缩小图层数据范围。", "xpack.maps.layerPanel.filterEditor.queryBarSubmitButtonLabel": "设置筛选", "xpack.maps.layerPanel.filterEditor.title": "筛选", - "xpack.maps.layerPanel.fitToBoundsAriaLabel": "适应边界", - "xpack.maps.layerPanel.fitToBoundsButtonLabel": "适应", "xpack.maps.layerPanel.footer.cancelButtonLabel": "取消", "xpack.maps.layerPanel.footer.closeButtonLabel": "关闭", "xpack.maps.layerPanel.footer.removeLayerButtonLabel": "移除图层", @@ -10713,7 +10673,6 @@ "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.startDateLabel": "开始日期", "xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage": "必须设置存储桶跨度", "xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage": "找到重复的检测工具。", - "xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage": "{value} 不是有效的时间间隔格式,例如,{tenMinutes}、{oneHour}。还需要大于零。", "xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists": "组 ID 已存在。组 ID 不能与现有作业或组相同。", "xpack.ml.newJob.wizard.validateJob.jobGroupAllowedCharactersDescription": "作业组名称可以包含小写字母数字(a-z 和 0-9)、连字符或下划线;必须以字母数字字符开头和结尾", "xpack.ml.newJob.wizard.validateJob.jobGroupMaxLengthDescription": "作业组名称的长度不得超过 {maxLength, plural, one {# 个字符} other {# 个字符}}。", @@ -10726,7 +10685,6 @@ "xpack.ml.newJob.wizard.validateJob.queryIsInvalidEsQuery": "数据馈送查询必须是有效的 Elasticsearch 查询。", "xpack.ml.overview.analyticsList.createFirstJobMessage": "创建您的首个数据帧分析作业", "xpack.ml.overview.analyticsList.createJobButtonText": "创建作业", - "xpack.ml.overview.analyticsList.emptyPromptText": "数据帧分析允许您对数据执行不同的分析,并使用结果标注数据。该作业会将标注的数据以及源数据的副本置于新的索引中。", "xpack.ml.overview.analyticsList.errorPromptTitle": "获取数据帧分析列表时发生错误。", "xpack.ml.overview.analyticsList.id": "ID", "xpack.ml.overview.analyticsList.manageJobsButtonText": "管理作业", @@ -11740,7 +11698,6 @@ "xpack.monitoring.metrics.apm.outputFailedEventsRate.failedDescription": "输出处理的事件(包括重试)", "xpack.monitoring.metrics.apm.outputFailedEventsRate.failedLabel": "失败", "xpack.monitoring.metrics.apm.outputFailedEventsRateTitle": "输出失败事件速率", - "xpack.monitoring.metrics.apm.perMinuteUnitLabel": "/分钟", "xpack.monitoring.metrics.apm.processedEvents.transactionDescription": "已处理事务事件", "xpack.monitoring.metrics.apm.processedEvents.transactionLabel": "事务", "xpack.monitoring.metrics.apm.processedEventsTitle": "已处理事件", @@ -15817,7 +15774,6 @@ "xpack.transform.cloneTransform.breadcrumbTitle": "克隆转换", "xpack.transform.createTransform.breadcrumbTitle": "创建转换", "xpack.transform.description": "描述", - "xpack.transform.destinationIndex": "目标 IP", "xpack.transform.groupby.popoverForm.aggLabel": "聚合", "xpack.transform.groupBy.popoverForm.aggNameAlreadyUsedError": "其他分组依据配置已使用该名称。", "xpack.transform.groupBy.popoverForm.aggNameInvalidCharError": "名称无效。不允许使用字符“[”、“]”和“>”,且名称不得以空格字符开头或结束。", @@ -15852,7 +15808,6 @@ "xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "预览请求未返回任何数据。请确保可选查询返回数据且存在分组依据和聚合字段使用的字段的值。", "xpack.transform.pivotPreview.PivotPreviewTitle": "转换数据透视表预览", "xpack.transform.progress": "进度", - "xpack.transform.sourceIndex": "源索引", "xpack.transform.statsBar.batchTransformsLabel": "批量", "xpack.transform.statsBar.continuousTransformsLabel": "连续", "xpack.transform.statsBar.failedTransformsLabel": "失败", @@ -16013,7 +15968,6 @@ "xpack.transform.transformList.transformDetails.messagesPane.timeLabel": "时间", "xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel": "消息", "xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel": "预览", - "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "作业详情", "xpack.transform.transformList.transformDocsLinkText": "转换文档", "xpack.transform.transformList.transformTitle": "数据帧作业", "xpack.transform.transformsDescription": "使用转换将现有 Elasticsearch 索引透视成摘要式或以实体为中心的索引。", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 6379c4e94866af..5d52896cc628f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -277,6 +277,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { onSelectionChange(updatedSelectedItemsList: ActionConnectorTableItem[]) { setSelectedItems(updatedSelectedItemsList); }, + selectable: ({ isPreconfigured }: ActionConnectorTableItem) => !isPreconfigured, } : undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx index 09acf4fe1ef68d..fe592aadb37a5d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx @@ -136,14 +136,14 @@ export const ThresholdExpression = ({ ) : null} 0 || !threshold[i]} + isInvalid={errors[`threshold${i}`]?.length > 0 || !threshold[i]} error={errors[`threshold${i}`]} > 0 || !threshold[i]} + isInvalid={errors[`threshold${i}`]?.length > 0 || !threshold[i]} onChange={(e) => { const { value } = e.target; const thresholdVal = value !== '' ? parseFloat(value) : undefined; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 9c2593ee79f937..dea9974791a881 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -6,7 +6,7 @@ jest.mock('../es_indices_state_check', () => ({ esIndicesStateCheck: jest.fn() })); import { BehaviorSubject } from 'rxjs'; import { Logger } from 'src/core/server'; -import { loggingServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { IndexGroup, @@ -60,7 +60,7 @@ describe('reindexService', () => { runWhileIndexGroupLocked: jest.fn(async (group: string, f: any) => f({ attributes: {} })), }; callCluster = jest.fn(); - log = loggingServiceMock.create().get(); + log = loggingSystemMock.create().get(); licensingPluginSetup = licensingMock.createSetup(); licensingPluginSetup.license$ = new BehaviorSubject( licensingMock.createLicense({ diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx index a6bb097de45ad7..a1e23ab8b38a72 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_chart.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,6 +26,7 @@ import { getTickFormat } from './get_tick_format'; import { ChartEmptyState } from './chart_empty_state'; import { DurationAnomaliesBar } from './duration_line_bar_list'; import { AnomalyRecords } from '../../../state/actions'; +import { UptimeThemeContext } from '../../../contexts'; interface DurationChartProps { /** @@ -59,6 +60,8 @@ export const DurationChartComponent = ({ const [hiddenLegends, setHiddenLegends] = useState([]); + const { chartTheme } = useContext(UptimeThemeContext); + const onBrushEnd: BrushEndListener = ({ x }) => { if (!x) { return; @@ -93,6 +96,7 @@ export const DurationChartComponent = ({ legendPosition={Position.Bottom} onBrushEnd={onBrushEnd} onLegendItemClick={legendToggleVisibility} + {...chartTheme} /> { const { colors: { danger }, + chartTheme, } = useContext(UptimeThemeContext); const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd } = getUrlParams(); @@ -62,6 +63,7 @@ export const MonitorBarSeries = ({ histogramSeries }: MonitorBarSeriesProps) => = ({ }) => { const { colors: { danger, gray }, + chartTheme, } = useContext(UptimeThemeContext); const [, updateUrlParams] = useUrlParams(); @@ -128,6 +129,7 @@ export const PingHistogramComponent: React.FC = ({ }} showLegend={false} onBrushEnd={onBrushEnd} + {...chartTheme} /> = ({ darkMo const value = useMemo(() => { return { colors, + chartTheme: { + baseTheme: darkMode ? DARK_THEME : LIGHT_THEME, + theme: darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme, + }, }; - }, [colors]); + }, [colors, darkMode]); return ; }; diff --git a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts index 45f6a1515d0493..01c631d074c53e 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts @@ -9,6 +9,7 @@ import { deleteEventsStream, deleteMetadataStream, deletePolicyStream, + deleteAlertsStream, } from '../data_stream_helper'; import { indexHostsAndAlerts } from '../../../../../plugins/security_solution/common/endpoint/index_data'; @@ -42,9 +43,10 @@ export default function ({ getService }: FtrProviderContext) { 'alerts-seed', numberOfHosts, 1, - 'metrics-endpoint.metadata-default-1', - 'metrics-endpoint.policy-default-1', - 'events-endpoint-1', + 'metrics-endpoint.metadata-default', + 'metrics-endpoint.policy-default', + 'logs-endpoint.events.process-default', + 'logs-endpoint.alerts-default', numberOfAlertsPerHost ); }); @@ -54,6 +56,7 @@ export default function ({ getService }: FtrProviderContext) { // to do it manually await Promise.all([ deleteEventsStream(getService), + deleteAlertsStream(getService), deleteMetadataStream(getService), deletePolicyStream(getService), ]); diff --git a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts index 8ab08ff501f5bd..d2e99a80ef8a13 100644 --- a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts +++ b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts @@ -8,7 +8,9 @@ import { Client } from '@elastic/elasticsearch'; import { metadataIndexPattern, eventsIndexPattern, + alertsIndexPattern, policyIndexPattern, + metadataMirrorIndexPattern, } from '../../../../plugins/security_solution/common/endpoint/constants'; export async function deleteDataStream(getService: (serviceName: 'es') => Client, index: string) { @@ -28,10 +30,18 @@ export async function deleteMetadataStream(getService: (serviceName: 'es') => Cl await deleteDataStream(getService, metadataIndexPattern); } +export async function deleteMetadataMirrorStream(getService: (serviceName: 'es') => Client) { + await deleteDataStream(getService, metadataMirrorIndexPattern); +} + export async function deleteEventsStream(getService: (serviceName: 'es') => Client) { await deleteDataStream(getService, eventsIndexPattern); } +export async function deleteAlertsStream(getService: (serviceName: 'es') => Client) { + await deleteDataStream(getService, alertsIndexPattern); +} + export async function deletePolicyStream(getService: (serviceName: 'es') => Client) { await deleteDataStream(getService, policyIndexPattern); } diff --git a/x-pack/test/api_integration/apis/endpoint/metadata.ts b/x-pack/test/api_integration/apis/endpoint/metadata.ts index 41531269ddeb95..0d77486e07536c 100644 --- a/x-pack/test/api_integration/apis/endpoint/metadata.ts +++ b/x-pack/test/api_integration/apis/endpoint/metadata.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { deleteMetadataStream } from './data_stream_helper'; +import { deleteMetadataMirrorStream, deleteMetadataStream } from './data_stream_helper'; /** * The number of host documents in the es archive. @@ -33,6 +33,40 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('POST /api/endpoint/metadata when metadata mirror index contains unenrolled host', () => { + before(async () => { + await esArchiver.load('endpoint/metadata/unenroll_feature/metadata', { useCreate: true }); + await esArchiver.load('endpoint/metadata/unenroll_feature/metadata_mirror', { + useCreate: true, + }); + }); + + after(async () => { + await deleteMetadataStream(getService); + await deleteMetadataMirrorStream(getService); + }); + + it('metadata api should return only enrolled host', async () => { + const { body } = await supertest + .post('/api/endpoint/metadata') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(1); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return 400 when an unenrolled host is retrieved', async () => { + const { body } = await supertest + .get('/api/endpoint/metadata/1fdca33f-799f-49f4-939c-ea4383c77671') + .send() + .expect(400); + expect(body.message).to.eql('the requested endpoint is unenrolled'); + }); + }); + describe('POST /api/endpoint/metadata when index is not empty', () => { before( async () => await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }) diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index c9356de07f7116..eeca8ee54e32f3 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -6,8 +6,8 @@ import _ from 'lodash'; import expect from '@kbn/expect'; import { - ChildNode, - LifecycleNode, + ResolverChildNode, + ResolverLifecycleNode, ResolverAncestry, ResolverEvent, ResolverRelatedEvents, @@ -35,7 +35,7 @@ import { Options, GeneratedTrees } from '../../services/resolver'; * @param node a lifecycle node containing the start and end events for a node * @param nodeMap a map of entity_ids to nodes to look for the passed in `node` */ -const expectLifecycleNodeInMap = (node: LifecycleNode, nodeMap: Map) => { +const expectLifecycleNodeInMap = (node: ResolverLifecycleNode, nodeMap: Map) => { const genNode = nodeMap.get(node.entityID); expect(genNode).to.be.ok(); compareArrays(genNode!.lifecycle, node.lifecycle, true); @@ -49,7 +49,11 @@ const expectLifecycleNodeInMap = (node: LifecycleNode, nodeMap: Map { +const verifyAncestry = ( + ancestors: ResolverLifecycleNode[], + tree: Tree, + verifyLastParent: boolean +) => { // group the ancestors by their entity_id mapped to a lifecycle node const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); // group by parent entity_id @@ -97,7 +101,7 @@ const verifyAncestry = (ancestors: LifecycleNode[], tree: Tree, verifyLastParent * * @param ancestors an array of ancestor nodes */ -const retrieveDistantAncestor = (ancestors: LifecycleNode[]) => { +const retrieveDistantAncestor = (ancestors: ResolverLifecycleNode[]) => { // group the ancestors by their entity_id mapped to a lifecycle node const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); let node = ancestors[0]; @@ -124,7 +128,7 @@ const retrieveDistantAncestor = (ancestors: LifecycleNode[]) => { * @param childrenPerParent an optional number to compare that there are a certain number of children for each parent */ const verifyChildren = ( - children: ChildNode[], + children: ResolverChildNode[], tree: Tree, numberOfParents?: number, childrenPerParent?: number @@ -178,7 +182,11 @@ const compareArrays = ( * @param relatedEvents the related events received for a particular node * @param categories the related event info used when generating the resolver tree */ -const verifyStats = (stats: ResolverNodeStats | undefined, categories: RelatedEventInfo[]) => { +const verifyStats = ( + stats: ResolverNodeStats | undefined, + categories: RelatedEventInfo[], + relatedAlerts: number +) => { expect(stats).to.not.be(undefined); let totalExpEvents = 0; for (const cat of categories) { @@ -196,6 +204,7 @@ const verifyStats = (stats: ResolverNodeStats | undefined, categories: RelatedEv totalExpEvents += cat.count; } expect(stats?.events.total).to.be(totalExpEvents); + expect(stats?.totalAlerts); }; /** @@ -204,9 +213,13 @@ const verifyStats = (stats: ResolverNodeStats | undefined, categories: RelatedEv * @param nodes an array of lifecycle nodes that should have a stats field defined * @param categories the related event info used when generating the resolver tree */ -const verifyLifecycleStats = (nodes: LifecycleNode[], categories: RelatedEventInfo[]) => { +const verifyLifecycleStats = ( + nodes: ResolverLifecycleNode[], + categories: RelatedEventInfo[], + relatedAlerts: number +) => { for (const node of nodes) { - verifyStats(node.stats, categories); + verifyStats(node.stats, categories, relatedAlerts); } }; @@ -220,13 +233,13 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC { category: RelatedEventCategory.File, count: 1 }, { category: RelatedEventCategory.Registry, count: 1 }, ]; - + const relatedAlerts = 4; let resolverTrees: GeneratedTrees; let tree: Tree; const treeOptions: Options = { ancestors: 5, relatedEvents: relatedEventsToGen, - relatedAlerts: 4, + relatedAlerts, children: 3, generations: 2, percentTerminated: 100, @@ -697,11 +710,11 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC expect(body.children.nextChild).to.equal(null); expect(body.children.childNodes.length).to.equal(12); verifyChildren(body.children.childNodes, tree, 4, 3); - verifyLifecycleStats(body.children.childNodes, relatedEventsToGen); + verifyLifecycleStats(body.children.childNodes, relatedEventsToGen, relatedAlerts); expect(body.ancestry.nextAncestor).to.equal(null); verifyAncestry(body.ancestry.ancestors, tree, true); - verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen); + verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen, relatedAlerts); expect(body.relatedEvents.nextEvent).to.equal(null); compareArrays(tree.origin.relatedEvents, body.relatedEvents.events, true); @@ -710,7 +723,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); compareArrays(tree.origin.lifecycle, body.lifecycle, true); - verifyStats(body.stats, relatedEventsToGen); + verifyStats(body.stats, relatedEventsToGen, relatedAlerts); }); }); }); diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index a8c026ac6a1bd0..df81b826132a9d 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -6,6 +6,7 @@ export default function loadTests({ loadTestFile }) { describe('Fleet Endpoints', () => { + loadTestFile(require.resolve('./setup')); loadTestFile(require.resolve('./delete_agent')); loadTestFile(require.resolve('./list_agent')); loadTestFile(require.resolve('./unenroll_agent')); @@ -16,7 +17,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); loadTestFile(require.resolve('./agents/actions')); - loadTestFile(require.resolve('./setup')); loadTestFile(require.resolve('./agent_flow')); }); } diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index b79dc3f3ffe59d..3f3294c85d6df3 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -27,9 +27,9 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./short_urls')); loadTestFile(require.resolve('./lens')); loadTestFile(require.resolve('./fleet')); - loadTestFile(require.resolve('./ingest_manager')); - loadTestFile(require.resolve('./endpoint')); loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./transform')); + loadTestFile(require.resolve('./endpoint')); + loadTestFile(require.resolve('./ingest_manager')); }); } diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index 219f2471f8e68b..e1756df42ca25b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -50,17 +50,18 @@ export default function ({ getService }: FtrProviderContext) { const deleteDataStream = (name: string) => { return es.dataManagement - .deleteComposableIndexTemplate({ + .deleteDataStream({ name, }) .then(() => - es.dataManagement.deleteDataStream({ + es.dataManagement.deleteComposableIndexTemplate({ name, }) ); }; - describe('Data streams', function () { + // Unskip once ES snapshot has been promoted that updates the data stream response + describe.skip('Data streams', function () { const testDataStreamName = 'test-data-stream'; describe('Get', () => { @@ -79,7 +80,7 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreams).to.eql([ { name: testDataStreamName, - timeStampField: '@timestamp', + timeStampField: { name: '@timestamp', mapping: { type: 'date' } }, indices: [ { name: indexName, diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_analysis.ts b/x-pack/test/api_integration/apis/metrics_ui/log_analysis.ts index 172e582e40de56..7bcea4c17cdcdb 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/log_analysis.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/log_analysis.ts @@ -37,7 +37,7 @@ export default ({ getService }: FtrProviderContext) => { before(() => esArchiver.load('empty_kibana')); after(() => esArchiver.unload('empty_kibana')); - it('should return buckets when the results index exists with matching documents', async () => { + it('should return buckets when there are matching ml result documents', async () => { const { body } = await supertest .post(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH) .set(COMMON_HEADERS) @@ -68,7 +68,7 @@ export default ({ getService }: FtrProviderContext) => { ).to.be(true); }); - it('should return no buckets when the results index exists without matching documents', async () => { + it('should return no buckets when there are no matching ml result documents', async () => { const { body } = await supertest .post(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH) .set(COMMON_HEADERS) @@ -78,7 +78,7 @@ export default ({ getService }: FtrProviderContext) => { sourceId: 'default', timeRange: { startTime: TIME_BEFORE_START - 10 * 15 * 60 * 1000, - endTime: TIME_BEFORE_START, + endTime: TIME_BEFORE_START - 1, }, bucketDuration: 15 * 60 * 1000, }, @@ -94,25 +94,6 @@ export default ({ getService }: FtrProviderContext) => { expect(logEntryRateBuckets.data.bucketDuration).to.be(15 * 60 * 1000); expect(logEntryRateBuckets.data.histogramBuckets).to.be.empty(); }); - - it('should return a NotFound error when the results index does not exist', async () => { - await supertest - .post(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH) - .set(COMMON_HEADERS) - .send( - getLogEntryRateRequestPayloadRT.encode({ - data: { - sourceId: 'does-not-exist', - timeRange: { - startTime: TIME_BEFORE_START, - endTime: TIME_AFTER_END, - }, - bucketDuration: 15 * 60 * 1000, - }, - }) - ) - .expect(404); - }); }); }); }); diff --git a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json index aa9e4cc125a900..e54a1c2210d486 100644 --- a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json +++ b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/cluster.json @@ -9,727 +9,1046 @@ "timeOfLastEvent": "2018-08-31T13:59:21.201Z" }, "metrics": { - "apm_cpu": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + "apm_cpu": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.cpu.total.value", + "metricAgg": "max", + "label": "Total", + "title": "CPU Utilization", + "description": "Percentage of CPU time spent executing (user+kernel mode) for the APM process", + "units": "%", + "format": "0.[00]", + "hasCalculation": true, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.1 + ], + [ + 1535723940000, + 0.26666666666666666 + ] + ] + } + ], + "apm_os_load": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.1", + "metricAgg": "max", + "label": "1m", + "title": "System Load", + "description": "Load average over the last 1 minute", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 1.37 + ], + [ + 1535723910000, + 1.01 + ], + [ + 1535723940000, + 0.61 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.cpu.total.value", - "metricAgg": "max", - "label": "Total", - "title": "CPU Utilization", - "description": "Percentage of CPU time spent executing (user+kernel mode) for the APM process", - "units": "%", - "format": "0.[00]", - "hasCalculation": true, - "isDerivative": true + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.5", + "metricAgg": "max", + "label": "5m", + "title": "System Load", + "description": "Load average over the last 5 minutes", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 1.72 + ], + [ + 1535723910000, + 1.6 + ], + [ + 1535723940000, + 1.45 + ] + ] }, - "data": [ - [1535723880000, null], - [1535723910000, 0.1], - [1535723940000, 0.26666666666666666] - ] - }], - "apm_os_load": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.15", + "metricAgg": "max", + "label": "15m", + "title": "System Load", + "description": "Load average over the last 15 minutes", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 2.5 + ], + [ + 1535723910000, + 2.43 + ], + [ + 1535723940000, + 2.35 + ] + ] + } + ], + "apm_memory": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.memory_alloc", + "metricAgg": "max", + "label": "Allocated Memory", + "title": "Memory", + "description": "Allocated memory", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 4660952 + ], + [ + 1535723910000, + 3888048 + ], + [ + 1535723940000, + 3445920 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.1", - "metricAgg": "max", - "label": "1m", - "title": "System Load", - "description": "Load average over the last 1 minute", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.rss", + "metricAgg": "max", + "label": "Process Total", + "title": "Memory", + "description": "Resident set size of memory reserved by the APM service from the OS", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 10866688 + ], + [ + 1535723910000, + 11456512 + ], + [ + 1535723940000, + 12095488 + ] + ] }, - "data": [ - [1535723880000, 1.37], - [1535723910000, 1.01], - [1535723940000, 0.61] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.gc_next", + "metricAgg": "max", + "label": "GC Next", + "title": "Memory", + "description": "Limit of allocated memory at which garbage collection will occur", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1535723880000, + 5212816 + ], + [ + 1535723910000, + 4996912 + ], + [ + 1535723940000, + 4886176 + ] + ] + } + ], + "apm_output_events_rate_success": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.total", + "metricAgg": "max", + "label": "Total", + "title": "Output Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.3 + ], + [ + 1535723940000, + 0.2 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.5", - "metricAgg": "max", - "label": "5m", - "title": "System Load", - "description": "Load average over the last 5 minutes", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.active", + "metricAgg": "max", + "label": "Active", + "title": "Output Active Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + null + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, 1.72], - [1535723910000, 1.6], - [1535723940000, 1.45] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.acked", + "metricAgg": "max", + "label": "Acked", + "title": "Output Acked Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.3 + ], + [ + 1535723940000, + 0.2 + ] + ] + } + ], + "apm_output_events_rate_failure": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.failed", + "metricAgg": "max", + "label": "Failed", + "title": "Output Failed Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.15", - "metricAgg": "max", - "label": "15m", - "title": "System Load", - "description": "Load average over the last 15 minutes", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.dropped", + "metricAgg": "max", + "label": "Dropped", + "title": "Output Dropped Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] + } + ], + "apm_responses_valid": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.count", + "metricAgg": "max", + "label": "Total", + "title": "Response Count Intake API", + "description": "HTTP Requests responded to by server", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 1.4 + ], + [ + 1535723940000, + 1.4 + ] + ] }, - "data": [ - [1535723880000, 2.5], - [1535723910000, 2.43], - [1535723940000, 2.35] - ] - }], - "apm_memory": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.valid.ok", + "metricAgg": "max", + "label": "Ok", + "title": "Ok", + "description": "200 OK response count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 1.3333333333333333 + ], + [ + 1535723940000, + 1.3666666666666667 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.memory_alloc", - "metricAgg": "max", - "label": "Allocated Memory", - "title": "Memory", - "description": "Allocated memory", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.valid.accepted", + "metricAgg": "max", + "label": "Accepted", + "title": "Accepted", + "description": "HTTP Requests successfully reporting new events", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.06666666666666667 + ], + [ + 1535723940000, + 0.03333333333333333 + ] + ] + } + ], + "apm_responses_errors": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.toolarge", + "metricAgg": "max", + "label": "Too large", + "title": "Response Errors Intake API", + "description": "HTTP Requests rejected due to excessive payload size", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, 4660952], - [1535723910000, 3888048], - [1535723940000, 3445920] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.validate", + "metricAgg": "max", + "label": "Validate", + "title": "Validate", + "description": "HTTP Requests rejected due to payload validation error", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.rss", - "metricAgg": "max", - "label": "Process Total", - "title": "Memory", - "description": "Resident set size of memory reserved by the APM service from the OS", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.method", + "metricAgg": "max", + "label": "Method", + "title": "Method", + "description": "HTTP Requests rejected due to incorrect HTTP method", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, 10866688], - [1535723910000, 11456512], - [1535723940000, 12095488] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.unauthorized", + "metricAgg": "max", + "label": "Unauthorized", + "title": "Unauthorized", + "description": "HTTP Requests rejected due to invalid secret token", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.gc_next", - "metricAgg": "max", - "label": "GC Next", - "title": "Memory", - "description": "Limit of allocated memory at which garbage collection will occur", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.ratelimit", + "metricAgg": "max", + "label": "Rate limit", + "title": "Rate limit", + "description": "HTTP Requests rejected to due excessive rate limit", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, 5212816], - [1535723910000, 4996912], - [1535723940000, 4886176] - ] - }], - "apm_output_events_rate_success": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.queue", + "metricAgg": "max", + "label": "Queue", + "title": "Queue", + "description": "HTTP Requests rejected to due internal queue filling up", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.total", - "metricAgg": "max", - "label": "Total", - "title": "Output Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.decode", + "metricAgg": "max", + "label": "Decode", + "title": "Decode", + "description": "HTTP Requests rejected to due decoding errors - invalid json, incorrect data type for entity", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, null], - [1535723910000, 18], - [1535723940000, 12] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.forbidden", + "metricAgg": "max", + "label": "Forbidden", + "title": "Forbidden", + "description": "Forbidden HTTP Requests rejected - CORS violation, disabled enpoint", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.active", - "metricAgg": "max", - "label": "Active", - "title": "Output Active Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.concurrency", + "metricAgg": "max", + "label": "Concurrency", + "title": "Concurrency", + "description": "HTTP Requests rejected due to overall concurrency limit breach", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, null], - [1535723910000, null], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.closed", + "metricAgg": "max", + "label": "Closed", + "title": "Closed", + "description": "HTTP Requests rejected during server shutdown", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.acked", - "metricAgg": "max", - "label": "Acked", - "title": "Output Acked Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.internal", + "metricAgg": "max", + "label": "Internal", + "title": "Internal", + "description": "HTTP Requests rejected due to a miscellaneous internal error", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] + } + ], + "apm_requests": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.request.count", + "metricAgg": "max", + "label": "Requested", + "title": "Request Count Intake API", + "description": "HTTP Requests received by server", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 1.4 + ], + [ + 1535723940000, + 1.4 + ] + ] + } + ], + "apm_transformations": [ + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.transaction.transformations", + "metricAgg": "max", + "label": "Transaction", + "title": "Processed Events", + "description": "Transaction events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.1 + ], + [ + 1535723940000, + 0.06666666666666667 + ] + ] }, - "data": [ - [1535723880000, null], - [1535723910000, 18], - [1535723940000, 12] - ] - }], - "apm_output_events_rate_failure": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.span.transformations", + "metricAgg": "max", + "label": "Span", + "title": "Transformations", + "description": "Span events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0.2 + ], + [ + 1535723940000, + 0.13333333333333333 + ] + ] }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.failed", - "metricAgg": "max", - "label": "Failed", - "title": "Output Failed Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.error.transformations", + "metricAgg": "max", + "label": "Error", + "title": "Transformations", + "description": "Error events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.dropped", - "metricAgg": "max", - "label": "Dropped", - "title": "Output Dropped Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }], - "apm_responses_valid": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.count", - "metricAgg": "max", - "label": "Total", - "title": "Response Count Intake API", - "description": "HTTP Requests responded to by server", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 84], - [1535723940000, 84] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.valid.ok", - "metricAgg": "max", - "label": "Ok", - "title": "Ok", - "description": "200 OK response count", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 80], - [1535723940000, 82] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.valid.accepted", - "metricAgg": "max", - "label": "Accepted", - "title": "Accepted", - "description": "HTTP Requests successfully reporting new events", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 4], - [1535723940000, 2] - ] - }], - "apm_responses_errors": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.toolarge", - "metricAgg": "max", - "label": "Too large", - "title": "Response Errors Intake API", - "description": "HTTP Requests rejected due to excessive payload size", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.validate", - "metricAgg": "max", - "label": "Validate", - "title": "Validate", - "description": "HTTP Requests rejected due to payload validation error", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.method", - "metricAgg": "max", - "label": "Method", - "title": "Method", - "description": "HTTP Requests rejected due to incorrect HTTP method", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.unauthorized", - "metricAgg": "max", - "label": "Unauthorized", - "title": "Unauthorized", - "description": "HTTP Requests rejected due to invalid secret token", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.ratelimit", - "metricAgg": "max", - "label": "Rate limit", - "title": "Rate limit", - "description": "HTTP Requests rejected to due excessive rate limit", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.queue", - "metricAgg": "max", - "label": "Queue", - "title": "Queue", - "description": "HTTP Requests rejected to due internal queue filling up", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.decode", - "metricAgg": "max", - "label": "Decode", - "title": "Decode", - "description": "HTTP Requests rejected to due decoding errors - invalid json, incorrect data type for entity", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.forbidden", - "metricAgg": "max", - "label": "Forbidden", - "title": "Forbidden", - "description": "Forbidden HTTP Requests rejected - CORS violation, disabled enpoint", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.concurrency", - "metricAgg": "max", - "label": "Concurrency", - "title": "Concurrency", - "description": "HTTP Requests rejected due to overall concurrency limit breach", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.closed", - "metricAgg": "max", - "label": "Closed", - "title": "Closed", - "description": "HTTP Requests rejected during server shutdown", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.internal", - "metricAgg": "max", - "label": "Internal", - "title": "Internal", - "description": "HTTP Requests rejected due to a miscellaneous internal error", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }], - "apm_requests": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.request.count", - "metricAgg": "max", - "label": "Requested", - "title": "Request Count Intake API", - "description": "HTTP Requests received by server", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 84], - [1535723940000, 84] - ] - }], - "apm_transformations": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.transaction.transformations", - "metricAgg": "max", - "label": "Transaction", - "title": "Processed Events", - "description": "Transaction events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 6], - [1535723940000, 4] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.span.transformations", - "metricAgg": "max", - "label": "Span", - "title": "Transformations", - "description": "Span events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 12], - [1535723940000, 8] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.error.transformations", - "metricAgg": "max", - "label": "Error", - "title": "Transformations", - "description": "Error events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.metric.transformations", - "metricAgg": "max", - "label": "Metric", - "title": "Transformations", - "description": "Metric events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }] + { + "bucket_size": "30 seconds", + "timeRange": { + "min": 1535720389104, + "max": 1535723989104 + }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.metric.transformations", + "metricAgg": "max", + "label": "Metric", + "title": "Transformations", + "description": "Metric events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1535723880000, + null + ], + [ + 1535723910000, + 0 + ], + [ + 1535723940000, + 0 + ] + ] + } + ] } } diff --git a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/instance.json b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/instance.json index abcbf7557234d9..f1747507b71d53 100644 --- a/x-pack/test/api_integration/apis/monitoring/apm/fixtures/instance.json +++ b/x-pack/test/api_integration/apis/monitoring/apm/fixtures/instance.json @@ -1,727 +1,890 @@ { "metrics": { - "apm_cpu": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.cpu.total.value", - "metricAgg": "max", - "label": "Total", - "title": "CPU Utilization", - "description": "Percentage of CPU time spent executing (user+kernel mode) for the APM process", - "units": "%", - "format": "0.[00]", - "hasCalculation": true, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0.13333333333333333], - [1535723940000, 0.3] - ] - }], - "apm_os_load": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.1", - "metricAgg": "max", - "label": "1m", - "title": "System Load", - "description": "Load average over the last 1 minute", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 1.37], - [1535723910000, 1.01], - [1535723940000, 0.61] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.5", - "metricAgg": "max", - "label": "5m", - "title": "System Load", - "description": "Load average over the last 5 minutes", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 1.72], - [1535723910000, 1.6], - [1535723940000, 1.45] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.system.load.15", - "metricAgg": "max", - "label": "15m", - "title": "System Load", - "description": "Load average over the last 15 minutes", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 2.5], - [1535723910000, 2.43], - [1535723940000, 2.35] - ] - }], - "apm_memory": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.memory_alloc", - "metricAgg": "max", - "label": "Allocated Memory", - "title": "Memory", - "description": "Allocated memory", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 4421336], - [1535723910000, 3888048], - [1535723940000, 3087640] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.rss", - "metricAgg": "max", - "label": "Process Total", - "title": "Memory", - "description": "Resident set size of memory reserved by the APM service from the OS", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 10260480], - [1535723910000, 11456512], - [1535723940000, 12095488] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.beat.memstats.gc_next", - "metricAgg": "max", - "label": "GC Next", - "title": "Memory", - "description": "Limit of allocated memory at which garbage collection will occur", - "units": "B", - "format": "0,0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [ - [1535723880000, 4996912], - [1535723910000, 4996912], - [1535723940000, 4886176] - ] - }], - "apm_output_events_rate_success": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.total", - "metricAgg": "max", - "label": "Total", - "title": "Output Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 6], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.active", - "metricAgg": "max", - "label": "Active", - "title": "Output Active Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.acked", - "metricAgg": "max", - "label": "Acked", - "title": "Output Acked Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 6], - [1535723940000, 0] - ] - }], - "apm_output_events_rate_failure": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.failed", - "metricAgg": "max", - "label": "Failed", - "title": "Output Failed Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.libbeat.output.events.dropped", - "metricAgg": "max", - "label": "Dropped", - "title": "Output Dropped Events Rate", - "description": "Events processed by the output (including retries)", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }], - "apm_responses_valid": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.count", - "metricAgg": "max", - "label": "Total", - "title": "Response Count Intake API", - "description": "HTTP Requests responded to by server", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 42], - [1535723940000, 42] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.valid.ok", - "metricAgg": "max", - "label": "Ok", - "title": "Ok", - "description": "200 OK response count", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 40], - [1535723940000, 42] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.valid.accepted", - "metricAgg": "max", - "label": "Accepted", - "title": "Accepted", - "description": "HTTP Requests successfully reporting new events", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 2], - [1535723940000, 0] - ] - }], - "apm_responses_errors": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.toolarge", - "metricAgg": "max", - "label": "Too large", - "title": "Response Errors Intake API", - "description": "HTTP Requests rejected due to excessive payload size", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.validate", - "metricAgg": "max", - "label": "Validate", - "title": "Validate", - "description": "HTTP Requests rejected due to payload validation error", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.method", - "metricAgg": "max", - "label": "Method", - "title": "Method", - "description": "HTTP Requests rejected due to incorrect HTTP method", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.unauthorized", - "metricAgg": "max", - "label": "Unauthorized", - "title": "Unauthorized", - "description": "HTTP Requests rejected due to invalid secret token", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.ratelimit", - "metricAgg": "max", - "label": "Rate limit", - "title": "Rate limit", - "description": "HTTP Requests rejected to due excessive rate limit", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.queue", - "metricAgg": "max", - "label": "Queue", - "title": "Queue", - "description": "HTTP Requests rejected to due internal queue filling up", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.decode", - "metricAgg": "max", - "label": "Decode", - "title": "Decode", - "description": "HTTP Requests rejected to due decoding errors - invalid json, incorrect data type for entity", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.forbidden", - "metricAgg": "max", - "label": "Forbidden", - "title": "Forbidden", - "description": "Forbidden HTTP Requests rejected - CORS violation, disabled enpoint", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.concurrency", - "metricAgg": "max", - "label": "Concurrency", - "title": "Concurrency", - "description": "HTTP Requests rejected due to overall concurrency limit breach", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.closed", - "metricAgg": "max", - "label": "Closed", - "title": "Closed", - "description": "HTTP Requests rejected during server shutdown", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.response.errors.internal", - "metricAgg": "max", - "label": "Internal", - "title": "Internal", - "description": "HTTP Requests rejected due to a miscellaneous internal error", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }], - "apm_requests": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.server.request.count", - "metricAgg": "max", - "label": "Requested", - "title": "Request Count Intake API", - "description": "HTTP Requests received by server", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 42], - [1535723940000, 42] - ] - }], - "apm_transformations": [{ - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.transaction.transformations", - "metricAgg": "max", - "label": "Transaction", - "title": "Processed Events", - "description": "Transaction events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 2], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.span.transformations", - "metricAgg": "max", - "label": "Span", - "title": "Transformations", - "description": "Span events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 4], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.error.transformations", - "metricAgg": "max", - "label": "Error", - "title": "Transformations", - "description": "Error events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }, { - "bucket_size": "30 seconds", - "timeRange": { - "min": 1535720389104, - "max": 1535723989104 - }, - "metric": { - "app": "apm", - "field": "beats_stats.metrics.apm-server.processor.metric.transformations", - "metricAgg": "max", - "label": "Metric", - "title": "Transformations", - "description": "Metric events processed", - "units": "/m", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true - }, - "data": [ - [1535723880000, null], - [1535723910000, 0], - [1535723940000, 0] - ] - }] + "apm_cpu": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.cpu.total.value", + "metricAgg": "max", + "label": "Total", + "title": "CPU Utilization", + "description": "Percentage of CPU time spent executing (user+kernel mode) for the APM process", + "units": "%", + "format": "0.[00]", + "hasCalculation": true, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.13333333333333333], + [1535723940000, 0.3] + ] + } + ], + "apm_os_load": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.1", + "metricAgg": "max", + "label": "1m", + "title": "System Load", + "description": "Load average over the last 1 minute", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 1.37], + [1535723910000, 1.01], + [1535723940000, 0.61] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.5", + "metricAgg": "max", + "label": "5m", + "title": "System Load", + "description": "Load average over the last 5 minutes", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 1.72], + [1535723910000, 1.6], + [1535723940000, 1.45] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.system.load.15", + "metricAgg": "max", + "label": "15m", + "title": "System Load", + "description": "Load average over the last 15 minutes", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 2.5], + [1535723910000, 2.43], + [1535723940000, 2.35] + ] + } + ], + "apm_memory": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.memory_alloc", + "metricAgg": "max", + "label": "Allocated Memory", + "title": "Memory", + "description": "Allocated memory", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 4421336], + [1535723910000, 3888048], + [1535723940000, 3087640] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.rss", + "metricAgg": "max", + "label": "Process Total", + "title": "Memory", + "description": "Resident set size of memory reserved by the APM service from the OS", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 10260480], + [1535723910000, 11456512], + [1535723940000, 12095488] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.beat.memstats.gc_next", + "metricAgg": "max", + "label": "GC Next", + "title": "Memory", + "description": "Limit of allocated memory at which garbage collection will occur", + "units": "B", + "format": "0,0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [1535723880000, 4996912], + [1535723910000, 4996912], + [1535723940000, 4886176] + ] + } + ], + "apm_output_events_rate_success": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.total", + "metricAgg": "max", + "label": "Total", + "title": "Output Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.1], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.active", + "metricAgg": "max", + "label": "Active", + "title": "Output Active Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.acked", + "metricAgg": "max", + "label": "Acked", + "title": "Output Acked Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.1], + [1535723940000, 0] + ] + } + ], + "apm_output_events_rate_failure": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.failed", + "metricAgg": "max", + "label": "Failed", + "title": "Output Failed Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.libbeat.output.events.dropped", + "metricAgg": "max", + "label": "Dropped", + "title": "Output Dropped Events Rate", + "description": "Events processed by the output (including retries)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ], + "apm_responses_valid": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.count", + "metricAgg": "max", + "label": "Total", + "title": "Response Count Intake API", + "description": "HTTP Requests responded to by server", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.7], + [1535723940000, 0.7] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.valid.ok", + "metricAgg": "max", + "label": "Ok", + "title": "Ok", + "description": "200 OK response count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.6666666666666666], + [1535723940000, 0.7] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.valid.accepted", + "metricAgg": "max", + "label": "Accepted", + "title": "Accepted", + "description": "HTTP Requests successfully reporting new events", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.03333333333333333], + [1535723940000, 0] + ] + } + ], + "apm_responses_errors": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.toolarge", + "metricAgg": "max", + "label": "Too large", + "title": "Response Errors Intake API", + "description": "HTTP Requests rejected due to excessive payload size", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.validate", + "metricAgg": "max", + "label": "Validate", + "title": "Validate", + "description": "HTTP Requests rejected due to payload validation error", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.method", + "metricAgg": "max", + "label": "Method", + "title": "Method", + "description": "HTTP Requests rejected due to incorrect HTTP method", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.unauthorized", + "metricAgg": "max", + "label": "Unauthorized", + "title": "Unauthorized", + "description": "HTTP Requests rejected due to invalid secret token", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.ratelimit", + "metricAgg": "max", + "label": "Rate limit", + "title": "Rate limit", + "description": "HTTP Requests rejected to due excessive rate limit", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.queue", + "metricAgg": "max", + "label": "Queue", + "title": "Queue", + "description": "HTTP Requests rejected to due internal queue filling up", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.decode", + "metricAgg": "max", + "label": "Decode", + "title": "Decode", + "description": "HTTP Requests rejected to due decoding errors - invalid json, incorrect data type for entity", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.forbidden", + "metricAgg": "max", + "label": "Forbidden", + "title": "Forbidden", + "description": "Forbidden HTTP Requests rejected - CORS violation, disabled enpoint", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.concurrency", + "metricAgg": "max", + "label": "Concurrency", + "title": "Concurrency", + "description": "HTTP Requests rejected due to overall concurrency limit breach", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.closed", + "metricAgg": "max", + "label": "Closed", + "title": "Closed", + "description": "HTTP Requests rejected during server shutdown", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.response.errors.internal", + "metricAgg": "max", + "label": "Internal", + "title": "Internal", + "description": "HTTP Requests rejected due to a miscellaneous internal error", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ], + "apm_requests": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.server.request.count", + "metricAgg": "max", + "label": "Requested", + "title": "Request Count Intake API", + "description": "HTTP Requests received by server", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.7], + [1535723940000, 0.7] + ] + } + ], + "apm_transformations": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.transaction.transformations", + "metricAgg": "max", + "label": "Transaction", + "title": "Processed Events", + "description": "Transaction events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.03333333333333333], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.span.transformations", + "metricAgg": "max", + "label": "Span", + "title": "Transformations", + "description": "Span events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0.06666666666666667], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.error.transformations", + "metricAgg": "max", + "label": "Error", + "title": "Transformations", + "description": "Error events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.processor.metric.transformations", + "metricAgg": "max", + "label": "Metric", + "title": "Transformations", + "description": "Metric events processed", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ], + "apm_acm_response": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.count", + "metricAgg": "max", + "label": "Count", + "title": "Response Count Agent Configuration Management", + "description": "HTTP requests responded to by APM Server", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.count", + "metricAgg": "max", + "label": "Error Count", + "title": "Response Error Count Agent Configuration Management", + "description": "HTTP errors count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.valid.ok", + "metricAgg": "max", + "label": "OK", + "title": "Response OK Count Agent Configuration Management", + "description": "200 OK response count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.valid.notmodified", + "metricAgg": "max", + "label": "Not Modified", + "title": "Response Not Modified Agent Configuration Management", + "description": "304 Not modified response count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ], + "apm_acm_response_errors": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.forbidden", + "metricAgg": "max", + "label": "Count", + "title": "Response Errors Agent Configuration Management", + "description": "Forbidden HTTP requests rejected count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.unauthorized", + "metricAgg": "max", + "label": "Unauthorized", + "title": "Response Unauthorized Errors Agent Configuration Management", + "description": "Unauthorized HTTP requests rejected count", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.unavailable", + "metricAgg": "max", + "label": "Unavailable", + "title": "Response Unavailable Errors Agent Configuration Management", + "description": "Unavailable HTTP response count. Possible misconfiguration or unsupported version of Kibana", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.method", + "metricAgg": "max", + "label": "Method", + "title": "Response Method Errors Agent Configuration Management", + "description": "HTTP requests rejected due to incorrect HTTP method", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + }, + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.response.errors.invalidquery", + "metricAgg": "max", + "label": "Invalid Query", + "title": "Response Invalid Query Errors Agent Configuration Management", + "description": "Invalid HTTP query", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ], + "apm_acm_request_count": [ + { + "bucket_size": "30 seconds", + "timeRange": { "min": 1535720389104, "max": 1535723989104 }, + "metric": { + "app": "apm", + "field": "beats_stats.metrics.apm-server.acm.request.count", + "metricAgg": "max", + "label": "Count", + "title": "Requests Agent Configuration Management", + "description": "HTTP Requests received by agent configuration managemen", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [1535723880000, null], + [1535723910000, 0], + [1535723940000, 0] + ] + } + ] }, "apmSummary": { "uuid": "9b16f434-2092-4983-a401-80a2b61c79d6", diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry_local.js b/x-pack/test/api_integration/apis/telemetry/telemetry_local.js index f06af3baa2301c..9dbc3e1c8a5bb9 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry_local.js +++ b/x-pack/test/api_integration/apis/telemetry/telemetry_local.js @@ -76,9 +76,10 @@ export default function ({ getService }) { expect(stats.stack_stats.kibana.plugins.apm.services_per_agent).to.be.an('object'); expect(stats.stack_stats.kibana.plugins.infraops.last_24_hours).to.be.an('object'); expect(stats.stack_stats.kibana.plugins.kql.defaultQueryLanguage).to.be.a('string'); - expect(stats.stack_stats.kibana.plugins['maps-telemetry'].attributes.timeCaptured).to.be.a( - 'string' - ); + expect(stats.stack_stats.kibana.plugins.maps.timeCaptured).to.be.a('string'); + expect(stats.stack_stats.kibana.plugins.maps.attributes).to.be(undefined); + expect(stats.stack_stats.kibana.plugins.maps.id).to.be(undefined); + expect(stats.stack_stats.kibana.plugins.maps.type).to.be(undefined); expect(stats.stack_stats.kibana.plugins.reporting.enabled).to.be(true); expect(stats.stack_stats.kibana.plugins.rollups.index_patterns).to.be.an('object'); diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index 2a0327ff57104e..d135c43e2302c2 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -26,7 +26,7 @@ import { import { InfraOpsSourceConfigurationProvider } from './infraops_source_configuration'; import { InfraLogSourceConfigurationProvider } from './infra_log_source_configuration'; import { MachineLearningProvider } from './ml'; -import { IngestManagerProvider } from './ingest_manager'; +import { IngestManagerProvider } from '../../common/services/ingest_manager'; import { ResolverGeneratorProvider } from './resolver'; import { TransformProvider } from './transform'; diff --git a/x-pack/test/api_integration/services/resolver.ts b/x-pack/test/api_integration/services/resolver.ts index ec0067f6a24aff..7a100c37aea915 100644 --- a/x-pack/test/api_integration/services/resolver.ts +++ b/x-pack/test/api_integration/services/resolver.ts @@ -25,7 +25,8 @@ export interface Options extends TreeOptions { */ export interface GeneratedTrees { trees: Tree[]; - index: string; + eventsIndex: string; + alertsIndex: string; } export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { @@ -34,27 +35,30 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { return { async createTrees( options: Options, - eventsIndex: string = 'events-endpoint-1' + eventsIndex: string = 'logs-endpoint.events.process-default', + alertsIndex: string = 'logs-endpoint.alerts-default' ): Promise { const allTrees: Tree[] = []; const generator = new EndpointDocGenerator(); const numTrees = options.numTrees ?? 1; for (let j = 0; j < numTrees; j++) { const tree = generator.generateTree(options); - const body = tree.allEvents.reduce( - (array: Array>, doc) => ( - /** - * We're using data streams which require that a bulk use `create` instead of `index`. - */ - array.push({ create: { _index: eventsIndex } }, doc), array - ), - [] - ); + const body = tree.allEvents.reduce((array: Array>, doc) => { + let index = eventsIndex; + if (doc.event.kind === 'alert') { + index = alertsIndex; + } + /** + * We're using data streams which require that a bulk use `create` instead of `index`. + */ + array.push({ create: { _index: index } }, doc); + return array; + }, []); // force a refresh here otherwise the documents might not be available when the tests search for them await client.bulk({ body, refresh: 'true' }); allTrees.push(tree); } - return { trees: allTrees, index: eventsIndex }; + return { trees: allTrees, eventsIndex, alertsIndex }; }, async deleteTrees(trees: GeneratedTrees) { /** @@ -63,7 +67,14 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { * need to do raw requests here. Delete a data stream is slightly different than that of a regular index which * is why we're using _data_stream here. */ - await client.transport.request({ method: 'DELETE', path: `_data_stream/${trees.index}` }); + await client.transport.request({ + method: 'DELETE', + path: `_data_stream/${trees.eventsIndex}`, + }); + await client.transport.request({ + method: 'DELETE', + path: `_data_stream/${trees.alertsIndex}`, + }); }, }; } diff --git a/x-pack/test/common/services/index.ts b/x-pack/test/common/services/index.ts index 079687577c3e53..41b01bffbd8898 100644 --- a/x-pack/test/common/services/index.ts +++ b/x-pack/test/common/services/index.ts @@ -5,11 +5,13 @@ */ import { services as kibanaCommonServices } from '../../../../test/common/services'; +import { services as kibanaApiIntegrationServices } from '../../../../test/api_integration/services'; import { SpacesServiceProvider } from './spaces'; export const services = { ...kibanaCommonServices, + supertest: kibanaApiIntegrationServices.supertest, spaces: SpacesServiceProvider, }; diff --git a/x-pack/test/api_integration/services/ingest_manager.ts b/x-pack/test/common/services/ingest_manager.ts similarity index 100% rename from x-pack/test/api_integration/services/ingest_manager.ts rename to x-pack/test/common/services/ingest_manager.ts diff --git a/x-pack/test/encrypted_saved_objects_api_integration/config.ts b/x-pack/test/encrypted_saved_objects_api_integration/config.ts index fb643c2c5a901b..f061a38b72ce6d 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/config.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/config.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve } from 'path'; +import path from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; @@ -18,12 +18,16 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { junit: { reportName: 'X-Pack Encrypted Saved Objects API Integration Tests', }, + esArchiver: { + directory: path.join(__dirname, 'fixtures', 'es_archiver'), + }, esTestCluster: xPackAPITestsConfig.get('esTestCluster'), kbnTestServer: { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), - `--plugin-path=${resolve(__dirname, './fixtures/api_consumer_plugin')}`, + '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + `--plugin-path=${path.resolve(__dirname, './fixtures/api_consumer_plugin')}`, ], }, }; diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts index 7fb4de9ae4dc17..87bed7f4160191 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts @@ -9,6 +9,7 @@ import { CoreSetup, PluginInitializer, SavedObjectsNamespaceType, + SavedObjectUnsanitizedDoc, } from '../../../../../../src/core/server'; import { EncryptedSavedObjectsPluginSetup, @@ -23,6 +24,17 @@ const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE = 'saved-object-with-secret-and-multiple-spaces'; const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret'; +const SAVED_OBJECT_WITH_MIGRATION_TYPE = 'saved-object-with-migration'; +interface MigratedTypePre790 { + nonEncryptedAttribute: string; + encryptedAttribute: string; +} +interface MigratedType { + nonEncryptedAttribute: string; + encryptedAttribute: string; + additionalEncryptedAttribute: string; +} + export interface PluginsSetup { encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; spaces: SpacesPluginSetup; @@ -34,7 +46,7 @@ export interface PluginsStart { } export const plugin: PluginInitializer = () => ({ - setup(core: CoreSetup, deps) { + setup(core: CoreSetup, deps: PluginsSetup) { for (const [name, namespaceType, hidden] of [ [SAVED_OBJECT_WITH_SECRET_TYPE, 'single', false], [HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE, 'single', true], @@ -71,6 +83,8 @@ export const plugin: PluginInitializer = mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' } } }), }); + defineTypeWithMigration(core, deps); + const router = core.http.createRouter(); router.get( { @@ -103,3 +117,83 @@ export const plugin: PluginInitializer = start() {}, stop() {}, }); + +function defineTypeWithMigration(core: CoreSetup, deps: PluginsSetup) { + const typePriorTo790 = { + type: SAVED_OBJECT_WITH_MIGRATION_TYPE, + attributesToEncrypt: new Set(['encryptedAttribute']), + }; + + // current type is registered + deps.encryptedSavedObjects.registerType({ + type: SAVED_OBJECT_WITH_MIGRATION_TYPE, + attributesToEncrypt: new Set(['encryptedAttribute', 'additionalEncryptedAttribute']), + }); + + core.savedObjects.registerType({ + name: SAVED_OBJECT_WITH_MIGRATION_TYPE, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + nonEncryptedAttribute: { + type: 'keyword', + }, + encryptedAttribute: { + type: 'binary', + }, + additionalEncryptedAttribute: { + type: 'keyword', + }, + }, + }, + migrations: { + // in this version we migrated a non encrypted field and type didnt change + '7.8.0': deps.encryptedSavedObjects.createMigration( + function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectUnsanitizedDoc => { + const { + attributes: { nonEncryptedAttribute }, + } = doc; + return { + ...doc, + attributes: { + ...doc.attributes, + nonEncryptedAttribute: `${nonEncryptedAttribute}-migrated`, + }, + }; + }, + // type hasn't changed as the field we're updating is not an encrypted one + typePriorTo790, + typePriorTo790 + ), + // in this version we encrypted an existing non encrypted field + '7.9.0': deps.encryptedSavedObjects.createMigration( + function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectUnsanitizedDoc => { + const { + attributes: { nonEncryptedAttribute }, + } = doc; + return { + ...doc, + attributes: { + ...doc.attributes, + nonEncryptedAttribute, + // clone and modify the non encrypted field + additionalEncryptedAttribute: `${nonEncryptedAttribute}-encrypted`, + }, + }; + }, + typePriorTo790 + ), + }, + }); +} diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/data.json b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/data.json new file mode 100644 index 00000000000000..88ec54cdf3a54e --- /dev/null +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/data.json @@ -0,0 +1,370 @@ +{ + "type": "doc", + "value": { + "id": "config:8.0.0", + "index": ".kibana_1", + "source": { + "config": { + "buildNum": 9007199254740991 + }, + "migrationVersion": { + "config": "7.9.0" + }, + "references": [ + ], + "type": "config", + "updated_at": "2020-06-17T15:03:14.532Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "space": "6.6.0" + }, + "references": [ + ], + "space": { + "_reserved": true, + "color": "#00bfb3", + "description": "This is your default space!", + "disabledFeatures": [ + ], + "name": "Default" + }, + "type": "space", + "updated_at": "2020-06-17T15:03:27.426Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "apm-telemetry:apm-telemetry", + "index": ".kibana_1", + "source": { + "apm-telemetry": { + "agents": { + }, + "cardinality": { + "transaction": { + "name": { + "all_agents": { + "1d": 0 + }, + "rum": { + "1d": 0 + } + } + }, + "user_agent": { + "original": { + "all_agents": { + "1d": 0 + }, + "rum": { + "1d": 0 + } + } + } + }, + "counts": { + "agent_configuration": { + "all": 0 + }, + "error": { + "1d": 0, + "all": 0 + }, + "max_error_groups_per_service": { + "1d": 0 + }, + "max_transaction_groups_per_service": { + "1d": 0 + }, + "metric": { + "1d": 0, + "all": 0 + }, + "onboarding": { + "1d": 0, + "all": 0 + }, + "services": { + "1d": 0 + }, + "sourcemap": { + "1d": 0, + "all": 0 + }, + "span": { + "1d": 0, + "all": 0 + }, + "traces": { + "1d": 0 + }, + "transaction": { + "1d": 0, + "all": 0 + } + }, + "has_any_services": false, + "indices": { + "all": { + "total": { + "docs": { + "count": 0 + }, + "store": { + "size_in_bytes": 416 + } + } + }, + "shards": { + "total": 2 + } + }, + "integrations": { + "ml": { + "all_jobs_count": 0 + } + }, + "services_per_agent": { + "dotnet": 0, + "go": 0, + "java": 0, + "js-base": 0, + "nodejs": 0, + "python": 0, + "ruby": 0, + "rum-js": 0 + }, + "tasks": { + "agent_configuration": { + "took": { + "ms": 21 + } + }, + "agents": { + "took": { + "ms": 65 + } + }, + "cardinality": { + "took": { + "ms": 80 + } + }, + "groupings": { + "took": { + "ms": 25 + } + }, + "indices_stats": { + "took": { + "ms": 65 + } + }, + "integrations": { + "took": { + "ms": 108 + } + }, + "processor_events": { + "took": { + "ms": 113 + } + }, + "services": { + "took": { + "ms": 98 + } + }, + "versions": { + "took": { + "ms": 6 + } + } + } + }, + "references": [ + ], + "type": "apm-telemetry", + "updated_at": "2020-06-17T15:03:47.184Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "saved-object-with-migration:74f3e6d7-b7bb-477d-ac28-92ee22728e6e", + "index": ".kibana_1", + "source": { + "saved-object-with-migration": { + "encryptedAttribute": "JuDwwSjflpKmPKUIfjgo04E0DW9iyhp8C94hwvflgkS0SUUPt+862FQ1eja4VEfEG7HVUt7xxj+BWeZv9vrf4olxgbr4/f5RrT8BVic0EOVS9nhspiDVEv12mV0uDWGtdneB/UWyaZg+0Qr0tPrwceSl8BS///U=", + "nonEncryptedAttribute": "elastic" + }, + "migrationVersion": { + "saved-object-with-migration": "7.7.0" + }, + "references": [ + ], + "type": "saved-object-with-migration", + "updated_at": "2020-06-17T15:35:39.839Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "application_usage_transactional:5f01fd40-b0b0-11ea-9510-fdf248d5f2a4", + "index": ".kibana_1", + "source": { + "application_usage_transactional": { + "appId": "management", + "minutesOnScreen": 1.60245, + "numberOfClicks": 6, + "timestamp": "2020-06-17T15:36:54.292Z" + }, + "references": [ + ], + "type": "application_usage_transactional", + "updated_at": "2020-06-17T15:36:54.292Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "application_usage_transactional:4ca5ac00-b0b0-11ea-9510-fdf248d5f2a4", + "index": ".kibana_1", + "source": { + "application_usage_transactional": { + "appId": "home", + "minutesOnScreen": 0.4106666666666667, + "numberOfClicks": 3, + "timestamp": "2020-06-17T15:36:23.487Z" + }, + "references": [ + ], + "type": "application_usage_transactional", + "updated_at": "2020-06-17T15:36:23.488Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "ui-metric:kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", + "index": ".kibana_1", + "source": { + "references": [ + ], + "type": "ui-metric", + "ui-metric": { + "count": 1 + }, + "updated_at": "2020-06-17T15:36:23.487Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "ui-metric:Kibana_home:sampleDataDecline", + "index": ".kibana_1", + "source": { + "type": "ui-metric", + "ui-metric": { + "count": 1 + }, + "updated_at": "2020-06-17T15:36:23.488Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "ui-metric:Kibana_home:welcomeScreenMount", + "index": ".kibana_1", + "source": { + "type": "ui-metric", + "ui-metric": { + "count": 1 + }, + "updated_at": "2020-06-17T15:36:23.488Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "telemetry:telemetry", + "index": ".kibana_1", + "source": { + "references": [ + ], + "telemetry": { + "lastReported": 1592408310031, + "reportFailureCount": 0, + "userHasSeenNotice": true + }, + "type": "telemetry", + "updated_at": "2020-06-17T15:38:30.031Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "maps-telemetry:maps-telemetry", + "index": ".kibana_1", + "source": { + "maps-telemetry": { + "attributesPerMap": { + "dataSourcesCount": { + "avg": 0, + "max": 0, + "min": 0 + }, + "emsVectorLayersCount": { + }, + "layerTypesCount": { + }, + "layersCount": { + "avg": 0, + "max": 0, + "min": 0 + } + }, + "indexPatternsWithGeoFieldCount": 0, + "indexPatternsWithGeoPointFieldCount": 0, + "indexPatternsWithGeoShapeFieldCount": 0, + "mapsTotalCount": 0, + "settings": { + "showMapVisualizationTypes": false + }, + "timeCaptured": "2020-06-17T16:29:27.563Z" + }, + "references": [ + ], + "type": "maps-telemetry", + "updated_at": "2020-06-17T16:29:27.563Z" + } + } +} \ No newline at end of file diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/mappings.json b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/mappings.json new file mode 100644 index 00000000000000..c025ad9da1a9cb --- /dev/null +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects/mappings.json @@ -0,0 +1,2413 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "7b44fba6773e37c806ce290ea9b7024e", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "cases": "32aa96a6d3855ddda53010ae2048ac22", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "ae24d22d5986d04124cc6568f771066f", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "inventory-view": "88fc7e12fd1b45b6f0787323ce4f18d2", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "d33c68a69ff1e78c9888dedd2164ac22", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "bfd39d88aadadb4be597ea984d433dbe", + "metrics-explorer-view": "428e319af3e822c80a84cf87123ca35c", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "todo": "082a2cc96a590268344d5cd74c159ac4", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "fcdb453a30092f022f2642db29523d80", + "url": "b675c3be8d76ecf029294d51dc7ec65d", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "saved-object-with-migration": { + "properties": { + "encryptedAttribute": { + "type": "binary" + }, + "nonEncryptedAttribute": { + "type": "keyword" + }, + "additionalEncryptedAttribute": { + "type": "binary" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "cardinality": { + "properties": { + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "has_any_services": { + "type": "boolean" + }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } + } + } + }, + "application_usage_totals": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + } + } + }, + "application_usage_transactional": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + }, + "timestamp": { + "type": "date" + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "inventory-view": { + "properties": { + "accountId": { + "type": "keyword" + }, + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customMetrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "customOptions": { + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + }, + "type": "nested" + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + }, + "legend": { + "properties": { + "palette": { + "type": "keyword" + }, + "reverseColors": { + "type": "boolean" + }, + "steps": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "region": { + "type": "keyword" + }, + "sort": { + "properties": { + "by": { + "type": "keyword" + }, + "direction": { + "type": "keyword" + } + } + }, + "time": { + "type": "long" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "indexPatternsWithGeoFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoPointFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoShapeFieldCount": { + "type": "long" + }, + "mapsTotalCount": { + "type": "long" + }, + "settings": { + "properties": { + "showMapVisualizationTypes": { + "type": "boolean" + } + } + }, + "timeCaptured": { + "type": "date" + } + } + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "forceInterval": { + "type": "boolean" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "alert": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "saved-object-with-migration": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "todo": { + "properties": { + "icon": { + "type": "keyword" + }, + "task": { + "type": "text" + }, + "title": { + "type": "keyword" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "type": "keyword" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "integer" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "type": "keyword" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "type": "keyword" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "properties": { + "certAgeThreshold": { + "type": "long" + }, + "certExpirationThreshold": { + "type": "long" + }, + "heartbeatIndices": { + "type": "keyword" + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index 6b3ae620117046..8bdc1715bf487b 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -12,6 +12,7 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const randomness = getService('randomness'); const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; const HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE = 'hidden-saved-object-with-secret'; @@ -501,5 +502,32 @@ export default function ({ getService }: FtrProviderContext) { ); }); }); + + describe('migrations', () => { + before(async () => { + await esArchiver.load('encrypted_saved_objects'); + }); + + after(async () => { + await esArchiver.unload('encrypted_saved_objects'); + }); + + it('migrates unencrypted fields on saved objects', async () => { + const { body: decryptedResponse } = await supertest + .get( + `/api/saved_objects/get-decrypted-as-internal-user/saved-object-with-migration/74f3e6d7-b7bb-477d-ac28-92ee22728e6e` + ) + .expect(200); + + expect(decryptedResponse.attributes).to.eql({ + // ensures the encrypted field can still be decrypted after the migration + encryptedAttribute: 'this is my secret api key', + // ensures the non-encrypted field has been migrated in 7.8.0 + nonEncryptedAttribute: 'elastic-migrated', + // ensures the non-encrypted field has been migrated into a new encrypted field in 7.9.0 + additionalEncryptedAttribute: 'elastic-migrated-encrypted', + }); + }); + }); }); } diff --git a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts index 3483ddf769e5fc..bf2a4192af5437 100644 --- a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts +++ b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts @@ -12,15 +12,21 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const aceEditor = getService('aceEditor'); const retry = getService('retry'); + const security = getService('security'); const editorTestSubjectSelector = 'searchProfilerEditor'; describe('Search Profiler Editor', () => { before(async () => { + await security.testUser.setRoles(['global_devtools_read']); await PageObjects.common.navigateToApp('searchProfiler'); expect(await testSubjects.exists('searchProfilerEditor')).to.be(true); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('correctly parses triple quotes in JSON', async () => { // The below inputs are written to work _with_ ace's autocomplete unlike console's unit test // counterparts in src/legacy/core_plugins/console/public/tests/src/editor.test.js diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 181d41d77b4cb7..b399c9e915e27a 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardAddPanel = getService('dashboardAddPanel'); const elasticChart = getService('elasticChart'); const browser = getService('browser'); + const retry = getService('retry'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const listingTable = getService('listingTable'); @@ -93,7 +94,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.closeAddPanel(); await PageObjects.lens.goToTimeRange(); await clickOnBarHistogram(); - await testSubjects.click('applyFiltersPopoverButton'); + + await retry.try(async () => { + await testSubjects.click('applyFiltersPopoverButton'); + await testSubjects.missingOrFail('applyFiltersPopoverButton'); + }); await assertExpectedChart(); await assertExpectedTimerange(); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts index 357ea362135213..525e25d0158bf6 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts @@ -156,25 +156,45 @@ export default function ({ getService }: FtrProviderContext) { await ml.testResources.deleteIndexPatternByTitle(testData.job.dest!.index as string); }); - it('should open the flyout with a proper header', async () => { - expect(await ml.dataFrameAnalyticsCreation.getHeaderText()).to.be( - `Clone job from ${testData.job.id}` + it('should open the wizard with a proper header', async () => { + expect(await ml.dataFrameAnalyticsCreation.getHeaderText()).to.match( + /Clone analytics job/ ); }); - it('should have correct init form values', async () => { - await ml.dataFrameAnalyticsCreation.assertInitialCloneJobForm( + it('should have correct init form values for config step', async () => { + await ml.dataFrameAnalyticsCreation.assertInitialCloneJobConfigStep( testData.job as DataFrameAnalyticsConfig ); }); - it('should have disabled Create button on open', async () => { - expect(await ml.dataFrameAnalyticsCreation.isCreateButtonDisabled()).to.be(true); + it('should continue to the additional options step', async () => { + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); }); - it('should enable Create button on a valid form input', async () => { + it('should have correct init form values for additional options step', async () => { + await ml.dataFrameAnalyticsCreation.assertInitialCloneJobAdditionalOptionsStep( + testData.job as DataFrameAnalyticsConfig + ); + }); + + it('should continue to the details step', async () => { + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + }); + + it('should have correct init form values for details step', async () => { + await ml.dataFrameAnalyticsCreation.assertInitialCloneJobDetailsStep( + testData.job as DataFrameAnalyticsConfig + ); await ml.dataFrameAnalyticsCreation.setJobId(cloneJobId); await ml.dataFrameAnalyticsCreation.setDestIndex(cloneDestIndex); + }); + + it('should continue to the create step', async () => { + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + }); + + it('should have enabled Create button on a valid form input', async () => { expect(await ml.dataFrameAnalyticsCreation.isCreateButtonDisabled()).to.be(false); }); @@ -182,11 +202,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.createAnalyticsJob(cloneJobId); }); - it('finishes analytics processing', async () => { + it('should finish analytics processing', async () => { await ml.dataFrameAnalytics.waitForAnalyticsCompletion(cloneJobId); }); - it('displays the created job in the analytics table', async () => { + it('should display the created job in the analytics table', async () => { + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); await ml.dataFrameAnalyticsTable.filterWithSearchString(cloneJobId); const rows = await ml.dataFrameAnalyticsTable.parseAnalyticsTable(); diff --git a/x-pack/test/functional/apps/status_page/status_page.ts b/x-pack/test/functional/apps/status_page/status_page.ts index b6f0fdce8b2897..eeb9bc9b84450b 100644 --- a/x-pack/test/functional/apps/status_page/status_page.ts +++ b/x-pack/test/functional/apps/status_page/status_page.ts @@ -13,7 +13,7 @@ export default function statusPageFunctonalTests({ const PageObjects = getPageObjects(['security', 'statusPage', 'home']); describe('Status Page', function () { - this.tags('includeFirefox'); + this.tags(['skipCloud', 'includeFirefox']); before(async () => await esArchiver.load('empty_kibana')); after(async () => await esArchiver.unload('empty_kibana')); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 55b2f4a0220ad3..bf267c80cdcced 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -436,8 +436,6 @@ export default function ({ getService }: FtrProviderContext) { await transform.table.assertTransformRowFields(testData.transformId, { id: testData.transformId, description: testData.transformDescription, - sourceIndex: testData.source, - destinationIndex: testData.destinationIndex, status: testData.expected.row.status, mode: testData.expected.row.mode, progress: testData.expected.row.progress, diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index ad62f06d1f3cde..bc4ded49660f45 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -239,8 +239,6 @@ export default function ({ getService }: FtrProviderContext) { await transform.table.assertTransformRowFields(testData.transformId, { id: testData.transformId, description: testData.transformDescription, - sourceIndex: testData.expected.sourceIndex, - destinationIndex: testData.destinationIndex, status: testData.expected.row.status, mode: testData.expected.row.mode, progress: testData.expected.row.progress, diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index d5e3f82878d6b5..14e05d21b87535 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -231,6 +231,17 @@ export default async function ({ readConfigFile }) { ], }, + global_devtools_read: { + kibana: [ + { + feature: { + dev_tools: ['read'], + }, + spaces: ['*'], + }, + ], + }, + //Kibana feature privilege isn't specific to advancedSetting. It can be anything. https://github.com/elastic/kibana/issues/35965 test_api_keys: { elasticsearch: { diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz index feb2af93b0fd18..3f72463627f9d8 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/alerts/api_feature/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz index 2921abededde5a..ccb19cb97faa1d 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/alerts/host_api_feature/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json index 1434aae60c9674..ac35ef3dcab996 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json @@ -2,7 +2,7 @@ "type": "doc", "value": { "id": "3KVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579881969541, "agent": { @@ -57,7 +57,7 @@ "type": "doc", "value": { "id": "3aVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579881969541, "agent": { @@ -111,7 +111,7 @@ "type": "doc", "value": { "id": "3qVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579881969541, "agent": { @@ -163,7 +163,7 @@ "type": "doc", "value": { "id": "36VN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579878369541, "agent": { @@ -218,7 +218,7 @@ "type": "doc", "value": { "id": "4KVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579878369541, "agent": { @@ -271,7 +271,7 @@ "type": "doc", "value": { "id": "4aVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579878369541, "agent": { @@ -324,7 +324,7 @@ "type": "doc", "value": { "id": "4qVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579874769541, "agent": { @@ -378,7 +378,7 @@ "type": "doc", "value": { "id": "46VN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579874769541, "agent": { @@ -431,7 +431,7 @@ "type": "doc", "value": { "id": "5KVN2G8BYQH1gtPUuYk7", - "index": "metrics-endpoint.metadata-default-1", + "index": "metrics-endpoint.metadata-default", "source": { "@timestamp": 1579874769541, "agent": { diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz new file mode 100644 index 00000000000000..d7b130e4051569 Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz new file mode 100644 index 00000000000000..3b4da7c47d9f22 Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/metadata/unenroll_feature/metadata_mirror/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz b/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz index d9fcf03f43f37d..1f492487d317b7 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz differ diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 081eb8775fa5b6..f67ea583e25cdf 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -124,37 +124,15 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await this.assertJobDescriptionValue(jobDescription); }, - async assertSourceIndexInputExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutSourceIndexSelect > comboBoxInput'); - }, - - async assertSourceIndexSelection(expectedSelection: string[]) { - const actualSelection = await comboBox.getComboBoxSelectedOptions( - 'mlAnalyticsCreateJobFlyoutSourceIndexSelect > comboBoxInput' - ); - expect(actualSelection).to.eql( - expectedSelection, - `Source index should be '${expectedSelection}' (got '${actualSelection}')` - ); - }, - - async assertExcludedFieldsSelection(expectedSelection: string[]) { - const actualSelection = await comboBox.getComboBoxSelectedOptions( - 'mlAnalyticsCreateJobFlyoutExcludesSelect > comboBoxInput' - ); - expect(actualSelection).to.eql( - expectedSelection, - `Excluded fields should be '${expectedSelection}' (got '${actualSelection}')` - ); - }, - - async selectSourceIndex(sourceIndex: string) { - await comboBox.set( - 'mlAnalyticsCreateJobFlyoutSourceIndexSelect > comboBoxInput', - sourceIndex - ); - await this.assertSourceIndexSelection([sourceIndex]); - }, + // async assertExcludedFieldsSelection(expectedSelection: string[]) { + // const actualSelection = await comboBox.getComboBoxSelectedOptions( + // 'mlAnalyticsCreateJobWizardExcludesSelect' + // ); + // expect(actualSelection).to.eql( + // expectedSelection, + // `Excluded fields should be '${expectedSelection}' (got '${actualSelection}')` + // ); + // }, async assertDestIndexInputExists() { await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutDestinationIndexInput'); @@ -384,24 +362,29 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async getHeaderText() { - return await testSubjects.getVisibleText('mlDataFrameAnalyticsFlyoutHeaderTitle'); + return await testSubjects.getVisibleText('mlDataFrameAnalyticsWizardHeaderTitle'); }, - async assertInitialCloneJobForm(job: DataFrameAnalyticsConfig) { + async assertInitialCloneJobConfigStep(job: DataFrameAnalyticsConfig) { const jobType = Object.keys(job.analysis)[0]; await this.assertJobTypeSelection(jobType); - await this.assertJobIdValue(''); // id should be empty - await this.assertJobDescriptionValue(String(job.description)); - await this.assertSourceIndexSelection(job.source.index as string[]); - await this.assertDestIndexValue(''); // destination index should be empty if (isClassificationAnalysis(job.analysis) || isRegressionAnalysis(job.analysis)) { await this.assertDependentVariableSelection([job.analysis[jobType].dependent_variable]); await this.assertTrainingPercentValue(String(job.analysis[jobType].training_percent)); } - await this.assertExcludedFieldsSelection(job.analyzed_fields.excludes); + // await this.assertExcludedFieldsSelection(job.analyzed_fields.excludes); + }, + + async assertInitialCloneJobAdditionalOptionsStep(job: DataFrameAnalyticsConfig) { await this.assertModelMemoryValue(job.model_memory_limit); }, + async assertInitialCloneJobDetailsStep(job: DataFrameAnalyticsConfig) { + await this.assertJobIdValue(''); // id should be empty + await this.assertJobDescriptionValue(String(job.description)); + await this.assertDestIndexValue(''); // destination index should be empty + }, + async assertCreationCalloutMessagesExist() { await testSubjects.existOrFail('analyticsWizardCreationCallout_0'); await testSubjects.existOrFail('analyticsWizardCreationCallout_1'); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts index 60507f5ab33311..f452c9cce7a1aa 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts @@ -126,7 +126,7 @@ export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: F public async cloneJob(analyticsId: string) { await this.openRowActions(analyticsId); await testSubjects.click(`mlAnalyticsJobCloneButton`); - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyout'); + await testSubjects.existOrFail('mlAnalyticsCreationContainer'); } })(); } diff --git a/x-pack/test/functional/services/transform/transform_table.ts b/x-pack/test/functional/services/transform/transform_table.ts index 3155ef0b260500..0c9a5414bdd2b4 100644 --- a/x-pack/test/functional/services/transform/transform_table.ts +++ b/x-pack/test/functional/services/transform/transform_table.ts @@ -31,16 +31,6 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { .find('.euiTableCellContent') .text() .trim(), - sourceIndex: $tr - .findTestSubject('transformListColumnSourceIndex') - .find('.euiTableCellContent') - .text() - .trim(), - destinationIndex: $tr - .findTestSubject('transformListColumnDestinationIndex') - .find('.euiTableCellContent') - .text() - .trim(), status: $tr .findTestSubject('transformListColumnStatus') .find('.euiTableCellContent') diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts index 41be67592cbeb2..810a80c3401ca8 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -189,6 +189,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await testSubjects.exists('deleteConnector')).to.be(false); expect(await testSubjects.exists('preConfiguredTitleMessage')).to.be(true); + + const checkboxSelectRow = await testSubjects.find('checkboxSelectRow-my-server-log'); + expect(await checkboxSelectRow.getAttribute('disabled')).to.be('true'); }); it('should not be able to edit a preconfigured connector', async () => { diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 57c91bd49808e8..54b37fe52cc56c 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -102,7 +102,7 @@ export default function ({ getService }: FtrProviderContext) { }); } - it('should be able to log in via IdP initiated login for any configured realm', async () => { + it('should be able to log in via IdP initiated login for any configured provider', async () => { for (const providerName of ['saml1', 'saml2']) { const authenticationResponse = await supertest .post('/api/security/saml/callback') @@ -124,6 +124,57 @@ export default function ({ getService }: FtrProviderContext) { } }); + it('should redirect to URL from relay state in case of IdP initiated login only for providers that explicitly enabled that behaviour', async () => { + for (const { providerName, redirectURL } of [ + { providerName: 'saml1', redirectURL: '/' }, + { providerName: 'saml2', redirectURL: '/app/kibana#/dashboards' }, + ]) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .type('form') + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .send({ RelayState: '/app/kibana#/dashboards' }) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be(redirectURL); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should not redirect to URL from relay state in case of IdP initiated login if URL is not internal', async () => { + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .type('form') + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .send({ RelayState: 'http://www.elastic.co/app/kibana#/dashboards' }) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { const basicAuthenticationResponse = await supertest .post('/internal/security/login') @@ -193,6 +244,43 @@ export default function ({ getService }: FtrProviderContext) { await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); }); + it('should redirect to URL from relay state in case of IdP initiated login even if session with other SAML provider exists', async () => { + // First login with `saml1`. + const saml1AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml1` }), + }) + .expect(302); + + const saml1SessionCookie = request.cookie( + saml1AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml1SessionCookie, 'a@b.c', 'saml1'); + + // And now try to login with `saml2`. + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1SessionCookie.cookieString()) + .type('form') + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .send({ RelayState: '/app/kibana#/dashboards' }) + .expect(302); + + // It should be `/overwritten_session` with `?next='/app/kibana#/dashboards'` instead of just + // `'/app/kibana#/dashboards'` once it's generalized. + expect(saml2AuthenticationResponse.headers.location).to.be('/app/kibana#/dashboards'); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + // Ideally we should be able to abandon intermediate session and let user log in, but for the // time being we cannot distinguish errors coming from Elasticsearch for the case when SAML // response just doesn't correspond to request ID we have in intermediate cookie and the case diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/login_selector_api_integration/config.ts index ba7aadb121e825..67bc2e6f17b565 100644 --- a/x-pack/test/login_selector_api_integration/config.ts +++ b/x-pack/test/login_selector_api_integration/config.ts @@ -127,7 +127,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { oidc: { oidc1: { order: 3, realm: 'oidc1' } }, saml: { saml1: { order: 1, realm: 'saml1' }, - saml2: { order: 5, realm: 'saml2', maxRedirectURLSize: '100b' }, + saml2: { + order: 5, + realm: 'saml2', + maxRedirectURLSize: '100b', + useRelayStateDeepLink: true, + }, }, })}`, ], diff --git a/x-pack/test/reporting_api_integration/services.ts b/x-pack/test/reporting_api_integration/services.ts index dadb466d45982a..85f5a98c69b2e8 100644 --- a/x-pack/test/reporting_api_integration/services.ts +++ b/x-pack/test/reporting_api_integration/services.ts @@ -7,8 +7,7 @@ import expect from '@kbn/expect'; import * as Rx from 'rxjs'; import { filter, first, mapTo, switchMap, timeout } from 'rxjs/operators'; -// @ts-ignore no module definition -import { indexTimestamp } from '../../plugins/reporting/server/lib/esqueue/helpers/index_timestamp'; +import { indexTimestamp } from '../../plugins/reporting/server/lib/store/index_timestamp'; import { services as xpackServices } from '../functional/services'; import { FtrProviderContext } from './ftr_provider_context'; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index bbc78cf399f711..730beb9143406b 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { deleteMetadataStream } from '../../../api_integration/apis/endpoint/data_stream_helper'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'endpoint', 'header', 'endpointPageUtils']); @@ -17,11 +18,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { this.tags('ciGroup7'); const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); before(async () => { - await esArchiver.load('endpoint/metadata/api_feature'); + await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); await pageObjects.endpoint.navigateToEndpointList(); }); - it('finds title', async () => { + it('finds page title', async () => { const title = await testSubjects.getVisibleText('pageViewHeaderLeftTitle'); expect(title).to.equal('Endpoints'); }); @@ -77,54 +78,61 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(tableData).to.eql(expectedData); }); - it('no details flyout when endpoint page displayed', async () => { + it('does not show the details flyout initially', async () => { await testSubjects.missingOrFail('hostDetailsFlyout'); }); - it('display details flyout when the hostname is clicked on', async () => { - await (await testSubjects.find('hostnameCellLink')).click(); - await testSubjects.existOrFail('hostDetailsUpperList'); - await testSubjects.existOrFail('hostDetailsLowerList'); - }); + describe('when the hostname is clicked on,', () => { + it('display the details flyout', async () => { + await (await testSubjects.find('hostnameCellLink')).click(); + await testSubjects.existOrFail('hostDetailsUpperList'); + await testSubjects.existOrFail('hostDetailsLowerList'); + }); - it('update details flyout when new hostname is clicked on', async () => { - // display flyout for the first host in the list - await (await testSubjects.findAll('hostnameCellLink'))[0].click(); - await testSubjects.existOrFail('hostDetailsFlyoutTitle'); - const hostDetailTitle0 = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); - // select the 2nd host in the host list - await (await testSubjects.findAll('hostnameCellLink'))[1].click(); - await pageObjects.endpoint.waitForVisibleTextToChange( - 'hostDetailsFlyoutTitle', - hostDetailTitle0 - ); - const hostDetailTitle1 = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); - expect(hostDetailTitle1).to.not.eql(hostDetailTitle0); - }); + it('updates the details flyout when a new hostname is selected from the list', async () => { + // display flyout for the first host in the list + await (await testSubjects.findAll('hostnameCellLink'))[0].click(); + await testSubjects.existOrFail('hostDetailsFlyoutTitle'); + const hostDetailTitle0 = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); + // select the 2nd host in the host list + await (await testSubjects.findAll('hostnameCellLink'))[1].click(); + await pageObjects.endpoint.waitForVisibleTextToChange( + 'hostDetailsFlyoutTitle', + hostDetailTitle0 + ); + const hostDetailTitle1 = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); + expect(hostDetailTitle1).to.not.eql(hostDetailTitle0); + }); + + it('has the same flyout info when the same hostname is selected', async () => { + // display flyout for the first host in the list + await (await testSubjects.findAll('hostnameCellLink'))[1].click(); + await testSubjects.existOrFail('hostDetailsFlyoutTitle'); + const hostDetailTitleInitial = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); + // select the same host in the host list + await (await testSubjects.findAll('hostnameCellLink'))[1].click(); + await sleep(500); // give page time to refresh and verify it did not change + const hostDetailTitleNew = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); + expect(hostDetailTitleNew).to.equal(hostDetailTitleInitial); + }); - it('details flyout remains the same when current hostname is clicked on', async () => { - // display flyout for the first host in the list - await (await testSubjects.findAll('hostnameCellLink'))[1].click(); - await testSubjects.existOrFail('hostDetailsFlyoutTitle'); - const hostDetailTitleInitial = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); - // select the same host in the host list - await (await testSubjects.findAll('hostnameCellLink'))[1].click(); - await sleep(500); // give page time to refresh and verify it did not change - const hostDetailTitleNew = await testSubjects.getVisibleText('hostDetailsFlyoutTitle'); - expect(hostDetailTitleNew).to.equal(hostDetailTitleInitial); + it('navigates to ingest fleet when the Reassign Policy link is clicked', async () => { + await (await testSubjects.find('hostDetailsLinkToIngest')).click(); + await testSubjects.existOrFail('fleetAgentListTable'); + }); }); - describe('no data', () => { + describe('when there is no data,', () => { before(async () => { // clear out the data and reload the page - await esArchiver.unload('endpoint/metadata/api_feature'); + await deleteMetadataStream(getService); await pageObjects.endpoint.navigateToEndpointList(); }); after(async () => { // reload the data so the other tests continue to pass - await esArchiver.load('endpoint/metadata/api_feature'); + await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); }); - it('displays no items found when empty', async () => { + it('displays No items found when empty', async () => { // get the endpoint list table data and verify message const [, [noItemsFoundMessage]] = await pageObjects.endpointPageUtils.tableData( 'hostListTable' @@ -166,7 +174,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'Windows 10', '', '0', - '00000000-0000-0000-0000-000000000000', + 'Default', 'Unknown', '10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c', 'rezzani-7.example.com', @@ -175,7 +183,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); after(async () => { - await esArchiver.unload('endpoint/metadata/api_feature'); + await deleteMetadataStream(getService); }); }); }; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index 199d138d1c450a..d94ee260b27820 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -5,10 +5,13 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ loadTestFile }: FtrProviderContext) { +export default function ({ loadTestFile, getService }: FtrProviderContext) { describe('endpoint', function () { this.tags('ciGroup7'); - + const ingestManager = getService('ingestManager'); + before(async () => { + await ingestManager.setup(); + }); loadTestFile(require.resolve('./endpoint_list')); loadTestFile(require.resolve('./policy_list')); loadTestFile(require.resolve('./policy_details')); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 036f82a591fb3f..b0c161ca1d0c24 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -9,7 +9,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { PolicyTestResourceInfo } from '../../services/endpoint_policy'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common', 'endpoint', 'policy', 'endpointPageUtils']); + const pageObjects = getPageObjects([ + 'common', + 'endpoint', + 'policy', + 'endpointPageUtils', + 'ingestManagerCreateDatasource', + ]); const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); @@ -185,5 +191,38 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); }); + + describe('when on Ingest Configurations Edit Datasource page', async () => { + let policyInfo: PolicyTestResourceInfo; + beforeEach(async () => { + // Create a policy and navigate to Ingest app + policyInfo = await policyTestResources.createPolicy(); + await pageObjects.ingestManagerCreateDatasource.navigateToAgentConfigEditDatasource( + policyInfo.agentConfig.id, + policyInfo.datasource.id + ); + }); + afterEach(async () => { + if (policyInfo) { + await policyInfo.cleanup(); + } + }); + it('should show a link to Policy Details', async () => { + await testSubjects.existOrFail('editLinkToPolicyDetails'); + }); + it('should navigate to Policy Details when the link is clicked', async () => { + const linkToPolicy = await testSubjects.find('editLinkToPolicyDetails'); + await linkToPolicy.click(); + await pageObjects.policy.ensureIsOnDetailsPage(); + }); + it('should allow the user to navigate, edit and save Policy Details', async () => { + await (await testSubjects.find('editLinkToPolicyDetails')).click(); + await pageObjects.policy.ensureIsOnDetailsPage(); + await pageObjects.endpointPageUtils.clickOnEuiCheckbox('policyWindowsEvent_dns'); + await pageObjects.policy.confirmAndSave(); + + await testSubjects.existOrFail('policyDetailsSuccessMessage'); + }); + }); }); } diff --git a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts index f50cde6285be72..e104b8701276c4 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts @@ -6,13 +6,14 @@ import { FtrProviderContext } from '../ftr_provider_context'; -export function IngestManagerCreateDatasource({ getService }: FtrProviderContext) { +export function IngestManagerCreateDatasource({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const find = getService('find'); + const pageObjects = getPageObjects(['common']); return { /** - * Validates that the page shown is the Datasource Craete Page + * Validates that the page shown is the Datasource Create Page */ async ensureOnCreatePageOrFail() { await testSubjects.existOrFail('createDataSource_header'); @@ -75,5 +76,22 @@ export function IngestManagerCreateDatasource({ getService }: FtrProviderContext async waitForSaveSuccessNotification() { await testSubjects.existOrFail('datasourceCreateSuccessToast'); }, + + /** + * Validates that the page shown is the Datasource Edit Page + */ + async ensureOnEditPageOrFail() { + await testSubjects.existOrFail('editDataSource_header'); + }, + + /** + * Navigates to the Ingest Agent configuration Edit Datasource page + */ + async navigateToAgentConfigEditDatasource(agentConfigId: string, datasourceId: string) { + await pageObjects.common.navigateToApp('ingestManager', { + hash: `/configs/${agentConfigId}/edit-datasource/${datasourceId}`, + }); + await this.ensureOnEditPageOrFail(); + }, }; } diff --git a/x-pack/test/security_solution_endpoint/services/index.ts b/x-pack/test/security_solution_endpoint/services/index.ts index 0247d9b00968a1..7eecae41aae4a3 100644 --- a/x-pack/test/security_solution_endpoint/services/index.ts +++ b/x-pack/test/security_solution_endpoint/services/index.ts @@ -6,8 +6,10 @@ import { services as xPackFunctionalServices } from '../../functional/services'; import { EndpointPolicyTestResourcesProvider } from './endpoint_policy'; +import { IngestManagerProvider } from '../../common/services/ingest_manager'; export const services = { ...xPackFunctionalServices, policyTestResources: EndpointPolicyTestResourcesProvider, + ingestManager: IngestManagerProvider, }; diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 306294c57b3c66..e978702a356342 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -14,6 +14,7 @@ "test/**/*", "plugins/security_solution/cypress/**/*", "plugins/apm/e2e/cypress/**/*", + "plugins/apm/scripts/**/*", "**/typespec_tests.ts" ], "compilerOptions": { diff --git a/yarn.lock b/yarn.lock index b600ccb75c9fa8..93db6de88775cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2111,6 +2111,15 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@dabh/diagnostics@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31" + integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@elastic/apm-rum-core@^5.3.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.3.0.tgz#3ae5e84eba5b5287b92458a49755f6e39e7bba5b" @@ -2288,6 +2297,11 @@ resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-1.1.1.tgz#619b70322c9cce4a7ee5fbf8f678b1baa7f06095" integrity sha512-F6tIk8Txdqjg8Siv60iAvXzO9ZdQI87K3sS/fh5xd2XaWK+T5ZfqeTvsT7srwG6fr6uCBfuQEJV1KBBl+JpLZA== +"@elastic/node-crypto@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-1.2.1.tgz#dfd9218f9b5729fa519762e6a6968aaf61b86eb0" + integrity sha512-RlZg+poLA2SwZZUM5RMJDJiKojlSB1mJkumIvLgXvvTCcCliC6rM0lUaNecV9pbQLIHrGlX2BrbwiuPWhv0czQ== + "@elastic/numeral@^2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@elastic/numeral/-/numeral-2.5.0.tgz#8da714827fc278f17546601fdfe55f5c920e2bc5" @@ -8478,16 +8492,16 @@ backo2@1.0.2: resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= -backport@5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/backport/-/backport-5.4.1.tgz#b066e8bbece91bc813187c13b7bea69ef5355471" - integrity sha512-vFR5Juss2pveS2OyyoE5n14j7ZDqeZXakzv4KngTEUTsb+5r/AVj2OG8LfJ14RJBMKBYSf1ojSKgDiWtUi0r+w== +backport@5.4.6: + version "5.4.6" + resolved "https://registry.yarnpkg.com/backport/-/backport-5.4.6.tgz#8d8d8cb7c0df4079a40c6f4892f393daa92c1ef8" + integrity sha512-O3fFmQXKZN5sP6R6GwXeobsEgoFzvnuTGj8/TTTjxt1xA07pfhTY67M16rr0eiDDtuSxAqWMX9Zo+5Q3DuxfpQ== dependencies: axios "^0.19.2" dedent "^0.7.0" del "^5.1.0" find-up "^4.1.0" - inquirer "^7.1.0" + inquirer "^7.2.0" lodash.flatmap "^4.5.0" lodash.isempty "^4.4.0" lodash.isstring "^4.0.1" @@ -8496,7 +8510,7 @@ backport@5.4.1: ora "^4.0.4" safe-json-stringify "^1.2.0" strip-json-comments "^3.1.0" - winston "^3.2.1" + winston "^3.3.3" yargs "^15.3.1" bail@^1.0.0: @@ -12992,6 +13006,11 @@ enabled@1.0.x: dependencies: env-variable "0.0.x" +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + encodeurl@^1.0.2, encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -14499,6 +14518,11 @@ fecha@^2.3.3: resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd" integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg== +fecha@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.0.tgz#3ffb6395453e3f3efff850404f0a59b6747f5f41" + integrity sha512-aN3pcx/DSmtyoovUudctc8+6Hl4T+hI9GBBHLjA76jdZl7+b1sgh5g4k+u/GL3dTy1/pnYzKp69FpJ0OicE3Wg== + fetch-mock@^7.3.9: version "7.3.9" resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-7.3.9.tgz#a80fd2a1728f72e0634ef7a9734bc61200096487" @@ -14909,6 +14933,11 @@ fmin@0.0.2: tape "^4.5.1" uglify-js "^2.6.2" +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + focus-lock@^0.5.2: version "0.5.4" resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.5.4.tgz#537644d61b9e90fd97075aa680b8add1de24e819" @@ -17816,10 +17845,10 @@ inquirer@^7.0.0: strip-ansi "^5.1.0" through "^2.3.6" -inquirer@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" - integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== +inquirer@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a" + integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ== dependencies: ansi-escapes "^4.2.1" chalk "^3.0.0" @@ -20116,6 +20145,11 @@ kuler@1.0.x: dependencies: colornames "^1.1.1" +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + last-run@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/last-run/-/last-run-1.1.1.tgz#45b96942c17b1c79c772198259ba943bebf8ca5b" @@ -20954,6 +20988,17 @@ logform@^2.1.1: ms "^2.1.1" triple-beam "^1.3.0" +logform@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2" + integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg== + dependencies: + colors "^1.2.1" + fast-safe-stringify "^2.0.4" + fecha "^4.2.0" + ms "^2.1.1" + triple-beam "^1.3.0" + loglevel@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.4.tgz#f408f4f006db8354d0577dcf6d33485b3cb90d56" @@ -23186,6 +23231,13 @@ one-time@0.0.4: resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e" integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4= +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + onetime@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" @@ -26172,7 +26224,7 @@ read-pkg@^5.1.1, read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" -"readable-stream@1 || 2", readable-stream@~2.3.3: +"readable-stream@1 || 2", readable-stream@^2.3.7, readable-stream@~2.3.3: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -32875,6 +32927,14 @@ winston-transport@^4.3.0: readable-stream "^2.3.6" triple-beam "^1.2.0" +winston-transport@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" + integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw== + dependencies: + readable-stream "^2.3.7" + triple-beam "^1.2.0" + winston@3.2.1, winston@^3.0.0, winston@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07" @@ -32890,6 +32950,21 @@ winston@3.2.1, winston@^3.0.0, winston@^3.2.1: triple-beam "^1.3.0" winston-transport "^4.3.0" +winston@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" + integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== + dependencies: + "@dabh/diagnostics" "^2.0.2" + async "^3.1.0" + is-stream "^2.0.0" + logform "^2.2.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.4.0" + with@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/with/-/with-5.1.1.tgz#fa4daa92daf32c4ea94ed453c81f04686b575dfe"