diff --git a/.node-version b/.node-version index 19c4c189d3640..c91434ab584a7 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14.15.3 +14.15.4 diff --git a/.nvmrc b/.nvmrc index 19c4c189d3640..c91434ab584a7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14.15.3 +14.15.4 diff --git a/NOTICE.txt b/NOTICE.txt index bf3cb4aa4ac87..2341a478cbda9 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,5 +1,5 @@ Kibana source code with Kibana X-Pack source code -Copyright 2012-2020 Elasticsearch B.V. +Copyright 2012-2021 Elasticsearch B.V. --- Pretty handling of logarithmic axes. diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index d73ed716e6b19..b0730d1b762d6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -44,6 +44,7 @@ readonly links: { readonly aggs: { readonly date_histogram: string; readonly date_range: string; + readonly date_format_pattern: string; readonly filter: string; readonly filters: string; readonly geohash_grid: string; @@ -104,5 +105,11 @@ readonly links: { readonly ml: Record; readonly transforms: Record; readonly visualize: Record; + readonly apis: Record; + readonly observability: Record; + readonly alerting: Record; + readonly maps: Record; + readonly monitoring: Record; + readonly security: Record; }; ``` 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 7aa170eef9b50..2d06876f42b6a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [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 guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: 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 addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: 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 date_format_pattern: 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 addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Record<string, string>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Record<string, string>;
} | | diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 2d91eb07c5236..8c16c76c62569 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -195,11 +195,11 @@ a| `xpack.reporting.capture.browser` Defaults to `false`. a| `xpack.reporting.capture.browser` -.chromium.proxy.server` +`.chromium.proxy.server` | The uri for the proxy server. Providing the username and password for the proxy server via the uri is not supported. a| `xpack.reporting.capture.browser` -.chromium.proxy.bypass` +`.chromium.proxy.bypass` | An array of hosts that should not go through the proxy server and should use a direct connection instead. Examples of valid entries are "elastic.co", "*.elastic.co", ".elastic.co", ".elastic.co:5601". diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index df9fa2dca81fd..b292c1ae5e03f 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -133,6 +133,12 @@ Example: `{{split event.value ","}}` +|encodeURIComponent +a|Escapes string using built in `encodeURIComponent` function. + +|encodeURIQuery +a|Escapes string using built in `encodeURIComponent` function, while keeping "@", ":", "$", ",", and ";" characters as is. + |=== diff --git a/docs/user/ml/images/ml-data-visualizer-sample.jpg b/docs/user/ml/images/ml-data-visualizer-sample.jpg index ce2bb660d7da1..4d77ef3010c3f 100644 Binary files a/docs/user/ml/images/ml-data-visualizer-sample.jpg and b/docs/user/ml/images/ml-data-visualizer-sample.jpg differ diff --git a/package.json b/package.json index 61b13a06bffe9..b657c8273517a 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "**/typescript": "4.1.2" }, "engines": { - "node": "14.15.3", + "node": "14.15.4", "yarn": "^1.21.1" }, "dependencies": { @@ -116,7 +116,7 @@ "@hapi/good-squeeze": "6.0.0", "@hapi/h2o2": "^9.0.2", "@hapi/hapi": "^20.0.3", - "@hapi/hoek": "^9.1.0", + "@hapi/hoek": "^9.1.1", "@hapi/inert": "^6.0.3", "@hapi/podium": "^4.1.1", "@hapi/statehood": "^7.0.3", diff --git a/packages/kbn-i18n/GUIDELINE.md b/packages/kbn-i18n/GUIDELINE.md index b7c1371d59ea4..437e73bb27019 100644 --- a/packages/kbn-i18n/GUIDELINE.md +++ b/packages/kbn-i18n/GUIDELINE.md @@ -387,6 +387,50 @@ Splitting sentences into several keys often inadvertently presumes a grammar, a ### Unit tests +#### How to test `FormattedMessage` and `i18n.translate()` components. + +To make `FormattedMessage` component work properly, wrapping it with `I18nProvider` is required. In development/production app, this is done in the ancestor components and developers don't have to worry about that. + +But when unit-testing them, no other component provides that wrapping. That's why `shallowWithI18nProvider` and `mountWithI18nProvider` helpers are created. + +For example, there is a component that has `FormattedMessage` inside, like `SaveModal` component: + +```js +// ... +export const SaveModal = (props) => { + return ( +
+ {/* Other things. */} + + + + {/* More other things. */} +
+ ) +} +``` + +To test `SaveModal` component, it should be wrapped with `I18nProvider` by using `shallowWithI18nProvider`: + +```js +// ... +it('should render normally', async () => { + const component = shallowWithI18nProvider( + + ); + + expect(component).toMatchSnapshot(); +}); +// ... +``` + +If a component uses only `i18n.translate()`, it doesn't need `I18nProvider`. In that case, you can test them with `shallow` and `mount` functions that `enzyme` providers out of the box. + +#### How to test `injectI18n` HOC components. + Testing React component that uses the `injectI18n` higher-order component is more complicated because `injectI18n()` creates a wrapper component around the original component. With shallow rendering only top level component is rendered, that is a wrapper itself, not the original component. Since we want to test the rendering of the original component, we need to access it via the wrapper's `WrappedComponent` property. Its value will be the component we passed into `injectI18n()`. diff --git a/src/core/server/http/prototype_pollution/__snapshots__/validate_object.test.ts.snap b/packages/kbn-std/src/__snapshots__/ensure_no_unsafe_properties.test.ts.snap similarity index 100% rename from src/core/server/http/prototype_pollution/__snapshots__/validate_object.test.ts.snap rename to packages/kbn-std/src/__snapshots__/ensure_no_unsafe_properties.test.ts.snap diff --git a/src/core/server/http/prototype_pollution/validate_object.test.ts b/packages/kbn-std/src/ensure_no_unsafe_properties.test.ts similarity index 89% rename from src/core/server/http/prototype_pollution/validate_object.test.ts rename to packages/kbn-std/src/ensure_no_unsafe_properties.test.ts index 23d6c4ae3b49f..c12626b8d777e 100644 --- a/src/core/server/http/prototype_pollution/validate_object.test.ts +++ b/packages/kbn-std/src/ensure_no_unsafe_properties.test.ts @@ -17,14 +17,14 @@ * under the License. */ -import { validateObject } from './validate_object'; +import { ensureNoUnsafeProperties } from './ensure_no_unsafe_properties'; test(`fails on circular references`, () => { const foo: Record = {}; foo.myself = foo; expect(() => - validateObject({ + ensureNoUnsafeProperties({ payload: foo, }) ).toThrowErrorMatchingInlineSnapshot(`"circular reference detected"`); @@ -57,7 +57,7 @@ test(`fails on circular references`, () => { [property]: value, }; test(`can submit ${JSON.stringify(obj)}`, () => { - expect(() => validateObject(obj)).not.toThrowError(); + expect(() => ensureNoUnsafeProperties(obj)).not.toThrowError(); }); }); }); @@ -74,6 +74,6 @@ test(`fails on circular references`, () => { JSON.parse(`{ "foo": { "bar": { "constructor": { "prototype" : null } } } }`), ].forEach((value) => { test(`can't submit ${JSON.stringify(value)}`, () => { - expect(() => validateObject(value)).toThrowErrorMatchingSnapshot(); + expect(() => ensureNoUnsafeProperties(value)).toThrowErrorMatchingSnapshot(); }); }); diff --git a/src/core/server/http/prototype_pollution/validate_object.ts b/packages/kbn-std/src/ensure_no_unsafe_properties.ts similarity index 97% rename from src/core/server/http/prototype_pollution/validate_object.ts rename to packages/kbn-std/src/ensure_no_unsafe_properties.ts index cab6ce295ce92..47cbea5ecf3ee 100644 --- a/src/core/server/http/prototype_pollution/validate_object.ts +++ b/packages/kbn-std/src/ensure_no_unsafe_properties.ts @@ -31,7 +31,7 @@ const hasOwnProperty = (obj: any, property: string) => const isObject = (obj: any) => typeof obj === 'object' && obj !== null; // we're using a stack instead of recursion so we aren't limited by the call stack -export function validateObject(obj: any) { +export function ensureNoUnsafeProperties(obj: any) { if (!isObject(obj)) { return; } diff --git a/packages/kbn-std/src/index.ts b/packages/kbn-std/src/index.ts index c111428017539..a5b5088f9105f 100644 --- a/packages/kbn-std/src/index.ts +++ b/packages/kbn-std/src/index.ts @@ -27,4 +27,5 @@ export { withTimeout } from './promise'; export { isRelativeUrl, modifyUrl, getUrlOrigin, URLMeaningfulParts } from './url'; export { unset } from './unset'; export { getFlattenedObject } from './get_flattened_object'; +export { ensureNoUnsafeProperties } from './ensure_no_unsafe_properties'; export * from './rxjs_7'; diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 8f53d6f7cf58b..2dfc9ded66201 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -141,22 +141,27 @@ export function runFtrCli() { config: 'test/functional/config.js', }, help: ` - --config=path path to a config file - --bail stop tests after the first failure - --grep pattern used to select which tests to run - --invert invert grep to exclude tests - --include=file a test file to be included, pass multiple times for multiple files - --exclude=file a test file to be excluded, pass multiple times for multiple files - --include-tag=tag a tag to be included, pass multiple times for multiple tags - --exclude-tag=tag a tag to be excluded, pass multiple times for multiple tags - --test-stats print the number of tests (included and excluded) to STDERR - --updateBaselines replace baseline screenshots with whatever is generated from the test - --updateSnapshots replace inline and file snapshots with whatever is generated from the test - -u replace both baseline screenshots and snapshots - --kibana-install-dir directory where the Kibana install being tested resides - --throttle enable network throttling in Chrome browser - --headless run browser in headless mode - `, + --config=path path to a config file + --bail stop tests after the first failure + --grep pattern used to select which tests to run + --invert invert grep to exclude tests + --include=file a test file to be included, pass multiple times for multiple files + --exclude=file a test file to be excluded, pass multiple times for multiple files + --include-tag=tag a tag to be included, pass multiple times for multiple tags. Only + suites which have one of the passed include-tag tags will be executed. + When combined with the --exclude-tag flag both conditions must be met + for a suite to run. + --exclude-tag=tag a tag to be excluded, pass multiple times for multiple tags. Any suite + which has any of the exclude-tags will be excluded. When combined with + the --include-tag flag both conditions must be met for a suite to run. + --test-stats print the number of tests (included and excluded) to STDERR + --updateBaselines replace baseline screenshots with whatever is generated from the test + --updateSnapshots replace inline and file snapshots with whatever is generated from the test + -u replace both baseline screenshots and snapshots + --kibana-install-dir directory where the Kibana install being tested resides + --throttle enable network throttling in Chrome browser + --headless run browser in headless mode + `, }, } ); diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 2893f4c9a9878..0c65a26b2387b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -72,6 +72,7 @@ export class DocLinksService { aggs: { date_histogram: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-datehistogram-aggregation.html`, date_range: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-daterange-aggregation.html`, + date_format_pattern: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-daterange-aggregation.html#date-format-pattern`, filter: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-filter-aggregation.html`, filters: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-filters-aggregation.html`, geohash_grid: `${ELASTICSEARCH_DOCS}search-aggregations-bucket-geohashgrid-aggregation.html`, @@ -107,6 +108,7 @@ export class DocLinksService { painless: `${ELASTICSEARCH_DOCS}modules-scripting-painless.html`, painlessApi: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, painlessSyntax: `${ELASTICSEARCH_DOCS}modules-scripting-painless-syntax.html`, + painlessLanguage: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, luceneExpressions: `${ELASTICSEARCH_DOCS}modules-scripting-expression.html`, }, indexPatterns: { @@ -150,7 +152,7 @@ export class DocLinksService { classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-class-aucroc`, }, transforms: { - guide: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/transforms.html`, + guide: `${ELASTICSEARCH_DOCS}transforms.html`, }, visualize: { guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/visualize.html`, @@ -158,6 +160,38 @@ export class DocLinksService { lens: `${ELASTIC_WEBSITE_URL}what-is/kibana-lens`, maps: `${ELASTIC_WEBSITE_URL}maps`, }, + observability: { + guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`, + }, + alerting: { + guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/managing-alerts-and-actions.html`, + actionTypes: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/action-types.html`, + }, + maps: { + guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-maps.html`, + }, + monitoring: { + alertsKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-alerts.html`, + monitorElasticsearch: `${ELASTICSEARCH_DOCS}configuring-metricbeat.html`, + monitorKibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html`, + }, + security: { + elasticsearchSettings: `${ELASTICSEARCH_DOCS}security-settings.html`, + kibanaTLS: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`, + kibanaPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-privileges.html`, + indicesPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-indices`, + mappingRoles: `${ELASTICSEARCH_DOCS}mapping-roles.html`, + }, + apis: { + createIndex: `${ELASTICSEARCH_DOCS}indices-create-index.html`, + createSnapshotLifecylePolicy: `${ELASTICSEARCH_DOCS}slm-api-put-policy.html`, + createRoleMapping: `${ELASTICSEARCH_DOCS}security-api-put-role-mapping.html`, + createApiKey: `${ELASTICSEARCH_DOCS}security-api-create-api-key.html`, + createPipeline: `${ELASTICSEARCH_DOCS}put-pipeline-api.html`, + createTransformRequest: `${ELASTICSEARCH_DOCS}put-transform.html#put-transform-request-body`, + openIndex: `${ELASTICSEARCH_DOCS}indices-open-close.html`, + updateTransform: `${ELASTICSEARCH_DOCS}update-transform.html`, + }, }, }); } @@ -204,6 +238,7 @@ export interface DocLinksStart { readonly aggs: { readonly date_histogram: string; readonly date_range: string; + readonly date_format_pattern: string; readonly filter: string; readonly filters: string; readonly geohash_grid: string; @@ -264,5 +299,11 @@ export interface DocLinksStart { readonly ml: Record; readonly transforms: Record; readonly visualize: Record; + readonly apis: Record; + readonly observability: Record; + readonly alerting: Record; + readonly maps: Record; + readonly monitoring: Record; + readonly security: Record; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 0303eb62b6419..4d00ebb213564 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -523,6 +523,7 @@ export interface DocLinksStart { readonly aggs: { readonly date_histogram: string; readonly date_range: string; + readonly date_format_pattern: string; readonly filter: string; readonly filters: string; readonly geohash_grid: string; @@ -583,6 +584,12 @@ export interface DocLinksStart { readonly ml: Record; readonly transforms: Record; readonly visualize: Record; + readonly apis: Record; + readonly observability: Record; + readonly alerting: Record; + readonly maps: Record; + readonly monitoring: Record; + readonly security: Record; }; } diff --git a/src/core/server/http/cookie_session_storage.ts b/src/core/server/http/cookie_session_storage.ts index 1ff0670d78f4e..40bca89c21cb3 100644 --- a/src/core/server/http/cookie_session_storage.ts +++ b/src/core/server/http/cookie_session_storage.ts @@ -19,8 +19,6 @@ import { Request, Server } from '@hapi/hapi'; import hapiAuthCookie from '@hapi/cookie'; -// @ts-expect-error no TS definitions -import Statehood from '@hapi/statehood'; import { KibanaRequest, ensureRawRequest } from './router'; import { SessionStorageFactory, SessionStorage } from './session_storage'; @@ -148,7 +146,7 @@ export async function createCookieSessionStorageFactory( path: basePath === undefined ? '/' : basePath, clearInvalid: false, isHttpOnly: true, - isSameSite: cookieOptions.sameSite === 'None' ? false : cookieOptions.sameSite ?? false, + isSameSite: cookieOptions.sameSite ?? false, }, validateFunc: async (req: Request, session: T | T[]) => { const result = cookieOptions.validate(session); @@ -159,23 +157,6 @@ export async function createCookieSessionStorageFactory( }, }); - // A hack to support SameSite: 'None'. - // Remove it after update Hapi to v19 that supports SameSite: 'None' out of the box. - if (cookieOptions.sameSite === 'None') { - log.debug('Patching Statehood.prepareValue'); - const originalPrepareValue = Statehood.prepareValue; - Statehood.prepareValue = function kibanaStatehoodPrepareValueWrapper( - name: string, - value: unknown, - options: any - ) { - if (name === cookieOptions.name) { - options.isSameSite = cookieOptions.sameSite; - } - return originalPrepareValue(name, value, options); - }; - } - return { asScoped(request: KibanaRequest) { return new ScopedCookieSessionStorage(log, server, ensureRawRequest(request)); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 42e89b66d9c51..81f7c9c45ba50 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Server, ServerRoute } from '@hapi/hapi'; +import { Server } from '@hapi/hapi'; import HapiStaticFiles from '@hapi/inert'; import url from 'url'; import uuid from 'uuid'; @@ -167,6 +167,8 @@ export class HttpServer { for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { this.log.debug(`registering route handler for [${route.path}]`); + // Hapi does not allow payload validation to be specified for 'head' or 'get' requests + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; const { authRequired, tags, body = {}, timeout } = route.options; const { accepts: allow, maxBytes, output, parse } = body; @@ -174,7 +176,7 @@ export class HttpServer { xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), }; - const routeOpts: ServerRoute = { + this.server.route({ handler: route.handler, method: route.method, path: route.path, @@ -182,6 +184,11 @@ export class HttpServer { auth: this.getAuthOption(authRequired), app: kibanaRouteOptions, tags: tags ? Array.from(tags) : undefined, + // TODO: This 'validate' section can be removed once the legacy platform is completely removed. + // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default + // validation applied in ./http_tools#getServerOptions + // (All NP routes are already required to specify their own validation in order to access the payload) + validate, // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) ? { @@ -197,22 +204,7 @@ export class HttpServer { socket: timeout?.idleSocket ?? this.config!.socketTimeout, }, }, - }; - - // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - if (!isSafeMethod(route.method)) { - // TODO: This 'validate' section can be removed once the legacy platform is completely removed. - // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default - // validation applied in ./http_tools#getServerOptions - // (All NP routes are already required to specify their own validation in order to access the payload) - // TODO: Move the setting of the validate option back up to being set at `routeOpts` creation-time once - // https://github.com/hapijs/hoek/pull/365 is merged and released in @hapi/hoek v9.1.1. At that point I - // imagine the ts-error below will go away as well. - // @ts-expect-error "Property 'validate' does not exist on type 'RouteOptions'" <-- ehh?!? yes it does! - routeOpts.options!.validate = { payload: true }; - } - - this.server.route(routeOpts); + }); } } diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 8bec26f31fa26..f09f3dc2730a1 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -29,8 +29,8 @@ import Hoek from '@hapi/hoek'; import type { ServerOptions as TLSOptions } from 'https'; import type { ValidationError } from 'joi'; import uuid from 'uuid'; +import { ensureNoUnsafeProperties } from '@kbn/std'; import { HttpConfig } from './http_config'; -import { validateObject } from './prototype_pollution'; const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf']; /** @@ -69,7 +69,7 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = { // This is a default payload validation which applies to all LP routes which do not specify their own // `validate.payload` handler, in order to reduce the likelyhood of prototype pollution vulnerabilities. // (All NP routes are already required to specify their own validation in order to access the payload) - payload: (value) => Promise.resolve(validateObject(value)), + payload: (value) => Promise.resolve(ensureNoUnsafeProperties(value)), }, }, state: { diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index 7573d0b837416..34c3c325e7328 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -18,7 +18,7 @@ */ import moment from 'moment-timezone'; -import { merge } from 'lodash'; +import { merge } from '@kbn/std'; import { schema } from '@kbn/config-schema'; import { LogRecord, Layout } from '@kbn/logging'; @@ -53,22 +53,19 @@ export class JsonLayout implements Layout { } public format(record: LogRecord): string { - return JSON.stringify( - merge( - { - '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), - message: record.message, - error: JsonLayout.errorToSerializableObject(record.error), - log: { - level: record.level.id.toUpperCase(), - logger: record.context, - }, - process: { - pid: record.pid, - }, - }, - record.meta - ) - ); + const log = { + '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + message: record.message, + error: JsonLayout.errorToSerializableObject(record.error), + log: { + level: record.level.id.toUpperCase(), + logger: record.context, + }, + process: { + pid: record.pid, + }, + }; + const output = record.meta ? merge(log, record.meta) : log; + return JSON.stringify(output); } } diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 05432d65c0558..abda7cf82b121 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -908,7 +908,8 @@ describe('migration actions', () => { }); }); - describe('createIndex', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/87160 + describe.skip('createIndex', () => { afterAll(async () => { await client.indices.delete({ index: 'yellow_then_green_index' }); }); diff --git a/src/dev/cli_dev_mode/log.ts b/src/dev/cli_dev_mode/log.ts index f349026ca9cab..3a5d60e65c3f1 100644 --- a/src/dev/cli_dev_mode/log.ts +++ b/src/dev/cli_dev_mode/log.ts @@ -25,7 +25,7 @@ export interface Log { good(label: string, ...args: any[]): void; warn(label: string, ...args: any[]): void; bad(label: string, ...args: any[]): void; - write(label: string, ...args: any[]): void; + write(...args: any[]): void; } export class CliLog implements Log { @@ -58,9 +58,9 @@ export class CliLog implements Log { console.log(Chalk.white.bgRed(` ${label.trim()} `), ...args); } - write(label: string, ...args: any[]) { + write(...args: any[]) { // eslint-disable-next-line no-console - console.log(` ${label.trim()} `, ...args); + console.log(...args); } } @@ -88,10 +88,10 @@ export class TestLog implements Log { }); } - write(label: string, ...args: any[]) { + write(...args: any[]) { this.messages.push({ type: 'write', - args: [label, ...args], + args, }); } } diff --git a/src/dev/cli_dev_mode/optimizer.test.ts b/src/dev/cli_dev_mode/optimizer.test.ts index 8a82012499b33..6017ab2c35d0f 100644 --- a/src/dev/cli_dev_mode/optimizer.test.ts +++ b/src/dev/cli_dev_mode/optimizer.test.ts @@ -191,8 +191,8 @@ it('is ready when optimizer phase is success or issue and logs in familiar forma const lines = await linesPromise; expect(lines).toMatchInlineSnapshot(` Array [ - "np bld log [timestamp] [success][@kbn/optimizer] 0 bundles compiled successfully after 0 sec", - "np bld log [timestamp] [error][@kbn/optimizer] webpack compile errors", + " np bld log [timestamp] [success][@kbn/optimizer] 0 bundles compiled successfully after 0 sec", + " np bld log [timestamp] [error][@kbn/optimizer] webpack compile errors", ] `); }); diff --git a/src/dev/cli_dev_mode/optimizer.ts b/src/dev/cli_dev_mode/optimizer.ts index 9aac414f02b29..f618a0fdbe72f 100644 --- a/src/dev/cli_dev_mode/optimizer.ts +++ b/src/dev/cli_dev_mode/optimizer.ts @@ -105,7 +105,7 @@ export class Optimizer { ToolingLogTextWriter.write( options.writeLogTo ?? process.stdout, - `${dim} log [${time()}] [${level(msg.type)}][${name}] `, + ` ${dim} log [${time()}] [${level(msg.type)}][${name}] `, msg ); return true; diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index 9673737372478..1ea6355b9c558 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { parse, ParsedQuery } from 'query-string'; import { render, unmountComponentAtNode } from 'react-dom'; -import { Switch, Route, RouteComponentProps, HashRouter } from 'react-router-dom'; +import { Switch, Route, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom'; import { DashboardListing } from './listing'; import { DashboardApp } from './dashboard_app'; @@ -202,6 +202,9 @@ export async function mountApp({ render={renderDashboard} /> + + + diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 5e6fd5323c0b7..7b65805a482dd 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -71,19 +71,27 @@ export function FilterItem(props: Props) { useEffect(() => { const index = props.filter.meta.index; + let isSubscribed = true; if (index) { getIndexPatterns() .get(index) .then((indexPattern) => { - setIndexPatternExists(!!indexPattern); + if (isSubscribed) { + setIndexPatternExists(!!indexPattern); + } }) .catch(() => { - setIndexPatternExists(false); + if (isSubscribed) { + setIndexPatternExists(false); + } }); - } else { + } else if (isSubscribed) { // Allow filters without an index pattern and don't validate them. setIndexPatternExists(true); } + return () => { + isSubscribed = false; + }; }, [props.filter.meta.index]); function handleBadgeClick(e: MouseEvent) { diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover/public/application/angular/context_app.html index d20b1ca999af9..8dc3e5c87e504 100644 --- a/src/plugins/discover/public/application/angular/context_app.html +++ b/src/plugins/discover/public/application/angular/context_app.html @@ -1,15 +1,3 @@ - - - diff --git a/src/plugins/discover/public/application/angular/context_app.js b/src/plugins/discover/public/application/angular/context_app.js index 145d3afe23224..d9e2452eb8bd6 100644 --- a/src/plugins/discover/public/application/angular/context_app.js +++ b/src/plugins/discover/public/application/angular/context_app.js @@ -56,13 +56,14 @@ getAngularModule().directive('contextApp', function ContextApp() { }); function ContextAppController($scope, Private) { - const { filterManager, indexPatterns, uiSettings } = getServices(); + const { filterManager, indexPatterns, uiSettings, navigation } = getServices(); const queryParameterActions = getQueryParameterActions(filterManager, indexPatterns); const queryActions = Private(QueryActionsProvider); this.state = createInitialState( parseInt(uiSettings.get(CONTEXT_STEP_SETTING), 10), getFirstSortableField(this.indexPattern, uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)) ); + this.topNavMenu = navigation.ui.TopNavMenu; this.actions = _.mapValues( { diff --git a/src/core/server/http/prototype_pollution/index.ts b/src/plugins/discover/public/application/components/context_app/__mocks__/top_nav_menu.tsx similarity index 90% rename from src/core/server/http/prototype_pollution/index.ts rename to src/plugins/discover/public/application/components/context_app/__mocks__/top_nav_menu.tsx index e1a33ffba155e..be02fd7bc46d2 100644 --- a/src/core/server/http/prototype_pollution/index.ts +++ b/src/plugins/discover/public/application/components/context_app/__mocks__/top_nav_menu.tsx @@ -17,4 +17,6 @@ * under the License. */ -export { validateObject } from './validate_object'; +import React from 'react'; + +export const TopNavMenuMock = () =>
Hello World
; diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx b/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx index f76e0178e98b0..cf6dc70e92d03 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx @@ -25,6 +25,7 @@ import { DocTableLegacy } from '../../angular/doc_table/create_doc_table_react'; import { findTestSubject } from '@elastic/eui/lib/test'; import { ActionBar } from '../../angular/context/components/action_bar/action_bar'; import { ContextErrorMessage } from '../context_error_message'; +import { TopNavMenuMock } from './__mocks__/top_nav_menu'; describe('ContextAppLegacy test', () => { const hit = { @@ -64,6 +65,17 @@ describe('ContextAppLegacy test', () => { onChangeSuccessorCount: jest.fn(), predecessorStatus: 'loaded', successorStatus: 'loaded', + topNavMenu: TopNavMenuMock, + }; + const topNavProps = { + appName: 'context', + showSearchBar: true, + showQueryBar: false, + showFilterBar: true, + showSaveQuery: false, + showDatePicker: false, + indexPatterns: [indexPattern], + useDefaultBehaviors: true, }; it('renders correctly', () => { @@ -72,6 +84,9 @@ describe('ContextAppLegacy test', () => { const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator'); expect(loadingIndicator.length).toBe(0); expect(component.find(ActionBar).length).toBe(2); + const topNavMenu = component.find(TopNavMenuMock); + expect(topNavMenu.length).toBe(1); + expect(topNavMenu.props()).toStrictEqual(topNavProps); }); it('renders loading indicator', () => { @@ -82,6 +97,7 @@ describe('ContextAppLegacy test', () => { const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator'); expect(loadingIndicator.length).toBe(1); expect(component.find(ActionBar).length).toBe(2); + expect(component.find(TopNavMenuMock).length).toBe(1); }); it('renders error message', () => { @@ -90,6 +106,7 @@ describe('ContextAppLegacy test', () => { props.reason = 'something went wrong'; const component = mountWithIntl(); expect(component.find(DocTableLegacy).length).toBe(0); + expect(component.find(TopNavMenuMock).length).toBe(0); const errorMessage = component.find(ContextErrorMessage); expect(errorMessage.length).toBe(1); }); diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx index af99c995c60eb..f519df8a0b80d 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx @@ -27,8 +27,10 @@ import { import { IIndexPattern, IndexPatternField } from '../../../../../data/common/index_patterns'; import { LOADING_STATUS } from './constants'; import { ActionBar, ActionBarProps } from '../../angular/context/components/action_bar/action_bar'; +import { TopNavMenuProps } from '../../../../../navigation/public'; export interface ContextAppProps { + topNavMenu: React.ComponentType; columns: string[]; hits: Array>; indexPattern: IIndexPattern; @@ -96,6 +98,20 @@ export function ContextAppLegacy(renderProps: ContextAppProps) { } as DocTableLegacyProps; }; + const TopNavMenu = renderProps.topNavMenu; + const getNavBarProps = () => { + return { + appName: 'context', + showSearchBar: true, + showQueryBar: false, + showFilterBar: true, + showSaveQuery: false, + showDatePicker: false, + indexPatterns: [renderProps.indexPattern], + useDefaultBehaviors: true, + }; + }; + const loadingFeedback = () => { if (status === LOADING_STATUS.UNINITIALIZED || status === LOADING_STATUS.LOADING) { return ( @@ -112,20 +128,23 @@ export function ContextAppLegacy(renderProps: ContextAppProps) { {isFailed ? ( ) : ( - - - - {loadingFeedback()} - - {isLoaded ? ( -
- -
- ) : null} - - -
-
+
+ + + + + {loadingFeedback()} + + {isLoaded ? ( +
+ +
+ ) : null} + + +
+
+
)} ); diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts b/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts index bc4b7c4babd21..dfb5d90c2befe 100644 --- a/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts @@ -37,5 +37,6 @@ export function createContextAppLegacy(reactDirective: any) { ['successorAvailable', { watchDepth: 'reference' }], ['successorStatus', { watchDepth: 'reference' }], ['onChangeSuccessorCount', { watchDepth: 'reference' }], + ['topNavMenu', { watchDepth: 'reference' }], ]); } diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 2ace65c31cc03..c32cf3023a25e 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -50,8 +50,6 @@ import { PromiseServiceCreator, registerListenEventListener, watchMultiDecorator, - createTopNavDirective, - createTopNavHelper, } from '../../kibana_legacy/public'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; @@ -98,7 +96,6 @@ export function initializeInnerAngularModule( createLocalI18nModule(); createLocalPrivateModule(); createLocalPromiseModule(); - createLocalTopNavModule(navigation); createLocalStorageModule(); createPagerFactoryModule(); createDocTableModule(); @@ -131,7 +128,6 @@ export function initializeInnerAngularModule( 'discoverI18n', 'discoverPrivate', 'discoverPromise', - 'discoverTopNav', 'discoverLocalStorageProvider', 'discoverDocTable', 'discoverPagerFactory', @@ -151,13 +147,6 @@ function createLocalPrivateModule() { angular.module('discoverPrivate', []).provider('Private', PrivateProvider); } -function createLocalTopNavModule(navigation: NavigationStart) { - angular - .module('discoverTopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); -} - function createLocalI18nModule() { angular .module('discoverI18n', []) diff --git a/src/plugins/expressions/public/react_expression_renderer.test.tsx b/src/plugins/expressions/public/react_expression_renderer.test.tsx index ac6fcab33acbf..d9a4f095127b8 100644 --- a/src/plugins/expressions/public/react_expression_renderer.test.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.test.tsx @@ -146,6 +146,35 @@ describe('ExpressionRenderer', () => { instance.unmount(); }); + it('should not update twice immediately after rendering', () => { + jest.useFakeTimers(); + + const refreshSubject = new Subject(); + const loaderUpdate = jest.fn(); + + (ExpressionLoader as jest.Mock).mockImplementation(() => { + return { + render$: new Subject(), + data$: new Subject(), + loading$: new Subject(), + update: loaderUpdate, + destroy: jest.fn(), + }; + }); + + const instance = mount( + + ); + + act(() => { + jest.runAllTimers(); + }); + + expect(loaderUpdate).toHaveBeenCalledTimes(1); + + instance.unmount(); + }); + it('waits for debounce period on other loader option change if specified', () => { jest.useFakeTimers(); diff --git a/src/plugins/expressions/public/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer.tsx index 3227b34dcc1ff..caa8e209ec170 100644 --- a/src/plugins/expressions/public/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.tsx @@ -91,7 +91,12 @@ export const ReactExpressionRenderer = ({ ); const [debouncedExpression, setDebouncedExpression] = useState(expression); const [waitingForDebounceToComplete, setDebouncePending] = useState(false); + const firstRender = useRef(true); useShallowCompareEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } if (debounce === undefined) { return; } diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index 5afc91c4445e8..a1b72eac756d3 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -24,7 +24,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new NavigationPublicPlugin(initializerContext); } -export { TopNavMenuData, TopNavMenu } from './top_nav_menu'; +export { TopNavMenuData, TopNavMenu, TopNavMenuProps } from './top_nav_menu'; export { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index bba086720da0a..3ed9aaaaea226 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -19,6 +19,7 @@ import { IRouter, KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; +import { ensureNoUnsafeProperties } from '@kbn/std'; import { getVisData, GetVisDataOptions } from '../lib/get_vis_data'; import { visPayloadSchema } from '../../common/vis_schema'; import { ROUTES } from '../../common/constants'; @@ -40,6 +41,14 @@ export const visDataRoutes = ( }, }, async (requestContext, request, response) => { + try { + ensureNoUnsafeProperties(request.body); + } catch (error) { + return response.badRequest({ + body: error.message, + }); + } + try { visPayloadSchema.validate(request.body); } catch (error) { diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 221e93fd7b839..18be6e69a2637 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -115,14 +115,16 @@ def functionalXpack(Map params = [:]) { task(kibanaPipeline.functionalTestProcess('xpack-savedObjectsFieldMetrics', './test/scripts/jenkins_xpack_saved_objects_field_metrics.sh')) } - whenChanged([ - 'x-pack/plugins/security_solution/', - 'x-pack/test/security_solution_cypress/', - 'x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/', - 'x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx', - ]) { - task(kibanaPipeline.functionalTestProcess('xpack-securitySolutionCypress', './test/scripts/jenkins_security_solution_cypress.sh')) - } + whenChanged([ + 'x-pack/plugins/security_solution/', + 'x-pack/test/security_solution_cypress/', + 'x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/', + 'x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx', + ]) { + if (githubPr.isPr()) { + task(kibanaPipeline.functionalTestProcess('xpack-securitySolutionCypress', './test/scripts/jenkins_security_solution_cypress.sh')) + } + } } } diff --git a/x-pack/README.md b/x-pack/README.md index 41ea4cc4e469a..3f651336f7f3b 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -84,8 +84,9 @@ Jest integration tests can be used to test behavior with Elasticsearch and the K yarn test:jest_integration ``` -An example test exists at [test_utils/jest/integration_tests/example_integration.test.ts](test_utils/jest/integration_tests/example_integration.test.ts) - #### Running Reporting functional tests -See [here](test/reporting/README.md) for more information on running reporting tests. +See [here](./test/functional/apps/dashboard/reporting/README.md) for more information on running reporting tests. + +#### Running Security Solution Cypress E2E/integration tests +See [here](./plugins/security_solution/cypress/README.md) for information on running this test suite. diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index ce7e110a5f914..51c034e510024 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -1,13 +1,15 @@ # Chromium build -We ship our own headless build of Chromium which is significantly smaller than the standard binaries shipped by Google. The scripts in this folder can be used to initialize the build environments and run the build on Mac, Windows, and Linux. +We ship our own headless build of Chromium which is significantly smaller than +the standard binaries shipped by Google. The scripts in this folder can be used +to accept a commit hash from the Chromium repository, and initialize the build +environments and run the build on Mac, Windows, and Linux. -The official Chromium build process is poorly documented, and seems to have breaking changes fairly regularly. The build pre-requisites, and the build flags change over time, so it is likely that the scripts in this directory will be out of date by the time we have to do another Chromium build. - -This document is an attempt to note all of the gotchas we've come across while building, so that the next time we have to tinker here, we'll have a good starting point. - -# Before you begin -You'll need access to our GCP account, which is where we have two machines provisioned for the Linux and Windows builds. Mac builds can be achieved locally, and are a great place to start to gain familiarity. +## Before you begin +If you wish to use a remote VM to build, you'll need access to our GCP account, +which is where we have two machines provisioned for the Linux and Windows +builds. Mac builds can be achieved locally, and are a great place to start to +gain familiarity. 1. Login to our GCP instance [here using your okta credentials](https://console.cloud.google.com/). 2. Click the "Compute Engine" tab. @@ -15,21 +17,89 @@ You'll need access to our GCP account, which is where we have two machines provi 4. If #3 fails, you'll have to spin up new instances. Generally, these need `n1-standard-8` types or 8 vCPUs/30 GB memory. 5. Ensure that there's enough room left on the disk. `ncdu` is a good linux util to verify what's claming space. +## Usage + +``` +# Create a dedicated working directory for this directory of Python scripts. +mkdir ~/chromium && cd ~/chromium +# Copy the scripts from the Kibana repo to use them conveniently in the working directory +cp -r ~/path/to/kibana/x-pack/build_chromium . +# Install the OS packages, configure the environment, download the chromium source +python ./build_chromium/init.sh [arch_name] + +# Run the build script with the path to the chromium src directory, the git commit id +python ./build_chromium/build.py + +# You can add an architecture flag for ARM +python ./build_chromium/build.py arm64 +``` + +## Getting the Commit ID +Getting `` can be tricky. The best technique seems to be: +1. Create a temporary working directory and intialize yarn +2. `yarn add puppeteer # install latest puppeter` +3. Look through puppeteer's node module files to find the "chromium revision" (a custom versioning convention for Chromium). +4. Use `https://crrev.com` and look up the revision and find the git commit info. + +The official Chromium build process is poorly documented, and seems to have +breaking changes fairly regularly. The build pre-requisites, and the build +flags change over time, so it is likely that the scripts in this directory will +be out of date by the time we have to do another Chromium build. + +This document is an attempt to note all of the gotchas we've come across while +building, so that the next time we have to tinker here, we'll have a good +starting point. + ## Build args -Chromium is built via a build tool called "ninja". The build can be configured by specifying build flags either in an "args.gn" file or via commandline args. We have an "args.gn" file per platform: +A good how-to on building Chromium from source is +[here](https://chromium.googlesource.com/chromium/src/+/master/docs/get_the_code.md). + +There are documents for each OS that will explain how to customize arguments +for the build using the `gn` tool. Those instructions do not apply for the +Kibana Chromium build. Our `build.py` script ensure the correct `args.gn` +file gets used for build arguments. -- mac: darwin/args.gn -- linux 64bit: linux-x64/args.gn +We have an `args.gn` file per platform: + +- mac: `darwin/args.gn` +- linux 64bit: `linux-x64/args.gn` +- windows: `windows/args.gn` - ARM 64bit: linux-aarch64/args.gn -- windows: windows/args.gn -The various build flags are not well documented. Some are documented [here](https://www.chromium.org/developers/gn-build-configuration). Some, such as `enable_basic_printing = false`, I only found by poking through 3rd party build scripts. +To get a list of the build arguments that are enabled, install `depot_tools` and run +`gn args out/headless --list`. It prints out all of the flags and their +settings, including the defaults. + +The various build flags are not well documented. Some are documented +[here](https://www.chromium.org/developers/gn-build-configuration). -As of this writing, there is an officially supported headless Chromium build args file for Linux: `build/args/headless.gn`. This does not work on Windows or Mac, so we have taken that as our starting point, and modified it until the Windows / Mac builds succeeded. +As of this writing, there is an officially supported headless Chromium build +args file for Linux: `build/args/headless.gn`. This does not work on Windows or +Mac, so we have taken that as our starting point, and modified it until the +Windows / Mac builds succeeded. **NOTE:** Please, make sure you consult @elastic/kibana-security before you change, remove or add any of the build flags. +## Building locally + +You can skip the step of running `/init.sh` for your OS if you already +have your environment set up, and the chromium source cloned. + +To get the Chromium code, refer to the [documentation](https://chromium.googlesource.com/chromium/src/+/master/docs/get_the_code.md). +Install `depot_tools` as suggested, since it comes with useful scripts. Use the +`fetch` command to clone the chromium repository. To set up and run the build, +use the Kibana `build.py` script (in this directory). + +It's recommended that you create a working directory for the chromium source +code and all the build tools, and run the commands from there: +``` +mkdir ~/chromium && cd ~/chromium +cp -r ~/path/to/kibana/x-pack/build_chromium . +python ./build_chromium/init.sh [arch_name] +python ./build_chromium/build.py +``` + ## VMs I ran Linux and Windows VMs in GCP with the following specs: @@ -57,7 +127,8 @@ The more cores the better, as the build makes effective use of each. For Linux, ## Initializing each VM / environment -You only need to initialize each environment once. NOTE: on Mac OS you'll need to install XCode and accept the license agreement. +In a VM, you'll want to use the init scripts to to initialize each environment. +On Mac OS you'll need to install XCode and accept the license agreement. Create the build folder: @@ -86,16 +157,6 @@ In windows, at least, you will need to do a number of extra steps: ## Building -Find the sha of the Chromium commit you wish to build. Most likely, you want to build the Chromium revision that is tied to the version of puppeteer that we're using. - -Find the Chromium revision (run in kibana's working directory): - -- `cat node_modules/puppeteer-core/package.json | grep chromium_revision` -- Take the revision number from that, and tack it to the end of this URL: https://crrev.com - - (For example, puppeteer@1.19.0 has rev (674921): https://crrev.com/674921) -- Grab the SHA from there - - (For example, rev 674921 has sha 312d84c8ce62810976feda0d3457108a6dfff9e6) - Note: In Linux, you should run the build command in tmux so that if your ssh session disconnects, the build can keep going. To do this, just type `tmux` into your terminal to hop into a tmux session. If you get disconnected, you can hop back in like so: - SSH into the server diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 52ba325d6f726..8622f4a9d4c0b 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -1,55 +1,80 @@ -import subprocess, os, sys, platform, zipfile, hashlib, shutil -from build_util import runcmd, mkdir, md5_file, script_dir, root_dir, configure_environment +import os, subprocess, sys, platform, zipfile, hashlib, shutil +from os import path +from build_util import ( + runcmd, + runcmdsilent, + mkdir, + md5_file, + configure_environment, +) # This file builds Chromium headless on Windows, Mac, and Linux. # Verify that we have an argument, and if not print instructions if (len(sys.argv) < 2): print('Usage:') - print('python build.py {chromium_version}') + print('python build.py {chromium_version} [arch_name]') print('Example:') print('python build.py 68.0.3440.106') print('python build.py 4747cc23ae334a57a35ed3c8e6adcdbc8a50d479') + print('python build.py 4747cc23ae334a57a35ed3c8e6adcdbc8a50d479 arm64 # build for ARM architecture') + print sys.exit(1) +src_path = path.abspath(path.join(os.curdir, 'chromium', 'src')) +build_path = path.abspath(path.join(src_path, '..', '..')) +build_chromium_path = path.abspath(path.dirname(__file__)) +argsgn_file = path.join(build_chromium_path, platform.system().lower(), 'args.gn') + # The version of Chromium we wish to build. This can be any valid git # commit, tag, or branch, so: 68.0.3440.106 or # 4747cc23ae334a57a35ed3c8e6adcdbc8a50d479 source_version = sys.argv[1] +base_version = source_version[:7].strip('.') # Set to "arm" to build for ARM on Linux arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'x64' -print('Building Chromium ' + source_version + ' for ' + arch_name) - -# Set the environment variables required by the build tools -print('Configuring the build environment') -configure_environment() - -# Sync the codebase to the correct version, syncing master first -# to ensure that we actually have all the versions we may refer to -print('Syncing source code') - -os.chdir(os.path.join(root_dir, 'chromium/src')) - -runcmd('git checkout master') -runcmd('git fetch origin') -runcmd('gclient sync --with_branch_heads --with_tags --jobs 16') -runcmd('git checkout ' + source_version) -runcmd('gclient sync --with_branch_heads --with_tags --jobs 16') -runcmd('gclient runhooks') +if arch_name != 'x64' and arch_name != 'arm64': + raise Exception('Unexpected architecture: ' + arch_name) + +print('Building Chromium ' + source_version + ' for ' + arch_name + ' from ' + src_path) +print('src path: ' + src_path) +print('depot_tools path: ' + path.join(build_path, 'depot_tools')) +print('build_chromium_path: ' + build_chromium_path) +print('args.gn file: ' + argsgn_file) +print + +# Sync the codebase to the correct version +print('Setting local tracking branch') +print(' > cd ' + src_path) +os.chdir(src_path) + +checked_out = runcmdsilent('git checkout build-' + base_version) +if checked_out != 0: + print('Syncing remote version') + runcmd('git fetch origin ' + source_version) + print('Creating a new branch for tracking the source version') + runcmd('git checkout -b build-' + base_version + ' ' + source_version) + +depot_tools_path = os.path.join(build_path, 'depot_tools') +path_value = depot_tools_path + os.pathsep + os.environ['PATH'] +print('Updating PATH for depot_tools: ' + path_value) +os.environ['PATH'] = path_value +print('Updating all modules') +runcmd('gclient sync') # Copy build args/{Linux | Darwin | Windows}.gn from the root of our directory to out/headless/args.gn, -platform_build_args = os.path.join(script_dir, platform.system().lower(), 'args.gn') +argsgn_destination = path.abspath('out/headless/args.gn') print('Generating platform-specific args') -print('Copying build args: ' + platform_build_args + ' to out/headless/args.gn') mkdir('out/headless') -shutil.copyfile(platform_build_args, 'out/headless/args.gn') +print(' > cp ' + argsgn_file + ' ' + argsgn_destination) +shutil.copyfile(argsgn_file, argsgn_destination) print('Adding target_cpu to args') f = open('out/headless/args.gn', 'a') -f.write('\rtarget_cpu = "' + arch_name + '"') +f.write('\rtarget_cpu = "' + arch_name + '"\r') f.close() runcmd('gn gen out/headless') @@ -67,37 +92,38 @@ # Create the zip and generate the md5 hash using filenames like: # chromium-4747cc2-linux_x64.zip -base_filename = 'out/headless/chromium-' + source_version[:7].strip('.') + '-' + platform.system().lower() + '_' + arch_name +base_filename = 'out/headless/chromium-' + base_version + '-' + platform.system().lower() + '_' + arch_name zip_filename = base_filename + '.zip' md5_filename = base_filename + '.md5' -print('Creating ' + zip_filename) +print('Creating ' + path.join(src_path, zip_filename)) archive = zipfile.ZipFile(zip_filename, mode='w', compression=zipfile.ZIP_DEFLATED) def archive_file(name): """A little helper function to write individual files to the zip file""" - from_path = os.path.join('out/headless', name) - to_path = os.path.join('headless_shell-' + platform.system().lower() + '_' + arch_name, name) + from_path = path.join('out/headless', name) + to_path = path.join('headless_shell-' + platform.system().lower() + '_' + arch_name, name) archive.write(from_path, to_path) + return to_path # Each platform has slightly different requirements for what dependencies # must be bundled with the Chromium executable. if platform.system() == 'Linux': archive_file('headless_shell') - archive_file(os.path.join('swiftshader', 'libEGL.so')) - archive_file(os.path.join('swiftshader', 'libGLESv2.so')) + archive_file(path.join('swiftshader', 'libEGL.so')) + archive_file(path.join('swiftshader', 'libGLESv2.so')) if arch_name == 'arm64': - archive_file(os.path.join('swiftshader', 'libEGL.so')) + archive_file(path.join('swiftshader', 'libEGL.so')) elif platform.system() == 'Windows': archive_file('headless_shell.exe') archive_file('dbghelp.dll') archive_file('icudtl.dat') - archive_file(os.path.join('swiftshader', 'libEGL.dll')) - archive_file(os.path.join('swiftshader', 'libEGL.dll.lib')) - archive_file(os.path.join('swiftshader', 'libGLESv2.dll')) - archive_file(os.path.join('swiftshader', 'libGLESv2.dll.lib')) + archive_file(path.join('swiftshader', 'libEGL.dll')) + archive_file(path.join('swiftshader', 'libEGL.dll.lib')) + archive_file(path.join('swiftshader', 'libGLESv2.dll')) + archive_file(path.join('swiftshader', 'libGLESv2.dll.lib')) elif platform.system() == 'Darwin': archive_file('headless_shell') @@ -107,6 +133,6 @@ def archive_file(name): archive.close() -print('Creating ' + md5_filename) +print('Creating ' + path.join(src_path, md5_filename)) with open (md5_filename, 'w') as f: f.write(md5_file(zip_filename)) diff --git a/x-pack/build_chromium/build_util.py b/x-pack/build_chromium/build_util.py index 00ca13d32dba8..eaa94e5170d5c 100644 --- a/x-pack/build_chromium/build_util.py +++ b/x-pack/build_chromium/build_util.py @@ -1,33 +1,45 @@ -import os, hashlib +import os, hashlib, platform, sys # This file contains various utility functions used by the init and build scripts -# Compute the root build and script directory as relative to this file -script_dir = os.path.realpath(os.path.join(__file__, '..')) -root_dir = os.path.realpath(os.path.join(script_dir, '..')) +def runcmdsilent(cmd): + """Executes a string command in the shell""" + print(' > ' + cmd) + return os.system(cmd) def runcmd(cmd): """Executes a string command in the shell""" - print(cmd) + print(' > ' + cmd) result = os.system(cmd) if result != 0: raise Exception(cmd + ' returned ' + str(result)) def mkdir(dir): + print(' > mkdir -p ' + dir) """Makes a directory if it doesn't exist""" if not os.path.exists(dir): - print('mkdir -p ' + dir) return os.makedirs(dir) def md5_file(filename): """Builds a hex md5 hash of the given file""" md5 = hashlib.md5() - with open(filename, 'rb') as f: - for chunk in iter(lambda: f.read(128 * md5.block_size), b''): + with open(filename, 'rb') as f: + for chunk in iter(lambda: f.read(128 * md5.block_size), b''): md5.update(chunk) return md5.hexdigest() -def configure_environment(): - """Configures temporary environment variables required by Chromium's build""" - depot_tools_path = os.path.join(root_dir, 'depot_tools') - os.environ['PATH'] = depot_tools_path + os.pathsep + os.environ['PATH'] +def configure_environment(arch_name, build_path, src_path): + """Runs install scripts for deps, and configures temporary environment variables required by Chromium's build""" + + if platform.system() == 'Linux': + if arch_name: + print('Running sysroot install script...') + sysroot_cmd = src_path + '/build/linux/sysroot_scripts/install-sysroot.py --arch=' + arch_name + runcmd(sysroot_cmd) + print('Running install-build-deps...') + runcmd(src_path + '/build/install-build-deps.sh') + + depot_tools_path = os.path.join(build_path, 'depot_tools') + full_path = depot_tools_path + os.pathsep + os.environ['PATH'] + print('Updating PATH for depot_tools: ' + full_path) + os.environ['PATH'] = full_path diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index f543922f7653a..c0dd60f1cfcb0 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -1,38 +1,47 @@ import os, platform, sys -from build_util import runcmd, mkdir, md5_file, root_dir, configure_environment +from os import path +from build_util import runcmd, mkdir, md5_file, configure_environment # This is a cross-platform initialization script which should only be run # once per environment, and isn't intended to be run directly. You should # run the appropriate platform init script (e.g. Linux/init.sh) which will # call this once the platform-specific initialization has completed. -os.chdir(root_dir) +# Set to "arm" to build for ARM on Linux +arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'x64' +build_path = path.abspath(os.curdir) +src_path = path.abspath(path.join(build_path, 'chromium', 'src')) + +if arch_name != 'x64' and arch_name != 'arm64': + raise Exception('Unexpected architecture: ' + arch_name) # Configure git +print('Configuring git globals...') runcmd('git config --global core.autocrlf false') runcmd('git config --global core.filemode false') runcmd('git config --global branch.autosetuprebase always') # Grab Chromium's custom build tools, if they aren't already installed # (On Windows, they are installed before this Python script is run) -if not os.path.isdir('depot_tools'): +# Put depot_tools on the path so we can properly run the fetch command +if not path.isdir('depot_tools'): + print('Installing depot_tools...') runcmd('git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git') +else: + print('Updating depot_tools...') + original_dir = os.curdir + os.chdir(path.join(build_path, 'depot_tools')) + runcmd('git checkout master') + runcmd('git pull origin master') + os.chdir(original_dir) -# Put depot_tools on the path so we can properly run the fetch command -configure_environment() +configure_environment(arch_name, build_path, src_path) # Fetch the Chromium source code -mkdir('chromium') -os.chdir('chromium') -runcmd('fetch chromium') - -# Build Linux deps -if platform.system() == 'Linux': - os.chdir('src') - - if len(sys.argv) >= 2: - sysroot_cmd = 'build/linux/sysroot_scripts/install-sysroot.py --arch=' + sys.argv[1] - print('Running `' + sysroot_cmd + '`') - runcmd(sysroot_cmd) - - runcmd('build/install-build-deps.sh') +chromium_dir = path.join(build_path, 'chromium') +if not path.isdir(chromium_dir): + mkdir(chromium_dir) + os.chdir(chromium_dir) + runcmd('fetch chromium') +else: + print('Directory exists: ' + chromium_dir + '. Skipping chromium fetch.') diff --git a/x-pack/examples/reporting_example/.eslintrc.js b/x-pack/examples/reporting_example/.eslintrc.js new file mode 100644 index 0000000000000..b267018448ba6 --- /dev/null +++ b/x-pack/examples/reporting_example/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], + rules: { + '@kbn/eslint/require-license-header': 'off', + }, +}; diff --git a/x-pack/examples/reporting_example/README.md b/x-pack/examples/reporting_example/README.md new file mode 100755 index 0000000000000..186a3fa37f93b --- /dev/null +++ b/x-pack/examples/reporting_example/README.md @@ -0,0 +1,33 @@ +# Example Reporting integration! + +Use this example code to understand how to add a "Generate Report" button to a +Kibana page. This simple example shows that the end-to-end functionality of +generating a screenshot report of a page just requires you to render a React +component that you import from the Reportinng plugin. + +A "reportable" Kibana page is one that has an **alternate version to show the data in a "screenshot-friendly" way**. The alternate version can be reached at a variation of the page's URL that the App team builds. + +A "screenshot-friendly" page has **all interactive features turned off**. These are typically notifications, popups, tooltips, controls, autocomplete libraries, etc. + +Turning off these features **keeps glitches out of the screenshot**, and makes the server-side headless browser **run faster and use less RAM**. + +The URL that Reporting captures is controlled by the application, is a part of +a "jobParams" object that gets passed to the React component imported from +Reporting. The job params give the app control over the end-resulting report: + +- Layout + - Page dimensions + - DOM attributes to select where the visualization container(s) is/are. The App team must add the attributes to DOM elements in their app. + - DOM events that the page fires off and signals when the rendering is done. The App team must implement triggering the DOM events around rendering the data in their app. +- Export type definition + - Processes the jobParams into output data, which is stored in Elasticsearch in the Reporting system index. + - Export type definitions are registered with the Reporting plugin at setup time. + +The existing export type definitions are PDF, PNG, and CSV. They should be +enough for nearly any use case. + +If the existing options are too limited for a future use case, the AppServices +team can assist the App team to implement a custom export type definition of +their own, and register it using the Reporting plugin API **(documentation coming soon)**. + +--- diff --git a/x-pack/examples/reporting_example/common/index.ts b/x-pack/examples/reporting_example/common/index.ts new file mode 100644 index 0000000000000..e47604bd7b823 --- /dev/null +++ b/x-pack/examples/reporting_example/common/index.ts @@ -0,0 +1,2 @@ +export const PLUGIN_ID = 'reportingExample'; +export const PLUGIN_NAME = 'reportingExample'; diff --git a/x-pack/examples/reporting_example/kibana.json b/x-pack/examples/reporting_example/kibana.json new file mode 100644 index 0000000000000..22768338aec37 --- /dev/null +++ b/x-pack/examples/reporting_example/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "reportingExample", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "optionalPlugins": [], + "requiredPlugins": ["reporting", "developerExamples", "navigation"] +} diff --git a/x-pack/examples/reporting_example/public/application.tsx b/x-pack/examples/reporting_example/public/application.tsx new file mode 100644 index 0000000000000..1bb944faad3ea --- /dev/null +++ b/x-pack/examples/reporting_example/public/application.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '../../../../src/core/public'; +import { StartDeps } from './types'; +import { ReportingExampleApp } from './components/app'; + +export const renderApp = ( + coreStart: CoreStart, + startDeps: StartDeps, + { appBasePath, element }: AppMountParameters +) => { + ReactDOM.render( + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/examples/reporting_example/public/components/app.tsx b/x-pack/examples/reporting_example/public/components/app.tsx new file mode 100644 index 0000000000000..8f7176675f2c2 --- /dev/null +++ b/x-pack/examples/reporting_example/public/components/app.tsx @@ -0,0 +1,130 @@ +import { + EuiCard, + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import React, { useEffect, useState } from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import * as Rx from 'rxjs'; +import { takeWhile } from 'rxjs/operators'; +import { CoreStart } from '../../../../../src/core/public'; +import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public'; +import { constants, ReportingStart } from '../../../../../x-pack/plugins/reporting/public'; +import { JobParamsPDF } from '../../../../plugins/reporting/server/export_types/printable_pdf/types'; + +interface ReportingExampleAppDeps { + basename: string; + notifications: CoreStart['notifications']; + http: CoreStart['http']; + navigation: NavigationPublicPluginStart; + reporting: ReportingStart; +} + +const sourceLogos = ['Beats', 'Cloud', 'Logging', 'Kibana']; + +export const ReportingExampleApp = ({ + basename, + notifications, + http, + reporting, +}: ReportingExampleAppDeps) => { + const { getDefaultLayoutSelectors, ReportingAPIClient } = reporting; + const [logos, setLogos] = useState([]); + + useEffect(() => { + Rx.timer(2200) + .pipe(takeWhile(() => logos.length < sourceLogos.length)) + .subscribe(() => { + setLogos([...sourceLogos.slice(0, logos.length + 1)]); + }); + }); + + const getPDFJobParams = (): JobParamsPDF => { + return { + layout: { + id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + selectors: getDefaultLayoutSelectors(), + }, + relativeUrls: ['/app/reportingExample#/intended-visualization'], + objectType: 'develeloperExample', + title: 'Reporting Developer Example', + }; + }; + + // Render the application DOM. + return ( + + + + + + +

Reporting Example

+
+
+ + + +

+ Use the ReportingStart.components.ScreenCapturePanel{' '} + component to add the Reporting panel to your page. +

+ + + + + + + + + + + + + +

+ The logos below are in a data-shared-items-container element + for Reporting. +

+ +
+ + {logos.map((item, index) => ( + + } + title={`Elastic ${item}`} + description="Example of a card's description. Stick to one or two sentences." + onClick={() => {}} + /> + + ))} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/x-pack/examples/reporting_example/public/index.ts b/x-pack/examples/reporting_example/public/index.ts new file mode 100644 index 0000000000000..a490cf96895be --- /dev/null +++ b/x-pack/examples/reporting_example/public/index.ts @@ -0,0 +1,6 @@ +import { ReportingExamplePlugin } from './plugin'; + +export function plugin() { + return new ReportingExamplePlugin(); +} +export { PluginSetup, PluginStart } from './types'; diff --git a/x-pack/examples/reporting_example/public/plugin.ts b/x-pack/examples/reporting_example/public/plugin.ts new file mode 100644 index 0000000000000..95b4d917f549a --- /dev/null +++ b/x-pack/examples/reporting_example/public/plugin.ts @@ -0,0 +1,41 @@ +import { + AppMountParameters, + AppNavLinkStatus, + CoreSetup, + CoreStart, + Plugin, +} from '../../../../src/core/public'; +import { PLUGIN_ID, PLUGIN_NAME } from '../common'; +import { SetupDeps, StartDeps } from './types'; + +export class ReportingExamplePlugin implements Plugin { + public setup(core: CoreSetup, { developerExamples, ...depsSetup }: SetupDeps): void { + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + const [coreStart, depsStart] = (await core.getStartServices()) as [ + CoreStart, + StartDeps, + unknown + ]; + // Render the application + return renderApp(coreStart, { ...depsSetup, ...depsStart }, params); + }, + }); + + // Show the app in Developer Examples + developerExamples.register({ + appId: 'reportingExample', + title: 'Reporting integration', + description: 'Demonstrate how to put an Export button on a page and generate reports.', + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/examples/reporting_example/public/types.ts b/x-pack/examples/reporting_example/public/types.ts new file mode 100644 index 0000000000000..d574053266fae --- /dev/null +++ b/x-pack/examples/reporting_example/public/types.ts @@ -0,0 +1,16 @@ +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; +import { ReportingStart } from '../../../plugins/reporting/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginStart {} + +export interface SetupDeps { + developerExamples: DeveloperExamplesSetup; +} +export interface StartDeps { + navigation: NavigationPublicPluginStart; + reporting: ReportingStart; +} diff --git a/x-pack/examples/reporting_example/tsconfig.json b/x-pack/examples/reporting_example/tsconfig.json new file mode 100644 index 0000000000000..ef727b3368b12 --- /dev/null +++ b/x-pack/examples/reporting_example/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target" + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "common/**/*.ts", + "../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../src/core/tsconfig.json" } + ] +} + diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index b311a602212c7..81d6c3550a53c 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -31,6 +31,9 @@ export type ActionTypeSecrets = Record; export type ActionTypeParams = Record; export interface Services { + /** + * @deprecated Use `scopedClusterClient` instead. + */ callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; scopedClusterClient: ElasticsearchClient; diff --git a/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts b/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts index 91c3f5954d6d0..1c7320d3df6f3 100644 --- a/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts @@ -193,6 +193,7 @@ async function invalidateApiKeys( encryptedSavedObjectsClient: EncryptedSavedObjectsClient, securityPluginStart?: SecurityPluginStart ) { + // TODO: This could probably send a single request to ES now that the invalidate API supports multiple ids in a single request let totalInvalidated = 0; await Promise.all( apiKeysToInvalidate.saved_objects.map(async (apiKeyObj) => { diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 027f875e2d08d..bb2d429a7c8b5 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -47,6 +47,9 @@ declare module 'src/core/server' { } export interface Services { + /** + * @deprecated Use `scopedClusterClient` instead. + */ callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; scopedClusterClient: ElasticsearchClient; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx index be4edbe2ea270..9f3a65583ddb7 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -41,6 +41,7 @@ interface Props { export function AgentConfigurationList({ status, data, refetch }: Props) { const { core } = useApmPluginContext(); + const canSave = core.application.capabilities.apm.save; const { basePath } = core.http; const { search } = useLocation(); const theme = useTheme(); @@ -180,28 +181,36 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { ), }, - { - width: px(units.double), - name: '', - render: (config: Config) => ( - - ), - }, - { - width: px(units.double), - name: '', - render: (config: Config) => ( - setConfigToBeDeleted(config)} - /> - ), - }, + ...(canSave + ? [ + { + width: px(units.double), + name: '', + render: (config: Config) => ( + + ), + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + setConfigToBeDeleted(config)} + /> + ), + }, + ] + : []), ]; return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index c408d5e960cf3..c1f5ec154792d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -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 { EuiToolTip } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, @@ -73,15 +74,35 @@ function CreateConfigurationButton() { const { basePath } = core.http; const { search } = useLocation(); const href = createAgentConfigurationHref(search, basePath); + const canSave = core.application.capabilities.apm.save; return ( - - {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { - defaultMessage: 'Create configuration', - })} - + + + {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { + defaultMessage: 'Create configuration', + })} + + diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 5a5d20cde9ade..ba08af32d65b6 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -4,25 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; import { - EuiTitle, + EuiButton, + EuiButtonEmpty, + EuiFieldText, EuiFlexGroup, EuiFlexItem, + EuiForm, + EuiFormRow, EuiPanel, EuiSpacer, EuiText, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiButton, - EuiButtonEmpty, + EuiTitle, + EuiToolTip, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useFetcher } from '../../../../hooks/use_fetcher'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; import { clearCache } from '../../../../services/rest/callApi'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { callApmApi } from '../../../../services/rest/createCallApmApi'; const APM_INDEX_LABELS = [ { @@ -85,17 +86,22 @@ async function saveApmIndices({ const INITIAL_STATE = [] as []; export function ApmIndices() { - const { toasts } = useApmPluginContext().core.notifications; + const { core } = useApmPluginContext(); + const { notifications, application } = core; + const canSave = application.capabilities.apm.save; const [apmIndices, setApmIndices] = useState>({}); const [isSaving, setIsSaving] = useState(false); - const { data = INITIAL_STATE, status, refetch } = useFetcher( - (_callApmApi) => - _callApmApi({ - endpoint: `GET /api/apm/settings/apm-index-settings`, - }), - [] + const { data = INITIAL_STATE, refetch } = useFetcher( + (_callApmApi) => { + if (canSave) { + return _callApmApi({ + endpoint: `GET /api/apm/settings/apm-index-settings`, + }); + } + }, + [canSave] ); useEffect(() => { @@ -119,7 +125,7 @@ export function ApmIndices() { setIsSaving(true); try { await saveApmIndices({ apmIndices }); - toasts.addSuccess({ + notifications.toasts.addSuccess({ title: i18n.translate( 'xpack.apm.settings.apmIndices.applyChanges.succeeded.title', { defaultMessage: 'Indices applied' } @@ -133,7 +139,7 @@ export function ApmIndices() { ), }); } catch (error) { - toasts.addDanger({ + notifications.toasts.addDanger({ title: i18n.translate( 'xpack.apm.settings.apmIndices.applyChanges.failed.title', { defaultMessage: 'Indices could not be applied.' } @@ -205,6 +211,7 @@ export function ApmIndices() { fullWidth > - - {i18n.translate( - 'xpack.apm.settings.apmIndices.applyButton', - { defaultMessage: 'Apply changes' } - )} - + + {i18n.translate( + 'xpack.apm.settings.apmIndices.applyButton', + { defaultMessage: 'Apply changes' } + )} + + diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx index 56b3eaf425af7..3b4c127aab1e5 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx @@ -3,17 +3,40 @@ * 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 { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; export function CreateCustomLinkButton({ onClick }: { onClick: () => void }) { + const { core } = useApmPluginContext(); + const canSave = core.application.capabilities.apm.save; return ( - - {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.createCustomLink', - { defaultMessage: 'Create custom link' } - )} - + + + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.createCustomLink', + { defaultMessage: 'Create custom link' } + )} + + ); } diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx index d512ea19c7892..4bc1adee04bf4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -13,6 +13,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; import { CustomLink } from '../../../../../../common/custom_link/custom_link_types'; import { units, px } from '../../../../../style/variables'; import { ManagedTable } from '../../../../shared/ManagedTable'; @@ -26,6 +27,8 @@ interface Props { export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { const [searchTerm, setSearchTerm] = useState(''); + const { core } = useApmPluginContext(); + const canSave = core.application.capabilities.apm.save; const columns = [ { @@ -61,22 +64,26 @@ export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { width: px(units.triple), name: '', actions: [ - { - name: i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel', - { defaultMessage: 'Edit' } - ), - description: i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription', - { defaultMessage: 'Edit this custom link' } - ), - icon: 'pencil', - color: 'primary', - type: 'icon', - onClick: (customLink: CustomLink) => { - onCustomLinkSelected(customLink); - }, - }, + ...(canSave + ? [ + { + name: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel', + { defaultMessage: 'Edit' } + ), + description: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription', + { defaultMessage: 'Edit this custom link' } + ), + icon: 'pencil', + color: 'primary', + type: 'icon', + onClick: (customLink: CustomLink) => { + onCustomLinkSelected(customLink); + }, + }, + ] + : []), ], }, ]; diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 1da7d415b5660..4477ee5a99be3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -7,22 +7,26 @@ import { fireEvent, render, - waitFor, RenderResult, + waitFor, } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import * as apmApi from '../../../../../services/rest/createCallApmApi'; +import { CustomLinkOverview } from '.'; import { License } from '../../../../../../../licensing/common/license'; -import * as hooks from '../../../../../hooks/use_fetcher'; +import { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context'; +import { + mockApmPluginContextValue, + MockApmPluginContextWrapper, +} from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../../../context/license/license_context'; -import { CustomLinkOverview } from '.'; +import * as hooks from '../../../../../hooks/use_fetcher'; +import * as apmApi from '../../../../../services/rest/createCallApmApi'; import { expectTextsInDocument, expectTextsNotInDocument, } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CreateEditCustomLinkFlyout/saveCustomLink'; -import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; const data = [ { @@ -39,6 +43,16 @@ const data = [ }, ]; +function getMockAPMContext({ canSave }: { canSave: boolean }) { + return ({ + ...mockApmPluginContextValue, + core: { + ...mockApmPluginContextValue.core, + application: { capabilities: { apm: { save: canSave }, ml: {} } }, + }, + } as unknown) as ApmPluginContextValue; +} + describe('CustomLink', () => { beforeAll(() => { jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({}); @@ -70,9 +84,11 @@ describe('CustomLink', () => { }); it('shows when no link is available', () => { const component = render( - - - + + + + + ); expectTextsInDocument(component, ['No links found.']); }); @@ -91,6 +107,34 @@ describe('CustomLink', () => { jest.clearAllMocks(); }); + it('enables create button when user has writte privileges', () => { + const mockContext = getMockAPMContext({ canSave: true }); + + const { getByTestId } = render( + + + + + + ); + const createButton = getByTestId('createButton') as HTMLButtonElement; + expect(createButton.disabled).toBeFalsy(); + }); + + it('enables edit button on custom link table when user has writte privileges', () => { + const mockContext = getMockAPMContext({ canSave: true }); + + const { getAllByText } = render( + + + + + + ); + + expect(getAllByText('Edit').length).toEqual(2); + }); + it('shows a table with all custom link', () => { const component = render( @@ -108,9 +152,11 @@ describe('CustomLink', () => { }); it('checks if create custom link button is available and working', () => { + const mockContext = getMockAPMContext({ canSave: true }); + const { queryByText, getByText } = render( - + @@ -137,9 +183,10 @@ describe('CustomLink', () => { }); const openFlyout = () => { + const mockContext = getMockAPMContext({ canSave: true }); const component = render( - + @@ -173,9 +220,10 @@ describe('CustomLink', () => { }); it('deletes a custom link', async () => { + const mockContext = getMockAPMContext({ canSave: true }); const component = render( - + @@ -356,4 +404,34 @@ describe('CustomLink', () => { expectTextsNotInDocument(component, ['Start free 30-day trial']); }); }); + + describe('with read-only user', () => { + it('disables create custom link button', () => { + const mockContext = getMockAPMContext({ canSave: false }); + + const { getByTestId } = render( + + + + + + ); + const createButton = getByTestId('createButton') as HTMLButtonElement; + expect(createButton.disabled).toBeTruthy(); + }); + + it('removes edit button on custom link table', () => { + const mockContext = getMockAPMContext({ canSave: false }); + + const { queryAllByText } = render( + + + + + + ); + + expect(queryAllByText('Edit').length).toEqual(0); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx index fa890260a3060..5fe371c33475a 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx @@ -17,7 +17,7 @@ import { px } from '../../../../style/variables'; interface IconPopoverProps { title: string; children: React.ReactChild; - onOpen: () => void; + onClick: () => void; onClose: () => void; detailsFetchStatus: FETCH_STATUS; isOpen: boolean; @@ -27,7 +27,7 @@ export function IconPopover({ icon, title, children, - onOpen, + onClick, onClose, detailsFetchStatus, isOpen, @@ -44,7 +44,7 @@ export function IconPopover({ anchorPosition="downCenter" ownFocus={false} button={ - + } diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx index 327198e46131f..f6a712c562bff 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx @@ -82,6 +82,7 @@ export function ServiceIcons({ serviceName }: Props) { (callApmApi) => { if (selectedIconPopover && serviceName && start && end) { return callApmApi({ + isCachable: true, endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', params: { path: { serviceName }, @@ -143,8 +144,10 @@ export function ServiceIcons({ serviceName }: Props) { icon={item.icon} detailsFetchStatus={detailsFetchStatus} title={item.title} - onOpen={() => { - setSelectedIconPopover(item.key); + onClick={() => { + setSelectedIconPopover((prevSelectedIconPopover) => + item.key === prevSelectedIconPopover ? null : item.key + ); }} onClose={() => { setSelectedIconPopover(null); diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx index 27a2cf6418ece..175fad2993782 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx @@ -234,7 +234,12 @@ export function ServiceList({ items, noItemsMessage }: Props) { : 'transactionsPerMinute'; return ( - + diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 3f74b80bab064..312513db80886 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -63,7 +63,13 @@ export function TransactionActionMenu({ transaction }: Props) { isOpen={isActionPopoverOpen} anchorPosition="downRight" button={ - setIsActionPopoverOpen(true)} /> + + setIsActionPopoverOpen( + (prevIsActionPopoverOpen) => !prevIsActionPopoverOpen + ) + } + /> } >
diff --git a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts index 14047f4bacea9..f40ae7803e364 100644 --- a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.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 { rangeFilter } from '../../../common/utils/range_filter'; import { ProcessorEvent } from '../../../common/processor_event'; import { TRACE_ID } from '../../../common/elasticsearch_fieldnames'; import { @@ -10,14 +11,19 @@ import { ExternalConnectionNode, ServiceConnectionNode, } from '../../../common/service_map'; -import { Setup } from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; export async function fetchServicePathsFromTraceIds( - setup: Setup, + setup: Setup & SetupTimeRange, traceIds: string[] ) { const { apmEventClient } = setup; + // make sure there's a range so ES can skip shards + const dayInMs = 24 * 60 * 60 * 1000; + const start = setup.start - dayInMs; + const end = setup.end + dayInMs; + const serviceMapParams = { apm: { events: [ProcessorEvent.span, ProcessorEvent.transaction], @@ -32,6 +38,7 @@ export async function fetchServicePathsFromTraceIds( [TRACE_ID]: traceIds, }, }, + { range: rangeFilter(start, end) }, ], }, }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts index 14cfece22d053..b650602062c0b 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -10,7 +10,7 @@ import { SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; import { Connection, ConnectionNode } from '../../../common/service_map'; -import { Setup } from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { fetchServicePathsFromTraceIds } from './fetch_service_paths_from_trace_ids'; export function getConnections({ @@ -79,7 +79,7 @@ export async function getServiceMapFromTraceIds({ serviceName, environment, }: { - setup: Setup; + setup: Setup & SetupTimeRange; traceIds: string[]; serviceName?: string; environment?: string; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index fdf2fe3521d7e..70755540721dd 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -64,7 +64,7 @@ export const createCustomLinkRoute = createRoute({ params: t.type({ body: payloadRt, }), - options: { tags: ['access:apm'] }, + options: { tags: ['access:apm', 'access:apm_write'] }, handler: async ({ context, request }) => { if (!isActiveGoldLicense(context.licensing.license)) { throw Boom.forbidden(INVALID_LICENSE); diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts index 7b14a723d7877..f26ba0dd0b46c 100644 --- a/x-pack/plugins/data_enhanced/common/index.ts +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -15,4 +15,5 @@ export { BackgroundSessionSavedObjectAttributes, BackgroundSessionFindOptions, BackgroundSessionStatus, + BackgroundSessionSearchInfo, } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/session/types.ts b/x-pack/plugins/data_enhanced/common/search/session/types.ts index 0b82c9160ea1a..1310c05ed6854 100644 --- a/x-pack/plugins/data_enhanced/common/search/session/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/session/types.ts @@ -19,7 +19,12 @@ export interface BackgroundSessionSavedObjectAttributes { urlGeneratorId: string; initialState: Record; restoreState: Record; - idMapping: Record; + idMapping: Record; +} + +export interface BackgroundSessionSearchInfo { + id: string; // ID of the async search request + strategy: string; // Search strategy used to submit the search request } export interface BackgroundSessionFindOptions { diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx index e08773c6a8a76..6fa9abd0f1ab6 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.test.tsx @@ -57,8 +57,35 @@ test('should show indicator in case there is an active search session', async () await waitFor(() => getByTestId('backgroundSessionIndicator')); }); +test('should be disabled when permissions are off', async () => { + const state$ = new BehaviorSubject(SessionState.Loading); + coreStart.application.currentAppId$ = new BehaviorSubject('discover'); + (coreStart.application.capabilities as any) = { + discover: { + storeSearchSession: false, + }, + }; + const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + }); + + render(); + + await waitFor(() => screen.getByTestId('backgroundSessionIndicator')); + + expect(screen.getByTestId('backgroundSessionIndicator').querySelector('button')).toBeDisabled(); +}); + test('should be disabled during auto-refresh', async () => { const state$ = new BehaviorSubject(SessionState.Loading); + coreStart.application.currentAppId$ = new BehaviorSubject('discover'); + (coreStart.application.capabilities as any) = { + discover: { + storeSearchSession: true, + }, + }; const BackgroundSessionIndicator = createConnectedBackgroundSessionIndicator({ sessionService: { ...sessionService, state$ }, application: coreStart.application, diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx index b80295d87d202..1469c96d7166e 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_background_session_indicator/connected_background_session_indicator.tsx @@ -29,12 +29,35 @@ export const createConnectedBackgroundSessionIndicator = ({ .getRefreshIntervalUpdate$() .pipe(map(isAutoRefreshEnabled), distinctUntilChanged()); + const getCapabilitiesByAppId = ( + capabilities: ApplicationStart['capabilities'], + appId?: string + ) => { + switch (appId) { + case 'dashboards': + return capabilities.dashboard; + case 'discover': + return capabilities.discover; + default: + return undefined; + } + }; + return () => { const state = useObservable(sessionService.state$.pipe(debounceTime(500))); const autoRefreshEnabled = useObservable(isAutoRefreshEnabled$, isAutoRefreshEnabled()); + const appId = useObservable(application.currentAppId$, undefined); + let disabled = false; let disabledReasonText: string = ''; + if (getCapabilitiesByAppId(application.capabilities, appId)?.storeSearchSession !== true) { + disabled = true; + disabledReasonText = i18n.translate('xpack.data.backgroundSessionIndicator.noCapability', { + defaultMessage: "You don't have permissions to send to background.", + }); + } + if (autoRefreshEnabled) { disabled = true; disabledReasonText = i18n.translate( diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index 766de908353f5..f14df97f00c12 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -30,6 +30,7 @@ describe('BackgroundSessionService', () => { const MOCK_SESSION_ID = 'session-id-mock'; const MOCK_ASYNC_ID = '123456'; + const MOCK_STRATEGY = 'ese'; const MOCK_KEY_HASH = '608de49a4600dbb5b173492759792e4a'; const createMockInternalSavedObjectClient = ( @@ -47,7 +48,10 @@ describe('BackgroundSessionService', () => { attributes: { sessionId: MOCK_SESSION_ID, idMapping: { - 'another-key': 'another-async-id', + 'another-key': { + id: 'another-async-id', + strategy: 'another-strategy', + }, }, }, id: MOCK_SESSION_ID, @@ -283,7 +287,7 @@ describe('BackgroundSessionService', () => { await service.trackId( searchRequest, searchId, - { sessionId, isStored }, + { sessionId, isStored, strategy: MOCK_STRATEGY }, { savedObjectsClient } ); @@ -313,7 +317,8 @@ describe('BackgroundSessionService', () => { ); const [setSessionId, setParams] = setSpy.mock.calls[0]; - expect(setParams.ids.get(requestHash)).toBe(searchId); + expect(setParams.ids.get(requestHash).id).toBe(searchId); + expect(setParams.ids.get(requestHash).strategy).toBe(MOCK_STRATEGY); expect(setSessionId).toBe(sessionId); }); @@ -326,12 +331,17 @@ describe('BackgroundSessionService', () => { await service.trackId( searchRequest, searchId, - { sessionId, isStored }, + { sessionId, isStored, strategy: MOCK_STRATEGY }, { savedObjectsClient } ); expect(savedObjectsClient.update).toHaveBeenCalledWith(BACKGROUND_SESSION_TYPE, sessionId, { - idMapping: { [requestHash]: searchId }, + idMapping: { + [requestHash]: { + id: searchId, + strategy: MOCK_STRATEGY, + }, + }, }); }); }); @@ -380,7 +390,12 @@ describe('BackgroundSessionService', () => { name: 'my_name', appId: 'my_app_id', urlGeneratorId: 'my_url_generator_id', - idMapping: { [requestHash]: searchId }, + idMapping: { + [requestHash]: { + id: searchId, + strategy: MOCK_STRATEGY, + }, + }, }, references: [], }; @@ -419,7 +434,10 @@ describe('BackgroundSessionService', () => { const findSpy = jest.fn().mockResolvedValue({ saved_objects: [] }); createMockInternalSavedObjectClient(findSpy); - const mockIdMapping = createMockIdMapping([[MOCK_KEY_HASH, MOCK_ASYNC_ID]], moment()); + const mockIdMapping = createMockIdMapping( + [[MOCK_KEY_HASH, { id: MOCK_ASYNC_ID, strategy: MOCK_STRATEGY }]], + moment() + ); Object.defineProperty(service, 'sessionSearchMap', { get: () => mockIdMapping, @@ -438,7 +456,7 @@ describe('BackgroundSessionService', () => { createMockInternalSavedObjectClient(findSpy); const mockIdMapping = createMockIdMapping( - [[MOCK_KEY_HASH, MOCK_ASYNC_ID]], + [[MOCK_KEY_HASH, { id: MOCK_ASYNC_ID, strategy: MOCK_STRATEGY }]], moment().subtract(2, 'm') ); @@ -459,7 +477,7 @@ describe('BackgroundSessionService', () => { createMockInternalSavedObjectClient(findSpy); const mockIdMapping = createMockIdMapping( - [[MOCK_KEY_HASH, MOCK_ASYNC_ID]], + [[MOCK_KEY_HASH, { id: MOCK_ASYNC_ID, strategy: MOCK_STRATEGY }]], moment(), MAX_UPDATE_RETRIES ); @@ -528,7 +546,10 @@ describe('BackgroundSessionService', () => { attributes: { idMapping: { b: 'c', - [MOCK_KEY_HASH]: MOCK_ASYNC_ID, + [MOCK_KEY_HASH]: { + id: MOCK_ASYNC_ID, + strategy: MOCK_STRATEGY, + }, }, }, }, @@ -566,7 +587,10 @@ describe('BackgroundSessionService', () => { id: MOCK_SESSION_ID, attributes: { idMapping: { - b: 'c', + b: { + id: 'c', + strategy: MOCK_STRATEGY, + }, }, }, }, diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index d426e73b48510..01291919001f5 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -32,6 +32,7 @@ import { import { BackgroundSessionSavedObjectAttributes, BackgroundSessionFindOptions, + BackgroundSessionSearchInfo, BackgroundSessionStatus, } from '../../../common'; import { BACKGROUND_SESSION_TYPE } from '../../saved_objects'; @@ -51,7 +52,7 @@ export interface BackgroundSessionDependencies { export interface SessionInfo { insertTime: Moment; retryCount: number; - ids: Map; + ids: Map; } export class BackgroundSessionService implements ISessionService { @@ -316,25 +317,31 @@ export class BackgroundSessionService implements ISessionService { public trackId = async ( searchRequest: IKibanaSearchRequest, searchId: string, - { sessionId, isStored }: ISearchOptions, + { sessionId, isStored, strategy }: ISearchOptions, deps: BackgroundSessionDependencies ) => { if (!sessionId || !searchId) return; this.logger.debug(`trackId | ${sessionId} | ${searchId}`); const requestHash = createRequestHash(searchRequest.params); + const searchInfo = { + id: searchId, + strategy: strategy!, + }; // If there is already a saved object for this session, update it to include this request/ID. // Otherwise, just update the in-memory mapping for this session for when the session is saved. if (isStored) { - const attributes = { idMapping: { [requestHash]: searchId } }; + const attributes = { + idMapping: { [requestHash]: searchInfo }, + }; await this.update(sessionId, attributes, deps); } else { const map = this.sessionSearchMap.get(sessionId) ?? { insertTime: moment(), retryCount: 0, - ids: new Map(), + ids: new Map(), }; - map.ids.set(requestHash, searchId); + map.ids.set(requestHash, searchInfo); this.sessionSearchMap.set(sessionId, map); } }; @@ -363,7 +370,7 @@ export class BackgroundSessionService implements ISessionService { throw new Error('No search ID in this session matching the given search request'); } - return session.attributes.idMapping[requestHash]; + return session.attributes.idMapping[requestHash].id; }; public asScopedProvider = ({ savedObjects }: CoreStart) => { diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index e3730084d7020..1c6d7e4066187 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -443,3 +443,77 @@ describe('UrlDrilldown', () => { }); }); }); + +describe('encoding', () => { + const urlDrilldown = createDrilldown(); + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + test('encodes URL by default', async () => { + const config: Config = { + url: { + template: 'https://elastic.co?foo=head%26shoulders', + }, + openInNewTab: false, + }; + const url = await urlDrilldown.getHref(config, context); + + expect(url).toBe('https://elastic.co?foo=head%2526shoulders'); + }); + + test('encodes URL when encoding is enabled', async () => { + const config: Config = { + url: { + template: 'https://elastic.co?foo=head%26shoulders', + }, + openInNewTab: false, + encodeUrl: true, + }; + const url = await urlDrilldown.getHref(config, context); + + expect(url).toBe('https://elastic.co?foo=head%2526shoulders'); + }); + + test('does not encode URL when encoding is not enabled', async () => { + const config: Config = { + url: { + template: 'https://elastic.co?foo=head%26shoulders', + }, + openInNewTab: false, + encodeUrl: false, + }; + const url = await urlDrilldown.getHref(config, context); + + expect(url).toBe('https://elastic.co?foo=head%26shoulders'); + }); + + test('can encode URI component using "encodeURIComponent" Handlebars helper', async () => { + const config: Config = { + url: { + template: 'https://elastic.co?foo={{encodeURIComponent "head%26shoulders@gmail.com"}}', + }, + openInNewTab: false, + encodeUrl: false, + }; + const url = await urlDrilldown.getHref(config, context); + + expect(url).toBe('https://elastic.co?foo=head%2526shoulders%40gmail.com'); + }); + + test('can encode URI component using "encodeURIQuery" Handlebars helper', async () => { + const config: Config = { + url: { + template: 'https://elastic.co?foo={{encodeURIQuery "head%26shoulders@gmail.com"}}', + }, + openInNewTab: false, + encodeUrl: false, + }; + const url = await urlDrilldown.getHref(config, context); + + expect(url).toBe('https://elastic.co?foo=head%2526shoulders@gmail.com'); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index bfeab263d20e3..ffb0687305168 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -104,7 +104,8 @@ export class UrlDrilldown implements Drilldown ({ url: { template: '' }, - openInNewTab: false, + openInNewTab: true, + encodeUrl: true, }); public readonly isConfigValid = (config: Config): config is Config => { @@ -133,7 +134,12 @@ export class UrlDrilldown implements Drilldown { const values = { textInput: 'hello world', + isUploading: false, + errors: [], configuredLimits: { engine: { maxDocumentByteSize: 102400, @@ -24,6 +27,7 @@ describe('PasteJsonText', () => { }; const actions = { setTextInput: jest.fn(), + onSubmitJson: jest.fn(), closeDocumentCreation: jest.fn(), }; @@ -58,6 +62,16 @@ describe('PasteJsonText', () => { textarea.simulate('change', { target: { value: 'dolor sit amet' } }); expect(actions.setTextInput).toHaveBeenCalledWith('dolor sit amet'); }); + + it('shows an error banner and sets invalid form props if errors exist', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(false); + + setMockValues({ ...values, errors: ['some error'] }); + rerender(wrapper); + expect(wrapper.find(EuiTextArea).prop('isInvalid')).toBe(true); + expect(wrapper.prop('banner').type).toEqual(Errors); + }); }); describe('FlyoutFooter', () => { @@ -68,6 +82,13 @@ describe('PasteJsonText', () => { expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); + it('submits json', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.onSubmitJson).toHaveBeenCalled(); + }); + it('disables/enables the Continue button based on whether text has been entered', () => { const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(false); @@ -76,5 +97,14 @@ describe('PasteJsonText', () => { rerender(wrapper); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx index ad83e0eb1a286..b1f83095f30af 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/paste_json_text.tsx @@ -25,6 +25,7 @@ import { import { AppLogic } from '../../../app_logic'; import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CANCEL_BUTTON, FLYOUT_CONTINUE_BUTTON } from '../constants'; +import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../'; import './paste_json_text.scss'; @@ -55,11 +56,11 @@ export const FlyoutBody: React.FC = () => { const { configuredLimits } = useValues(AppLogic); const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; - const { textInput } = useValues(DocumentCreationLogic); + const { textInput, errors } = useValues(DocumentCreationLogic); const { setTextInput } = useActions(DocumentCreationLogic); return ( - + }>

{i18n.translate( @@ -76,6 +77,7 @@ export const FlyoutBody: React.FC = () => { setTextInput(e.target.value)} + isInvalid={errors.length > 0} aria-label={i18n.translate( 'xpack.enterpriseSearch.appSearch.documentCreation.pasteJsonText.label', { defaultMessage: 'Paste JSON here' } @@ -89,8 +91,8 @@ export const FlyoutBody: React.FC = () => { }; export const FlyoutFooter: React.FC = () => { - const { textInput } = useValues(DocumentCreationLogic); - const { closeDocumentCreation } = useActions(DocumentCreationLogic); + const { textInput, isUploading } = useValues(DocumentCreationLogic); + const { onSubmitJson, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( @@ -99,7 +101,7 @@ export const FlyoutFooter: React.FC = () => { {FLYOUT_CANCEL_BUTTON} - + {FLYOUT_CONTINUE_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx index 72a245df817ba..a5cb1885d9a04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.test.tsx @@ -3,11 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * 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 { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; import { rerender } from '../../../../__mocks__'; @@ -16,12 +11,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { EuiFilePicker, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { Errors } from '../creation_response_components'; import { UploadJsonFile, FlyoutHeader, FlyoutBody, FlyoutFooter } from './upload_json_file'; describe('UploadJsonFile', () => { const mockFile = new File(['mock'], 'mock.json', { type: 'application/json' }); const values = { fileInput: null, + isUploading: false, + errors: [], configuredLimits: { engine: { maxDocumentByteSize: 102400, @@ -30,6 +28,7 @@ describe('UploadJsonFile', () => { }; const actions = { setFileInput: jest.fn(), + onSubmitFile: jest.fn(), closeDocumentCreation: jest.fn(), }; @@ -63,6 +62,25 @@ describe('UploadJsonFile', () => { wrapper.find(EuiFilePicker).simulate('change', []); expect(actions.setFileInput).toHaveBeenCalledWith(null); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiFilePicker).prop('isLoading')).toBe(true); + }); + + it('shows an error banner and sets invalid form props if errors exist', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(false); + + setMockValues({ ...values, errors: ['some error'] }); + rerender(wrapper); + expect(wrapper.find(EuiFilePicker).prop('isInvalid')).toBe(true); + expect(wrapper.prop('banner').type).toEqual(Errors); + }); }); describe('FlyoutFooter', () => { @@ -73,6 +91,13 @@ describe('UploadJsonFile', () => { expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); + it('submits the json file', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.onSubmitFile).toHaveBeenCalled(); + }); + it('disables/enables the Continue button based on whether files have been uploaded', () => { const wrapper = shallow(); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); @@ -81,5 +106,14 @@ describe('UploadJsonFile', () => { rerender(wrapper); expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); }); + + it('sets isLoading based on isUploading', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(false); + + setMockValues({ ...values, isUploading: true }); + rerender(wrapper); + expect(wrapper.find(EuiButton).prop('isLoading')).toBe(true); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx index 6c5b1de79c320..86841223c7255 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_mode_components/upload_json_file.tsx @@ -3,11 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * 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 { useValues, useActions } from 'kea'; @@ -30,6 +25,7 @@ import { import { AppLogic } from '../../../app_logic'; import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CANCEL_BUTTON, FLYOUT_CONTINUE_BUTTON } from '../constants'; +import { Errors } from '../creation_response_components'; import { DocumentCreationLogic } from '../'; export const UploadJsonFile: React.FC = () => ( @@ -59,10 +55,11 @@ export const FlyoutBody: React.FC = () => { const { configuredLimits } = useValues(AppLogic); const maxDocumentByteSize = configuredLimits?.engine?.maxDocumentByteSize; + const { isUploading, errors } = useValues(DocumentCreationLogic); const { setFileInput } = useActions(DocumentCreationLogic); return ( - + }>

{i18n.translate( @@ -80,14 +77,16 @@ export const FlyoutBody: React.FC = () => { onChange={(files) => setFileInput(files?.length ? files[0] : null)} accept="application/json" fullWidth + isLoading={isUploading} + isInvalid={errors.length > 0} /> ); }; export const FlyoutFooter: React.FC = () => { - const { fileInput } = useValues(DocumentCreationLogic); - const { closeDocumentCreation } = useActions(DocumentCreationLogic); + const { fileInput, isUploading } = useValues(DocumentCreationLogic); + const { onSubmitFile, closeDocumentCreation } = useActions(DocumentCreationLogic); return ( @@ -96,7 +95,7 @@ export const FlyoutFooter: React.FC = () => { {FLYOUT_CANCEL_BUTTON} - + {FLYOUT_CONTINUE_BUTTON} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx new file mode 100644 index 0000000000000..ec73184621b53 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 { setMockValues } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiCallOut } from '@elastic/eui'; + +import { Errors } from './'; + +describe('Errors', () => { + it('does not render if no errors or warnings to render', () => { + setMockValues({ errors: [], warnings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + }); + + it('renders errors', () => { + setMockValues({ errors: ['error 1', 'error 2'], warnings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiCallOut).prop('title')).toEqual( + 'Something went wrong. Please address the errors and try again.' + ); + expect(wrapper.find('p').first().text()).toEqual('error 1'); + expect(wrapper.find('p').last().text()).toEqual('error 2'); + }); + + it('renders warnings', () => { + setMockValues({ errors: [], warnings: ['document size warning'] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiCallOut).prop('title')).toEqual('Warning!'); + expect(wrapper.find('p').text()).toEqual('document size warning'); + }); + + it('renders both errors and warnings', () => { + setMockValues({ errors: ['some error'], warnings: ['some warning'] }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx new file mode 100644 index 0000000000000..cf0c4e1c46a13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/errors.tsx @@ -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 React from 'react'; +import { useValues } from 'kea'; + +import { EuiCallOut } from '@elastic/eui'; + +import { DOCUMENT_CREATION_ERRORS, DOCUMENT_CREATION_WARNINGS } from '../constants'; +import { DocumentCreationLogic } from '../'; + +export const Errors: React.FC = () => { + const { errors, warnings } = useValues(DocumentCreationLogic); + + return ( + <> + {errors.length > 0 && ( + + {errors.map((message, index) => ( +

{message}

+ ))} + + )} + {warnings.length > 0 && ( + + {warnings.map((message, index) => ( +

{message}

+ ))} +
+ )} + + ); +}; diff --git a/x-pack/plugins/logstash/server/routes/upgrade/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts similarity index 77% rename from x-pack/plugins/logstash/server/routes/upgrade/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts index 3a5b0868b446b..eb4aec46d1f08 100644 --- a/x-pack/plugins/logstash/server/routes/upgrade/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { registerUpgradeRoute } from './upgrade'; +export { Errors } from './errors'; +export { Summary } from './summary'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx new file mode 100644 index 0000000000000..9882166f63ba0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.test.tsx @@ -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 { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlyoutBody, EuiCallOut, EuiButton } from '@elastic/eui'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; +import { Summary, FlyoutHeader, FlyoutBody, FlyoutFooter } from './summary'; + +describe('Summary', () => { + const values = { + summary: { + invalidDocuments: { + total: 0, + }, + }, + }; + const actions = { + setCreationStep: jest.fn(), + closeDocumentCreation: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(FlyoutHeader)).toHaveLength(1); + expect(wrapper.find(FlyoutBody)).toHaveLength(1); + expect(wrapper.find(FlyoutFooter)).toHaveLength(1); + }); + + describe('FlyoutHeader', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('h2').text()).toEqual('Indexing summary'); + }); + }); + + describe('FlyoutBody', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(InvalidDocumentsSummary)).toHaveLength(1); + expect(wrapper.find(ValidDocumentsSummary)).toHaveLength(1); + expect(wrapper.find(SchemaFieldsSummary)).toHaveLength(1); + }); + + it('shows an error callout as a flyout banner when the upload contained invalid document(s)', () => { + setMockValues({ summary: { invalidDocuments: { total: 1 } } }); + const wrapper = shallow(); + const banner = wrapper.find(EuiFlyoutBody).prop('banner') as any; + + expect(banner.type).toEqual(EuiCallOut); + expect(banner.props.color).toEqual('danger'); + expect(banner.props.iconType).toEqual('alert'); + expect(banner.props.title).toEqual( + 'Something went wrong. Please address the errors and try again.' + ); + }); + }); + + describe('FlyoutFooter', () => { + it('closes the flyout', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + expect(actions.closeDocumentCreation).toHaveBeenCalled(); + }); + + it('shows a "Fix errors" button when the upload contained invalid document(s)', () => { + setMockValues({ summary: { invalidDocuments: { total: 5 } } }); + const wrapper = shallow(); + + wrapper.find(EuiButton).last().simulate('click'); + expect(actions.setCreationStep).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx new file mode 100644 index 0000000000000..7c7b2c805a710 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary.tsx @@ -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 React from 'react'; +import { useValues, useActions } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiCallOut, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; + +import { FLYOUT_ARIA_LABEL_ID, FLYOUT_CLOSE_BUTTON, DOCUMENT_CREATION_ERRORS } from '../constants'; +import { DocumentCreationStep } from '../types'; +import { DocumentCreationLogic } from '../'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; + +export const Summary: React.FC = () => { + return ( + <> + + + + + ); +}; + +export const FlyoutHeader: React.FC = () => { + return ( + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.showSummary.title', { + defaultMessage: 'Indexing summary', + })} +

+
+
+ ); +}; + +export const FlyoutBody: React.FC = () => { + const { summary } = useValues(DocumentCreationLogic); + const hasInvalidDocuments = summary.invalidDocuments.total > 0; + const invalidDocumentsBanner = ( + + ); + + return ( + + + + + + ); +}; + +export const FlyoutFooter: React.FC = () => { + const { setCreationStep, closeDocumentCreation } = useActions(DocumentCreationLogic); + const { summary } = useValues(DocumentCreationLogic); + const hasInvalidDocuments = summary.invalidDocuments.total > 0; + + return ( + + + + {FLYOUT_CLOSE_BUTTON} + + {hasInvalidDocuments && ( + + setCreationStep(DocumentCreationStep.AddDocuments)}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.fixErrors', + { defaultMessage: 'Fix errors' } + )} + + + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx new file mode 100644 index 0000000000000..790b0b7197383 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.test.tsx @@ -0,0 +1,46 @@ +/* + * 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 { shallow } from 'enzyme'; +import { EuiCodeBlock, EuiCallOut } from '@elastic/eui'; + +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +describe('ExampleDocumentJson', () => { + const exampleDocument = { hello: 'world' }; + const expectedJson = `{ + "hello": "world" +}`; + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCodeBlock).prop('children')).toEqual(expectedJson); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + }); + + it('renders invalid documents with error callouts', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('h3').text()).toEqual('This document was not indexed!'); + expect(wrapper.find(EuiCallOut)).toHaveLength(2); + expect(wrapper.find(EuiCallOut).first().prop('title')).toEqual('Bad JSON error'); + expect(wrapper.find(EuiCallOut).last().prop('title')).toEqual('Schema error'); + }); +}); + +describe('MoreDocumentsText', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('p').text()).toEqual('and 100 other documents.'); + + wrapper.setProps({ documents: 1 }); + expect(wrapper.find('p').text()).toEqual('and 1 other document.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx new file mode 100644 index 0000000000000..338020d26dec0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_documents.tsx @@ -0,0 +1,64 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiCodeBlock, EuiCallOut, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface ExampleDocumentJsonProps { + document: object; + errors?: string[]; +} +export const ExampleDocumentJson: React.FC = ({ document, errors }) => { + return ( + <> + {errors && ( + <> + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.documentNotIndexed', + { defaultMessage: 'This document was not indexed!' } + )} +

+
+ + {errors.map((errorMessage, index) => ( + + + + + ))} + + )} + + {JSON.stringify(document, null, 2)} + + + + ); +}; + +interface MoreDocumentsTextProps { + documents: number; +} +export const MoreDocumentsText: React.FC = ({ documents }) => { + return ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.showSummary.otherDocuments', + { + defaultMessage: + 'and {documents, number} other {documents, plural, one {document} other {documents}}.', + values: { documents }, + } + )} +

+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss new file mode 100644 index 0000000000000..029fcdd25554c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.scss @@ -0,0 +1,26 @@ +/* + * 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. + */ + +.documentCreationSummarySection { + padding: $euiSize $euiSizeM; + color: $euiTextSubduedColor; + border-top: $euiBorderThin; + border-bottom: $euiBorderThin; + + & + & { + border-top: 0; + } + + &__title { + display: flex; + align-items: center; + height: $euiSizeL; + + .euiIcon { + margin-right: $euiSizeS; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx new file mode 100644 index 0000000000000..0af2327c6bbac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.test.tsx @@ -0,0 +1,73 @@ +/* + * 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, { ReactElement } from 'react'; +import { shallow } from 'enzyme'; +import { EuiAccordion, EuiIcon } from '@elastic/eui'; + +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; + +describe('SummarySectionAccordion', () => { + const props = { + id: 'some-id', + status: 'success' as 'success' | 'error' | 'info', + title: 'Some title', + }; + + it('renders', () => { + const wrapper = shallow( + Hello World + ); + + expect(wrapper.type()).toEqual(EuiAccordion); + expect(wrapper.hasClass('documentCreationSummarySection')).toBe(true); + expect(wrapper.find(EuiAccordion).prop('children')).toEqual('Hello World'); + }); + + it('renders a title', () => { + const wrapper = shallow(); + const buttonContent = shallow(wrapper.find(EuiAccordion).prop('buttonContent') as ReactElement); + + expect(buttonContent.find('.documentCreationSummarySection__title').text()).toEqual( + 'Hello World' + ); + }); + + it('renders icons based on the status prop', () => { + const wrapper = shallow(); + const getIcon = () => { + const buttonContent = shallow( + wrapper.find(EuiAccordion).prop('buttonContent') as ReactElement + ); + return buttonContent.find(EuiIcon); + }; + + wrapper.setProps({ status: 'error' }); + expect(getIcon().prop('type')).toEqual('crossInACircleFilled'); + expect(getIcon().prop('color')).toEqual('danger'); + + wrapper.setProps({ status: 'success' }); + expect(getIcon().prop('type')).toEqual('checkInCircleFilled'); + expect(getIcon().prop('color')).toEqual('success'); + + wrapper.setProps({ status: 'info' }); + expect(getIcon().prop('type')).toEqual('iInCircle'); + expect(getIcon().prop('color')).toEqual('default'); + }); +}); + +describe('SummarySectionEmpty', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.hasClass('documentCreationSummarySection')).toBe(true); + expect(wrapper.find('.documentCreationSummarySection__title').text()).toEqual( + 'No new documents' + ); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('iInCircle'); + expect(wrapper.find(EuiIcon).prop('color')).toEqual('default'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx new file mode 100644 index 0000000000000..d50779e7ff003 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_section.tsx @@ -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 React from 'react'; + +import { EuiAccordion, EuiIcon } from '@elastic/eui'; + +import './summary_section.scss'; + +const ICON_PROPS = { + error: { type: 'crossInACircleFilled', color: 'danger' }, + success: { type: 'checkInCircleFilled', color: 'success' }, + info: { type: 'iInCircle', color: 'default' }, +}; + +interface SummarySectionAccordionProps { + id: string; + status: 'success' | 'error' | 'info'; + title: string; +} +export const SummarySectionAccordion: React.FC = ({ + id, + status, + title, + children, +}) => { + return ( + + + {title} +
+ } + > + {children} + + ); +}; + +interface SummarySectionEmptyProps { + title: string; +} +export const SummarySectionEmpty: React.FC = ({ title }) => { + return ( +
+
+ + {title} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx new file mode 100644 index 0000000000000..86cea8ef23587 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.test.tsx @@ -0,0 +1,168 @@ +/* + * 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 { setMockValues } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiBadge } from '@elastic/eui'; +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +import { + InvalidDocumentsSummary, + ValidDocumentsSummary, + SchemaFieldsSummary, +} from './summary_sections'; + +describe('InvalidDocumentsSummary', () => { + const mockDocument = { hello: 'world' }; + const mockExample = { document: mockDocument, errors: ['bad schema'] }; + + it('renders', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 1, + examples: [mockExample], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + '1 document with errors...' + ); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(0); + }); + + it('renders with MoreDocumentsText if more than 5 documents exist', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 100, + examples: [mockExample, mockExample, mockExample, mockExample, mockExample], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + '100 documents with errors...' + ); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(5); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText).prop('documents')).toEqual(95); + }); + + it('does not render if there are no invalid documents', () => { + setMockValues({ + summary: { + invalidDocuments: { + total: 0, + examples: [], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); + +describe('ValidDocumentsSummary', () => { + const mockDocument = { hello: 'world' }; + + it('renders', () => { + setMockValues({ + summary: { + validDocuments: { + total: 1, + examples: [mockDocument], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual('Added 1 document.'); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(0); + }); + + it('renders with MoreDocumentsText if more than 5 documents exist', () => { + setMockValues({ + summary: { + validDocuments: { + total: 7, + examples: [mockDocument, mockDocument, mockDocument, mockDocument, mockDocument], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual('Added 7 documents.'); + expect(wrapper.find(ExampleDocumentJson)).toHaveLength(5); + expect(wrapper.find(MoreDocumentsText)).toHaveLength(1); + expect(wrapper.find(MoreDocumentsText).prop('documents')).toEqual(2); + }); + + it('renders SummarySectionEmpty if there are no valid documents', () => { + setMockValues({ + summary: { + validDocuments: { + total: 0, + examples: [], + }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new documents.'); + }); +}); + +describe('SchemaFieldsSummary', () => { + it('renders', () => { + setMockValues({ + summary: { + newSchemaFields: ['test'], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + "Added 1 field to the Engine's schema." + ); + expect(wrapper.find(EuiBadge)).toHaveLength(1); + }); + + it('renders multiple new schema fields', () => { + setMockValues({ + summary: { + newSchemaFields: ['foo', 'bar', 'baz', 'qux', 'quux', 'quuz'], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionAccordion).prop('title')).toEqual( + "Added 6 fields to the Engine's schema." + ); + expect(wrapper.find(EuiBadge)).toHaveLength(6); + }); + + it('renders SummarySectionEmpty if there are no new schema fields', () => { + setMockValues({ + summary: { + newSchemaFields: [], + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SummarySectionEmpty).prop('title')).toEqual('No new schema fields.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.tsx new file mode 100644 index 0000000000000..2a13622dfbc8e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/creation_response_components/summary_sections.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 from 'react'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; + +import { DocumentCreationLogic } from '../'; + +import { SummarySectionAccordion, SummarySectionEmpty } from './summary_section'; +import { ExampleDocumentJson, MoreDocumentsText } from './summary_documents'; + +export const InvalidDocumentsSummary: React.FC = () => { + const { + summary: { invalidDocuments }, + } = useValues(DocumentCreationLogic); + + const hasInvalidDocuments = invalidDocuments.total > 0; + const unshownInvalidDocuments = invalidDocuments.total - invalidDocuments.examples.length; + + return hasInvalidDocuments ? ( + + {invalidDocuments.examples.map(({ document, errors }, index) => ( + + ))} + {unshownInvalidDocuments > 0 && } + + ) : null; +}; + +export const ValidDocumentsSummary: React.FC = () => { + const { + summary: { validDocuments }, + } = useValues(DocumentCreationLogic); + + const hasValidDocuments = validDocuments.total > 0; + const unshownValidDocuments = validDocuments.total - validDocuments.examples.length; + + return hasValidDocuments ? ( + + {validDocuments.examples.map((document, index) => ( + + ))} + {unshownValidDocuments > 0 && } + + ) : ( + + ); +}; + +export const SchemaFieldsSummary: React.FC = () => { + const { + summary: { newSchemaFields }, + } = useValues(DocumentCreationLogic); + + return newSchemaFields.length ? ( + + + {newSchemaFields.map((schemaField: string) => ( + + {schemaField} + + ))} + + + ) : ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx index f2799cde41e97..cc9a671e41e5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx @@ -16,6 +16,7 @@ import { PasteJsonText, UploadJsonFile, } from './creation_mode_components'; +import { Summary } from './creation_response_components'; import { DocumentCreationStep } from './types'; import { DocumentCreationFlyout, FlyoutContent } from './document_creation_flyout'; @@ -82,28 +83,11 @@ describe('DocumentCreationFlyout', () => { }); }); - describe('creation steps', () => { - it('renders an error page', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowError }); - const wrapper = shallow(); - - expect(wrapper.text()).toBe('DocumentCreationError'); // TODO: actual component - }); - - it('renders an error summary', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowErrorSummary }); - const wrapper = shallow(); - - expect(wrapper.text()).toBe('DocumentCreationSummary'); // TODO: actual component - }); - - it('renders a success summary', () => { - setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSuccessSummary }); - const wrapper = shallow(); + it('renders a summary', () => { + setMockValues({ ...values, creationStep: DocumentCreationStep.ShowSummary }); + const wrapper = shallow(); - // TODO: Figure out if the error and success summary should remain the same vs different components - expect(wrapper.text()).toBe('DocumentCreationSummary'); // TODO: actual component - }); + expect(wrapper.find(Summary)).toHaveLength(1); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx index ca52d14befb38..2dd00f0ded17d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.tsx @@ -19,6 +19,7 @@ import { PasteJsonText, UploadJsonFile, } from './creation_mode_components'; +import { Summary } from './creation_response_components'; export const DocumentCreationFlyout: React.FC = () => { const { closeDocumentCreation } = useActions(DocumentCreationLogic); @@ -48,11 +49,7 @@ export const FlyoutContent: React.FC = () => { case 'file': return ; } - case DocumentCreationStep.ShowError: - return <>DocumentCreationError; - case DocumentCreationStep.ShowErrorSummary: - return <>DocumentCreationSummary; - case DocumentCreationStep.ShowSuccessSummary: - return <>DocumentCreationSummary; + case DocumentCreationStep.ShowSummary: + return ; } }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 1145d7853cb1a..bb0103b07b072 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -7,6 +7,20 @@ import { resetContext } from 'kea'; import dedent from 'dedent'; +jest.mock('./utils', () => ({ + readUploadedFileAsText: jest.fn(), +})); +import { readUploadedFileAsText } from './utils'; + +jest.mock('../../../shared/http', () => ({ + HttpLogic: { values: { http: { post: jest.fn() } } }, +})); +import { HttpLogic } from '../../../shared/http'; + +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'test-engine' } }, +})); + import { DOCUMENTS_API_JSON_EXAMPLE } from './constants'; import { DocumentCreationStep } from './types'; import { DocumentCreationLogic } from './'; @@ -18,11 +32,29 @@ describe('DocumentCreationLogic', () => { creationStep: DocumentCreationStep.AddDocuments, textInput: dedent(DOCUMENTS_API_JSON_EXAMPLE), fileInput: null, + isUploading: false, + warnings: [], + errors: [], + summary: {}, }; const mockFile = new File(['mockFile'], 'mockFile.json'); - const mount = () => { - resetContext({}); + const mount = (defaults?: object) => { + if (!defaults) { + resetContext({}); + } else { + resetContext({ + defaults: { + enterprise_search: { + app_search: { + document_creation_logic: { + ...defaults, + }, + }, + }, + }, + }); + } DocumentCreationLogic.mount(); }; @@ -120,17 +152,39 @@ describe('DocumentCreationLogic', () => { }); }); }); + + describe('errors & warnings', () => { + it('should be cleared', () => { + mount({ errors: ['error'], warnings: ['warnings'] }); + DocumentCreationLogic.actions.closeDocumentCreation(); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: [], + warnings: [], + }); + }); + }); + + describe('textInput & fileInput', () => { + it('should be reset to default values', () => { + mount({ textInput: 'test', fileInput: mockFile }); + DocumentCreationLogic.actions.closeDocumentCreation(); + + expect(DocumentCreationLogic.values).toEqual(DEFAULT_VALUES); + }); + }); }); describe('setCreationStep', () => { describe('creationStep', () => { it('should be set to the provided value', () => { mount(); - DocumentCreationLogic.actions.setCreationStep(DocumentCreationStep.ShowSuccessSummary); + DocumentCreationLogic.actions.setCreationStep(DocumentCreationStep.ShowSummary); expect(DocumentCreationLogic.values).toEqual({ ...DEFAULT_VALUES, - creationStep: 3, + creationStep: 2, }); }); }); @@ -163,5 +217,393 @@ describe('DocumentCreationLogic', () => { }); }); }); + + describe('setWarnings', () => { + describe('warnings', () => { + it('should be set to the provided value', () => { + mount(); + DocumentCreationLogic.actions.setWarnings(['warning!']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + warnings: ['warning!'], + }); + }); + }); + }); + + describe('setErrors', () => { + describe('errors', () => { + beforeAll(() => { + mount(); + }); + + it('should be set to the provided value', () => { + DocumentCreationLogic.actions.setErrors(['error 1', 'error 2']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error 1', 'error 2'], + }); + }); + + it('should gracefully array wrap single errors', () => { + DocumentCreationLogic.actions.setErrors('error'); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error'], + }); + }); + }); + + describe('isUploading', () => { + it('resets isUploading to false', () => { + mount({ isUploading: true }); + DocumentCreationLogic.actions.setErrors(['error']); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + errors: ['error'], + isUploading: false, + }); + }); + }); + }); + + describe('setSummary', () => { + const mockSummary = { + errors: [], + validDocuments: { + total: 1, + examples: [{ foo: 'bar' }], + }, + invalidDocuments: { + total: 0, + examples: [], + }, + newSchemaFields: ['foo'], + }; + + describe('summary', () => { + it('should be set to the provided value', () => { + mount(); + DocumentCreationLogic.actions.setSummary(mockSummary); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + summary: mockSummary, + }); + }); + }); + + describe('isUploading', () => { + it('resets isUploading to false', () => { + mount({ isUploading: true }); + DocumentCreationLogic.actions.setSummary(mockSummary); + + expect(DocumentCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + summary: mockSummary, + isUploading: false, + }); + }); + }); + }); + + describe('onSubmitFile', () => { + describe('with a valid file', () => { + beforeAll(() => { + mount({ fileInput: mockFile }); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson').mockImplementation(); + }); + + it('should read the text in the file and submit it as JSON', async () => { + (readUploadedFileAsText as jest.Mock).mockReturnValue(Promise.resolve('some mock text')); + await DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.values.textInput).toEqual('some mock text'); + expect(DocumentCreationLogic.actions.onSubmitJson).toHaveBeenCalled(); + }); + + it('should set isUploading to true', () => { + DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.values.isUploading).toEqual(true); + }); + }); + + describe('with an invalid file', () => { + beforeAll(() => { + mount({ fileInput: mockFile }); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return an error', async () => { + (readUploadedFileAsText as jest.Mock).mockReturnValue(Promise.reject()); + await DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.actions.onSubmitJson).not.toHaveBeenCalled(); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Problem parsing file.', + ]); + }); + }); + + describe('without a file', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'onSubmitJson'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return an error', () => { + DocumentCreationLogic.actions.onSubmitFile(); + + expect(DocumentCreationLogic.actions.onSubmitJson).not.toHaveBeenCalled(); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith(['No file found.']); + }); + }); + }); + + describe('onSubmitJson', () => { + describe('with large JSON files', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setWarnings'); + }); + + it('should set a warning', () => { + jest.spyOn(global.Buffer, 'byteLength').mockImplementation(() => 55000000); // 55MB + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setWarnings).toHaveBeenCalledWith([ + expect.stringContaining("You're uploading an extremely large file"), + ]); + + jest.restoreAllMocks(); + }); + }); + + describe('with invalid JSON', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should return malformed JSON errors', () => { + DocumentCreationLogic.actions.setTextInput('invalid JSON'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Unexpected token i in JSON at position 0', + ]); + expect(DocumentCreationLogic.actions.uploadDocuments).not.toHaveBeenCalled(); + }); + + it('should error on non-array/object JSON', () => { + DocumentCreationLogic.actions.setTextInput('null'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'Document contents must be a valid JSON array or object.', + ]); + expect(DocumentCreationLogic.actions.uploadDocuments).not.toHaveBeenCalled(); + }); + }); + + describe('with valid JSON', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'uploadDocuments').mockImplementation(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should accept an array of JSON objs', () => { + const mockJson = [{ foo: 'bar' }, { bar: 'baz' }]; + DocumentCreationLogic.actions.setTextInput('[{"foo":"bar"},{"bar":"baz"}]'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.uploadDocuments).toHaveBeenCalledWith({ + documents: mockJson, + }); + expect(DocumentCreationLogic.actions.setErrors).not.toHaveBeenCalled(); + }); + + it('should accept a single JSON obj', () => { + const mockJson = { foo: 'bar' }; + DocumentCreationLogic.actions.setTextInput('{"foo":"bar"}'); + DocumentCreationLogic.actions.onSubmitJson(); + + expect(DocumentCreationLogic.actions.uploadDocuments).toHaveBeenCalledWith({ + documents: [mockJson], + }); + expect(DocumentCreationLogic.actions.setErrors).not.toHaveBeenCalled(); + }); + }); + }); + + describe('uploadDocuments', () => { + describe('valid uploads', () => { + const mockValidDocuments = [{ foo: 'bar', bar: 'baz', qux: 'quux' }]; + const mockValidResponse = { + errors: [], + validDocuments: { total: 3, examples: mockValidDocuments }, + invalidDocuments: { total: 0, examples: [] }, + newSchemaFields: ['foo', 'bar', 'qux'], + }; + + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setSummary'); + jest.spyOn(DocumentCreationLogic.actions, 'setCreationStep'); + }); + + it('should set and show summary from the returned response', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.resolve(mockValidResponse) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: mockValidDocuments }); + await promise; + + expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith(mockValidResponse); + expect(DocumentCreationLogic.actions.setCreationStep).toHaveBeenCalledWith( + DocumentCreationStep.ShowSummary + ); + }); + }); + + describe('invalid uploads', () => { + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('handles API errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.reject({ + body: { + statusCode: 400, + error: 'Bad Request', + message: 'Invalid request payload JSON format', + }, + }) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( + '[400 Bad Request] Invalid request payload JSON format' + ); + }); + + it('handles client-side errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce(new Error()); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( + "Cannot read property 'total' of undefined" + ); + }); + + // NOTE: I can't seem to reproduce this in a production setting. + it('handles errors returned from the API', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock).mockReturnValueOnce( + Promise.resolve({ + errors: ['JSON cannot be empty'], + }) + ); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); + await promise; + + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'JSON cannot be empty', + ]); + }); + }); + + describe('chunks large uploads', () => { + // Using an array of #s for speed, it doesn't really matter what the contents of the documents are for this test + const largeDocumentsArray = ([...Array(200).keys()] as unknown) as object[]; + + const mockFirstResponse = { + validDocuments: { total: 99, examples: largeDocumentsArray.slice(0, 98) }, + invalidDocuments: { + total: 1, + examples: [{ document: largeDocumentsArray[99], error: ['some error'] }], + }, + newSchemaFields: ['foo', 'bar'], + }; + const mockSecondResponse = { + validDocuments: { total: 99, examples: largeDocumentsArray.slice(1, 99) }, + invalidDocuments: { + total: 1, + examples: [{ document: largeDocumentsArray[0], error: ['another error'] }], + }, + newSchemaFields: ['bar', 'baz'], + }; + + beforeAll(() => { + mount(); + jest.spyOn(DocumentCreationLogic.actions, 'setSummary'); + jest.spyOn(DocumentCreationLogic.actions, 'setErrors'); + }); + + it('should correctly merge multiple API calls into a single summary obj', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock) + .mockReturnValueOnce(mockFirstResponse) + .mockReturnValueOnce(mockSecondResponse); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); + await promise; + + expect(http.post).toHaveBeenCalledTimes(2); + expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith({ + errors: [], + validDocuments: { + total: 198, + examples: largeDocumentsArray.slice(0, 5), + }, + invalidDocuments: { + total: 2, + examples: [ + { document: largeDocumentsArray[99], error: ['some error'] }, + { document: largeDocumentsArray[0], error: ['another error'] }, + ], + }, + newSchemaFields: ['foo', 'bar', 'baz'], + }); + }); + + it('should correctly merge response errors', async () => { + const { http } = HttpLogic.values; + const promise = (http.post as jest.Mock) + .mockReturnValueOnce({ ...mockFirstResponse, errors: ['JSON cannot be empty'] }) + .mockReturnValueOnce({ ...mockSecondResponse, errors: ['Too large to render'] }); + + await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); + await promise; + + expect(http.post).toHaveBeenCalledTimes(2); + expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ + 'JSON cannot be empty', + 'Too large to render', + ]); + }); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts index 5b85e7f2ab54e..119baed74f684 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.ts @@ -6,9 +6,18 @@ import { kea, MakeLogicType } from 'kea'; import dedent from 'dedent'; +import { isPlainObject, chunk, uniq } from 'lodash'; -import { DOCUMENTS_API_JSON_EXAMPLE } from './constants'; -import { DocumentCreationMode, DocumentCreationStep } from './types'; +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { + DOCUMENTS_API_JSON_EXAMPLE, + DOCUMENT_CREATION_ERRORS, + DOCUMENT_CREATION_WARNINGS, +} from './constants'; +import { DocumentCreationMode, DocumentCreationStep, DocumentCreationSummary } from './types'; +import { readUploadedFileAsText } from './utils'; interface DocumentCreationValues { isDocumentCreationOpen: boolean; @@ -16,6 +25,10 @@ interface DocumentCreationValues { creationStep: DocumentCreationStep; textInput: string; fileInput: File | null; + isUploading: boolean; + warnings: string[]; + errors: string[]; + summary: DocumentCreationSummary; } interface DocumentCreationActions { @@ -25,6 +38,12 @@ interface DocumentCreationActions { setCreationStep(creationStep: DocumentCreationStep): { creationStep: DocumentCreationStep }; setTextInput(textInput: string): { textInput: string }; setFileInput(fileInput: File | null): { fileInput: File | null }; + setWarnings(warnings: string[]): { warnings: string[] }; + setErrors(errors: string[] | string): { errors: string[] }; + setSummary(summary: DocumentCreationSummary): { summary: DocumentCreationSummary }; + onSubmitFile(): void; + onSubmitJson(): void; + uploadDocuments(args: { documents: object[] }): { documents: object[] }; } export const DocumentCreationLogic = kea< @@ -38,6 +57,12 @@ export const DocumentCreationLogic = kea< setCreationStep: (creationStep) => ({ creationStep }), setTextInput: (textInput) => ({ textInput }), setFileInput: (fileInput) => ({ fileInput }), + setWarnings: (warnings) => ({ warnings }), + setErrors: (errors) => ({ errors }), + setSummary: (summary) => ({ summary }), + onSubmitJson: () => null, + onSubmitFile: () => null, + uploadDocuments: ({ documents }) => ({ documents }), }), reducers: () => ({ isDocumentCreationOpen: [ @@ -66,13 +91,134 @@ export const DocumentCreationLogic = kea< dedent(DOCUMENTS_API_JSON_EXAMPLE), { setTextInput: (_, { textInput }) => textInput, + closeDocumentCreation: () => dedent(DOCUMENTS_API_JSON_EXAMPLE), }, ], fileInput: [ null, { setFileInput: (_, { fileInput }) => fileInput, + closeDocumentCreation: () => null, + }, + ], + isUploading: [ + false, + { + onSubmitFile: () => true, + onSubmitJson: () => true, + setErrors: () => false, + setSummary: () => false, + }, + ], + warnings: [ + [], + { + onSubmitJson: () => [], + setWarnings: (_, { warnings }) => warnings, + closeDocumentCreation: () => [], + }, + ], + errors: [ + [], + { + onSubmitJson: () => [], + setErrors: (_, { errors }) => (Array.isArray(errors) ? errors : [errors]), + closeDocumentCreation: () => [], }, ], + summary: [ + {} as DocumentCreationSummary, + { + setSummary: (_, { summary }) => summary, + }, + ], + }), + listeners: ({ values, actions }) => ({ + onSubmitFile: async () => { + const { fileInput } = values; + + if (!fileInput) { + return actions.setErrors([DOCUMENT_CREATION_ERRORS.NO_FILE]); + } + try { + const textInput = await readUploadedFileAsText(fileInput); + actions.setTextInput(textInput); + actions.onSubmitJson(); + } catch { + actions.setErrors([DOCUMENT_CREATION_ERRORS.NO_VALID_FILE]); + } + }, + onSubmitJson: () => { + const { textInput } = values; + + const MAX_UPLOAD_BYTES = 50 * 1000000; // 50 MB + if (Buffer.byteLength(textInput) > MAX_UPLOAD_BYTES) { + actions.setWarnings([DOCUMENT_CREATION_WARNINGS.LARGE_FILE]); + } + + let documents; + try { + documents = JSON.parse(textInput); + } catch (error) { + return actions.setErrors([error.message]); + } + + if (Array.isArray(documents)) { + actions.uploadDocuments({ documents }); + } else if (isPlainObject(documents)) { + actions.uploadDocuments({ documents: [documents] }); + } else { + actions.setErrors([DOCUMENT_CREATION_ERRORS.NOT_VALID]); + } + }, + uploadDocuments: async ({ documents }) => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const CHUNK_SIZE = 100; + const MAX_EXAMPLES = 5; + + const promises = chunk(documents, CHUNK_SIZE).map((documentsChunk) => { + const body = JSON.stringify({ documents: documentsChunk }); + return http.post(`/api/app_search/engines/${engineName}/documents`, { body }); + }); + + try { + const responses = await Promise.all(promises); + const summary: DocumentCreationSummary = { + errors: [], + validDocuments: { total: 0, examples: [] }, + invalidDocuments: { total: 0, examples: [] }, + newSchemaFields: [], + }; + responses.forEach((response) => { + if (response.errors?.length > 0) { + summary.errors = uniq([...summary.errors, ...response.errors]); + return; + } + summary.validDocuments.total += response.validDocuments.total; + summary.invalidDocuments.total += response.invalidDocuments.total; + summary.validDocuments.examples = [ + ...summary.validDocuments.examples, + ...response.validDocuments.examples, + ].slice(0, MAX_EXAMPLES); + summary.invalidDocuments.examples = [ + ...summary.invalidDocuments.examples, + ...response.invalidDocuments.examples, + ].slice(0, MAX_EXAMPLES); + summary.newSchemaFields = uniq([...summary.newSchemaFields, ...response.newSchemaFields]); + }); + + if (summary.errors.length > 0) { + actions.setErrors(summary.errors); + } else { + actions.setSummary(summary); + actions.setCreationStep(DocumentCreationStep.ShowSummary); + } + } catch ({ body, message }) { + const errors = body ? `[${body.statusCode} ${body.error}] ${body.message}` : message; + actions.setErrors(errors); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts index d29bff162c197..ba641326f76b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/types.ts @@ -9,7 +9,21 @@ export type DocumentCreationMode = 'text' | 'file' | 'api'; export enum DocumentCreationStep { ShowCreationModes, AddDocuments, - ShowErrorSummary, - ShowSuccessSummary, - ShowError, + ShowSummary, +} + +export interface DocumentCreationSummary { + errors: string[]; + validDocuments: { + total: number; + examples: object[]; + }; + invalidDocuments: { + total: number; + examples: Array<{ + document: object; + errors: string[]; + }>; + }; + newSchemaFields: string[]; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts new file mode 100644 index 0000000000000..0df98c8d3030e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.test.ts @@ -0,0 +1,20 @@ +/* + * 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 { readUploadedFileAsText } from './utils'; + +describe('readUploadedFileAsText', () => { + it('reads a file as text', async () => { + const file = new File(['a mock file'], 'mockFile.json'); + const text = await readUploadedFileAsText(file); + expect(text).toEqual('a mock file'); + }); + + it('throws an error if the file cannot be read', async () => { + const badFile = ('causes an error' as unknown) as File; + await expect(readUploadedFileAsText(badFile)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts new file mode 100644 index 0000000000000..d2b207c51d22a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts @@ -0,0 +1,21 @@ +/* + * 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 readUploadedFileAsText = (fileInput: File): Promise => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = () => { + resolve(reader.result as string); + }; + try { + reader.readAsText(fileInput); + } catch { + reader.abort(); + reject(new Error()); + } + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts index 533adbaf5bab9..78e1fa9e7f3a2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts @@ -16,19 +16,16 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema) => { sortField: 'id', }, searchQuery: { - result_fields: Object.keys(schema || {}).reduce( - (acc: { [key: string]: object }, key: string) => { - acc[key] = { - snippet: { - size: 300, - fallback: true, - }, - raw: {}, - }; - return acc; - }, - {} - ), + result_fields: Object.keys(schema).reduce((acc: { [key: string]: object }, key: string) => { + acc[key] = { + snippet: { + size: 300, + fallback: true, + }, + raw: {}, + }; + return acc; + }, {}), }, }; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index e8609c169855b..74976c1c61f0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -23,17 +23,22 @@ import { EngineOverview } from '../engine_overview'; import { EngineRouter } from './'; describe('EngineRouter', () => { - const values = { dataLoading: false, engineNotFound: false, myRole: {} }; + const values = { + dataLoading: false, + engineNotFound: false, + myRole: {}, + engineName: 'some-engine', + }; const actions = { setEngineName: jest.fn(), initializeEngine: jest.fn(), clearEngine: jest.fn() }; beforeEach(() => { setMockValues(values); setMockActions(actions); + (useParams as jest.Mock).mockReturnValue({ engineName: 'some-engine' }); }); describe('useEffect', () => { beforeEach(() => { - (useParams as jest.Mock).mockReturnValue({ engineName: 'some-engine' }); shallow(); }); @@ -45,7 +50,7 @@ describe('EngineRouter', () => { expect(actions.initializeEngine).toHaveBeenCalled(); }); - it('clears engine on unmount', () => { + it('clears engine on unmount and on update', () => { unmountHandler(); expect(actions.clearEngine).toHaveBeenCalled(); }); @@ -68,6 +73,16 @@ describe('EngineRouter', () => { expect(wrapper.find(Loading)).toHaveLength(1); }); + // This would happen if a user jumps around from one engine route to another. If the engine name + // on the path has changed, but we still have an engine stored in state, we do not want to load + // any route views as they would be rendering with the wrong data. + it('renders a loading component if the engine stored in state is stale', () => { + setMockValues({ ...values, engineName: 'some-engine' }); + (useParams as jest.Mock).mockReturnValue({ engineName: 'some-new-engine' }); + const wrapper = shallow(); + expect(wrapper.find(Loading)).toHaveLength(1); + }); + it('renders a default engine overview', () => { const wrapper = shallow(); @@ -76,7 +91,7 @@ describe('EngineRouter', () => { }); it('renders an analytics view', () => { - setMockValues({ myRole: { canViewEngineAnalytics: true } }); + setMockValues({ ...values, myRole: { canViewEngineAnalytics: true } }); const wrapper = shallow(); expect(wrapper.find('[data-test-subj="AnalyticsTODO"]')).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 9e0b043a87364..d3501a5ee7af3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -69,29 +69,31 @@ export const EngineRouter: React.FC = () => { }, } = useValues(AppLogic); - const { dataLoading, engineNotFound } = useValues(EngineLogic); + const { engineName, dataLoading, engineNotFound } = useValues(EngineLogic); const { setEngineName, initializeEngine, clearEngine } = useActions(EngineLogic); - const { engineName } = useParams() as { engineName: string }; - const engineBreadcrumb = [ENGINES_TITLE, engineName]; + const { engineName: engineNameParam } = useParams() as { engineName: string }; + const engineBreadcrumb = [ENGINES_TITLE, engineNameParam]; + + const isEngineInStateStale = () => engineName !== engineNameParam; useEffect(() => { - setEngineName(engineName); + setEngineName(engineNameParam); initializeEngine(); return clearEngine; - }, [engineName]); + }, [engineNameParam]); if (engineNotFound) { setQueuedErrorMessage( i18n.translate('xpack.enterpriseSearch.appSearch.engine.notFound', { - defaultMessage: "No engine with name '{engineName}' could be found.", - values: { engineName }, + defaultMessage: "No engine with name '{engineNameParam}' could be found.", + values: { engineNameParam }, }) ); return ; } - if (dataLoading) return ; + if (isEngineInStateStale() || dataLoading) return ; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/messaging/determine_tooltip_content.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/messaging/determine_tooltip_content.ts index 385831dc511da..b1476dbd171ad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/messaging/determine_tooltip_content.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/messaging/determine_tooltip_content.ts @@ -26,21 +26,12 @@ export const determineTooltipContent = ( if (!logRetentionSettings.enabled) { return renderOrReturnMessage(messages.noLogging); } - if (logRetentionSettings.enabled && !ilmEnabled) { + if (!ilmEnabled) { return renderOrReturnMessage(messages.ilmDisabled); } - if ( - logRetentionSettings.enabled && - ilmEnabled && - !logRetentionSettings.retentionPolicy?.isDefault - ) { + if (!logRetentionSettings.retentionPolicy?.isDefault) { return renderOrReturnMessage(messages.customPolicy); - } - if ( - logRetentionSettings.enabled && - ilmEnabled && - logRetentionSettings.retentionPolicy?.isDefault - ) { + } else { return renderOrReturnMessage(messages.defaultPolicy); } }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.test.tsx index e10d56ddc09b0..67add0ca94520 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/schema_add_field_modal.test.tsx @@ -41,10 +41,10 @@ describe('SchemaAddFieldModal', () => { expect(wrapper.find(EuiModal)).toHaveLength(1); }); - // No matter what I try I can't get this to actually achieve coverage. it('sets loading state in useEffect', () => { setState(true); - const wrapper = mount(); + const wrapper = mount(); + wrapper.setProps({ ...errors }); const input = wrapper.find(EuiFieldText); expect(input.prop('isLoading')).toEqual(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx index 4007f7a69f77a..b411d749b8a25 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx @@ -24,4 +24,10 @@ describe('SourceIcon', () => { expect(wrapper.find('.wrapped-icon')).toHaveLength(1); }); + + it('renders a full bleed icon', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiIcon).prop('type')).toEqual('test-file-stub'); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts index d5fed4c6f97cb..5f57db40cd7e6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.test.ts @@ -6,7 +6,60 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { registerDocumentRoutes } from './documents'; +import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; + +describe('documents routes', () => { + describe('POST /api/app_search/engines/{engineName}/documents', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/documents', + payload: 'body', + }); + + registerDocumentsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { documents: [{ foo: 'bar' }] }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/some-engine/documents/new', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { documents: [{ foo: 'bar' }] } }; + mockRouter.shouldValidate(request); + }); + + it('missing documents', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + + it('wrong document type', () => { + const request = { body: { documents: ['test'] } }; + mockRouter.shouldThrow(request); + }); + + it('non-array documents type', () => { + const request = { body: { documents: { foo: 'bar' } } }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); describe('document routes', () => { describe('GET /api/app_search/engines/{engineName}/documents/{documentId}', () => { diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts index a2f4b323a91aa..60cd64b32479c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/documents.ts @@ -8,6 +8,30 @@ import { schema } from '@kbn/config-schema'; import { RouteDependencies } from '../../plugin'; +export function registerDocumentsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/engines/{engineName}/documents', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + documents: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/${request.params.engineName}/documents/new`, + })(context, request, response); + } + ); +} + export function registerDocumentRoutes({ router, enterpriseSearchRequestHandler, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index f64e45c656fa1..67dcbfdc4f4d5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,11 +9,12 @@ import { RouteDependencies } from '../../plugin'; import { registerEnginesRoutes } from './engines'; import { registerCredentialsRoutes } from './credentials'; import { registerSettingsRoutes } from './settings'; -import { registerDocumentRoutes } from './documents'; +import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerEnginesRoutes(dependencies); registerCredentialsRoutes(dependencies); registerSettingsRoutes(dependencies); + registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); }; diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 680429a4f5946..b1d7a2d434968 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -73,6 +73,7 @@ Array [ "dashboard", "query", "url", + "background-session", ], "read": Array [ "index-pattern", @@ -83,7 +84,6 @@ Array [ "lens", "map", "tag", - "background-session", ], }, "ui": Array [ @@ -92,6 +92,7 @@ Array [ "showWriteControls", "saveQuery", "createShortUrl", + "storeSearchSession", ], }, "privilegeId": "all", @@ -205,8 +206,8 @@ Array [ "search", "query", "index-pattern", - "background-session", "url", + "background-session", ], "read": Array [], }, @@ -215,6 +216,7 @@ Array [ "save", "saveQuery", "createShortUrl", + "storeSearchSession", ], }, "privilegeId": "all", @@ -557,6 +559,7 @@ Array [ "dashboard", "query", "url", + "background-session", ], "read": Array [ "index-pattern", @@ -567,7 +570,6 @@ Array [ "lens", "map", "tag", - "background-session", ], }, "ui": Array [ @@ -576,6 +578,7 @@ Array [ "showWriteControls", "saveQuery", "createShortUrl", + "storeSearchSession", ], }, "privilegeId": "all", @@ -689,8 +692,8 @@ Array [ "search", "query", "index-pattern", - "background-session", "url", + "background-session", ], "read": Array [], }, @@ -699,6 +702,7 @@ Array [ "save", "saveQuery", "createShortUrl", + "storeSearchSession", ], }, "privilegeId": "all", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 209e26821aedd..c38fdf8b29d12 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -28,7 +28,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS app: ['discover', 'kibana'], catalogue: ['discover'], savedObject: { - all: ['search', 'query', 'index-pattern', 'background-session'], + all: ['search', 'query', 'index-pattern'], read: [], }, ui: ['show', 'save', 'saveQuery'], @@ -71,6 +71,33 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, ], }, + { + name: i18n.translate('xpack.features.ossFeatures.discoverSearchSessionsFeatureName', { + defaultMessage: 'Store Search Sessions', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'store_search_session', + name: i18n.translate( + 'xpack.features.ossFeatures.discoverStoreSearchSessionsPrivilegeName', + { + defaultMessage: 'Store Search Sessions', + } + ), + includeIn: 'all', + savedObject: { + all: ['background-session'], + read: [], + }, + ui: ['storeSearchSession'], + }, + ], + }, + ], + }, ], }, { @@ -156,7 +183,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS 'lens', 'map', 'tag', - 'background-session', ], }, ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], @@ -210,6 +236,33 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, ], }, + { + name: i18n.translate('xpack.features.ossFeatures.dashboardSearchSessionsFeatureName', { + defaultMessage: 'Store Search Sessions', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'store_search_session', + name: i18n.translate( + 'xpack.features.ossFeatures.dashboardStoreSearchSessionsPrivilegeName', + { + defaultMessage: 'Store Search Sessions', + } + ), + includeIn: 'all', + savedObject: { + all: ['background-session'], + read: [], + }, + ui: ['storeSearchSession'], + }, + ], + }, + ], + }, ], }, { diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index b94c2cd12cd5f..5ba4de914c724 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -3,8 +3,8 @@ * 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 PACKAGES_SAVED_OBJECT_TYPE = 'epm-packages'; +export const ASSETS_SAVED_OBJECT_TYPE = 'epm-packages-assets'; export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; export const MAX_TIME_COMPLETE_INSTALL = 60000; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 1d00855de8935..e9b11a2f5ac83 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -1274,6 +1274,15 @@ "put": { "summary": "PackagePolicies - Update", "operationId": "put-packagePolicies-packagePolicyId", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/update_package_policy" + } + } + } + }, "responses": { "200": { "description": "OK", @@ -2077,6 +2086,22 @@ "download", "path" ] + }, + "update_package_policy": { + "title": "UpdatePackagePolicy", + "allOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + { + "$ref": "#/components/schemas/new_package_policy" + } + ] } } }, diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9ab85ab2b8232..05b5b239dc980 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -789,6 +789,11 @@ paths: put: summary: PackagePolicies - Update operationId: put-packagePolicies-packagePolicyId + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/update_package_policy' responses: '200': description: OK @@ -1323,5 +1328,13 @@ components: - format_version - download - path + update_package_policy: + title: UpdatePackagePolicy + allOf: + - type: object + properties: + version: + type: string + - $ref: '#/components/schemas/new_package_policy' security: - basicAuth: [] diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml new file mode 100644 index 0000000000000..054a0e1a48be0 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml @@ -0,0 +1,7 @@ +title: UpdatePackagePolicy +allOf: + - type: object + properties: + version: + type: string + - $ref: ./new_package_policy.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml index 3b177be3d032e..4e0315556b614 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml @@ -23,6 +23,11 @@ parameters: put: summary: PackagePolicies - Update operationId: put-packagePolicies-packagePolicyId + requestBody: + content: + application/json: + schema: + $ref: ../components/schemas/update_package_policy.yaml responses: '200': description: OK diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index a9893b170492f..77625e48dbc96 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -8,6 +8,7 @@ // TODO: Update when https://github.com/elastic/kibana/issues/53021 is closed import { SavedObject, SavedObjectAttributes, SavedObjectReference } from 'src/core/public'; import { + ASSETS_SAVED_OBJECT_TYPE, agentAssetTypes, dataTypes, defaultPackages, @@ -268,6 +269,7 @@ export type PackageInfo = export interface Installation extends SavedObjectAttributes { installed_kibana: KibanaAssetReference[]; installed_es: EsAssetReference[]; + package_assets: PackageAssetReference[]; es_index_patterns: Record; name: string; version: string; @@ -297,6 +299,10 @@ export type EsAssetReference = Pick & { type: ElasticsearchAssetType; }; +export type PackageAssetReference = Pick & { + type: typeof ASSETS_SAVED_OBJECT_TYPE; +}; + export type RequiredPackage = typeof requiredPackages; export type DefaultPackages = typeof defaultPackages; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_fleet_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_fleet_status.tsx index 18bcb4539c740..2bb328a51c60a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_fleet_status.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_fleet_status.tsx @@ -13,6 +13,7 @@ interface FleetStatusState { enabled: boolean; isLoading: boolean; isReady: boolean; + error?: Error; missingRequirements?: GetFleetStatusResponse['missing_requirements']; } @@ -44,7 +45,7 @@ export const FleetStatusProvider: React.FC = ({ children }) => { missingRequirements: res.data?.missing_requirements, })); } catch (error) { - setState((s) => ({ ...s, isLoading: true })); + setState((s) => ({ ...s, isLoading: false, error })); } } useEffect(() => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx index cac133acd4d2d..19f6be3db51b0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx @@ -51,8 +51,8 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ -

- {from === 'edit' ? ( +

+ {from === 'edit' || from === 'package-edit' ? ( -

+

); - }, [from, packageInfo]); + }, [dataTestSubj, from, packageInfo]); const pageDescription = useMemo(() => { return from === 'edit' || from === 'package-edit' ? ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index 758131a9a4b7e..8c6163578617c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { PAGE_ROUTING_PATHS } from '../../constants'; -import { Loading } from '../../components'; +import { Loading, Error } from '../../components'; import { useConfig, useFleetStatus, useBreadcrumbs, useCapabilities } from '../../hooks'; import { AgentListPage } from './agent_list_page'; import { SetupPage } from './setup_page'; @@ -14,6 +15,7 @@ import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; import { EnrollmentTokenListPage } from './enrollment_token_list_page'; import { ListLayout } from './components/list_layout'; +import { WithoutHeaderLayout } from '../../layouts'; export const FleetApp: React.FunctionComponent = () => { useBreadcrumbs('fleet'); @@ -27,6 +29,22 @@ export const FleetApp: React.FunctionComponent = () => { return ; } + if (fleetStatus.error) { + return ( + + + } + error={fleetStatus.error} + /> + + ); + } + if (fleetStatus.isReady === false) { return ( { + const { path, buffer, name, version, installSource } = opts; + const fileExt = extname(path); + const contentType = mime.lookup(fileExt); + const mediaType = mime.contentType(contentType || fileExt); + // can use to create a data URL like `data:${mediaType};base64,${base64Data}` + + const bufferIsBinary = await isBinaryFile(buffer); + const dataUtf8 = bufferIsBinary ? '' : buffer.toString('utf8'); + const dataBase64 = bufferIsBinary ? buffer.toString('base64') : ''; + + // validation: filesize? asset type? anything else + if (dataUtf8.length > MAX_ES_ASSET_BYTES) { + throw new Error(`File at ${path} is larger than maximum allowed size of ${MAX_ES_ASSET_BYTES}`); + } + + if (dataBase64.length > MAX_ES_ASSET_BYTES) { + throw new Error( + `After base64 encoding file at ${path} is larger than maximum allowed size of ${MAX_ES_ASSET_BYTES}` + ); + } + + return { + package_name: name, + package_version: version, + install_source: installSource, + asset_path: path, + media_type: mediaType || '', + data_utf8: dataUtf8, + data_base64: dataBase64, + }; +} + +export async function removeArchiveEntries(opts: { + savedObjectsClient: SavedObjectsClientContract; + refs: PackageAssetReference[]; +}) { + const { savedObjectsClient, refs } = opts; + const results = await Promise.all( + refs.map((ref) => savedObjectsClient.delete(ASSETS_SAVED_OBJECT_TYPE, ref.id)) + ); + return results; +} + +export async function saveArchiveEntries(opts: { + savedObjectsClient: SavedObjectsClientContract; + paths: string[]; + packageInfo: InstallablePackage; + installSource: InstallSource; +}) { + const { savedObjectsClient, paths, packageInfo, installSource } = opts; + const bulkBody = await Promise.all( + paths.map((path) => { + const buffer = getArchiveEntry(path); + if (!buffer) throw new Error(`Could not find ArchiveEntry at ${path}`); + const { name, version } = packageInfo; + return archiveEntryToBulkCreateObject({ path, buffer, name, version, installSource }); + }) + ); + + const results = await savedObjectsClient.bulkCreate(bulkBody); + return results; +} + +export async function archiveEntryToBulkCreateObject(opts: { + path: string; + buffer: Buffer; + name: string; + version: string; + installSource: InstallSource; +}): Promise> { + const { path, buffer, name, version, installSource } = opts; + const doc = await archiveEntryToESDocument({ path, buffer, name, version, installSource }); + return { + id: assetPathToObjectId(doc.asset_path), + type: ASSETS_SAVED_OBJECT_TYPE, + attributes: doc, + }; +} + +export async function getAsset(opts: { + savedObjectsClient: SavedObjectsClientContract; + path: string; +}) { + const { savedObjectsClient, path } = opts; + const assetSavedObject = await savedObjectsClient.get( + ASSETS_SAVED_OBJECT_TYPE, + assetPathToObjectId(path) + ); + const storedAsset = assetSavedObject?.attributes; + if (!storedAsset) { + return; + } + + return storedAsset; +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.test.ts index 78aa17da5030c..0634e3b25f89e 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.test.ts @@ -20,3 +20,18 @@ test('getBaseName', () => { const name = getRegistryDataStreamAssetBaseName(dataStream); expect(name).toStrictEqual('logs-nginx.access'); }); + +test('getBaseName for hidden index', () => { + const dataStream: RegistryDataStream = { + dataset: 'nginx.access', + title: 'Nginx Acess Logs', + release: 'beta', + type: 'logs', + ingest_pipeline: 'default', + package: 'nginx', + path: 'access', + hidden: true, + }; + const name = getRegistryDataStreamAssetBaseName(dataStream); + expect(name).toStrictEqual('.logs-nginx.access'); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.ts index 17cd28cc8a081..a7647cc95cbaa 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/index.ts @@ -11,5 +11,6 @@ import { RegistryDataStream } from '../../../types'; * {type}-{dataset} */ export function getRegistryDataStreamAssetBaseName(dataStream: RegistryDataStream): string { - return `${dataStream.type}-${dataStream.dataset}`; + const baseName = `${dataStream.type}-${dataStream.dataset}`; + return dataStream.hidden ? `.${baseName}` : baseName; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index c7500a9cfeaf6..c0e2fcb12bcf8 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -5,7 +5,13 @@ */ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { InstallablePackage, InstallSource, MAX_TIME_COMPLETE_INSTALL } from '../../../../common'; +import { + InstallablePackage, + InstallSource, + PackageAssetReference, + MAX_TIME_COMPLETE_INSTALL, + ASSETS_SAVED_OBJECT_TYPE, +} from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import { AssetReference, @@ -24,6 +30,7 @@ import { deleteKibanaSavedObjectsAssets } from './remove'; import { installTransform } from '../elasticsearch/transform/install'; import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; import { installIlmForDataStream } from '../elasticsearch/datastream_ilm/install'; +import { saveArchiveEntries } from '../archive/storage'; import { ConcurrentInstallOperationError } from '../../../errors'; // this is only exported for testing @@ -188,11 +195,26 @@ export async function _installPackage({ if (installKibanaAssetsError) throw installKibanaAssetsError; await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]); + const packageAssetResults = await saveArchiveEntries({ + savedObjectsClient, + paths, + packageInfo, + installSource, + }); + const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( + (result) => ({ + id: result.id, + type: ASSETS_SAVED_OBJECT_TYPE, + }) + ); + // update to newly installed version when all assets are successfully installed if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { install_version: pkgVersion, install_status: 'installed', + package_assets: packageAssetRefs, }); return [ diff --git a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts index 4ad6fc96218de..fe7b8be23b03b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -43,6 +43,7 @@ const mockInstallation: SavedObject = { id: 'test-pkg', installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], + package_assets: [], es_index_patterns: { pattern: 'pattern-name' }, name: 'test package', version: '1.0.0', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts index a41511260c6e7..2dcfc7949d5e5 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get_install_type.test.ts @@ -15,6 +15,7 @@ const mockInstallation: SavedObject = { id: 'test-pkg', installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], + package_assets: [], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', version: '1.0.0', @@ -32,6 +33,7 @@ const mockInstallationUpdateFail: SavedObject = { id: 'test-pkg', installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], + package_assets: [], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', version: '1.0.0', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 48dd589dd0b8f..176bcf1381674 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -379,6 +379,7 @@ export async function createInstallation(options: { { installed_kibana: [], installed_es: [], + package_assets: [], es_index_patterns: toSaveESIndexPatterns, name: pkgName, version: pkgVersion, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 0b4a0faddf0cc..331b6bfa882da 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -24,6 +24,7 @@ import { packagePolicyService, appContextService } from '../..'; import { splitPkgKey } from '../registry'; import { deletePackageCache } from '../archive'; import { deleteIlms } from '../elasticsearch/datastream_ilm/remove'; +import { removeArchiveEntries } from '../archive/storage'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; @@ -49,7 +50,7 @@ export async function removeInstallation(options: { `unable to remove package with existing package policy(s) in use by agent(s)` ); - // Delete the installed assets + // Delete the installed assets. Don't include installation.package_assets. Those are irrelevant to users const installedAssets = [...installation.installed_kibana, ...installation.installed_es]; await deleteAssets(installation, savedObjectsClient, callCluster); @@ -69,6 +70,8 @@ export async function removeInstallation(options: { version: pkgVersion, }); + await removeArchiveEntries({ savedObjectsClient, refs: installation.package_assets }); + // successful delete's in SO client return {}. return something more useful return installedAssets; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index f514f1ecb9ae6..c37eed1910883 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -137,7 +137,16 @@ export async function setupFleet( cluster: ['monitor', 'manage_api_key'], indices: [ { - names: ['logs-*', 'metrics-*', 'traces-*', '.ds-logs-*', '.ds-metrics-*', '.ds-traces-*'], + names: [ + 'logs-*', + 'metrics-*', + 'traces-*', + '.ds-logs-*', + '.ds-metrics-*', + '.ds-traces-*', + '.logs-endpoint.diagnostic.collection-*', + '.ds-.logs-endpoint.diagnostic.collection-*', + ], privileges: ['write', 'create_index', 'indices:admin/auto_create'], }, ], diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index b8eb73b99f45e..0744984a52eaa 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -51,6 +51,7 @@ export const AlertDropdown = () => { return ( <> 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 index fe57b9db0e8b7..8582be008a44a 100644 --- 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 @@ -79,7 +79,13 @@ export const LogEntryContextMenu: React.FC = ({ return ( - + 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 d091f55956923..5e18acb926a57 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -82,7 +82,7 @@ export const LogsPageContent: React.FunctionComponent = () => { )} size="s" color="primary" - iconType="plusInCircle" + iconType="indexOpen" > {ADD_DATA_LABEL} diff --git a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx index 3f109e7383c0e..62f2d89b65fdf 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx @@ -59,7 +59,7 @@ export const LogColumnsConfigurationPanel: React.FunctionComponent

@@ -182,7 +182,7 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{ ); return ( - +
@@ -212,7 +212,7 @@ const ExplainedLogColumnConfigurationPanel: 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 222278dde3314..24f9598484d71 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -92,7 +92,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { )} size="s" color="primary" - iconType="plusInCircle" + iconType="indexOpen" > {ADD_DATA_LABEL} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx index 4718ed09dc9b2..3f0798c4a1670 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx @@ -51,7 +51,7 @@ export const ProcessRow = ({ cells, item }: Props) => { {({ measureRef, bounds: { height = 0 } }) => ( - +
@@ -81,7 +81,7 @@ export const ProcessRow = ({ cells, item }: Props) => { )} - + {i18n.translate( @@ -92,7 +92,7 @@ export const ProcessRow = ({ cells, item }: Props) => { )} - {item.pid} + {item.pid} @@ -105,12 +105,12 @@ export const ProcessRow = ({ cells, item }: Props) => { )} - {item.user} + {item.user} - + )} @@ -120,11 +120,15 @@ export const ProcessRow = ({ cells, item }: Props) => { ); }; -export const CodeLine = euiStyled(EuiCode).attrs({ +const ExpandedRowDescriptionList = euiStyled(EuiDescriptionList).attrs({ + compressed: true, +})` + width: 100%; +`; + +const CodeListItem = euiStyled(EuiCode).attrs({ transparentBackground: true, })` - text-overflow: ellipsis; - overflow: hidden; padding: 0 !important; & code.euiCodeBlock__code { white-space: nowrap !important; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx index 7b7a285b5d6b8..af515ae75854c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx @@ -138,7 +138,7 @@ const ProcessChart = ({ timeseries, color, label }: ProcessChartProps) => { }; const ChartContainer = euiStyled.div` - width: 300px; + width: 100%; height: 140px; `; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx index 3e4b066afa157..1ea6e397e7768 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx @@ -28,7 +28,7 @@ import { FORMATTERS } from '../../../../../../../../common/formatters'; import { euiStyled } from '../../../../../../../../../observability/public'; import { SortBy } from '../../../../hooks/use_process_list'; import { Process } from './types'; -import { ProcessRow, CodeLine } from './process_row'; +import { ProcessRow } from './process_row'; import { StateBadge } from './state_badge'; import { STATE_ORDER } from './states'; @@ -150,7 +150,7 @@ export const ProcessesTable = ({ return ( <> - + {columns.map((column) => ( @@ -296,3 +296,11 @@ const columns: Array<{ render: (value: number) => FORMATTERS.percent(value), }, ]; + +const CodeLine = euiStyled.div` + font-family: ${(props) => props.theme.eui.euiCodeFontFamily}; + font-size: ${(props) => props.theme.eui.euiFontSizeS}; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx index 6efabf1b8c0ae..5bbba906b62f2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx @@ -7,7 +7,15 @@ import React, { useMemo } from 'react'; import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, EuiLoadingSpinner, EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiHorizontalRule, +} from '@elastic/eui'; import { euiStyled } from '../../../../../../../../../observability/public'; import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; import { STATE_NAMES } from './states'; @@ -17,63 +25,51 @@ interface Props { isLoading: boolean; } -type SummaryColumn = { +type SummaryRecord = { total: number; } & Record; export const SummaryTable = ({ processSummary, isLoading }: Props) => { const processCount = useMemo( () => - [ - { - total: isLoading ? -1 : processSummary.total, - ...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)), - ...(isLoading ? {} : processSummary), - }, - ] as SummaryColumn[], + ({ + total: isLoading ? -1 : processSummary.total, + ...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)), + ...(isLoading ? {} : processSummary), + } as SummaryRecord), [processSummary, isLoading] ); return ( - - - + <> + + {Object.entries(processCount).map(([field, value]) => ( + + + {columnTitles[field as keyof SummaryRecord]} + + {value === -1 ? : value} + + + + ))} + + + ); }; -const loadingRenderer = (value: number) => (value === -1 ? : value); - -const columns = [ - { - field: 'total', - name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', { - defaultMessage: 'Total processes', - }), - width: 125, - render: loadingRenderer, - }, - ...Object.entries(STATE_NAMES).map(([field, name]) => ({ field, name, render: loadingRenderer })), -] as Array>; +const columnTitles = { + total: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', { + defaultMessage: 'Total processes', + }), + ...STATE_NAMES, +}; const LoadingSpinner = euiStyled(EuiLoadingSpinner).attrs({ size: 'm' })` margin-top: 2px; margin-bottom: 3px; `; -const StyleWrapper = euiStyled.div` - & .euiTableHeaderCell { - border-bottom: none; - & .euiTableCellContent { - padding-bottom: 0; - } - & .euiTableCellContent__text { - font-size: ${(props) => props.theme.eui.euiFontSizeS}; - } - } - - & .euiTableRowCell { - border-top: none; - & .euiTableCellContent { - padding-top: 0; - } - } +const ColumnTitle = euiStyled(EuiDescriptionListTitle)` + white-space: nowrap; `; 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 1496b0c335322..5e38cb49114e9 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -74,6 +74,16 @@ function createMockFrame(): jest.Mocked { }; } +function createMockSearchService() { + let sessionIdCounter = 1; + return { + session: { + start: jest.fn(() => `sessionId-${sessionIdCounter++}`), + clear: jest.fn(), + }, + }; +} + function createMockFilterManager() { const unsubscribe = jest.fn(); @@ -118,16 +128,29 @@ function createMockQueryString() { function createMockTimefilter() { const unsubscribe = jest.fn(); + let timeFilter = { from: 'now-7d', to: 'now' }; + let subscriber: () => void; return { - getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), - setTime: jest.fn(), + getTime: jest.fn(() => timeFilter), + setTime: jest.fn((newTimeFilter) => { + timeFilter = newTimeFilter; + if (subscriber) { + subscriber(); + } + }), getTimeUpdate$: () => ({ subscribe: ({ next }: { next: () => void }) => { + subscriber = next; return unsubscribe; }, }), getRefreshInterval: () => {}, getRefreshIntervalDefaults: () => {}, + getAutoRefreshFetch$: () => ({ + subscribe: ({ next }: { next: () => void }) => { + return next; + }, + }), }; } @@ -209,6 +232,7 @@ describe('Lens App', () => { return new Promise((resolve) => resolve({ id })); }), }, + search: createMockSearchService(), } as unknown) as DataPublicPluginStart, storage: { get: jest.fn(), @@ -295,6 +319,7 @@ describe('Lens App', () => { "query": "", }, "savedQuery": undefined, + "searchSessionId": "sessionId-1", "showNoDataPopover": [Function], }, ], @@ -1072,6 +1097,53 @@ describe('Lens App', () => { }) ); }); + + it('updates the searchSessionId when the user changes query or time in the search bar', () => { + const { component, frame, services } = mountWith({}); + act(() => + component.find(TopNavMenu).prop('onQuerySubmit')!({ + dateRange: { from: 'now-14d', to: 'now-7d' }, + query: { query: '', language: 'lucene' }, + }) + ); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-1`, + }) + ); + + // trigger again, this time changing just the query + act(() => + component.find(TopNavMenu).prop('onQuerySubmit')!({ + dateRange: { from: 'now-14d', to: 'now-7d' }, + query: { query: 'new', language: 'lucene' }, + }) + ); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-2`, + }) + ); + + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const field = ({ name: 'myfield' } as unknown) as IFieldType; + act(() => + services.data.query.filterManager.setFilters([ + esFilters.buildExistsFilter(field, indexPattern), + ]) + ); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-3`, + }) + ); + }); }); describe('saved query handling', () => { @@ -1165,6 +1237,37 @@ describe('Lens App', () => { ); }); + it('updates the searchSessionId when the query is updated', () => { + const { component, frame } = mountWith({}); + act(() => { + component.find(TopNavMenu).prop('onSaved')!({ + id: '1', + attributes: { + title: '', + description: '', + query: { query: '', language: 'lucene' }, + }, + }); + }); + act(() => { + component.find(TopNavMenu).prop('onSavedQueryUpdated')!({ + id: '2', + attributes: { + title: 'new title', + description: '', + query: { query: '', language: 'lucene' }, + }, + }); + }); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-1`, + }) + ); + }); + it('clears all existing unpinned filters when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => @@ -1190,6 +1293,32 @@ describe('Lens App', () => { }) ); }); + + it('updates the searchSessionId when the active saved query is cleared', () => { + const { component, frame, services } = mountWith({}); + act(() => + component.find(TopNavMenu).prop('onQuerySubmit')!({ + dateRange: { from: 'now-14d', to: 'now-7d' }, + query: { query: 'new', language: 'lucene' }, + }) + ); + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const field = ({ name: 'myfield' } as unknown) as IFieldType; + const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; + const unpinned = esFilters.buildExistsFilter(field, indexPattern); + const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); + FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); + act(() => services.data.query.filterManager.setFilters([pinned, unpinned])); + component.update(); + act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-2`, + }) + ); + }); }); describe('showing a confirm message when leaving', () => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index bb77c5998519d..3f10cb341105c 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -7,7 +7,7 @@ import './app.scss'; import _ from 'lodash'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; import { EuiBreadcrumb } from '@elastic/eui'; @@ -71,7 +71,6 @@ export function App({ } = useKibana().services; const [state, setState] = useState(() => { - const currentRange = data.query.timefilter.timefilter.getTime(); return { query: data.query.queryString.getQuery(), // Do not use app-specific filters from previous app, @@ -81,14 +80,11 @@ export function App({ : data.query.filterManager.getFilters(), isLoading: Boolean(initialInput), indexPatternsForTopNav: [], - dateRange: { - fromDate: currentRange.from, - toDate: currentRange.to, - }, isLinkedToOriginatingApp: Boolean(incomingState?.originatingApp), isSaveModalVisible: false, indicateNoData: false, isSaveable: false, + searchSessionId: data.search.session.start(), }; }); @@ -107,10 +103,14 @@ export function App({ state.indicateNoData, state.query, state.filters, - state.dateRange, state.indexPatternsForTopNav, + state.searchSessionId, ]); + // Need a stable reference for the frame component of the dateRange + const { from: fromDate, to: toDate } = data.query.timefilter.timefilter.getTime(); + const currentDateRange = useMemo(() => ({ fromDate, toDate }), [fromDate, toDate]); + const onError = useCallback( (e: { message: string }) => notifications.toasts.addDanger({ @@ -160,24 +160,35 @@ export function App({ const filterSubscription = data.query.filterManager.getUpdates$().subscribe({ next: () => { - setState((s) => ({ ...s, filters: data.query.filterManager.getFilters() })); + setState((s) => ({ + ...s, + filters: data.query.filterManager.getFilters(), + searchSessionId: data.search.session.start(), + })); trackUiEvent('app_filters_updated'); }, }); const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ next: () => { - const currentRange = data.query.timefilter.timefilter.getTime(); setState((s) => ({ ...s, - dateRange: { - fromDate: currentRange.from, - toDate: currentRange.to, - }, + searchSessionId: data.search.session.start(), })); }, }); + const autoRefreshSubscription = data.query.timefilter.timefilter + .getAutoRefreshFetch$() + .subscribe({ + next: () => { + setState((s) => ({ + ...s, + searchSessionId: data.search.session.start(), + })); + }, + }); + const kbnUrlStateStorage = createKbnUrlStateStorage({ history, useHash: uiSettings.get('state:storeInSessionStorage'), @@ -192,10 +203,12 @@ export function App({ stopSyncingQueryServiceStateWithUrl(); filterSubscription.unsubscribe(); timeSubscription.unsubscribe(); + autoRefreshSubscription.unsubscribe(); }; }, [ data.query.filterManager, data.query.timefilter.timefilter, + data.search.session, notifications.toasts, uiSettings, data.query, @@ -594,21 +607,21 @@ export function App({ appName={'lens'} onQuerySubmit={(payload) => { const { dateRange, query } = payload; - if ( - dateRange.from !== state.dateRange.fromDate || - dateRange.to !== state.dateRange.toDate - ) { + const currentRange = data.query.timefilter.timefilter.getTime(); + if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) { data.query.timefilter.timefilter.setTime(dateRange); trackUiEvent('app_date_change'); } else { + // Query has changed, renew the session id. + // Time change will be picked up by the time subscription + setState((s) => ({ + ...s, + searchSessionId: data.search.session.start(), + })); trackUiEvent('app_query_change'); } setState((s) => ({ ...s, - dateRange: { - fromDate: dateRange.from, - toDate: dateRange.to, - }, query: query || s.query, })); }} @@ -622,12 +635,6 @@ export function App({ setState((s) => ({ ...s, savedQuery: { ...savedQuery }, // Shallow query for reference issues - dateRange: savedQuery.attributes.timefilter - ? { - fromDate: savedQuery.attributes.timefilter.from, - toDate: savedQuery.attributes.timefilter.to, - } - : s.dateRange, })); }} onClearSavedQuery={() => { @@ -640,8 +647,8 @@ export function App({ })); }} query={state.query} - dateRangeFrom={state.dateRange.fromDate} - dateRangeTo={state.dateRange.toDate} + dateRangeFrom={fromDate} + dateRangeTo={toDate} indicateNoData={state.indicateNoData} />
@@ -650,7 +657,8 @@ export function App({ className="lnsApp__frame" render={editorFrame.mount} nativeProps={{ - dateRange: state.dateRange, + searchSessionId: state.searchSessionId, + dateRange: currentDateRange, query: state.query, filters: state.filters, savedQuery: state.savedQuery, diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index fbfd9c5758948..e769e402ff0e1 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -216,6 +216,7 @@ export async function mountApp( params.element ); return () => { + data.search.session.clear(); instance.unmount(); unmountComponentAtNode(params.element); unlistenParentHistory(); diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 869ccf52fb0bd..af0feabe68cf7 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -55,16 +55,12 @@ export interface LensAppState { // Determines whether the lens editor shows the 'save and return' button, and the originating app breadcrumb. isLinkedToOriginatingApp?: boolean; - // Properties needed to interface with TopNav - dateRange: { - fromDate: string; - toDate: string; - }; query: Query; filters: Filter[]; savedQuery?: SavedQuery; isSaveable: boolean; activeData?: TableInspectorAdapter; + searchSessionId: string; } export interface RedirectToOriginProps { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index b0879ac8cb886..ef95314c55581 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -60,6 +60,7 @@ function getDefaultProps() { }, palettes: chartPluginMock.createPaletteRegistry(), showNoDataPopover: jest.fn(), + searchSessionId: 'sessionId', }; } @@ -264,6 +265,7 @@ describe('editor_frame', () => { filters: [], dateRange: { fromDate: 'now-7d', toDate: 'now' }, availablePalettes: defaultProps.palettes, + searchSessionId: 'sessionId', }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 977947b5afbeb..d872920d815ad 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -43,6 +43,7 @@ export interface EditorFrameProps { query: Query; filters: Filter[]; savedQuery?: SavedQuery; + searchSessionId: string; onChange: (arg: { filterableIndexPatterns: string[]; doc: Document; @@ -105,7 +106,7 @@ export function EditorFrame(props: EditorFrameProps) { dateRange: props.dateRange, query: props.query, filters: props.filters, - + searchSessionId: props.searchSessionId, availablePalettes: props.palettes, addNewLayer() { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index 792fdc6d1ace7..52328bc3a1440 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -39,6 +39,7 @@ describe('editor_frame state management', () => { query: { query: '', language: 'lucene' }, filters: [], showNoDataPopover: jest.fn(), + searchSessionId: 'sessionId', }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 338a998b6b4dc..e2c4fa959924a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -273,16 +273,18 @@ export function SuggestionPanel({ const contextRef = useRef(context); contextRef.current = context; + const sessionIdRef = useRef(frame.searchSessionId); + sessionIdRef.current = frame.searchSessionId; + const AutoRefreshExpressionRenderer = useMemo(() => { - const autoRefreshFetch$ = plugins.data.query.timefilter.timefilter.getAutoRefreshFetch$(); return (props: ReactExpressionRendererProps) => ( ); - }, [plugins.data.query.timefilter.timefilter, ExpressionRendererComponent]); + }, [ExpressionRendererComponent]); const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState(-1); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 99a5869a60872..eb16dabfd2f90 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -362,8 +362,6 @@ export const InnerVisualizationWrapper = ({ }; ExpressionRendererComponent: ReactExpressionRendererType; }) => { - const autoRefreshFetch$ = useMemo(() => timefilter.getAutoRefreshFetch$(), [timefilter]); - const context: ExecutionContextSearch = useMemo( () => ({ query: framePublicAPI.query, @@ -482,7 +480,7 @@ export const InnerVisualizationWrapper = ({ padding="m" expression={expression!} searchContext={context} - reload$={autoRefreshFetch$} + searchSessionId={framePublicAPI.searchSessionId} onEvent={onEvent} onData$={onData$} renderMode="edit" diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 5ab410a1c0af2..2152c18ffeda4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -132,6 +132,7 @@ export function createMockFramePublicAPI(): FrameMock { get: () => palette, getAll: () => [palette], }, + searchSessionId: 'sessionId', }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx index e9f8013ef7e2d..3687e0cce2f1d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx @@ -57,6 +57,7 @@ describe('editor_frame service', () => { indexPatternId: '1', fieldName: 'test', }, + searchSessionId: 'sessionId', }); instance.unmount(); })() @@ -78,6 +79,7 @@ describe('editor_frame service', () => { query: { query: '', language: 'lucene' }, filters: [], showNoDataPopover: jest.fn(), + searchSessionId: 'sessionId', }); instance.unmount(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 0562e9bf4d32e..d4e9522f3bed1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -138,6 +138,7 @@ export class EditorFrameService { onChange, showNoDataPopover, initialContext, + searchSessionId, } ) => { domElement = element; @@ -172,6 +173,7 @@ export class EditorFrameService { onChange={onChange} showNoDataPopover={showNoDataPopover} initialContext={initialContext} + searchSessionId={searchSessionId} /> , domElement diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index de768e92efb3d..97a63de4f7ba2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1876,7 +1876,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions.length).toBe(6); }); - it('returns an only metric version of a given table', () => { + it('returns an only metric version of a given table, but does not include current state as reduced', () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { indexPatternRefs: [], @@ -1953,6 +1953,21 @@ describe('IndexPattern Data Source suggestions', () => { }; const suggestions = getSuggestionSubset(getDatasourceSuggestionsFromCurrentState(state)); + expect(suggestions).not.toContainEqual( + expect.objectContaining({ + table: expect.objectContaining({ + changeType: 'reduced', + columns: [ + expect.objectContaining({ + operation: expect.objectContaining({ label: 'field2' }), + }), + expect.objectContaining({ + operation: expect.objectContaining({ label: 'Average of field1' }), + }), + ], + }), + }) + ); expect(suggestions).toContainEqual( expect.objectContaining({ table: expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 969324c67e909..9d7328b4dca37 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -583,8 +583,9 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer columnOrder: [...bucketedColumns, ...availableMetricColumns], }; - if (availableReferenceColumns.length) { - // Don't remove buckets when dealing with any refs. This can break refs. + if (availableBucketedColumns.length <= 1 || availableReferenceColumns.length) { + // Don't simplify when dealing with single-bucket table. Also don't break + // reference-based columns by removing buckets. return []; } else if (availableMetricColumns.length > 1) { return [{ ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] }]; @@ -597,7 +598,6 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer availableReferenceColumns.length ? [] : availableMetricColumns.map((columnId) => { - // build suggestions with only metrics return { ...layer, columnOrder: [columnId] }; }) ) @@ -606,8 +606,7 @@ function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layer state, layerId, updatedLayer, - changeType: - layer.columnOrder.length === updatedLayer.columnOrder.length ? 'unchanged' : 'reduced', + changeType: 'reduced', label: updatedLayer.columnOrder.length === 1 ? getMetricSuggestionTitle(updatedLayer, availableMetricColumns.length === 1) diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 70a98e4cf8589..b4c81cfb6e9c3 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -38,6 +38,15 @@ import { } from '../../../../../src/plugins/charts/public'; import { LensIconChartDonut } from '../assets/chart_donut'; +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + const EMPTY_SLICE = Symbol('empty_slice'); export function PieComponent( @@ -251,6 +260,7 @@ export function PieComponent( >