diff --git a/docs/CHANGELOG.asciidoc b/docs/CHANGELOG.asciidoc index c66970283580c4..c8d54a4c0f9da7 100644 --- a/docs/CHANGELOG.asciidoc +++ b/docs/CHANGELOG.asciidoc @@ -205,6 +205,21 @@ The existing agents in {kib} are not migrated as part of the migration to Fleet. The existing agent API keys are invalidated and display as `Inactive` on the *Agents* page. ==== +[discrete] +[[breaking-98039]] +.Disable Explore underlying data context menu +[%collapsible] +==== +*Details* + +The *Explore underlying data* context menu on dashboards is now disabled by default. For more information, refer to {kibana-pull}98039[#98039]. + +*Impact* + +To enable the *Explore underlying data* context menu, set `xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled` to `true` in kibana.yml. +==== + +// end::notable-breaking-changes[] + + [float] [[deprecations-7.13.0]] === Deprecations diff --git a/docs/discover/search-sessions.asciidoc b/docs/discover/search-sessions.asciidoc index fec1b8b26dd741..b503e8cfba3b40 100644 --- a/docs/discover/search-sessions.asciidoc +++ b/docs/discover/search-sessions.asciidoc @@ -68,3 +68,19 @@ behaves differently: * Relative dates are converted to absolute dates. * Panning and zooming is disabled for maps. * Changing a filter, query, or drilldown starts a new search session, which can be slow. + +[float] +==== Limitations + +Certain visualization features do not fully support background search sessions yet. If a dashboard using these features gets restored, +all panels using unsupported features won't load immediately, but instead send out additional data requests which can take a while to complete. +In this case a warning *Your search session is still running* will be shown. + +You can either wait for these additional requests to complete or come back to the dashboard later when all data requests have been finished. + +A panel on a dashboard can behave like this if one of the following features is used: +* *Lens* - A *top values* dimension with an enabled setting *Group other values as "Other"* (configurable in the *Advanced* section of the dimension) +* *Lens* - An *intervals* dimension is used +* *Aggregation based* visualizations - A *terms* aggregation is used with an enabled setting *Group other values in separate bucket* +* *Aggregation based* visualizations - A *histogram* aggregation is used +* *Maps* - Layers using joins, blended layers or tracks layers are used diff --git a/docs/user/dashboard/aggregation-reference.asciidoc b/docs/user/dashboard/aggregation-reference.asciidoc index 39e596df4af347..001114578a1cd0 100644 --- a/docs/user/dashboard/aggregation-reference.asciidoc +++ b/docs/user/dashboard/aggregation-reference.asciidoc @@ -23,7 +23,7 @@ This reference can help simplify the comparison if you need a specific feature. | Table with summary row ^| X -| +^| X | | | @@ -65,7 +65,7 @@ This reference can help simplify the comparison if you need a specific feature. | Heat map ^| X -| +^| X | | ^| X @@ -333,7 +333,7 @@ build their advanced visualization. | Math on aggregated data | -| +^| X ^| X ^| X ^| X @@ -352,6 +352,13 @@ build their advanced visualization. ^| X ^| X +| Time shifts +| +^| X +^| X +^| X +^| X + | Fully custom {es} queries | | diff --git a/docs/user/dashboard/create-panels-with-editors.asciidoc b/docs/user/dashboard/create-panels-with-editors.asciidoc index 17d3b5fb8a8a5a..77a4706e249fd8 100644 --- a/docs/user/dashboard/create-panels-with-editors.asciidoc +++ b/docs/user/dashboard/create-panels-with-editors.asciidoc @@ -30,13 +30,16 @@ [[lens-editor]] === Lens -*Lens* is the drag and drop editor that creates visualizations of your data. +*Lens* is the drag and drop editor that creates visualizations of your data, recommended for most +users. With *Lens*, you can: * Use the automatically generated suggestions to change the visualization type. * Create visualizations with multiple layers and indices. * Change the aggregation and labels to customize the data. +* Perform math on aggregations using *Formula*. +* Use time shifts to compare data at two times, such as month over month. [role="screenshot"] image:dashboard/images/lens_advanced_1_1.png[Lens] diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 9f17a380bc209a..7927489c596d77 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -300,7 +300,9 @@ image::images/lens_missing_values_strategy.png[Lens Missing values strategies me [[is-it-possible-to-change-the-scale-of-Y-axis]] ===== Is it possible to statically define the scale of the y-axis in a visualization? -The ability to start the y-axis from another value than 0, or use a logarithmic scale, is unsupported in *Lens*. +Yes, you can set the bounds on bar, line and area chart types in Lens, unless using percentage mode. Bar +and area charts must have 0 in the bounds. Logarithmic scales are unsupported in *Lens*. +To set the y-axis bounds, click the icon representing the axis you want to customize. [float] [[is-it-possible-to-have-pagination-for-datatable]] diff --git a/package.json b/package.json index dbe5fe0b78620c..8b327f760753ff 100644 --- a/package.json +++ b/package.json @@ -667,7 +667,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^90.0.0", + "chromedriver": "^91.0.1", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -835,4 +835,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} +} \ No newline at end of file diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx index 14086e6ee81370..1352081eaa30bc 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx @@ -11,9 +11,7 @@ import React, { createContext, useContext } from 'react'; import { useRequest } from '../../../public'; -import { Error as CustomError } from './section_error'; - -import { Privileges } from '../types'; +import { Privileges, Error as CustomError } from '../types'; interface Authorization { isLoading: boolean; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts index b1306028737243..f8eb7e3c7c0c83 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/index.ts @@ -16,4 +16,6 @@ export { WithPrivileges } from './with_privileges'; export { NotAuthorizedSection } from './not_authorized_section'; -export { Error, SectionError } from './section_error'; +export { SectionError } from './section_error'; + +export { PageError } from './page_error'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx new file mode 100644 index 00000000000000..0a27b4098681b6 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/page_error.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiSpacer, EuiEmptyPrompt, EuiPageContent } from '@elastic/eui'; +import React from 'react'; +import { APP_WRAPPER_CLASS } from '../../../../../../src/core/public'; +import { Error } from '../types'; + +interface Props { + title: React.ReactNode; + error: Error; + actions?: JSX.Element; + isCentered?: boolean; +} + +/* + * A reusable component to handle full page errors. + * This is based on Kibana design guidelines related + * to the new management navigation structure. + * In some scenarios, it replaces the usage of . + */ + +export const PageError: React.FunctionComponent = ({ + title, + error, + actions, + isCentered, + ...rest +}) => { + const { + error: errorString, + cause, // wrapEsError() on the server adds a "cause" array + message, + } = error; + + const errorContent = ( + + {title}} + body={ + <> + {cause ? message || errorString :

{message || errorString}

} + {cause && ( + <> + +
    + {cause.map((causeMsg, i) => ( +
  • {causeMsg}
  • + ))} +
+ + )} + + } + iconType="alert" + actions={actions} + {...rest} + /> +
+ ); + + if (isCentered) { + return
{errorContent}
; + } + + return errorContent; +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx index c0b3533c8594b4..a1652b4e153f58 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/section_error.tsx @@ -8,12 +8,7 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import React, { Fragment } from 'react'; - -export interface Error { - error: string; - cause?: string[]; - message?: string; -} +import { Error } from '../types'; interface Props { title: React.ReactNode; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts index 089dc890c3e6cf..e63d98512a2cd8 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/index.ts @@ -12,8 +12,8 @@ export { AuthorizationProvider, AuthorizationContext, SectionError, - Error, + PageError, useAuthorizationContext, } from './components'; -export { Privileges, MissingPrivileges } from './types'; +export { Privileges, MissingPrivileges, Error } from './types'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts index b10318aa415b34..70b54b0b6e425e 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/types.ts @@ -14,3 +14,9 @@ export interface Privileges { hasAllPrivileges: boolean; missingPrivileges: MissingPrivileges; } + +export interface Error { + error: string; + cause?: string[]; + message?: string; +} diff --git a/src/plugins/es_ui_shared/public/authorization/index.ts b/src/plugins/es_ui_shared/public/authorization/index.ts index 483fffd9c48595..f68ad3da2a4b54 100644 --- a/src/plugins/es_ui_shared/public/authorization/index.ts +++ b/src/plugins/es_ui_shared/public/authorization/index.ts @@ -14,6 +14,7 @@ export { NotAuthorizedSection, Privileges, SectionError, + PageError, useAuthorizationContext, WithPrivileges, } from '../../__packages_do_not_import__/authorization'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index b46a23994fe935..7b9013c043a0e1 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -40,6 +40,7 @@ export { Privileges, MissingPrivileges, SectionError, + PageError, Error, useAuthorizationContext, } from './authorization'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 181bd9959c1bbd..fb334afb22b137 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -18,7 +18,7 @@ const DEFAULT_OPTIONS = { stripEmptyFields: true, }; -interface UseFormReturn { +export interface UseFormReturn { form: FormHook; } diff --git a/src/plugins/newsfeed/kibana.json b/src/plugins/newsfeed/kibana.json index b9f37b67f6921b..0e7ae7cd11c35f 100644 --- a/src/plugins/newsfeed/kibana.json +++ b/src/plugins/newsfeed/kibana.json @@ -2,5 +2,6 @@ "id": "newsfeed", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredPlugins": ["screenshotMode"] } diff --git a/src/plugins/newsfeed/public/lib/api.test.mocks.ts b/src/plugins/newsfeed/public/lib/api.test.mocks.ts index 677bc203cbef3f..8ac66eae6c2f6c 100644 --- a/src/plugins/newsfeed/public/lib/api.test.mocks.ts +++ b/src/plugins/newsfeed/public/lib/api.test.mocks.ts @@ -8,6 +8,7 @@ import { storageMock } from './storage.mock'; import { driverMock } from './driver.mock'; +import { NeverFetchNewsfeedApiDriver } from './never_fetch_driver'; export const storageInstanceMock = storageMock.create(); jest.doMock('./storage', () => ({ @@ -18,3 +19,7 @@ export const driverInstanceMock = driverMock.create(); jest.doMock('./driver', () => ({ NewsfeedApiDriver: jest.fn().mockImplementation(() => driverInstanceMock), })); + +jest.doMock('./never_fetch_driver', () => ({ + NeverFetchNewsfeedApiDriver: jest.fn(() => new NeverFetchNewsfeedApiDriver()), +})); diff --git a/src/plugins/newsfeed/public/lib/api.test.ts b/src/plugins/newsfeed/public/lib/api.test.ts index a4894573932e6c..58d06e72cd77c4 100644 --- a/src/plugins/newsfeed/public/lib/api.test.ts +++ b/src/plugins/newsfeed/public/lib/api.test.ts @@ -7,12 +7,16 @@ */ import { driverInstanceMock, storageInstanceMock } from './api.test.mocks'; + import moment from 'moment'; import { getApi } from './api'; import { TestScheduler } from 'rxjs/testing'; import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; import { take } from 'rxjs/operators'; +import { NewsfeedApiDriver as MockNewsfeedApiDriver } from './driver'; +import { NeverFetchNewsfeedApiDriver as MockNeverFetchNewsfeedApiDriver } from './never_fetch_driver'; + const kibanaVersion = '8.0.0'; const newsfeedId = 'test'; @@ -46,6 +50,8 @@ describe('getApi', () => { afterEach(() => { storageInstanceMock.isAnyUnread$.mockReset(); driverInstanceMock.fetchNewsfeedItems.mockReset(); + (MockNewsfeedApiDriver as jest.Mock).mockClear(); + (MockNeverFetchNewsfeedApiDriver as jest.Mock).mockClear(); }); it('merges the newsfeed and unread observables', () => { @@ -60,7 +66,7 @@ describe('getApi', () => { a: createFetchResult({ feedItems: ['item' as any] }), }) ); - const api = getApi(createConfig(1000), kibanaVersion, newsfeedId); + const api = getApi(createConfig(1000), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(1))).toBe('(a|)', { a: createFetchResult({ @@ -83,7 +89,7 @@ describe('getApi', () => { a: createFetchResult({ feedItems: ['item' as any] }), }) ); - const api = getApi(createConfig(2), kibanaVersion, newsfeedId); + const api = getApi(createConfig(2), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(2))).toBe('a-(b|)', { a: createFetchResult({ @@ -111,7 +117,7 @@ describe('getApi', () => { a: createFetchResult({}), }) ); - const api = getApi(createConfig(10), kibanaVersion, newsfeedId); + const api = getApi(createConfig(10), kibanaVersion, newsfeedId, false); expectObservable(api.fetchResults$.pipe(take(2))).toBe('a--(b|)', { a: createFetchResult({ @@ -123,4 +129,16 @@ describe('getApi', () => { }); }); }); + + it('uses the news feed API driver if in not screenshot mode', () => { + getApi(createConfig(10), kibanaVersion, newsfeedId, false); + expect(MockNewsfeedApiDriver).toHaveBeenCalled(); + expect(MockNeverFetchNewsfeedApiDriver).not.toHaveBeenCalled(); + }); + + it('uses the never fetch news feed API driver if in not screenshot mode', () => { + getApi(createConfig(10), kibanaVersion, newsfeedId, true); + expect(MockNewsfeedApiDriver).not.toHaveBeenCalled(); + expect(MockNeverFetchNewsfeedApiDriver).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/newsfeed/public/lib/api.ts b/src/plugins/newsfeed/public/lib/api.ts index 4fbbd8687b73fc..7aafc9fd276250 100644 --- a/src/plugins/newsfeed/public/lib/api.ts +++ b/src/plugins/newsfeed/public/lib/api.ts @@ -11,6 +11,7 @@ import { map, catchError, filter, mergeMap, tap } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; import { NewsfeedApiDriver } from './driver'; +import { NeverFetchNewsfeedApiDriver } from './never_fetch_driver'; import { NewsfeedStorage } from './storage'; export enum NewsfeedApiEndpoint { @@ -40,13 +41,23 @@ export interface NewsfeedApi { export function getApi( config: NewsfeedPluginBrowserConfig, kibanaVersion: string, - newsfeedId: string + newsfeedId: string, + isScreenshotMode: boolean ): NewsfeedApi { - const userLanguage = i18n.getLocale(); - const fetchInterval = config.fetchInterval.asMilliseconds(); - const mainInterval = config.mainInterval.asMilliseconds(); const storage = new NewsfeedStorage(newsfeedId); - const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); + const mainInterval = config.mainInterval.asMilliseconds(); + + const createNewsfeedApiDriver = () => { + if (isScreenshotMode) { + return new NeverFetchNewsfeedApiDriver(); + } + + const userLanguage = i18n.getLocale(); + const fetchInterval = config.fetchInterval.asMilliseconds(); + return new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage); + }; + + const driver = createNewsfeedApiDriver(); const results$ = timer(0, mainInterval).pipe( filter(() => driver.shouldFetch()), diff --git a/src/plugins/newsfeed/public/lib/driver.ts b/src/plugins/newsfeed/public/lib/driver.ts index 0efa981e8c89d6..1762c4a4287844 100644 --- a/src/plugins/newsfeed/public/lib/driver.ts +++ b/src/plugins/newsfeed/public/lib/driver.ts @@ -10,6 +10,7 @@ import moment from 'moment'; import * as Rx from 'rxjs'; import { NEWSFEED_DEFAULT_SERVICE_BASE_URL } from '../../common/constants'; import { ApiItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types'; +import { INewsfeedApiDriver } from './types'; import { convertItems } from './convert_items'; import type { NewsfeedStorage } from './storage'; @@ -19,7 +20,7 @@ interface NewsfeedResponse { items: ApiItem[]; } -export class NewsfeedApiDriver { +export class NewsfeedApiDriver implements INewsfeedApiDriver { private readonly kibanaVersion: string; private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service diff --git a/src/plugins/newsfeed/public/lib/never_fetch_driver.ts b/src/plugins/newsfeed/public/lib/never_fetch_driver.ts new file mode 100644 index 00000000000000..e95ca9c2d499a7 --- /dev/null +++ b/src/plugins/newsfeed/public/lib/never_fetch_driver.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import { FetchResult } from '../types'; +import { INewsfeedApiDriver } from './types'; + +/** + * NewsfeedApiDriver variant that never fetches results. This is useful for instances where Kibana is started + * without any user interaction like when generating a PDF or PNG report. + */ +export class NeverFetchNewsfeedApiDriver implements INewsfeedApiDriver { + shouldFetch(): boolean { + return false; + } + + fetchNewsfeedItems(): Observable { + throw new Error('Not implemented!'); + } +} diff --git a/src/plugins/newsfeed/public/lib/types.ts b/src/plugins/newsfeed/public/lib/types.ts new file mode 100644 index 00000000000000..5a62a929eeb7ff --- /dev/null +++ b/src/plugins/newsfeed/public/lib/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Observable } from 'rxjs'; +import type { FetchResult, NewsfeedPluginBrowserConfig } from '../types'; + +export interface INewsfeedApiDriver { + /** + * Check whether newsfeed items should be (re-)fetched + */ + shouldFetch(): boolean; + + fetchNewsfeedItems(config: NewsfeedPluginBrowserConfig['service']): Observable; +} diff --git a/src/plugins/newsfeed/public/plugin.test.ts b/src/plugins/newsfeed/public/plugin.test.ts new file mode 100644 index 00000000000000..4be69feb79f555 --- /dev/null +++ b/src/plugins/newsfeed/public/plugin.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { coreMock } from '../../../core/public/mocks'; +import { NewsfeedPublicPlugin } from './plugin'; +import { NewsfeedApiEndpoint } from './lib/api'; + +describe('Newsfeed plugin', () => { + let plugin: NewsfeedPublicPlugin; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + plugin = new NewsfeedPublicPlugin(coreMock.createPluginInitializerContext()); + }); + + describe('#start', () => { + beforeEach(() => { + plugin.setup(coreMock.createSetup()); + }); + + beforeEach(() => { + /** + * We assume for these tests that the newsfeed stream exposed by start will fetch newsfeed items + * on the first tick for new subscribers + */ + jest.spyOn(window, 'fetch'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('base case', () => { + it('makes fetch requests', () => { + const startContract = plugin.start(coreMock.createStart(), { + screenshotMode: { isScreenshotMode: () => false }, + }); + const sub = startContract + .createNewsFeed$(NewsfeedApiEndpoint.KIBANA) // Any endpoint will do + .pipe(take(1)) + .subscribe(() => {}); + jest.runOnlyPendingTimers(); + expect(window.fetch).toHaveBeenCalled(); + sub.unsubscribe(); + }); + }); + + describe('when in screenshot mode', () => { + it('makes no fetch requests in screenshot mode', () => { + const startContract = plugin.start(coreMock.createStart(), { + screenshotMode: { isScreenshotMode: () => true }, + }); + const sub = startContract + .createNewsFeed$(NewsfeedApiEndpoint.KIBANA) // Any endpoint will do + .pipe(take(1)) + .subscribe(() => {}); + jest.runOnlyPendingTimers(); + expect(window.fetch).not.toHaveBeenCalled(); + sub.unsubscribe(); + }); + }); + }); +}); diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx index fdda0a24b8bd56..656fc2ef00bb9f 100644 --- a/src/plugins/newsfeed/public/plugin.tsx +++ b/src/plugins/newsfeed/public/plugin.tsx @@ -13,7 +13,7 @@ import React from 'react'; import moment from 'moment'; import { I18nProvider } from '@kbn/i18n/react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { NewsfeedPluginBrowserConfig } from './types'; +import { NewsfeedPluginBrowserConfig, NewsfeedPluginStartDependencies } from './types'; import { NewsfeedNavButton } from './components/newsfeed_header_nav_button'; import { getApi, NewsfeedApi, NewsfeedApiEndpoint } from './lib/api'; @@ -41,8 +41,10 @@ export class NewsfeedPublicPlugin return {}; } - public start(core: CoreStart) { - const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA); + public start(core: CoreStart, { screenshotMode }: NewsfeedPluginStartDependencies) { + const isScreenshotMode = screenshotMode.isScreenshotMode(); + + const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA, isScreenshotMode); core.chrome.navControls.registerRight({ order: 1000, mount: (target) => this.mount(api, target), @@ -56,7 +58,7 @@ export class NewsfeedPublicPlugin pathTemplate: `/${endpoint}/v{VERSION}.json`, }, }); - const { fetchResults$ } = this.createNewsfeedApi(config, endpoint); + const { fetchResults$ } = this.createNewsfeedApi(config, endpoint, isScreenshotMode); return fetchResults$; }, }; @@ -68,9 +70,10 @@ export class NewsfeedPublicPlugin private createNewsfeedApi( config: NewsfeedPluginBrowserConfig, - newsfeedId: NewsfeedApiEndpoint + newsfeedId: NewsfeedApiEndpoint, + isScreenshotMode: boolean ): NewsfeedApi { - const api = getApi(config, this.kibanaVersion, newsfeedId); + const api = getApi(config, this.kibanaVersion, newsfeedId, isScreenshotMode); return { markAsRead: api.markAsRead, fetchResults$: api.fetchResults$.pipe( diff --git a/src/plugins/newsfeed/public/types.ts b/src/plugins/newsfeed/public/types.ts index cca656565f4ca5..a7ff917f6f9750 100644 --- a/src/plugins/newsfeed/public/types.ts +++ b/src/plugins/newsfeed/public/types.ts @@ -7,6 +7,10 @@ */ import { Duration, Moment } from 'moment'; +import type { ScreenshotModePluginStart } from 'src/plugins/screenshot_mode/public'; +export interface NewsfeedPluginStartDependencies { + screenshotMode: ScreenshotModePluginStart; +} // Ideally, we may want to obtain the type from the configSchema and exposeToBrowser keys... export interface NewsfeedPluginBrowserConfig { diff --git a/src/plugins/newsfeed/tsconfig.json b/src/plugins/newsfeed/tsconfig.json index 66244a22336c77..18e6f2de1bc6fb 100644 --- a/src/plugins/newsfeed/tsconfig.json +++ b/src/plugins/newsfeed/tsconfig.json @@ -7,13 +7,9 @@ "declaration": true, "declarationMap": true }, - "include": [ - "public/**/*", - "server/**/*", - "common/*", - "../../../typings/**/*" - ], + "include": ["public/**/*", "server/**/*", "common/*", "../../../typings/**/*"], "references": [ - { "path": "../../core/tsconfig.json" } + { "path": "../../core/tsconfig.json" }, + { "path": "../screenshot_mode/tsconfig.json" } ] } diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts new file mode 100644 index 00000000000000..91c461646c280c --- /dev/null +++ b/src/plugins/presentation_util/public/mocks.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreStart } from 'kibana/public'; +import { PresentationUtilPluginStart } from './types'; +import { pluginServices } from './services'; +import { registry } from './services/kibana'; + +const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins: {} as any })); + + const startContract: PresentationUtilPluginStart = { + ContextProvider: pluginServices.getContextProvider(), + labsService: pluginServices.getServices().labs, + }; + return startContract; +}; + +export const presentationUtilPluginMock = { + createStartContract, +}; diff --git a/src/plugins/screenshot_mode/public/index.ts b/src/plugins/screenshot_mode/public/index.ts index a5ad37dd5b760d..012f57e837f416 100644 --- a/src/plugins/screenshot_mode/public/index.ts +++ b/src/plugins/screenshot_mode/public/index.ts @@ -18,4 +18,4 @@ export { KBN_SCREENSHOT_MODE_ENABLED_KEY, } from '../common'; -export { ScreenshotModePluginSetup } from './types'; +export { ScreenshotModePluginSetup, ScreenshotModePluginStart } from './types'; diff --git a/src/plugins/screenshot_mode/public/plugin.test.ts b/src/plugins/screenshot_mode/public/plugin.test.ts index 33ae5014668760..f2c0970d0ff60c 100644 --- a/src/plugins/screenshot_mode/public/plugin.test.ts +++ b/src/plugins/screenshot_mode/public/plugin.test.ts @@ -21,7 +21,7 @@ describe('Screenshot mode public', () => { setScreenshotModeDisabled(); }); - describe('setup contract', () => { + describe('public contract', () => { it('detects screenshot mode "true"', () => { setScreenshotModeEnabled(); const screenshotMode = plugin.setup(coreMock.createSetup()); @@ -34,10 +34,4 @@ describe('Screenshot mode public', () => { expect(screenshotMode.isScreenshotMode()).toBe(false); }); }); - - describe('start contract', () => { - it('returns nothing', () => { - expect(plugin.start(coreMock.createStart())).toBe(undefined); - }); - }); }); diff --git a/src/plugins/screenshot_mode/public/plugin.ts b/src/plugins/screenshot_mode/public/plugin.ts index 7a166566a0173b..a005bb7c3d055d 100644 --- a/src/plugins/screenshot_mode/public/plugin.ts +++ b/src/plugins/screenshot_mode/public/plugin.ts @@ -8,18 +8,22 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; -import { ScreenshotModePluginSetup } from './types'; +import { ScreenshotModePluginSetup, ScreenshotModePluginStart } from './types'; import { getScreenshotMode } from '../common'; export class ScreenshotModePlugin implements Plugin { + private publicContract = Object.freeze({ + isScreenshotMode: () => getScreenshotMode() === true, + }); + public setup(core: CoreSetup): ScreenshotModePluginSetup { - return { - isScreenshotMode: () => getScreenshotMode() === true, - }; + return this.publicContract; } - public start(core: CoreStart) {} + public start(core: CoreStart): ScreenshotModePluginStart { + return this.publicContract; + } public stop() {} } diff --git a/src/plugins/screenshot_mode/public/types.ts b/src/plugins/screenshot_mode/public/types.ts index 744ea8615f2a79..f6963de0cbd63f 100644 --- a/src/plugins/screenshot_mode/public/types.ts +++ b/src/plugins/screenshot_mode/public/types.ts @@ -15,3 +15,4 @@ export interface IScreenshotModeService { } export type ScreenshotModePluginSetup = IScreenshotModeService; +export type ScreenshotModePluginStart = IScreenshotModeService; diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 33bb1f06d045c7..6a39951ad49583 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -24,6 +24,7 @@ import { TypedLensByValueInput, PersistedIndexPatternLayer, XYState, + LensEmbeddableInput, } from '../../../plugins/lens/public'; import { StartDependencies } from './plugin'; @@ -112,12 +113,15 @@ export const App = (props: { }) => { const [color, setColor] = useState('green'); const [isLoading, setIsLoading] = useState(false); + const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const LensComponent = props.plugins.lens.EmbeddableComponent; + const LensSaveModalComponent = props.plugins.lens.SaveModalComponent; const [time, setTime] = useState({ from: 'now-5d', to: 'now', }); + return ( @@ -172,7 +176,18 @@ export const App = (props: { setColor(newColor); }} > - Edit + Edit in Lens + + + + { + setIsSaveModalVisible(true); + }} + > + Save Visualization @@ -197,6 +212,19 @@ export const App = (props: { // call back event for on table row click event }} /> + {isSaveModalVisible && ( + {}} + onClose={() => setIsSaveModalVisible(false)} + /> + )} ) : (

This demo only works if your default index pattern is set and time based

diff --git a/x-pack/examples/embedded_lens_example/public/mount.tsx b/x-pack/examples/embedded_lens_example/public/mount.tsx index 5cf7c25fbf160e..ff1e6ef8818f05 100644 --- a/x-pack/examples/embedded_lens_example/public/mount.tsx +++ b/x-pack/examples/embedded_lens_example/public/mount.tsx @@ -23,7 +23,13 @@ export const mount = (coreSetup: CoreSetup) => async ({ const defaultIndexPattern = await plugins.data.indexPatterns.getDefault(); - const reactElement = ; + const i18nCore = core.i18n; + + const reactElement = ( + + + + ); render(reactElement, element); return () => unmountComponentAtNode(element); }; diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index bfc0a3daf6f0ed..77e7f2834b080d 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -107,7 +107,7 @@ export class ApmPlugin implements Plugin { // APM navigation { label: 'APM', - sortKey: 200, + sortKey: 400, entries: [ { label: servicesTitle, app: 'apm', path: '/services' }, { label: tracesTitle, app: 'apm', path: '/traces' }, @@ -118,7 +118,7 @@ export class ApmPlugin implements Plugin { // UX navigation { label: 'User Experience', - sortKey: 201, + sortKey: 600, entries: [ { label: i18n.translate('xpack.apm.ux.overview.heading', { diff --git a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap index 79eb0fbce54987..b54e9726667e6c 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap @@ -58,6 +58,11 @@ Object { "transaction.type": "page-load", }, }, + Object { + "term": Object { + "service.environment": "staging", + }, + }, ], }, }, @@ -492,6 +497,11 @@ Object { "field": "transaction.marks.navigationTiming.fetchStart", }, }, + Object { + "term": Object { + "service.environment": "staging", + }, + }, ], }, }, @@ -534,6 +544,11 @@ Object { "transaction.type": "page-load", }, }, + Object { + "term": Object { + "service.environment": "staging", + }, + }, ], }, }, diff --git a/x-pack/plugins/apm/server/lib/rum_client/queries.test.ts b/x-pack/plugins/apm/server/lib/rum_client/queries.test.ts index aaf6401b9f407a..452f451b11f863 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/queries.test.ts @@ -16,6 +16,7 @@ import { getRumServices } from './get_rum_services'; import { getLongTaskMetrics } from './get_long_task_metrics'; import { getWebCoreVitals } from './get_web_core_vitals'; import { getJSErrors } from './get_js_errors'; +import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; describe('rum client dashboard queries', () => { let mock: SearchParamsMock; @@ -25,32 +26,38 @@ describe('rum client dashboard queries', () => { }); it('fetches client metrics', async () => { - mock = await inspectSearchParams((setup) => - getClientMetrics({ - setup, - }) + mock = await inspectSearchParams( + (setup) => + getClientMetrics({ + setup, + }), + { uiFilters: { environment: 'staging' } } ); expect(mock.params).toMatchSnapshot(); }); it('fetches page view trends', async () => { - mock = await inspectSearchParams((setup) => - getPageViewTrends({ - setup, - }) + mock = await inspectSearchParams( + (setup) => + getPageViewTrends({ + setup, + }), + { uiFilters: { environment: 'staging' } } ); expect(mock.params).toMatchSnapshot(); }); it('fetches page load distribution', async () => { - mock = await inspectSearchParams((setup) => - getPageLoadDistribution({ - setup, - minPercentile: '0', - maxPercentile: '99', - }) + mock = await inspectSearchParams( + (setup) => + getPageLoadDistribution({ + setup, + minPercentile: '0', + maxPercentile: '99', + }), + { uiFilters: { environment: 'staging' } } ); expect(mock.params).toMatchSnapshot(); }); @@ -65,10 +72,12 @@ describe('rum client dashboard queries', () => { }); it('fetches rum core vitals', async () => { - mock = await inspectSearchParams((setup) => - getWebCoreVitals({ - setup, - }) + mock = await inspectSearchParams( + (setup) => + getWebCoreVitals({ + setup, + }), + { uiFilters: { environment: ENVIRONMENT_ALL.value } } ); expect(mock.params).toMatchSnapshot(); }); diff --git a/x-pack/plugins/apm/server/lib/rum_client/ui_filters/get_es_filter.ts b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/get_es_filter.ts index 43cbb485c4510f..6b8cee244a1926 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/ui_filters/get_es_filter.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/get_es_filter.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../../typings/elasticsearch'; import { UIFilters } from '../../../../typings/ui_filters'; import { localUIFilters, localUIFilterNames } from './local_ui_filters/config'; +import { environmentQuery } from '../../../utils/queries'; export function getEsFilter(uiFilters: UIFilters) { const localFilterValues = uiFilters; @@ -23,5 +24,5 @@ export function getEsFilter(uiFilters: UIFilters) { }; }) as ESFilter[]; - return mappedFilters; + return [...mappedFilters, ...environmentQuery(uiFilters.environment)]; } diff --git a/x-pack/plugins/apm/server/utils/test_helpers.tsx b/x-pack/plugins/apm/server/utils/test_helpers.tsx index 6252c33c5994dd..29879cf5c0ebcc 100644 --- a/x-pack/plugins/apm/server/utils/test_helpers.tsx +++ b/x-pack/plugins/apm/server/utils/test_helpers.tsx @@ -17,6 +17,7 @@ interface Options { mockResponse?: ( request: ESSearchRequest ) => ESSearchResponse; + uiFilters?: Record; } interface MockSetup { @@ -86,7 +87,7 @@ export async function inspectSearchParams( }, } ) as APMConfig, - uiFilters: {}, + uiFilters: options?.uiFilters ?? {}, indices: { /* eslint-disable @typescript-eslint/naming-convention */ 'apm_oss.sourcemapIndices': 'myIndex', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index 87e6ee62460fae..870e303a2930d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -16,18 +16,13 @@ import { engines } from '../../__mocks__/engines.mock'; import { nextTick } from '@kbn/test/jest'; import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; -import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; - const { - clearFlashMessages, - flashAPIErrors, - setSuccessMessage, - setErrorMessage, - } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; const { mount } = new LogicMounter(RoleMappingsLogic); const DEFAULT_VALUES = { attributes: [], @@ -50,15 +45,14 @@ describe('RoleMappingsLogic', () => { roleMappingErrors: [], }; - const mappingsServerProps = { multipleAuthProvidersConfig: true, roleMappings: [asRoleMapping] }; - const mappingServerProps = { + const mappingsServerProps = { + multipleAuthProvidersConfig: true, + roleMappings: [asRoleMapping], attributes: ['email', 'metadata', 'username', 'role'], authProviders: [ANY_AUTH_PROVIDER], availableEngines: engines, elasticsearchRoles: [], hasAdvancedRoles: false, - multipleAuthProvidersConfig: false, - roleMapping: asRoleMapping, }; beforeEach(() => { @@ -75,48 +69,20 @@ describe('RoleMappingsLogic', () => { it('sets data based on server response from the `mappings` (plural) endpoint', () => { RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); - expect(RoleMappingsLogic.values.roleMappings).toEqual([asRoleMapping]); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); - }); - }); - - describe('setRoleMappingData', () => { - it('sets state based on server response from the `mapping` (singular) endpoint', () => { - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); - expect(RoleMappingsLogic.values).toEqual({ ...DEFAULT_VALUES, - roleMapping: asRoleMapping, + roleMappings: [asRoleMapping], dataLoading: false, - attributes: mappingServerProps.attributes, - availableAuthProviders: mappingServerProps.authProviders, - availableEngines: mappingServerProps.availableEngines, + attributes: mappingsServerProps.attributes, + availableAuthProviders: mappingsServerProps.authProviders, + availableEngines: mappingsServerProps.availableEngines, accessAllEngines: true, - attributeName: 'role', - attributeValue: 'superuser', - elasticsearchRoles: mappingServerProps.elasticsearchRoles, - selectedEngines: new Set(engines.map((e) => e.name)), - selectedOptions: [ - { label: engines[0].name, value: engines[0].name }, - { label: engines[1].name, value: engines[1].name }, - ], - }); - }); - - it('will remove all selected engines if no roleMapping was returned from the server', () => { - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, - roleMapping: undefined, - }); - - expect(RoleMappingsLogic.values).toEqual({ - ...DEFAULT_VALUES, - dataLoading: false, + multipleAuthProvidersConfig: true, + attributeName: 'username', + attributeValue: '', + elasticsearchRoles: mappingsServerProps.elasticsearchRoles, selectedEngines: new Set(), - attributes: mappingServerProps.attributes, - availableAuthProviders: mappingServerProps.authProviders, - availableEngines: mappingServerProps.availableEngines, + selectedOptions: [], }); }); }); @@ -135,11 +101,13 @@ describe('RoleMappingsLogic', () => { const engine = engines[0]; const otherEngine = engines[1]; const mountedValues = { - ...mappingServerProps, - roleMapping: { - ...asRoleMapping, - engines: [engine, otherEngine], - }, + ...mappingsServerProps, + roleMappings: [ + { + ...asRoleMapping, + engines: [engine, otherEngine], + }, + ], selectedEngines: new Set([engine.name]), }; @@ -153,11 +121,18 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.selectedEngines).toEqual( new Set([engine.name, otherEngine.name]) ); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: engine.name, value: engine.name }, + { label: otherEngine.name, value: otherEngine.name }, + ]); }); it('handles removing an engine from selected engines', () => { RoleMappingsLogic.actions.handleEngineSelectionChange([engine.name]); expect(RoleMappingsLogic.values.selectedEngines).toEqual(new Set([engine.name])); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: engine.name, value: engine.name }, + ]); }); }); @@ -175,17 +150,19 @@ describe('RoleMappingsLogic', () => { it('sets values correctly', () => { mount({ - ...mappingServerProps, + ...mappingsServerProps, elasticsearchRoles, }); RoleMappingsLogic.actions.handleAttributeSelectorChange('role', elasticsearchRoles[0]); expect(RoleMappingsLogic.values).toEqual({ ...DEFAULT_VALUES, + multipleAuthProvidersConfig: true, attributeValue: elasticsearchRoles[0], - roleMapping: asRoleMapping, - attributes: mappingServerProps.attributes, - availableEngines: mappingServerProps.availableEngines, + roleMappings: [asRoleMapping], + roleMapping: null, + attributes: mappingsServerProps.attributes, + availableEngines: mappingsServerProps.availableEngines, accessAllEngines: true, attributeName: 'role', elasticsearchRoles, @@ -215,11 +192,13 @@ describe('RoleMappingsLogic', () => { describe('handleAuthProviderChange', () => { beforeEach(() => { mount({ - ...mappingServerProps, - roleMapping: { - ...asRoleMapping, - authProvider: ['foo'], - }, + ...mappingsServerProps, + roleMappings: [ + { + ...asRoleMapping, + authProvider: ['foo'], + }, + ], }); }); const providers = ['bar', 'baz']; @@ -244,11 +223,13 @@ describe('RoleMappingsLogic', () => { it('handles "any" auth in previous state', () => { mount({ - ...mappingServerProps, - roleMapping: { - ...asRoleMapping, - authProvider: [ANY_AUTH_PROVIDER], - }, + ...mappingsServerProps, + roleMappings: [ + { + ...asRoleMapping, + authProvider: [ANY_AUTH_PROVIDER], + }, + ], }); RoleMappingsLogic.actions.handleAuthProviderChange(providerWithAny); @@ -258,7 +239,6 @@ describe('RoleMappingsLogic', () => { it('resetState', () => { mount(mappingsServerProps); - mount(mappingServerProps); RoleMappingsLogic.actions.resetState(); expect(RoleMappingsLogic.values).toEqual(DEFAULT_VALUES); @@ -266,7 +246,7 @@ describe('RoleMappingsLogic', () => { }); it('openRoleMappingFlyout', () => { - mount(mappingServerProps); + mount(mappingsServerProps); RoleMappingsLogic.actions.openRoleMappingFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true); @@ -275,7 +255,7 @@ describe('RoleMappingsLogic', () => { it('closeRoleMappingFlyout', () => { mount({ - ...mappingServerProps, + ...mappingsServerProps, roleMappingFlyoutOpen: true, }); RoleMappingsLogic.actions.closeRoleMappingFlyout(); @@ -307,40 +287,20 @@ describe('RoleMappingsLogic', () => { }); describe('initializeRoleMapping', () => { - it('calls API and sets values for new mapping', async () => { - const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); - http.get.mockReturnValue(Promise.resolve(mappingServerProps)); - RoleMappingsLogic.actions.initializeRoleMapping(); - - expect(http.get).toHaveBeenCalledWith('/api/app_search/role_mappings/new'); - await nextTick(); - expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); - }); - - it('calls API and sets values for existing mapping', async () => { - const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); - http.get.mockReturnValue(Promise.resolve(mappingServerProps)); - RoleMappingsLogic.actions.initializeRoleMapping('123'); - - expect(http.get).toHaveBeenCalledWith('/api/app_search/role_mappings/123'); - await nextTick(); - expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); - }); - - it('handles error', async () => { - http.get.mockReturnValue(Promise.reject('this is an error')); - RoleMappingsLogic.actions.initializeRoleMapping(); - await nextTick(); + it('sets values for existing mapping', () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeRoleMapping(asRoleMapping.id); - expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + expect(setRoleMappingDataSpy).toHaveBeenCalledWith(asRoleMapping); }); - it('shows error when there is a 404 status', async () => { - http.get.mockReturnValue(Promise.reject({ status: 404 })); + it('does not set data for new mapping', async () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); RoleMappingsLogic.actions.initializeRoleMapping(); - await nextTick(); - expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND); + expect(setRoleMappingDataSpy).not.toHaveBeenCalledWith(asRoleMapping); }); }); @@ -362,7 +322,7 @@ describe('RoleMappingsLogic', () => { 'initializeRoleMappings' ); - http.post.mockReturnValue(Promise.resolve(mappingServerProps)); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); expect(http.post).toHaveBeenCalledWith('/api/app_search/role_mappings', { @@ -374,13 +334,16 @@ describe('RoleMappingsLogic', () => { }); it('calls API and refreshes list when existing mapping', async () => { - mount(mappingServerProps); + mount({ + ...mappingsServerProps, + roleMapping: asRoleMapping, + }); const initializeRoleMappingsSpy = jest.spyOn( RoleMappingsLogic.actions, 'initializeRoleMappings' ); - http.put.mockReturnValue(Promise.resolve(mappingServerProps)); + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); expect(http.put).toHaveBeenCalledWith(`/api/app_search/role_mappings/${asRoleMapping.id}`, { @@ -396,12 +359,13 @@ describe('RoleMappingsLogic', () => { const engine = engines[0]; mount({ - ...mappingServerProps, + ...mappingsServerProps, accessAllEngines: false, selectedEngines: new Set([engine.name]), + roleMapping: asRoleMapping, }); - http.put.mockReturnValue(Promise.resolve(mappingServerProps)); + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); expect(http.put).toHaveBeenCalledWith(`/api/app_search/role_mappings/${asRoleMapping.id}`, { @@ -449,7 +413,7 @@ describe('RoleMappingsLogic', () => { }); it('calls API and refreshes list', async () => { - mount(mappingServerProps); + mount(mappingsServerProps); const initializeRoleMappingsSpy = jest.spyOn( RoleMappingsLogic.actions, 'initializeRoleMappings' @@ -465,7 +429,7 @@ describe('RoleMappingsLogic', () => { }); it('handles error', async () => { - mount(mappingServerProps); + mount(mappingsServerProps); http.delete.mockReturnValue(Promise.reject('this is an error')); RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); await nextTick(); @@ -474,7 +438,7 @@ describe('RoleMappingsLogic', () => { }); it('will do nothing if not confirmed', () => { - mount(mappingServerProps); + mount(mappingsServerProps); jest.spyOn(window, 'confirm').mockReturnValueOnce(false); RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index 863e6746dbe958..fc0a235b23c77c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -13,10 +13,9 @@ import { clearFlashMessages, flashAPIErrors, setSuccessMessage, - setErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; -import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { AttributeName } from '../../../shared/types'; import { ASRoleMapping, RoleTypes } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; @@ -31,17 +30,12 @@ import { interface RoleMappingsServerDetails { roleMappings: ASRoleMapping[]; - multipleAuthProvidersConfig: boolean; -} - -interface RoleMappingServerDetails { attributes: string[]; authProviders: string[]; availableEngines: Engine[]; elasticsearchRoles: string[]; hasAdvancedRoles: boolean; multipleAuthProvidersConfig: boolean; - roleMapping?: ASRoleMapping; } const getFirstAttributeName = (roleMapping: ASRoleMapping) => @@ -64,7 +58,7 @@ interface RoleMappingsActions { initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; - setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; + setRoleMapping(roleMapping: ASRoleMapping): { roleMapping: ASRoleMapping }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; @@ -96,7 +90,7 @@ export const RoleMappingsLogic = kea data, - setRoleMappingData: (data: RoleMappingServerDetails) => data, + setRoleMapping: (roleMapping: ASRoleMapping) => ({ roleMapping }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), @@ -120,7 +114,6 @@ export const RoleMappingsLogic = kea false, - setRoleMappingData: () => false, resetState: () => true, }, ], @@ -135,40 +128,39 @@ export const RoleMappingsLogic = kea multipleAuthProvidersConfig, - setRoleMappingData: (_, { multipleAuthProvidersConfig }) => multipleAuthProvidersConfig, resetState: () => false, }, ], hasAdvancedRoles: [ false, { - setRoleMappingData: (_, { hasAdvancedRoles }) => hasAdvancedRoles, + setRoleMappingsData: (_, { hasAdvancedRoles }) => hasAdvancedRoles, }, ], availableEngines: [ [], { - setRoleMappingData: (_, { availableEngines }) => availableEngines, + setRoleMappingsData: (_, { availableEngines }) => availableEngines, resetState: () => [], }, ], attributes: [ [], { - setRoleMappingData: (_, { attributes }) => attributes, + setRoleMappingsData: (_, { attributes }) => attributes, resetState: () => [], }, ], elasticsearchRoles: [ [], { - setRoleMappingData: (_, { elasticsearchRoles }) => elasticsearchRoles, + setRoleMappingsData: (_, { elasticsearchRoles }) => elasticsearchRoles, }, ], roleMapping: [ null, { - setRoleMappingData: (_, { roleMapping }) => roleMapping || null, + setRoleMapping: (_, { roleMapping }) => roleMapping, resetState: () => null, closeRoleMappingFlyout: () => null, }, @@ -176,16 +168,14 @@ export const RoleMappingsLogic = kea - roleMapping ? (roleMapping.roleType as RoleTypes) : 'owner', + setRoleMapping: (_, { roleMapping }) => roleMapping.roleType as RoleTypes, handleRoleChange: (_, { roleType }) => roleType, }, ], accessAllEngines: [ true, { - setRoleMappingData: (_, { roleMapping }) => - roleMapping ? roleMapping.accessAllEngines : true, + setRoleMapping: (_, { roleMapping }) => roleMapping.accessAllEngines, handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), handleAccessAllEnginesChange: (_, { selected }) => selected, }, @@ -193,8 +183,7 @@ export const RoleMappingsLogic = kea - roleMapping ? getFirstAttributeValue(roleMapping) : '', + setRoleMapping: (_, { roleMapping }) => getFirstAttributeValue(roleMapping), handleAttributeSelectorChange: (_, { value, firstElasticsearchRole }) => value === 'role' ? firstElasticsearchRole : '', handleAttributeValueChange: (_, { value }) => value, @@ -205,8 +194,7 @@ export const RoleMappingsLogic = kea - roleMapping ? getFirstAttributeName(roleMapping) : 'username', + setRoleMapping: (_, { roleMapping }) => getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', closeRoleMappingFlyout: () => 'username', @@ -215,8 +203,8 @@ export const RoleMappingsLogic = kea - roleMapping ? new Set(roleMapping.engines.map((engine) => engine.name)) : new Set(), + setRoleMapping: (_, { roleMapping }) => + new Set(roleMapping.engines.map((engine: Engine) => engine.name)), handleAccessAllEnginesChange: () => new Set(), handleEngineSelectionChange: (_, { engineNames }) => { const newSelectedEngineNames = new Set() as Set; @@ -229,7 +217,7 @@ export const RoleMappingsLogic = kea authProviders, + setRoleMappingsData: (_, { authProviders }) => authProviders, }, ], selectedAuthProviders: [ @@ -246,8 +234,7 @@ export const RoleMappingsLogic = kea v !== ANY_AUTH_PROVIDER); return [ANY_AUTH_PROVIDER]; }, - setRoleMappingData: (_, { roleMapping }) => - roleMapping ? roleMapping.authProvider : [ANY_AUTH_PROVIDER], + setRoleMapping: (_, { roleMapping }) => roleMapping.authProvider, }, ], roleMappingFlyoutOpen: [ @@ -292,21 +279,8 @@ export const RoleMappingsLogic = kea { - const { http } = HttpLogic.values; - const route = roleMappingId - ? `/api/app_search/role_mappings/${roleMappingId}` - : '/api/app_search/role_mappings/new'; - - try { - const response = await http.get(route); - actions.setRoleMappingData(response); - } catch (e) { - if (e.status === 404) { - setErrorMessage(ROLE_MAPPING_NOT_FOUND); - } else { - flashAPIErrors(e); - } - } + const roleMapping = values.roleMappings.find(({ id }) => id === roleMappingId); + if (roleMapping) actions.setRoleMapping(roleMapping); }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index 82284be0907fba..7696cf03ed4b15 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; -import { EuiTextColor, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiIconTip, EuiTextColor, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; @@ -84,7 +84,7 @@ export const RoleMappingsTable: React.FC = ({ const roleCol: EuiBasicTableColumn = { field: 'roleType', name: ROLE_LABEL, - render: (_, { rules }: SharedRoleMapping) => getFirstAttributeValue(rules), + render: (_, { roleType }: SharedRoleMapping) => roleType, }; const accessItemsCol: EuiBasicTableColumn = { @@ -124,11 +124,16 @@ export const RoleMappingsTable: React.FC = ({ field: 'id', name: '', align: 'right', - render: (_, { id }: SharedRoleMapping) => ( - initializeRoleMapping(id)} - onDeleteClick={() => handleDeleteMapping(id)} - /> + render: (_, { id, toolTip }: SharedRoleMapping) => ( + <> + {id && ( + initializeRoleMapping(id)} + onDeleteClick={() => handleDeleteMapping(id)} + /> + )} + {toolTip && } + ), }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts index b9a49c416f2831..e1c2a3b76e3ff1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts @@ -8,3 +8,5 @@ export { WorkplaceSearchNav } from './nav'; export { WorkplaceSearchHeaderActions } from './kibana_header_actions'; export { AccountHeader } from './account_header'; +export { PersonalDashboardLayout } from './personal_dashboard_layout'; +export { PrivateSourcesSidebar, AccountSettingsSidebar } from './personal_dashboard_sidebar'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/index.ts new file mode 100644 index 00000000000000..40347aaee747d2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PersonalDashboardLayout } from './personal_dashboard_layout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss new file mode 100644 index 00000000000000..175f6b9ebca208 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +.personalDashboardLayout { + $sideBarWidth: $euiSize * 30; + $consoleHeaderHeight: 48px; // NOTE: Keep an eye on this for changes + $pageHeight: calc(100vh - #{$consoleHeaderHeight}); + + left: $sideBarWidth; + width: calc(100% - #{$sideBarWidth}); + min-height: $pageHeight; + + &__sideBar { + padding: 32px 40px 40px; + width: $sideBarWidth; + margin-left: -$sideBarWidth; + height: $pageHeight; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx new file mode 100644 index 00000000000000..faeaa7323e93f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { AccountHeader } from '..'; + +import { PersonalDashboardLayout } from './personal_dashboard_layout'; + +describe('PersonalDashboardLayout', () => { + const children =

test

; + const sidebar =

test

; + + it('renders', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="TestSidebar"]')).toHaveLength(1); + expect(wrapper.find(AccountHeader)).toHaveLength(1); + }); + + it('renders callout when in read-only mode', () => { + const wrapper = shallow( + + {children} + + ); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx new file mode 100644 index 00000000000000..1ab9e07dfa14d5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPage, EuiPageSideBar, EuiPageBody, EuiCallOut } from '@elastic/eui'; + +import { AccountHeader } from '..'; + +import { PRIVATE_DASHBOARD_READ_ONLY_MODE_WARNING } from '../../../views/content_sources/constants'; + +import './personal_dashboard_layout.scss'; + +interface LayoutProps { + restrictWidth?: boolean; + readOnlyMode?: boolean; + sidebar: React.ReactNode; +} + +export const PersonalDashboardLayout: React.FC = ({ + children, + restrictWidth, + readOnlyMode, + sidebar, +}) => { + return ( + <> + + + + {sidebar} + + + {readOnlyMode && ( + + )} + {children} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.test.tsx new file mode 100644 index 00000000000000..8edcf83a57e6f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ViewContentHeader } from '../../shared/view_content_header'; + +import { AccountSettingsSidebar } from './account_settings_sidebar'; + +describe('AccountSettingsSidebar', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.tsx new file mode 100644 index 00000000000000..490f3ff0ae4a51 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/account_settings_sidebar.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { ACCOUNT_SETTINGS_TITLE, ACCOUNT_SETTINGS_DESCRIPTION } from '../../../constants'; +import { ViewContentHeader } from '../../shared/view_content_header'; + +export const AccountSettingsSidebar = () => { + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/index.ts new file mode 100644 index 00000000000000..ffd241000c8a03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PrivateSourcesSidebar } from './private_sources_sidebar'; +export { AccountSettingsSidebar } from './account_settings_sidebar'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx similarity index 54% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx index 6af439814b8918..387724af970f89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx @@ -5,50 +5,42 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - -import { setMockValues } from '../../../__mocks__/kea_logic'; +import { setMockValues } from '../../../../__mocks__/kea_logic'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiCallOut } from '@elastic/eui'; - -import { AccountHeader } from '../../components/layout'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; - -import { SourceSubNav } from './components/source_sub_nav'; - import { PRIVATE_CAN_CREATE_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, -} from './constants'; -import { PrivateSourcesLayout } from './private_sources_layout'; +} from '../../../constants'; +import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; + +import { ViewContentHeader } from '../../shared/view_content_header'; -describe('PrivateSourcesLayout', () => { +import { PrivateSourcesSidebar } from './private_sources_sidebar'; + +describe('PrivateSourcesSidebar', () => { const mockValues = { account: { canCreatePersonalSources: true }, }; - const children =

test

; - beforeEach(() => { setMockValues({ ...mockValues }); }); it('renders', () => { - const wrapper = shallow({children}); + const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); expect(wrapper.find(SourceSubNav)).toHaveLength(1); - expect(wrapper.find(AccountHeader)).toHaveLength(1); }); it('uses correct title and description when private sources are enabled', () => { - const wrapper = shallow({children}); + const wrapper = shallow(); expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( @@ -58,17 +50,11 @@ describe('PrivateSourcesLayout', () => { it('uses correct title and description when private sources are disabled', () => { setMockValues({ account: { canCreatePersonalSources: false } }); - const wrapper = shallow({children}); + const wrapper = shallow(); expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION ); }); - - it('renders callout when in read-only mode', () => { - const wrapper = shallow({children}); - - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx new file mode 100644 index 00000000000000..5505ae57b2ad5f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { AppLogic } from '../../../app_logic'; +import { + PRIVATE_CAN_CREATE_PAGE_TITLE, + PRIVATE_VIEW_ONLY_PAGE_TITLE, + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, +} from '../../../constants'; +import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { ViewContentHeader } from '../../shared/view_content_header'; + +export const PrivateSourcesSidebar = () => { + const { + account: { canCreatePersonalSources }, + } = useValues(AppLogic); + + const PAGE_TITLE = canCreatePersonalSources + ? PRIVATE_CAN_CREATE_PAGE_TITLE + : PRIVATE_VIEW_ONLY_PAGE_TITLE; + const PAGE_DESCRIPTION = canCreatePersonalSources + ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; + + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index dcebc35d45f711..aa5419f12c7f30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -662,6 +662,49 @@ export const PRIVATE_SOURCES = i18n.translate( } ); +export const PRIVATE_CAN_CREATE_PAGE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.private.canCreate.title', + { + defaultMessage: 'Manage private content sources', + } +); + +export const PRIVATE_VIEW_ONLY_PAGE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.private.vewOnly.title', + { + defaultMessage: 'Review Group Sources', + } +); + +export const PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.private.vewOnly.description', + { + defaultMessage: 'Review the status of all sources shared with your Group.', + } +); + +export const PRIVATE_CAN_CREATE_PAGE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.private.canCreate.description', + { + defaultMessage: + 'Review the status of all connected private sources, and manage private sources for your account.', + } +); + +export const ACCOUNT_SETTINGS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.accountSettings.title', + { + defaultMessage: 'Account Settings', + } +); + +export const ACCOUNT_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.accountSettings.description', + { + defaultMessage: 'Manage access, passwords, and other account settings.', + } +); + export const CONFIRM_CHANGES_TEXT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 0fc8a6e7c7c0d0..7e911b31c516b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -19,6 +19,11 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; +import { + PersonalDashboardLayout, + PrivateSourcesSidebar, + AccountSettingsSidebar, +} from './components/layout'; import { GROUPS_PATH, SETUP_GUIDE_PATH, @@ -34,7 +39,6 @@ import { AccountSettings } from './views/account_settings'; import { SourcesRouter } from './views/content_sources'; import { SourceAdded } from './views/content_sources/components/source_added'; import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; -import { PrivateSourcesLayout } from './views/content_sources/private_sources_layout'; import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; @@ -101,14 +105,22 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { )} - + } + > - + - + } + > - + = ({ - children, - restrictWidth, - readOnlyMode, -}) => { - const { - account: { canCreatePersonalSources }, - } = useValues(AppLogic); - - const PAGE_TITLE = canCreatePersonalSources - ? PRIVATE_CAN_CREATE_PAGE_TITLE - : PRIVATE_VIEW_ONLY_PAGE_TITLE; - const PAGE_DESCRIPTION = canCreatePersonalSources - ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION - : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; - - return ( - <> - - - - - - - - {readOnlyMode && ( - - )} - {children} - - - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss index 549ca3ae9154e0..feccc0e1924d21 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss @@ -18,23 +18,6 @@ } } -.privateSourcesLayout { - $sideBarWidth: $euiSize * 30; - $consoleHeaderHeight: 48px; // NOTE: Keep an eye on this for changes - $pageHeight: calc(100vh - #{$consoleHeaderHeight}); - - left: $sideBarWidth; - width: calc(100% - #{$sideBarWidth}); - min-height: $pageHeight; - - &__sideBar { - padding: 32px 40px 40px; - width: $sideBarWidth; - margin-left: -$sideBarWidth; - height: $pageHeight; - } -} - .sourcesSubNav { li { display: block; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index 716cb8ebb6d47d..4ee530870284e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -16,13 +16,13 @@ import { groups } from '../../__mocks__/groups.mock'; import { nextTick } from '@kbn/test/jest'; import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; -import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; - const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; + const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers; const { mount } = new LogicMounter(RoleMappingsLogic); const defaultValues = { attributes: [], @@ -52,14 +52,13 @@ describe('RoleMappingsLogic', () => { name: 'Default', }; - const mappingsServerProps = { multipleAuthProvidersConfig: true, roleMappings: [wsRoleMapping] }; - const mappingServerProps = { + const mappingsServerProps = { + multipleAuthProvidersConfig: true, + roleMappings: [wsRoleMapping], attributes: [], authProviders: [], availableGroups: [roleGroup, defaultGroup], elasticsearchRoles: [], - multipleAuthProvidersConfig: false, - roleMapping: wsRoleMapping, }; beforeEach(() => { @@ -78,36 +77,17 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); expect(RoleMappingsLogic.values.dataLoading).toEqual(false); expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); - }); - - describe('setRoleMappingData', () => { - it('sets data correctly', () => { - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); - - expect(RoleMappingsLogic.values.roleMapping).toEqual(wsRoleMapping); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.attributes).toEqual(mappingServerProps.attributes); - expect(RoleMappingsLogic.values.availableGroups).toEqual( - mappingServerProps.availableGroups - ); - expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(true); - expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( - mappingServerProps.elasticsearchRoles - ); - expect(RoleMappingsLogic.values.selectedGroups).toEqual( - new Set([wsRoleMapping.groups[0].id]) - ); - expect(RoleMappingsLogic.values.selectedOptions).toEqual([]); - }); - - it('sets default group with new role mapping', () => { - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, - roleMapping: undefined, - }); - - expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); - }); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.attributes).toEqual(mappingsServerProps.attributes); + expect(RoleMappingsLogic.values.availableGroups).toEqual(mappingsServerProps.availableGroups); + expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(false); + expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( + mappingsServerProps.elasticsearchRoles + ); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: defaultGroup.name, value: defaultGroup.id }, + ]); + expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); }); it('handleRoleChange', () => { @@ -119,14 +99,17 @@ describe('RoleMappingsLogic', () => { it('handleGroupSelectionChange', () => { const group = wsRoleMapping.groups[0]; const otherGroup = groups[0]; - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, - roleMapping: { - ...wsRoleMapping, - groups: [group, otherGroup], - }, + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + roleMappings: [ + { + ...wsRoleMapping, + groups: [group, otherGroup], + }, + ], }); + RoleMappingsLogic.actions.initializeRoleMapping(wsRoleMapping.id); RoleMappingsLogic.actions.handleGroupSelectionChange([group.id, otherGroup.id]); expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([group.id, otherGroup.id])); expect(RoleMappingsLogic.values.selectedOptions).toEqual([ @@ -147,8 +130,8 @@ describe('RoleMappingsLogic', () => { const elasticsearchRoles = ['foo', 'bar']; it('sets values correctly', () => { - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, elasticsearchRoles, }); RoleMappingsLogic.actions.handleAttributeSelectorChange('role', elasticsearchRoles[0]); @@ -172,12 +155,14 @@ describe('RoleMappingsLogic', () => { describe('handleAuthProviderChange', () => { beforeEach(() => { - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, - roleMapping: { - ...wsRoleMapping, - authProvider: ['foo'], - }, + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + roleMappings: [ + { + ...wsRoleMapping, + authProvider: ['foo'], + }, + ], }); }); const providers = ['bar', 'baz']; @@ -201,28 +186,23 @@ describe('RoleMappingsLogic', () => { }); it('handles "any" auth in previous state', () => { - RoleMappingsLogic.actions.setRoleMappingData({ - ...mappingServerProps, - roleMapping: { - ...wsRoleMapping, - authProvider: [ANY_AUTH_PROVIDER], - }, + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + roleMappings: [ + { + ...wsRoleMapping, + authProvider: [ANY_AUTH_PROVIDER], + }, + ], }); RoleMappingsLogic.actions.handleAuthProviderChange(providerWithAny); expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual([providers[1]]); }); - - it('handles catch-all state', () => { - RoleMappingsLogic.actions.handleAuthProviderChange(providerWithAny); - - expect(RoleMappingsLogic.values.selectedAuthProviders).toEqual([ANY_AUTH_PROVIDER]); - }); }); it('resetState', () => { RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); RoleMappingsLogic.actions.resetState(); expect(RoleMappingsLogic.values.dataLoading).toEqual(true); @@ -234,7 +214,7 @@ describe('RoleMappingsLogic', () => { }); it('openRoleMappingFlyout', () => { - mount(mappingServerProps); + mount(mappingsServerProps); RoleMappingsLogic.actions.openRoleMappingFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true); @@ -243,7 +223,7 @@ describe('RoleMappingsLogic', () => { it('closeRoleMappingFlyout', () => { mount({ - ...mappingServerProps, + ...mappingsServerProps, roleMappingFlyoutOpen: true, }); RoleMappingsLogic.actions.closeRoleMappingFlyout(); @@ -275,40 +255,20 @@ describe('RoleMappingsLogic', () => { }); describe('initializeRoleMapping', () => { - it('calls API and sets values for new mapping', async () => { - const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); - http.get.mockReturnValue(Promise.resolve(mappingServerProps)); - RoleMappingsLogic.actions.initializeRoleMapping(); - - expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/role_mappings/new'); - await nextTick(); - expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); - }); - - it('calls API and sets values for existing mapping', async () => { - const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMappingData'); - http.get.mockReturnValue(Promise.resolve(mappingServerProps)); - RoleMappingsLogic.actions.initializeRoleMapping('123'); - - expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/role_mappings/123'); - await nextTick(); - expect(setRoleMappingDataSpy).toHaveBeenCalledWith(mappingServerProps); - }); - - it('handles error', async () => { - http.get.mockReturnValue(Promise.reject('this is an error')); - RoleMappingsLogic.actions.initializeRoleMapping(); - await nextTick(); + it('sets values for existing mapping', () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeRoleMapping(wsRoleMapping.id); - expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + expect(setRoleMappingDataSpy).toHaveBeenCalledWith(wsRoleMapping); }); - it('shows error when there is a 404 status', async () => { - http.get.mockReturnValue(Promise.reject({ status: 404 })); + it('does not set data for new mapping', async () => { + const setRoleMappingDataSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); RoleMappingsLogic.actions.initializeRoleMapping(); - await nextTick(); - expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND); + expect(setRoleMappingDataSpy).not.toHaveBeenCalledWith(wsRoleMapping); }); }); @@ -320,7 +280,7 @@ describe('RoleMappingsLogic', () => { ); RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); - http.post.mockReturnValue(Promise.resolve(mappingServerProps)); + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/org/role_mappings', { @@ -331,7 +291,7 @@ describe('RoleMappingsLogic', () => { rules: { username: '', }, - groups: [], + groups: [defaultGroup.id], }), }); await nextTick(); @@ -344,9 +304,9 @@ describe('RoleMappingsLogic', () => { RoleMappingsLogic.actions, 'initializeRoleMappings' ); - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); + RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); - http.put.mockReturnValue(Promise.resolve(mappingServerProps)); + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); RoleMappingsLogic.actions.handleSaveMapping(); expect(http.put).toHaveBeenCalledWith( @@ -408,7 +368,7 @@ describe('RoleMappingsLogic', () => { RoleMappingsLogic.actions, 'initializeRoleMappings' ); - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); + RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); http.delete.mockReturnValue(Promise.resolve({})); RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); @@ -421,7 +381,7 @@ describe('RoleMappingsLogic', () => { }); it('handles error', async () => { - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); + RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); http.delete.mockReturnValue(Promise.reject('this is an error')); RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); await nextTick(); @@ -430,7 +390,7 @@ describe('RoleMappingsLogic', () => { }); it('will do nothing if not confirmed', async () => { - RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps); + RoleMappingsLogic.actions.setRoleMapping(wsRoleMapping); window.confirm = () => false; RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index aee780ac18971f..361425b7a78a12 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -13,10 +13,9 @@ import { clearFlashMessages, flashAPIErrors, setSuccessMessage, - setErrorMessage, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; -import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants'; +import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { AttributeName } from '../../../shared/types'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; @@ -30,16 +29,11 @@ import { interface RoleMappingsServerDetails { roleMappings: WSRoleMapping[]; - multipleAuthProvidersConfig: boolean; -} - -interface RoleMappingServerDetails { attributes: string[]; authProviders: string[]; availableGroups: RoleGroup[]; elasticsearchRoles: string[]; multipleAuthProvidersConfig: boolean; - roleMapping?: WSRoleMapping; } const getFirstAttributeName = (roleMapping: WSRoleMapping): AttributeName => @@ -62,7 +56,7 @@ interface RoleMappingsActions { initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; - setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails; + setRoleMapping(roleMapping: WSRoleMapping): { roleMapping: WSRoleMapping }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; openRoleMappingFlyout(): void; closeRoleMappingFlyout(): void; @@ -93,7 +87,7 @@ export const RoleMappingsLogic = kea data, - setRoleMappingData: (data: RoleMappingServerDetails) => data, + setRoleMapping: (roleMapping: WSRoleMapping) => ({ roleMapping }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), @@ -117,7 +111,6 @@ export const RoleMappingsLogic = kea false, - setRoleMappingData: () => false, resetState: () => true, }, ], @@ -132,32 +125,31 @@ export const RoleMappingsLogic = kea multipleAuthProvidersConfig, - setRoleMappingData: (_, { multipleAuthProvidersConfig }) => multipleAuthProvidersConfig, resetState: () => false, }, ], availableGroups: [ [], { - setRoleMappingData: (_, { availableGroups }) => availableGroups, + setRoleMappingsData: (_, { availableGroups }) => availableGroups, }, ], attributes: [ [], { - setRoleMappingData: (_, { attributes }) => attributes, + setRoleMappingsData: (_, { attributes }) => attributes, }, ], elasticsearchRoles: [ [], { - setRoleMappingData: (_, { elasticsearchRoles }) => elasticsearchRoles, + setRoleMappingsData: (_, { elasticsearchRoles }) => elasticsearchRoles, }, ], roleMapping: [ null, { - setRoleMappingData: (_, { roleMapping }) => roleMapping || null, + setRoleMapping: (_, { roleMapping }) => roleMapping, resetState: () => null, closeRoleMappingFlyout: () => null, }, @@ -165,23 +157,21 @@ export const RoleMappingsLogic = kea - roleMapping ? (roleMapping.roleType as Role) : 'admin', + setRoleMapping: (_, { roleMapping }) => roleMapping.roleType as Role, handleRoleChange: (_, { roleType }) => roleType, }, ], includeInAllGroups: [ false, { - setRoleMappingData: (_, { roleMapping }) => (roleMapping ? roleMapping.allGroups : false), + setRoleMapping: (_, { roleMapping }) => roleMapping.allGroups, handleAllGroupsSelectionChange: (_, { selected }) => selected, }, ], attributeValue: [ '', { - setRoleMappingData: (_, { roleMapping }) => - roleMapping ? getFirstAttributeValue(roleMapping) : '', + setRoleMapping: (_, { roleMapping }) => getFirstAttributeValue(roleMapping), handleAttributeSelectorChange: (_, { value, firstElasticsearchRole }) => value === 'role' ? firstElasticsearchRole : '', handleAttributeValueChange: (_, { value }) => value, @@ -192,8 +182,7 @@ export const RoleMappingsLogic = kea - roleMapping ? getFirstAttributeName(roleMapping) : 'username', + setRoleMapping: (_, { roleMapping }) => getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', closeRoleMappingFlyout: () => 'username', @@ -202,14 +191,14 @@ export const RoleMappingsLogic = kea - roleMapping - ? new Set(roleMapping.groups.map((group) => group.id)) - : new Set( - availableGroups - .filter((group) => group.name === DEFAULT_GROUP_NAME) - .map((group) => group.id) - ), + setRoleMappingsData: (_, { availableGroups }) => + new Set( + availableGroups + .filter((group) => group.name === DEFAULT_GROUP_NAME) + .map((group) => group.id) + ), + setRoleMapping: (_, { roleMapping }) => + new Set(roleMapping.groups.map((group: RoleGroup) => group.id)), handleGroupSelectionChange: (_, { groupIds }) => { const newSelectedGroupNames = new Set() as Set; groupIds.forEach((groupId) => newSelectedGroupNames.add(groupId)); @@ -221,7 +210,7 @@ export const RoleMappingsLogic = kea authProviders, + setRoleMappingsData: (_, { authProviders }) => authProviders, }, ], selectedAuthProviders: [ @@ -230,15 +219,15 @@ export const RoleMappingsLogic = kea { const previouslyContainedAny = previous.includes(ANY_AUTH_PROVIDER); const newSelectionsContainAny = value.includes(ANY_AUTH_PROVIDER); + const hasItems = value.length > 0; - if (value.length < 1) return [ANY_AUTH_PROVIDER]; if (value.length === 1) return value; - if (!newSelectionsContainAny) return value; - if (previouslyContainedAny) return value.filter((v) => v !== ANY_AUTH_PROVIDER); + if (!newSelectionsContainAny && hasItems) return value; + if (previouslyContainedAny && hasItems) + return value.filter((v) => v !== ANY_AUTH_PROVIDER); return [ANY_AUTH_PROVIDER]; }, - setRoleMappingData: (_, { roleMapping }) => - roleMapping ? roleMapping.authProvider : [ANY_AUTH_PROVIDER], + setRoleMapping: (_, { roleMapping }) => roleMapping.authProvider, }, ], roleMappingFlyoutOpen: [ @@ -283,21 +272,8 @@ export const RoleMappingsLogic = kea { - const { http } = HttpLogic.values; - const route = roleMappingId - ? `/api/workplace_search/org/role_mappings/${roleMappingId}` - : '/api/workplace_search/org/role_mappings/new'; - - try { - const response = await http.get(route); - actions.setRoleMappingData(response); - } catch (e) { - if (e.status === 404) { - setErrorMessage(ROLE_MAPPING_NOT_FOUND); - } else { - flashAPIErrors(e); - } - } + const roleMapping = values.roleMappings.find(({ id }) => id === roleMappingId); + if (roleMapping) actions.setRoleMapping(roleMapping); }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts index a126d06f303b4c..718597c12e9c5b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts @@ -7,11 +7,7 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { - registerRoleMappingsRoute, - registerRoleMappingRoute, - registerNewRoleMappingRoute, -} from './role_mappings'; +import { registerRoleMappingsRoute, registerRoleMappingRoute } from './role_mappings'; const roleMappingBaseSchema = { rules: { username: 'user' }, @@ -80,29 +76,6 @@ describe('role mappings routes', () => { }); }); - describe('GET /api/app_search/role_mappings/{id}', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/app_search/role_mappings/{id}', - }); - - registerRoleMappingRoute({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/:id', - }); - }); - }); - describe('PUT /api/app_search/role_mappings/{id}', () => { let mockRouter: MockRouter; @@ -160,27 +133,4 @@ describe('role mappings routes', () => { }); }); }); - - describe('GET /api/app_search/role_mappings/new', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/app_search/role_mappings/new', - }); - - registerNewRoleMappingRoute({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/role_mappings/new', - }); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts index 86e17b575e019a..75724a3344d6de 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts @@ -48,20 +48,6 @@ export function registerRoleMappingRoute({ router, enterpriseSearchRequestHandler, }: RouteDependencies) { - router.get( - { - path: '/api/app_search/role_mappings/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/:id', - }) - ); - router.put( { path: '/api/app_search/role_mappings/{id}', @@ -92,23 +78,7 @@ export function registerRoleMappingRoute({ ); } -export function registerNewRoleMappingRoute({ - router, - enterpriseSearchRequestHandler, -}: RouteDependencies) { - router.get( - { - path: '/api/app_search/role_mappings/new', - validate: false, - }, - enterpriseSearchRequestHandler.createRequest({ - path: '/role_mappings/new', - }) - ); -} - export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerRoleMappingsRoute(dependencies); registerRoleMappingRoute(dependencies); - registerNewRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts index 0dade134767e44..a945866da5ef21 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts @@ -7,11 +7,7 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; -import { - registerOrgRoleMappingsRoute, - registerOrgRoleMappingRoute, - registerOrgNewRoleMappingRoute, -} from './role_mappings'; +import { registerOrgRoleMappingsRoute, registerOrgRoleMappingRoute } from './role_mappings'; describe('role mappings routes', () => { describe('GET /api/workplace_search/org/role_mappings', () => { @@ -60,29 +56,6 @@ describe('role mappings routes', () => { }); }); - describe('GET /api/workplace_search/org/role_mappings/{id}', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/workplace_search/org/role_mappings/{id}', - }); - - registerOrgRoleMappingRoute({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/org/role_mappings/:id', - }); - }); - }); - describe('PUT /api/workplace_search/org/role_mappings/{id}', () => { let mockRouter: MockRouter; @@ -128,27 +101,4 @@ describe('role mappings routes', () => { }); }); }); - - describe('GET /api/workplace_search/org/role_mappings/new', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/workplace_search/org/role_mappings/new', - }); - - registerOrgNewRoleMappingRoute({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/org/role_mappings/new', - }); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts index 5a6359c1cd8369..a0fcec63cbb272 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts @@ -48,20 +48,6 @@ export function registerOrgRoleMappingRoute({ router, enterpriseSearchRequestHandler, }: RouteDependencies) { - router.get( - { - path: '/api/workplace_search/org/role_mappings/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - }, - }, - enterpriseSearchRequestHandler.createRequest({ - path: '/ws/org/role_mappings/:id', - }) - ); - router.put( { path: '/api/workplace_search/org/role_mappings/{id}', @@ -92,23 +78,7 @@ export function registerOrgRoleMappingRoute({ ); } -export function registerOrgNewRoleMappingRoute({ - router, - enterpriseSearchRequestHandler, -}: RouteDependencies) { - router.get( - { - path: '/api/workplace_search/org/role_mappings/new', - validate: false, - }, - enterpriseSearchRequestHandler.createRequest({ - path: '/ws/org/role_mappings/new', - }) - ); -} - export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerOrgRoleMappingsRoute(dependencies); registerOrgRoleMappingRoute(dependencies); - registerOrgNewRoleMappingRoute(dependencies); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 49836e9ed4ca68..d707fd162ae020 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -18,7 +18,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import type { Section } from '../sections'; -import { AlphaMessaging, SettingFlyout } from '../components'; +import { SettingFlyout } from '../components'; import { useLink, useConfig, useUrlModal } from '../hooks'; interface Props { @@ -144,7 +144,6 @@ export const DefaultLayout: React.FunctionComponent = ({ ) : null} {children} - ); diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index a6958ba88449a0..b3626a83c41d1f 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -688,6 +688,129 @@ describe('Package policy service', () => { expect(modifiedStream.vars!.paths.value).toEqual(expect.arrayContaining(['north', 'south'])); expect(modifiedStream.vars!.period.value).toEqual('12mo'); }); + + it('should add new input vars when updating', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockPackagePolicy = createPackagePolicyMock(); + const mockInputs = [ + { + config: {}, + enabled: true, + keep_enabled: true, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'dalmatian', + }, + cat: { + type: 'text', + value: 'siamese', + frozen: true, + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['north', 'south'], + type: 'text', + frozen: true, + }, + }, + }, + ], + }, + ]; + const inputsUpdate = [ + { + config: {}, + enabled: false, + type: 'endpoint', + vars: { + dog: { + type: 'text', + value: 'labrador', + }, + cat: { + type: 'text', + value: 'tabby', + }, + }, + streams: [ + { + data_stream: { + type: 'birds', + dataset: 'migratory.patterns', + }, + enabled: false, + id: `endpoint-migratory.patterns-${mockPackagePolicy.id}`, + vars: { + paths: { + value: ['east', 'west'], + type: 'text', + }, + period: { + value: '12mo', + type: 'text', + }, + }, + }, + ], + }, + ]; + const attributes = { + ...mockPackagePolicy, + inputs: mockInputs, + }; + + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes, + }); + + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string, + attrs: any + ): Promise> => { + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: attrs, + }); + return attrs; + } + ); + const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const result = await packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + 'the-package-policy-id', + { ...mockPackagePolicy, inputs: inputsUpdate } + ); + + const [modifiedInput] = result.inputs; + expect(modifiedInput.enabled).toEqual(true); + expect(modifiedInput.vars!.dog.value).toEqual('labrador'); + expect(modifiedInput.vars!.cat.value).toEqual('siamese'); + const [modifiedStream] = modifiedInput.streams; + expect(modifiedStream.vars!.paths.value).toEqual(expect.arrayContaining(['north', 'south'])); + expect(modifiedStream.vars!.period.value).toEqual('12mo'); + }); }); describe('runExternalCallbacks', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 93bcef458279c0..1cda159429984c 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -649,11 +649,16 @@ function _enforceFrozenVars( newVars: Record ) { const resultVars: Record = {}; + for (const [key, val] of Object.entries(newVars)) { + if (oldVars[key]?.frozen) { + resultVars[key] = oldVars[key]; + } else { + resultVars[key] = val; + } + } for (const [key, val] of Object.entries(oldVars)) { - if (val.frozen) { + if (!newVars[key] && val.frozen) { resultVars[key] = val; - } else { - resultVars[key] = newVars[key]; } } return resultVars; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts index 76ebe21a513671..78e3f2dab0d1db 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -162,7 +162,7 @@ describe('', () => { const { exists, find } = testBed; expect(exists('pipelineLoadError')).toBe(true); - expect(find('pipelineLoadError').text()).toContain('Unable to load pipelines.'); + expect(find('pipelineLoadError').text()).toContain('Unable to load pipelines'); }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/app.tsx b/x-pack/plugins/ingest_pipelines/public/application/app.tsx index 1cca7a0721fbc0..da8f74e1efae59 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/app.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/app.tsx @@ -6,7 +6,7 @@ */ import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { Router, Switch, Route } from 'react-router-dom'; @@ -19,7 +19,6 @@ import { useAuthorizationContext, WithPrivileges, SectionLoading, - NotAuthorizedSection, } from '../shared_imports'; import { PipelinesList, PipelinesCreate, PipelinesEdit, PipelinesClone } from './sections'; @@ -61,35 +60,42 @@ export const App: FunctionComponent = () => { {({ isLoading, hasPrivileges, privilegesMissing }) => { if (isLoading) { return ( - - - + + + + + ); } if (!hasPrivileges) { return ( - - + +

+ +

} - message={ - + body={ +

+ +

} />
diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx index c51ed94cbc1166..f68b64cc5f6136 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx @@ -8,6 +8,7 @@ import React, { FunctionComponent, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import { EuiPageContent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SectionLoading, useKibana, attemptToURIDecode } from '../../../shared_imports'; @@ -45,12 +46,14 @@ export const PipelinesClone: FunctionComponent> if (isLoading && isInitialRequest) { return ( - - - + + + + + ); } else { // We still show the create form even if we were not able to load the diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx index eefc74e2dc6fad..5aa9205e1e1e55 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx @@ -8,15 +8,7 @@ import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiPageBody, - EuiPageContent, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPageHeader, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { getListPath } from '../../services/navigation'; import { Pipeline } from '../../../../common/types'; @@ -64,49 +56,43 @@ export const PipelinesCreate: React.FunctionComponent - - - - - -

- -

-
-
- - - - - - -
-
- - - - -
-
+ <> + + + + } + rightSideItems={[ + + + , + ]} + /> + + + + + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx index 6011a36d292fac..ea47f4c9a25e92 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx @@ -9,16 +9,14 @@ import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiPageBody, + EuiPageHeader, + EuiEmptyPrompt, EuiPageContent, EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, + EuiButton, EuiButtonEmpty, } from '@elastic/eui'; -import { EuiCallOut } from '@elastic/eui'; import { Pipeline } from '../../../../common/types'; import { useKibana, SectionLoading, attemptToURIDecode } from '../../../shared_imports'; @@ -42,7 +40,9 @@ export const PipelinesEdit: React.FunctionComponent { setIsSaving(true); @@ -68,88 +68,92 @@ export const PipelinesEdit: React.FunctionComponent - - + return ( + + + + + ); - } else if (error) { - content = ( - <> - + +

+ +

} - color="danger" - iconType="alert" - data-test-subj="fetchPipelineError" - > -

{error.message}

-
- - + body={

{error.message}

} + actions={ + + + + } + /> + ); - } else if (pipeline) { - content = ( + } + + return ( + <> + + + + } + rightSideItems={[ + + + , + ]} + /> + + + - ); - } - - return ( - - - - - - -

- -

-
-
- - - - - - -
-
- - - - {content} -
-
+ ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx index 7d69bd3fb8cf37..9f401bca5431f2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx @@ -8,7 +8,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt, EuiLink, EuiPageBody, EuiPageContent, EuiButton } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiLink, EuiPageContent, EuiButton } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { ScopedHistory } from 'kibana/public'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; @@ -20,46 +20,43 @@ export const EmptyList: FunctionComponent = () => { const history = useHistory() as ScopedHistory; return ( - - - - {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptTitle', { - defaultMessage: 'Start by creating a pipeline', + + + {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptTitle', { + defaultMessage: 'Start by creating a pipeline', + })} + + } + body={ +

+ {' '} + + {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptDocumentionLink', { + defaultMessage: 'Learn more', })} - - } - body={ -

- -
- - {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptDocumentionLink', { - defaultMessage: 'Learn more', - })} - -

- } - actions={ - - {i18n.translate('xpack.ingestPipelines.list.table.emptyPrompt.createButtonLabel', { - defaultMessage: 'Create a pipeline', - })} - - } - /> -
-
+ +

+ } + actions={ + + {i18n.translate('xpack.ingestPipelines.list.table.emptyPrompt.createButtonLabel', { + defaultMessage: 'Create a pipeline', + })} + + } + /> + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 454747fe0870e4..ae68cfcb399f09 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -12,16 +12,12 @@ import { Location } from 'history'; import { parse } from 'query-string'; import { - EuiPageBody, - EuiPageContent, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, + EuiPageHeader, EuiButtonEmpty, - EuiCallOut, - EuiLink, + EuiPageContent, + EuiEmptyPrompt, + EuiButton, EuiSpacer, - EuiText, } from '@elastic/eui'; import { Pipeline } from '../../../../common/types'; @@ -81,33 +77,50 @@ export const PipelinesList: React.FunctionComponent = ({ history.push(getListPath()); }; - if (data && data.length === 0) { - return ; + if (error) { + return ( + + + + + } + body={

{error.message}

} + actions={ + + + + } + /> +
+ ); } - let content: React.ReactNode; - if (isLoading) { - content = ( - - - - ); - } else if (data?.length) { - content = ( - + return ( + + + + + ); } + if (data && data.length === 0) { + return ; + } + const renderFlyout = (): React.ReactNode => { if (!showFlyout) { return; @@ -134,71 +147,47 @@ export const PipelinesList: React.FunctionComponent = ({ return ( <> - - - - - -

- -

-
- - - - - -
-
- - - - - - - - {/* Error call out for pipeline table */} - {error ? ( - - - - ), - }} - /> - } + + - ) : ( - content - )} -
-
+ + } + description={ + + } + rightSideItems={[ + + + , + ]} + /> + + + + + {renderFlyout()} {pipelinesToDelete?.length > 0 ? ( & { + returnToOrigin: boolean; + dashboardId?: string | null; + onTitleDuplicate?: OnSaveProps['onTitleDuplicate']; + newDescription?: string; + newTags?: string[]; +}; export function App({ history, @@ -48,26 +53,23 @@ export function App({ initialInput, incomingState, redirectToOrigin, - redirectToDashboard, setHeaderActionMenu, initialContext, }: LensAppProps) { + const lensAppServices = useKibana().services; + const { data, chrome, - overlays, uiSettings, application, - stateTransfer, notifications, - attributeService, - savedObjectsClient, savedObjectsTagging, getOriginatingAppName, // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag, - } = useKibana().services; + } = lensAppServices; const dispatch = useLensDispatch(); const dispatchSetState: DispatchSetState = useCallback( @@ -205,150 +207,39 @@ export function App({ getIsByValueMode, application, chrome, - initialInput, appState.isLinkedToOriginatingApp, appState.persistedDoc, ]); - const tagsIds = - appState.persistedDoc && savedObjectsTagging - ? savedObjectsTagging.ui.getTagIdsFromReferences(appState.persistedDoc.references) - : []; - - const runSave: RunSave = async (saveProps, options) => { - if (!lastKnownDoc) { - return; - } - - let references = lastKnownDoc.references; - if (savedObjectsTagging) { - references = savedObjectsTagging.ui.updateTagsReferences( - references, - saveProps.newTags || tagsIds - ); - } - - const docToSave = { - ...getLastKnownDocWithoutPinnedFilters(lastKnownDoc)!, - description: saveProps.newDescription, - title: saveProps.newTitle, - references, - }; - - // Required to serialize filters in by value mode until - // https://github.com/elastic/kibana/issues/77588 is fixed - if (getIsByValueMode()) { - docToSave.state.filters.forEach((filter) => { - if (typeof filter.meta.value === 'function') { - delete filter.meta.value; + const runSave = (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { + return runSaveLensVisualization( + { + lastKnownDoc, + getIsByValueMode, + savedObjectsTagging, + initialInput, + redirectToOrigin, + persistedDoc: appState.persistedDoc, + onAppLeave, + redirectTo, + ...lensAppServices, + }, + saveProps, + options + ).then( + (newState) => { + if (newState) { + dispatchSetState(newState); + setIsSaveModalVisible(false); } - }); - } - - const originalInput = saveProps.newCopyOnSave ? undefined : initialInput; - const originalSavedObjectId = (originalInput as LensByReferenceInput)?.savedObjectId; - if (options.saveToLibrary) { - try { - await checkForDuplicateTitle( - { - id: originalSavedObjectId, - title: docToSave.title, - copyOnSave: saveProps.newCopyOnSave, - lastSavedTitle: lastKnownDoc.title, - getEsType: () => 'lens', - getDisplayName: () => - i18n.translate('xpack.lens.app.saveModalType', { - defaultMessage: 'Lens visualization', - }), - }, - saveProps.isTitleDuplicateConfirmed, - saveProps.onTitleDuplicate, - { - savedObjectsClient, - overlays, - } - ); - } catch (e) { - // ignore duplicate title failure, user notified in save modal - return; - } - } - try { - const newInput = (await attributeService.wrapAttributes( - docToSave, - options.saveToLibrary, - originalInput - )) as LensEmbeddableInput; - - if (saveProps.returnToOrigin && redirectToOrigin) { - // disabling the validation on app leave because the document has been saved. - onAppLeave((actions) => { - return actions.default(); - }); - redirectToOrigin({ input: newInput, isCopied: saveProps.newCopyOnSave }); - return; - } else if (saveProps.dashboardId && redirectToDashboard) { - // disabling the validation on app leave because the document has been saved. - onAppLeave((actions) => { - return actions.default(); - }); - redirectToDashboard(newInput, saveProps.dashboardId); - return; - } - - notifications.toasts.addSuccess( - i18n.translate('xpack.lens.app.saveVisualization.successNotificationText', { - defaultMessage: `Saved '{visTitle}'`, - values: { - visTitle: docToSave.title, - }, - }) - ); - - if ( - attributeService.inputIsRefType(newInput) && - newInput.savedObjectId !== originalSavedObjectId - ) { - chrome.recentlyAccessed.add( - getFullPath(newInput.savedObjectId), - docToSave.title, - newInput.savedObjectId - ); - - dispatchSetState({ isLinkedToOriginatingApp: false }); - - setIsSaveModalVisible(false); - // remove editor state so the connection is still broken after reload - stateTransfer.clearEditorState(APP_ID); - - redirectTo(newInput.savedObjectId); - return; + }, + () => { + // error is handled inside the modal + // so ignoring it here } - - const newDoc = { - ...docToSave, - ...newInput, - }; - - dispatchSetState({ - isLinkedToOriginatingApp: false, - persistedDoc: newDoc, - lastKnownDoc: newDoc, - }); - - setIsSaveModalVisible(false); - } catch (e) { - // eslint-disable-next-line no-console - console.dir(e); - trackUiEvent('save_failed'); - setIsSaveModalVisible(false); - } + ); }; - const savingToLibraryPermitted = Boolean( - appState.isSaveable && application.capabilities.visualize.save - ); - return ( <>
@@ -371,21 +262,24 @@ export function App({ /> )}
- { setIsSaveModalVisible(false); }} getAppNameFromId={() => getOriginatingAppName()} lastKnownDoc={lastKnownDoc} + onAppLeave={onAppLeave} + persistedDoc={appState.persistedDoc} + initialInput={initialInput} + redirectTo={redirectTo} + redirectToOrigin={redirectToOrigin} returnToOriginSwitchLabel={ getIsByValueMode() && initialInput ? i18n.translate('xpack.lens.app.updatePanel', { @@ -419,20 +313,3 @@ const MemoizedEditorFrameWrapper = React.memo(function EditorFrameWrapper({ /> ); }); - -function getLastKnownDocWithoutPinnedFilters(doc?: Document) { - if (!doc) return undefined; - const [pinnedFilters, appFilters] = partition( - injectFilterReferences(doc.state?.filters || [], doc.references), - esFilters.isFilterPinned - ); - return pinnedFilters?.length - ? { - ...doc, - state: { - ...doc.state, - filters: appFilters, - }, - } - : doc; -} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 3e56fbb2003cb4..46d2009756d2ce 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -7,7 +7,7 @@ import React, { FC, useCallback } from 'react'; -import { AppMountParameters, CoreSetup } from 'kibana/public'; +import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { History } from 'history'; @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import { DashboardFeatureFlagConfig } from 'src/plugins/dashboard/public'; import { Provider } from 'react-redux'; -import { uniq, isEqual } from 'lodash'; +import { isEqual } from 'lodash'; import { EmbeddableEditorState } from 'src/plugins/embeddable/public'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -26,7 +26,7 @@ import { App } from './app'; import { EditorFrameStart } from '../types'; import { addHelpMenuToAppChrome } from '../help_menu_util'; import { LensPluginStartDependencies } from '../plugin'; -import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID, getFullPath } from '../../common'; +import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common'; import { LensEmbeddableInput, LensByReferenceInput, @@ -44,37 +44,21 @@ import { LensRootStore, setState, } from '../state_management'; -import { getAllIndexPatterns, getResolvedDateRange } from '../utils'; -import { injectFilterReferences } from '../persistence'; +import { getResolvedDateRange } from '../utils'; +import { getLastKnownDoc } from './save_modal_container'; -export async function mountApp( - core: CoreSetup, - params: AppMountParameters, - mountProps: { - createEditorFrame: EditorFrameStart['createInstance']; - getByValueFeatureFlag: () => Promise; - attributeService: () => Promise; - getPresentationUtilContext: () => Promise; - } -) { - const { - createEditorFrame, - getByValueFeatureFlag, - attributeService, - getPresentationUtilContext, - } = mountProps; - const [coreStart, startDependencies] = await core.getStartServices(); +export async function getLensServices( + coreStart: CoreStart, + startDependencies: LensPluginStartDependencies, + attributeService: () => Promise +): Promise { const { data, navigation, embeddable, savedObjectsTagging } = startDependencies; - const instance = await createEditorFrame(); const storage = new Storage(localStorage); const stateTransfer = embeddable?.getStateTransfer(); - const historyLocationState = params.history.location.state as HistoryLocationState; const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(APP_ID); - const dashboardFeatureFlag = await getByValueFeatureFlag(); - - const lensServices: LensAppServices = { + return { data, storage, navigation, @@ -88,6 +72,8 @@ export async function mountApp( application: coreStart.application, notifications: coreStart.notifications, savedObjectsClient: coreStart.savedObjects.client, + presentationUtil: startDependencies.presentationUtil, + dashboard: startDependencies.dashboard, getOriginatingAppName: () => { return embeddableEditorIncomingState?.originatingApp ? stateTransfer?.getAppNameFromId(embeddableEditorIncomingState.originatingApp) @@ -95,8 +81,29 @@ export async function mountApp( }, // Temporarily required until the 'by value' paradigm is default. - dashboardFeatureFlag, + dashboardFeatureFlag: startDependencies.dashboard.dashboardFeatureFlagConfig, }; +} + +export async function mountApp( + core: CoreSetup, + params: AppMountParameters, + mountProps: { + createEditorFrame: EditorFrameStart['createInstance']; + attributeService: () => Promise; + getPresentationUtilContext: () => Promise; + } +) { + const { createEditorFrame, attributeService, getPresentationUtilContext } = mountProps; + const [coreStart, startDependencies] = await core.getStartServices(); + const instance = await createEditorFrame(); + const historyLocationState = params.history.location.state as HistoryLocationState; + + const lensServices = await getLensServices(coreStart, startDependencies, attributeService); + + const { stateTransfer, data, storage, dashboardFeatureFlag } = lensServices; + + const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(APP_ID); addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks); coreStart.chrome.docTitle.change( @@ -130,23 +137,6 @@ export async function mountApp( } }; - const redirectToDashboard = (embeddableInput: LensEmbeddableInput, dashboardId: string) => { - if (!lensServices.dashboardFeatureFlag.allowByValueEmbeddables) { - throw new Error('redirectToDashboard called with by-value embeddables disabled'); - } - - const state = { - input: embeddableInput, - type: LENS_EMBEDDABLE_TYPE, - }; - - const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; - stateTransfer.navigateToWithEmbeddablePackage('dashboards', { - state, - path, - }); - }; - const redirectToOrigin = (props?: RedirectToOriginProps) => { if (!embeddableEditorIncomingState?.originatingApp) { throw new Error('redirectToOrigin called without an originating app'); @@ -215,7 +205,6 @@ export async function mountApp( initialInput={initialInput} redirectTo={redirectCallback} redirectToOrigin={redirectToOrigin} - redirectToDashboard={redirectToDashboard} onAppLeave={params.onAppLeave} setHeaderActionMenu={params.setHeaderActionMenu} history={props.history} @@ -299,73 +288,45 @@ export function loadDocument( } lensStore.dispatch(setState({ isAppLoading: true })); - attributeService - .unwrapAttributes(initialInput) - .then((attributes) => { - if (!initialInput) { - return; - } - const doc = { - ...initialInput, - ...attributes, - type: LENS_EMBEDDABLE_TYPE, - }; - - if (attributeService.inputIsRefType(initialInput)) { - chrome.recentlyAccessed.add( - getFullPath(initialInput.savedObjectId), - attributes.title, - initialInput.savedObjectId + getLastKnownDoc({ + initialInput, + attributeService, + data, + chrome, + notifications, + }).then( + (newState) => { + if (newState) { + const { doc, indexPatterns } = newState; + const currentSessionId = data.search.session.getSessionId(); + lensStore.dispatch( + setState({ + query: doc.state.query, + isAppLoading: false, + indexPatternsForTopNav: indexPatterns, + lastKnownDoc: doc, + searchSessionId: + dashboardFeatureFlag.allowByValueEmbeddables && + Boolean(embeddableEditorIncomingState?.originatingApp) && + !(initialInput as LensByReferenceInput)?.savedObjectId && + currentSessionId + ? currentSessionId + : data.search.session.start(), + ...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null), + }) ); + } else { + redirectCallback(); } - const indexPatternIds = uniq( - doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) - ); - getAllIndexPatterns(indexPatternIds, data.indexPatterns) - .then(({ indexPatterns }) => { - // Don't overwrite any pinned filters - data.query.filterManager.setAppFilters( - injectFilterReferences(doc.state.filters, doc.references) - ); - const currentSessionId = data.search.session.getSessionId(); - lensStore.dispatch( - setState({ - query: doc.state.query, - isAppLoading: false, - indexPatternsForTopNav: indexPatterns, - lastKnownDoc: doc, - searchSessionId: - dashboardFeatureFlag.allowByValueEmbeddables && - Boolean(embeddableEditorIncomingState?.originatingApp) && - !(initialInput as LensByReferenceInput)?.savedObjectId && - currentSessionId - ? currentSessionId - : data.search.session.start(), - ...(!isEqual(persistedDoc, doc) ? { persistedDoc: doc } : null), - }) - ); - }) - .catch((e) => { - lensStore.dispatch( - setState({ - isAppLoading: false, - }) - ); - redirectCallback(); - }); - }) - .catch((e) => { + }, + () => { lensStore.dispatch( setState({ isAppLoading: false, }) ); - notifications.toasts.addDanger( - i18n.translate('xpack.lens.app.docLoadingError', { - defaultMessage: 'Error loading saved document', - }) - ); redirectCallback(); - }); + } + ); } diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx new file mode 100644 index 00000000000000..27e8031f5fb6b8 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/save_modal_container.tsx @@ -0,0 +1,405 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { ChromeStart, NotificationsStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { partition, uniq } from 'lodash'; +import { SaveModal } from './save_modal'; +import { LensAppProps, LensAppServices } from './types'; +import type { SaveProps } from './app'; +import { Document, injectFilterReferences } from '../persistence'; +import { LensByReferenceInput, LensEmbeddableInput } from '../editor_frame_service/embeddable'; +import { LensAttributeService } from '../lens_attribute_service'; +import { + DataPublicPluginStart, + esFilters, + IndexPattern, +} from '../../../../../src/plugins/data/public'; +import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common'; +import { getAllIndexPatterns } from '../utils'; +import { trackUiEvent } from '../lens_ui_telemetry'; +import { checkForDuplicateTitle } from '../../../../../src/plugins/saved_objects/public'; +import { LensAppState } from '../state_management'; + +type ExtraProps = Pick & + Partial>; + +export type SaveModalContainerProps = { + isVisible: boolean; + originatingApp?: string; + persistedDoc?: Document; + lastKnownDoc?: Document; + returnToOriginSwitchLabel?: string; + onClose: () => void; + onSave?: () => void; + runSave?: (saveProps: SaveProps, options: { saveToLibrary: boolean }) => void; + isSaveable?: boolean; + getAppNameFromId?: () => string | undefined; + lensServices: LensAppServices; +} & ExtraProps; + +export function SaveModalContainer({ + returnToOriginSwitchLabel, + onClose, + onSave, + runSave, + isVisible, + persistedDoc, + originatingApp, + initialInput, + redirectTo, + redirectToOrigin, + getAppNameFromId = () => undefined, + isSaveable = true, + lastKnownDoc: initLastKnowDoc, + lensServices, +}: SaveModalContainerProps) { + const [lastKnownDoc, setLastKnownDoc] = useState(initLastKnowDoc); + + const { + attributeService, + notifications, + data, + chrome, + savedObjectsTagging, + application, + dashboardFeatureFlag, + } = lensServices; + + useEffect(() => { + setLastKnownDoc(initLastKnowDoc); + }, [initLastKnowDoc]); + + useEffect(() => { + async function loadLastKnownDoc() { + if (initialInput && isVisible) { + getLastKnownDoc({ + data, + initialInput, + chrome, + notifications, + attributeService, + }).then((result) => { + if (result) setLastKnownDoc(result.doc); + }); + } + } + + loadLastKnownDoc(); + }, [chrome, data, initialInput, notifications, attributeService, isVisible]); + + const tagsIds = + persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(persistedDoc.references) + : []; + + const runLensSave = (saveProps: SaveProps, options: { saveToLibrary: boolean }) => { + if (runSave) { + // inside lens, we use the function that's passed to it + runSave(saveProps, options); + } else { + if (attributeService && lastKnownDoc) { + runSaveLensVisualization( + { + ...lensServices, + lastKnownDoc, + initialInput, + attributeService, + redirectTo, + redirectToOrigin, + getIsByValueMode: () => false, + onAppLeave: () => {}, + }, + saveProps, + options + ).then(() => { + onSave?.(); + onClose(); + }); + } + } + }; + + const savingToLibraryPermitted = Boolean(isSaveable && application.capabilities.visualize.save); + + return ( + { + runLensSave(saveProps, options); + }} + onClose={onClose} + getAppNameFromId={getAppNameFromId} + lastKnownDoc={lastKnownDoc} + returnToOriginSwitchLabel={returnToOriginSwitchLabel} + /> + ); +} + +const redirectToDashboard = ({ + embeddableInput, + dashboardFeatureFlag, + dashboardId, + stateTransfer, +}: { + embeddableInput: LensEmbeddableInput; + dashboardId: string; + dashboardFeatureFlag: LensAppServices['dashboardFeatureFlag']; + stateTransfer: LensAppServices['stateTransfer']; +}) => { + if (!dashboardFeatureFlag.allowByValueEmbeddables) { + throw new Error('redirectToDashboard called with by-value embeddables disabled'); + } + + const state = { + input: embeddableInput, + type: LENS_EMBEDDABLE_TYPE, + }; + + const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { + state, + path, + }); +}; + +export const runSaveLensVisualization = async ( + props: { + lastKnownDoc?: Document; + getIsByValueMode: () => boolean; + persistedDoc?: Document; + } & ExtraProps & + LensAppServices, + saveProps: SaveProps, + options: { saveToLibrary: boolean } +): Promise | undefined> => { + if (!props.lastKnownDoc) { + return; + } + + const { + chrome, + initialInput, + lastKnownDoc, + persistedDoc, + savedObjectsClient, + overlays, + notifications, + stateTransfer, + attributeService, + savedObjectsTagging, + getIsByValueMode, + redirectToOrigin, + onAppLeave, + redirectTo, + dashboardFeatureFlag, + } = props; + + const tagsIds = + persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(persistedDoc.references) + : []; + + let references = lastKnownDoc.references; + if (savedObjectsTagging) { + references = savedObjectsTagging.ui.updateTagsReferences( + references, + saveProps.newTags || tagsIds + ); + } + + const docToSave = { + ...getLastKnownDocWithoutPinnedFilters(lastKnownDoc)!, + description: saveProps.newDescription, + title: saveProps.newTitle, + references, + }; + + // Required to serialize filters in by value mode until + // https://github.com/elastic/kibana/issues/77588 is fixed + if (getIsByValueMode()) { + docToSave.state.filters.forEach((filter) => { + if (typeof filter.meta.value === 'function') { + delete filter.meta.value; + } + }); + } + + const originalInput = saveProps.newCopyOnSave ? undefined : initialInput; + const originalSavedObjectId = (originalInput as LensByReferenceInput)?.savedObjectId; + if (options.saveToLibrary) { + try { + await checkForDuplicateTitle( + { + id: originalSavedObjectId, + title: docToSave.title, + copyOnSave: saveProps.newCopyOnSave, + lastSavedTitle: lastKnownDoc.title, + getEsType: () => 'lens', + getDisplayName: () => + i18n.translate('xpack.lens.app.saveModalType', { + defaultMessage: 'Lens visualization', + }), + }, + saveProps.isTitleDuplicateConfirmed, + saveProps.onTitleDuplicate, + { + savedObjectsClient, + overlays, + } + ); + } catch (e) { + // ignore duplicate title failure, user notified in save modal + throw e; + } + } + try { + const newInput = (await attributeService.wrapAttributes( + docToSave, + options.saveToLibrary, + originalInput + )) as LensEmbeddableInput; + + if (saveProps.returnToOrigin && redirectToOrigin) { + // disabling the validation on app leave because the document has been saved. + onAppLeave?.((actions) => { + return actions.default(); + }); + redirectToOrigin({ input: newInput, isCopied: saveProps.newCopyOnSave }); + return; + } else if (saveProps.dashboardId) { + // disabling the validation on app leave because the document has been saved. + onAppLeave?.((actions) => { + return actions.default(); + }); + redirectToDashboard({ + embeddableInput: newInput, + dashboardId: saveProps.dashboardId, + stateTransfer, + dashboardFeatureFlag, + }); + return; + } + + notifications.toasts.addSuccess( + i18n.translate('xpack.lens.app.saveVisualization.successNotificationText', { + defaultMessage: `Saved '{visTitle}'`, + values: { + visTitle: docToSave.title, + }, + }) + ); + + if ( + attributeService.inputIsRefType(newInput) && + newInput.savedObjectId !== originalSavedObjectId + ) { + chrome.recentlyAccessed.add( + getFullPath(newInput.savedObjectId), + docToSave.title, + newInput.savedObjectId + ); + + // remove editor state so the connection is still broken after reload + stateTransfer.clearEditorState?.(APP_ID); + + redirectTo?.(newInput.savedObjectId); + return { isLinkedToOriginatingApp: false }; + } + + const newDoc = { + ...docToSave, + ...newInput, + }; + + return { persistedDoc: newDoc, lastKnownDoc: newDoc, isLinkedToOriginatingApp: false }; + } catch (e) { + // eslint-disable-next-line no-console + console.dir(e); + trackUiEvent('save_failed'); + throw e; + } +}; + +export function getLastKnownDocWithoutPinnedFilters(doc?: Document) { + if (!doc) return undefined; + const [pinnedFilters, appFilters] = partition( + injectFilterReferences(doc.state?.filters || [], doc.references), + esFilters.isFilterPinned + ); + return pinnedFilters?.length + ? { + ...doc, + state: { + ...doc.state, + filters: appFilters, + }, + } + : doc; +} + +export const getLastKnownDoc = async ({ + initialInput, + attributeService, + data, + notifications, + chrome, +}: { + initialInput: LensEmbeddableInput; + attributeService: LensAttributeService; + data: DataPublicPluginStart; + notifications: NotificationsStart; + chrome: ChromeStart; +}): Promise<{ doc: Document; indexPatterns: IndexPattern[] } | undefined> => { + let doc: Document; + + try { + const attributes = await attributeService.unwrapAttributes(initialInput); + + doc = { + ...initialInput, + ...attributes, + type: LENS_EMBEDDABLE_TYPE, + }; + + if (attributeService.inputIsRefType(initialInput)) { + chrome.recentlyAccessed.add( + getFullPath(initialInput.savedObjectId), + attributes.title, + initialInput.savedObjectId + ); + } + const indexPatternIds = uniq( + doc.references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) + ); + const { indexPatterns } = await getAllIndexPatterns(indexPatternIds, data.indexPatterns); + + // Don't overwrite any pinned filters + data.query.filterManager.setAppFilters( + injectFilterReferences(doc.state.filters, doc.references) + ); + return { + doc, + indexPatterns, + }; + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.lens.app.docLoadingError', { + defaultMessage: 'Error loading saved document', + }) + ); + } +}; + +// eslint-disable-next-line import/no-default-export +export default SaveModalContainer; diff --git a/x-pack/plugins/lens/public/app_plugin/shared/saved_modal_lazy.tsx b/x-pack/plugins/lens/public/app_plugin/shared/saved_modal_lazy.tsx new file mode 100644 index 00000000000000..f1a537fe65928a --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/saved_modal_lazy.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense, useEffect, useState } from 'react'; + +import { EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui'; +import { CoreStart } from 'kibana/public'; +import type { SaveModalContainerProps } from '../save_modal_container'; +import type { LensAttributeService } from '../../lens_attribute_service'; +import type { LensPluginStartDependencies } from '../../plugin'; +import type { LensAppServices } from '../types'; +const SaveModal = React.lazy(() => import('../save_modal_container')); + +function LoadingSpinnerWithOverlay() { + return ( + + + + ); +} + +const LensSavedModalLazy = (props: SaveModalContainerProps) => { + return ( + }> + + + ); +}; + +export function getSaveModalComponent( + coreStart: CoreStart, + startDependencies: LensPluginStartDependencies, + attributeService: () => Promise +) { + return (props: Omit) => { + const [lensServices, setLensServices] = useState(); + + useEffect(() => { + async function loadLensService() { + const { getLensServices } = await import('../../async_services'); + const lensServicesT = await getLensServices(coreStart, startDependencies, attributeService); + + setLensServices(lensServicesT); + } + loadLensService(); + }, []); + + if (!lensServices) { + return ; + } + + const { ContextProvider: PresentationUtilContext } = lensServices.presentationUtil; + + return ( + + + + + + ); + }; +} diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 72850552723f33..d1e2d1cbdfc637 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -18,6 +18,7 @@ import { SavedObjectsStart, } from '../../../../../src/core/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { DashboardStart } from '../../../../../src/plugins/dashboard/public'; import { LensEmbeddableInput } from '../editor_frame_service/embeddable/embeddable'; import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public'; import { LensAttributeService } from '../lens_attribute_service'; @@ -33,6 +34,7 @@ import { EmbeddableStateTransfer, } from '../../../../../src/plugins/embeddable/public'; import { EditorFrameInstance } from '../types'; +import { PresentationUtilPluginStart } from '../../../../../src/plugins/presentation_util/public'; export interface RedirectToOriginProps { input?: LensEmbeddableInput; isCopied?: boolean; @@ -45,7 +47,6 @@ export interface LensAppProps { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; redirectTo: (savedObjectId?: string) => void; redirectToOrigin?: (props?: RedirectToOriginProps) => void; - redirectToDashboard?: (input: LensEmbeddableInput, dashboardId: string) => void; // The initial input passed in by the container when editing. Can be either by reference or by value. initialInput?: LensEmbeddableInput; @@ -91,6 +92,7 @@ export interface LensAppServices { chrome: ChromeStart; overlays: OverlayStart; storage: IStorageWrapper; + dashboard: DashboardStart; data: DataPublicPluginStart; uiSettings: IUiSettingsClient; application: ApplicationStart; @@ -101,6 +103,7 @@ export interface LensAppServices { savedObjectsClient: SavedObjectsStart['client']; savedObjectsTagging?: SavedObjectTaggingPluginStart; getOriginatingAppName: () => string | undefined; + presentationUtil: PresentationUtilPluginStart; // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag: DashboardFeatureFlagConfig; diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index b0ecc412c357ff..e7be2959556154 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -27,3 +27,4 @@ export * from './editor_frame_service/embeddable'; export * from './app_plugin/mounter'; export * from './lens_attribute_service'; export * from './lens_ui_telemetry'; +export * from './app_plugin/save_modal_container'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index a4be46f61990b6..7e3c8c3342e4ca 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -126,7 +126,11 @@ exports[`DatatableComponent it renders actions column when there are row actions }, }, "cellActions": undefined, - "display": "a", + "display":
+ a +
, "displayAsText": "a", "id": "a", }, @@ -163,7 +167,11 @@ exports[`DatatableComponent it renders actions column when there are row actions }, }, "cellActions": undefined, - "display": "b", + "display":
+ b +
, "displayAsText": "b", "id": "b", }, @@ -200,7 +208,11 @@ exports[`DatatableComponent it renders actions column when there are row actions }, }, "cellActions": undefined, - "display": "c", + "display":
+ c +
, "displayAsText": "c", "id": "c", }, @@ -360,7 +372,11 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, }, "cellActions": undefined, - "display": "a", + "display":
+ a +
, "displayAsText": "a", "id": "a", }, @@ -397,7 +413,11 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, }, "cellActions": undefined, - "display": "b", + "display":
+ b +
, "displayAsText": "b", "id": "b", }, @@ -434,7 +454,11 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, }, "cellActions": undefined, - "display": "c", + "display":
+ c +
, "displayAsText": "c", "id": "c", }, @@ -463,7 +487,7 @@ exports[`DatatableComponent it renders the title and value 1`] = ` `; -exports[`DatatableComponent it should not render actions on header when it is in read only mode 1`] = ` +exports[`DatatableComponent it should render hide and reset actions on header even when it is in read only mode 1`] = ` + a + , "displayAsText": "a", "id": "a", }, Object { "actions": Object { - "additional": Array [], + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -580,13 +646,36 @@ exports[`DatatableComponent it should not render actions on header when it is in "showSortDesc": false, }, "cellActions": undefined, - "display": "b", + "display":
+ b +
, "displayAsText": "b", "id": "b", }, Object { "actions": Object { - "additional": Array [], + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -594,7 +683,11 @@ exports[`DatatableComponent it should not render actions on header when it is in "showSortDesc": false, }, "cellActions": undefined, - "display": "c", + "display":
+ c +
, "displayAsText": "c", "id": "c", }, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx index 5c53d40f999b77..4372e2cd9e9640 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -35,7 +35,8 @@ export const createGridColumns = ( visibleColumns: string[], formatFactory: FormatFactory, onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void, - onColumnHide: (eventData: { columnId: string }) => void + onColumnHide: (eventData: { columnId: string }) => void, + alignments: Record ) => { const columnsReverseLookup = table.columns.reduce< Record @@ -151,31 +152,33 @@ export const createGridColumns = ( const additionalActions: EuiListGroupItemProps[] = []; - if (!isReadOnly) { + additionalActions.push({ + color: 'text', + size: 'xs', + onClick: () => onColumnResize({ columnId: originalColumnId || field, width: undefined }), + iconType: 'empty', + label: i18n.translate('xpack.lens.table.resize.reset', { + defaultMessage: 'Reset width', + }), + 'data-test-subj': 'lensDatatableResetWidth', + isDisabled: initialWidth == null, + }); + if (!isTransposed) { additionalActions.push({ color: 'text', size: 'xs', - onClick: () => onColumnResize({ columnId: originalColumnId || field, width: undefined }), - iconType: 'empty', - label: i18n.translate('xpack.lens.table.resize.reset', { - defaultMessage: 'Reset width', + onClick: () => onColumnHide({ columnId: originalColumnId || field }), + iconType: 'eyeClosed', + label: i18n.translate('xpack.lens.table.hide.hideLabel', { + defaultMessage: 'Hide', }), - 'data-test-subj': 'lensDatatableResetWidth', - isDisabled: initialWidth == null, + 'data-test-subj': 'lensDatatableHide', + isDisabled: !isHidden && visibleColumns.length <= 1, }); - if (!isTransposed) { - additionalActions.push({ - color: 'text', - size: 'xs', - onClick: () => onColumnHide({ columnId: originalColumnId || field }), - iconType: 'eyeClosed', - label: i18n.translate('xpack.lens.table.hide.hideLabel', { - defaultMessage: 'Hide', - }), - 'data-test-subj': 'lensDatatableHide', - isDisabled: !isHidden && visibleColumns.length <= 1, - }); - } else if (columnArgs?.bucketValues) { + } + + if (!isReadOnly) { + if (isTransposed && columnArgs?.bucketValues) { const bucketValues = columnArgs?.bucketValues; additionalActions.push({ color: 'text', @@ -200,11 +203,13 @@ export const createGridColumns = ( }); } } + const currentAlignment = alignments && alignments[field]; + const alignmentClassName = `lnsTableCell--${currentAlignment}`; const columnDefinition: EuiDataGridColumn = { id: field, cellActions, - display: name, + display:
{name}
, displayAsText: name, actions: { showHide: false, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index 49003af28f3f1b..3479a9e964d53e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui'; -import { FramePublicAPI, VisualizationDimensionEditorProps } from '../../types'; +import { FramePublicAPI, Operation, VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; import { mountWithIntl } from '@kbn/test/jest'; @@ -213,6 +213,22 @@ describe('data table dimension editor', () => { expect(instance.find(PalettePanelContainer).exists()).toBe(true); }); + it('should not show the dynamic coloring option for a bucketed operation', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn( + () => ({ isBucketed: true } as Operation) + ); + state.columns[0].colorMode = 'cell'; + const instance = mountWithIntl(); + + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]').exists()).toBe( + false + ); + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + false + ); + }); + it('should show the summary field for non numeric columns', () => { const instance = mountWithIntl(); expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_function"]').exists()).toBe( diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 6c39a04ae1504c..cf15df07ec72ca 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -104,6 +104,11 @@ export function TableDimensionEditor( currentData ); + const datasource = frame.datasourceLayers[state.layerId]; + const showDynamicColoringFeature = Boolean( + isNumeric && !datasource?.getOperationForColumnId(accessor)?.isBucketed + ); + const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; const hasTransposedColumn = state.columns.some(({ isTransposed }) => isTransposed); @@ -260,7 +265,7 @@ export function TableDimensionEditor( )} )} - {isNumeric && ( + {showDynamicColoringFeature && ( <> { ).toMatchSnapshot(); }); - test('it should not render actions on header when it is in read only mode', () => { + test('it should render hide and reset actions on header even when it is in read only mode', () => { const { data, args } = sampleArgs(); expect( diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index cd990149fdaf55..b48cb94563d3b0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -180,34 +180,6 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig] ); - const columns: EuiDataGridColumn[] = useMemo( - () => - createGridColumns( - bucketColumns, - firstLocalTable, - handleFilterClick, - handleTransposedColumnClick, - isReadOnlySorted, - columnConfig, - visibleColumns, - formatFactory, - onColumnResize, - onColumnHide - ), - [ - bucketColumns, - firstLocalTable, - handleFilterClick, - handleTransposedColumnClick, - isReadOnlySorted, - columnConfig, - visibleColumns, - formatFactory, - onColumnResize, - onColumnHide, - ] - ); - const isNumericMap: Record = useMemo(() => { const numericMap: Record = {}; for (const column of firstLocalTable.columns) { @@ -237,6 +209,36 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); }, [firstTable, isNumericMap, columnConfig]); + const columns: EuiDataGridColumn[] = useMemo( + () => + createGridColumns( + bucketColumns, + firstLocalTable, + handleFilterClick, + handleTransposedColumnClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize, + onColumnHide, + alignments + ), + [ + bucketColumns, + firstLocalTable, + handleFilterClick, + handleTransposedColumnClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize, + onColumnHide, + alignments, + ] + ); + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { return []; @@ -278,9 +280,13 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [formatters, columnConfig, props.uiSettings] ); - const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ - visibleColumns, - ]); + const columnVisibility = useMemo( + () => ({ + visibleColumns, + setVisibleColumns: () => {}, + }), + [visibleColumns] + ); const sorting = useMemo( () => createGridSortingConfig(sortBy, sortDirection as LensGridDirection, onEditAction), diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 0fdd3bf4262325..98e0198b9d0faa 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -55,6 +55,8 @@ export type { DerivativeIndexPatternColumn, MovingAverageIndexPatternColumn, } from './indexpattern_datasource/types'; +export type { LensEmbeddableInput } from './editor_frame_service/embeddable'; + export { LensPublicStart } from './plugin'; export const plugin = () => new LensPlugin(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index ba3bee415f3f49..1ae2f4421a0bc9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -1797,12 +1797,14 @@ describe('state_helpers', () => { it('should promote the inner references when switching away from reference to field-based operation (case a2)', () => { const expectedCol = { - label: 'Count of records', + label: 'Count of records -3h', dataType: 'number' as const, isBucketed: false, operationType: 'count' as const, sourceField: 'Records', + filter: { language: 'kuery', query: 'bytes > 4000' }, + timeShift: '3h', }; const layer: IndexPatternLayer = { indexPatternId: '1', @@ -1817,6 +1819,8 @@ describe('state_helpers', () => { // @ts-expect-error not a valid type operationType: 'testReference', references: ['col1'], + filter: { language: 'kuery', query: 'bytes > 4000' }, + timeShift: '3h', }, }, }; @@ -1845,6 +1849,8 @@ describe('state_helpers', () => { isBucketed: false, sourceField: 'bytes', operationType: 'average' as const, + filter: { language: 'kuery', query: 'bytes > 4000' }, + timeShift: '3h', }; const layer: IndexPatternLayer = { @@ -1858,6 +1864,8 @@ describe('state_helpers', () => { isBucketed: false, operationType: 'differences', references: ['metric'], + filter: { language: 'kuery', query: 'bytes > 4000' }, + timeShift: '3h', }, }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index b650a2818b2d48..4e3bcec4b6ca20 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -414,11 +414,21 @@ export function replaceColumn({ indexPattern, }); + const column = copyCustomLabel({ ...referenceColumn }, previousColumn); + // do not forget to move over also any filter/shift/anything (if compatible) + // from the reference definition to the new operation. + if (referencedOperation.filterable) { + column.filter = (previousColumn as ReferenceBasedIndexPatternColumn).filter; + } + if (referencedOperation.shiftable) { + column.timeShift = (previousColumn as ReferenceBasedIndexPatternColumn).timeShift; + } + tempLayer = { ...tempLayer, columns: { ...tempLayer.columns, - [columnId]: copyCustomLabel({ ...referenceColumn }, previousColumn), + [columnId]: column, }, }; return updateDefaultLabels( diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 07935bb2f241b8..b35353b98a5859 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -21,6 +21,7 @@ import { navigationPluginMock } from '../../../../src/plugins/navigation/public/ import { LensAppServices } from './app_plugin/types'; import { DOC_TYPE } from '../common'; import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public'; +import { dashboardPluginMock } from '../../../../src/plugins/dashboard/public/mocks'; import { LensByValueInput, LensSavedObjectAttributes, @@ -35,6 +36,7 @@ import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/publ import { makeConfigureStore, getPreloadedState, LensAppState } from './state_management/index'; import { getResolvedDateRange } from './utils'; +import { presentationUtilPluginMock } from '../../../../src/plugins/presentation_util/public/mocks'; export type Start = jest.Mocked; @@ -43,6 +45,9 @@ const createStartContract = (): Start => { EmbeddableComponent: jest.fn(() => { return Lens Embeddable Component; }), + SaveModalComponent: jest.fn(() => { + return Lens Save Modal Component; + }), canUseEditor: jest.fn(() => true), navigateToPrefilledEditor: jest.fn(), getXyVisTypes: jest.fn().mockReturnValue(new Promise((resolve) => resolve(visualizationTypes))), @@ -228,6 +233,8 @@ export function makeDefaultServices( navigation: navigationStartMock, notifications: core.notifications, attributeService: makeAttributeService(), + dashboard: dashboardPluginMock.createStartContract(), + presentationUtil: presentationUtilPluginMock.createStartContract(core), savedObjectsClient: core.savedObjects.client, dashboardFeatureFlag: { allowByValueEmbeddables: false }, stateTransfer: createEmbeddableStateTransferMock() as EmbeddableStateTransfer, diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index fe225dba6f2561..6d90691e2173ae 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -55,6 +55,8 @@ import { getEmbeddableComponent, } from './editor_frame_service/embeddable/embeddable_component'; import { HeatmapVisualization } from './heatmap_visualization'; +import { getSaveModalComponent } from './app_plugin/shared/saved_modal_lazy'; +import { SaveModalContainerProps } from './app_plugin/save_modal_container'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -91,6 +93,15 @@ export interface LensPublicStart { * @experimental */ EmbeddableComponent: React.ComponentType; + /** + * React component which can be used to embed a Lens Visualization Save Modal Component. + * See `x-pack/examples/embedded_lens_example` for exemplary usage. + * + * This API might undergo breaking changes even in minor versions. + * + * @experimental + */ + SaveModalComponent: React.ComponentType>; /** * Method which navigates to the Lens editor, loading the state specified by the `input` parameter. * See `x-pack/examples/embedded_lens_example` for exemplary usage. @@ -185,11 +196,6 @@ export class LensPlugin { visualizations.registerAlias(getLensAliasConfig()); - const getByValueFeatureFlag = async () => { - const [, deps] = await core.getStartServices(); - return deps.dashboard.dashboardFeatureFlagConfig; - }; - const getPresentationUtilContext = async () => { const [, deps] = await core.getStartServices(); const { ContextProvider } = deps.presentationUtil; @@ -214,7 +220,6 @@ export class LensPlugin { return mountApp(core, params, { createEditorFrame: this.createEditorFrame!, attributeService: this.attributeService!, - getByValueFeatureFlag, getPresentationUtilContext, }); }, @@ -251,6 +256,7 @@ export class LensPlugin { return { EmbeddableComponent: getEmbeddableComponent(startDependencies.embeddable), + SaveModalComponent: getSaveModalComponent(core, startDependencies, this.attributeService!), navigateToPrefilledEditor: (input: LensEmbeddableInput, openInNewTab?: boolean) => { // for openInNewTab, we set the time range in url via getEditPath below if (input.timeRange && !openInNewTab) { diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts index 8033f8d187fd51..edadc20a595ce8 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts @@ -374,11 +374,19 @@ export function clamp(val: number, min: number, max: number): number { export function scaleBounds(bounds: MapExtent, scaleFactor: number): MapExtent { const width = bounds.maxLon - bounds.minLon; const height = bounds.maxLat - bounds.minLat; + + const newMinLon = bounds.minLon - width * scaleFactor; + const nexMaxLon = bounds.maxLon + width * scaleFactor; + + const lonDelta = nexMaxLon - newMinLon; + const left = lonDelta > 360 ? -180 : newMinLon; + const right = lonDelta > 360 ? 180 : nexMaxLon; + return { - minLon: bounds.minLon - width * scaleFactor, - minLat: bounds.minLat - height * scaleFactor, - maxLon: bounds.maxLon + width * scaleFactor, - maxLat: bounds.maxLat + height * scaleFactor, + minLon: left, + minLat: clampToLatBounds(bounds.minLat - height * scaleFactor), + maxLon: right, + maxLat: clampToLonBounds(bounds.maxLat + height * scaleFactor), }; } diff --git a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts index d828aca4a1a001..7aef32dfb4f8a5 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts @@ -23,11 +23,31 @@ describe('createExtentFilter', () => { minLat: 35, minLon: -89, }; - const filter = createExtentFilter(mapExtent, [geoFieldName]); - expect(filter.geo_bounding_box).toEqual({ - location: { - top_left: [-89, 39], - bottom_right: [-83, 35], + expect(createExtentFilter(mapExtent, [geoFieldName])).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'location', + negate: false, + }, + query: { + bool: { + must: [ + { + exists: { + field: 'location', + }, + }, + { + geo_bounding_box: { + location: { + top_left: [-89, 39], + bottom_right: [-83, 35], + }, + }, + }, + ], + }, }, }); }); @@ -39,11 +59,31 @@ describe('createExtentFilter', () => { minLat: -100, minLon: -190, }; - const filter = createExtentFilter(mapExtent, [geoFieldName]); - expect(filter.geo_bounding_box).toEqual({ - location: { - top_left: [-180, 89], - bottom_right: [180, -89], + expect(createExtentFilter(mapExtent, [geoFieldName])).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'location', + negate: false, + }, + query: { + bool: { + must: [ + { + exists: { + field: 'location', + }, + }, + { + geo_bounding_box: { + location: { + top_left: [-180, 89], + bottom_right: [180, -89], + }, + }, + }, + ], + }, }, }); }); @@ -55,11 +95,31 @@ describe('createExtentFilter', () => { minLat: 35, minLon: 100, }; - const filter = createExtentFilter(mapExtent, [geoFieldName]); - expect(filter.geo_bounding_box).toEqual({ - location: { - top_left: [100, 39], - bottom_right: [-160, 35], + expect(createExtentFilter(mapExtent, [geoFieldName])).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'location', + negate: false, + }, + query: { + bool: { + must: [ + { + exists: { + field: 'location', + }, + }, + { + geo_bounding_box: { + location: { + top_left: [100, 39], + bottom_right: [-160, 35], + }, + }, + }, + ], + }, }, }); }); @@ -71,11 +131,31 @@ describe('createExtentFilter', () => { minLat: 35, minLon: -200, }; - const filter = createExtentFilter(mapExtent, [geoFieldName]); - expect(filter.geo_bounding_box).toEqual({ - location: { - top_left: [160, 39], - bottom_right: [-100, 35], + expect(createExtentFilter(mapExtent, [geoFieldName])).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'location', + negate: false, + }, + query: { + bool: { + must: [ + { + exists: { + field: 'location', + }, + }, + { + geo_bounding_box: { + location: { + top_left: [160, 39], + bottom_right: [-100, 35], + }, + }, + }, + ], + }, }, }); }); @@ -87,11 +167,31 @@ describe('createExtentFilter', () => { minLat: 35, minLon: -191, }; - const filter = createExtentFilter(mapExtent, [geoFieldName]); - expect(filter.geo_bounding_box).toEqual({ - location: { - top_left: [-180, 39], - bottom_right: [180, 35], + expect(createExtentFilter(mapExtent, [geoFieldName])).toEqual({ + meta: { + alias: null, + disabled: false, + key: 'location', + negate: false, + }, + query: { + bool: { + must: [ + { + exists: { + field: 'location', + }, + }, + { + geo_bounding_box: { + location: { + top_left: [-180, 39], + bottom_right: [180, 35], + }, + }, + }, + ], + }, }, }); }); @@ -184,23 +284,36 @@ describe('createSpatialFilterWithGeometry', () => { negate: false, type: 'spatial_filter', }, - geo_shape: { - 'geo.coordinates': { - relation: 'INTERSECTS', - shape: { - coordinates: [ - [ - [-101.21639, 48.1413], - [-101.21639, 41.84905], - [-90.95149, 41.84905], - [-90.95149, 48.1413], - [-101.21639, 48.1413], - ], - ], - type: 'Polygon', - }, + query: { + bool: { + must: [ + { + exists: { + field: 'geo.coordinates', + }, + }, + { + geo_shape: { + 'geo.coordinates': { + relation: 'INTERSECTS', + shape: { + coordinates: [ + [ + [-101.21639, 48.1413], + [-101.21639, 41.84905], + [-90.95149, 41.84905], + [-90.95149, 48.1413], + [-101.21639, 48.1413], + ], + ], + type: 'Polygon', + }, + }, + ignore_unmapped: true, + }, + }, + ], }, - ignore_unmapped: true, }, }); }); @@ -318,9 +431,22 @@ describe('createDistanceFilterWithMeta', () => { negate: false, type: 'spatial_filter', }, - geo_distance: { - distance: '1000km', - 'geo.coordinates': [120, 30], + query: { + bool: { + must: [ + { + exists: { + field: 'geo.coordinates', + }, + }, + { + geo_distance: { + distance: '1000km', + 'geo.coordinates': [120, 30], + }, + }, + ], + }, }, }); }); diff --git a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts index 70df9e9646f50f..9a2b2c21136dfb 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts @@ -31,13 +31,23 @@ function createMultiGeoFieldFilter( } if (geoFieldNames.length === 1) { - const geoFilter = createGeoFilter(geoFieldNames[0]); return { meta: { ...meta, key: geoFieldNames[0], }, - ...geoFilter, + query: { + bool: { + must: [ + { + exists: { + field: geoFieldNames[0], + }, + }, + createGeoFilter(geoFieldNames[0]), + ], + }, + }, }; } @@ -201,8 +211,9 @@ export function extractFeaturesFromFilters(filters: GeoFilter[]): Feature[] { } } else { const geoFieldName = filter.meta.key; - if (geoFieldName) { - geometry = extractGeometryFromFilter(geoFieldName, filter); + const spatialClause = filter?.query?.bool?.must?.[1]; + if (geoFieldName && spatialClause) { + geometry = extractGeometryFromFilter(geoFieldName, spatialClause); } } diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index 77ce23594447f8..fa69dad6167478 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -33,7 +33,9 @@ describe('map_actions', () => { describe('store mapState is empty', () => { beforeEach(() => { require('../selectors/map_selectors').getDataFilters = () => { - return {}; + return { + zoom: 5, + }; }; require('../selectors/map_selectors').getLayerList = () => { @@ -61,11 +63,13 @@ describe('map_actions', () => { minLat: 5, minLon: 95, }, + zoom: 5, }); await action(dispatchMock, getStoreMock); expect(dispatchMock).toHaveBeenCalledWith({ mapState: { + zoom: 5, extent: { maxLat: 10, maxLon: 100, @@ -73,10 +77,10 @@ describe('map_actions', () => { minLon: 95, }, buffer: { - maxLat: 12.5, - maxLon: 102.5, - minLat: 2.5, - minLon: 92.5, + maxLat: 21.94305, + maxLon: 112.5, + minLat: 0, + minLon: 90, }, }, type: 'MAP_EXTENT_CHANGED', @@ -154,10 +158,10 @@ describe('map_actions', () => { minLon: 85, }, buffer: { - maxLat: 7.5, - maxLon: 92.5, - minLat: -2.5, - minLon: 82.5, + maxLat: 7.71099, + maxLon: 92.8125, + minLat: -2.81137, + minLon: 82.26563, }, }, type: 'MAP_EXTENT_CHANGED', @@ -186,10 +190,10 @@ describe('map_actions', () => { minLon: 96, }, buffer: { - maxLat: 13.5, - maxLon: 103.5, - minLat: 3.5, - minLon: 93.5, + maxLat: 13.58192, + maxLon: 103.53516, + minLat: 3.33795, + minLon: 93.33984, }, }, type: 'MAP_EXTENT_CHANGED', diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 42ce96d102d7e0..3cdc5bf05ccee7 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -56,6 +56,7 @@ import { import { INITIAL_LOCATION } from '../../common/constants'; import { scaleBounds } from '../../common/elasticsearch_util'; import { cleanTooltipStateForLayer } from './tooltip_actions'; +import { expandToTileBoundaries } from '../../common/geo_tile_utils'; export interface MapExtentState { zoom: number; @@ -158,7 +159,9 @@ export function mapExtentChanged(mapExtentState: MapExtentState) { } if (!doesBufferContainExtent || currentZoom !== newZoom) { - dataFilters.buffer = scaleBounds(extent, 0.5); + const expandedExtent = scaleBounds(extent, 0.5); + // snap to the smallest tile-bounds, to avoid jitter in the bounds + dataFilters.buffer = expandToTileBoundaries(expandedExtent, Math.ceil(newZoom)); } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index ad413419a289b1..1043ed87783040 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -205,13 +205,31 @@ describe('ESGeoGridSource', () => { expect(getProperty('query')).toEqual(undefined); expect(getProperty('filter')).toEqual([ { - geo_bounding_box: { bar: { bottom_right: [180, -82.67628], top_left: [-180, 82.67628] } }, meta: { alias: null, disabled: false, key: 'bar', negate: false, }, + query: { + bool: { + must: [ + { + exists: { + field: 'bar', + }, + }, + { + geo_bounding_box: { + bar: { + top_left: [-180, 82.67628], + bottom_right: [180, -82.67628], + }, + }, + }, + ], + }, + }, }, ]); expect(getProperty('aggs')).toEqual({ @@ -262,20 +280,33 @@ describe('ESGeoGridSource', () => { }); describe('ITiledSingleLayerVectorSource', () => { + const mvtGeogridSource = new ESGeoGridSource( + { + id: 'foobar', + indexPatternId: 'fooIp', + geoField: geoFieldName, + metrics: [], + resolution: GRID_RESOLUTION.SUPER_FINE, + type: SOURCE_TYPES.ES_GEO_GRID, + requestType: RENDER_AS.HEATMAP, + }, + {} + ); + it('getLayerName', () => { - expect(geogridSource.getLayerName()).toBe('source_layer'); + expect(mvtGeogridSource.getLayerName()).toBe('source_layer'); }); it('getMinZoom', () => { - expect(geogridSource.getMinZoom()).toBe(0); + expect(mvtGeogridSource.getMinZoom()).toBe(0); }); it('getMaxZoom', () => { - expect(geogridSource.getMaxZoom()).toBe(24); + expect(mvtGeogridSource.getMaxZoom()).toBe(24); }); it('getUrlTemplateWithMeta', async () => { - const urlTemplateWithMeta = await geogridSource.getUrlTemplateWithMeta( + const urlTemplateWithMeta = await mvtGeogridSource.getUrlTemplateWithMeta( vectorSourceRequestMeta ); @@ -283,19 +314,19 @@ describe('ESGeoGridSource', () => { expect(urlTemplateWithMeta.minSourceZoom).toBe(0); expect(urlTemplateWithMeta.maxSourceZoom).toBe(24); expect(urlTemplateWithMeta.urlTemplate).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&requestBody=(foobar:ES_DSL_PLACEHOLDER,params:('0':('0':index,'1':(fields:())),'1':('0':size,'1':0),'2':('0':filter,'1':!((geo_bounding_box:(bar:(bottom_right:!(180,-82.67628),top_left:!(-180,82.67628))),meta:(alias:!n,disabled:!f,key:bar,negate:!f)))),'3':('0':query),'4':('0':index,'1':(fields:())),'5':('0':query,'1':(language:KQL,query:'',queryLastTriggeredAt:'2019-04-25T20:53:22.331Z')),'6':('0':aggs,'1':(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:bar))),geotile_grid:(bounds:!n,field:bar,precision:!n,shard_size:65535,size:65535))))))&requestType=heatmap&geoFieldType=geo_point" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&requestBody=(foobar:ES_DSL_PLACEHOLDER,params:('0':('0':index,'1':(fields:())),'1':('0':size,'1':0),'2':('0':filter,'1':!()),'3':('0':query),'4':('0':index,'1':(fields:())),'5':('0':query,'1':(language:KQL,query:'',queryLastTriggeredAt:'2019-04-25T20:53:22.331Z')),'6':('0':aggs,'1':(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:bar))),geotile_grid:(bounds:!n,field:bar,precision:!n,shard_size:65535,size:65535))))))&requestType=heatmap&geoFieldType=geo_point" ); }); it('should include searchSourceId in urlTemplateWithMeta', async () => { - const urlTemplateWithMeta = await geogridSource.getUrlTemplateWithMeta({ + const urlTemplateWithMeta = await mvtGeogridSource.getUrlTemplateWithMeta({ ...vectorSourceRequestMeta, searchSessionId: '1', }); expect( urlTemplateWithMeta.urlTemplate.startsWith( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&requestBody=(foobar:ES_DSL_PLACEHOLDER,params:('0':('0':index,'1':(fields:())),'1':('0':size,'1':0),'2':('0':filter,'1':!((geo_bounding_box:(bar:(bottom_right:!(180,-82.67628),top_left:!(-180,82.67628))),meta:(alias:!n,disabled:!f,key:bar,negate:!f)))),'3':('0':query),'4':('0':index,'1':(fields:())),'5':('0':query,'1':(language:KQL,query:'',queryLastTriggeredAt:'2019-04-25T20:53:22.331Z')),'6':('0':aggs,'1':(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:bar))),geotile_grid:(bounds:!n,field:bar,precision:!n,shard_size:65535,size:65535))))))&requestType=heatmap&geoFieldType=geo_point" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&requestBody=(foobar:ES_DSL_PLACEHOLDER,params:('0':('0':index,'1':(fields:())),'1':('0':size,'1':0),'2':('0':filter,'1':!()),'3':('0':query),'4':('0':index,'1':(fields:())),'5':('0':query,'1':(language:KQL,query:'',queryLastTriggeredAt:'2019-04-25T20:53:22.331Z')),'6':('0':aggs,'1':(gridSplit:(aggs:(gridCentroid:(geo_centroid:(field:bar))),geotile_grid:(bounds:!n,field:bar,precision:!n,shard_size:65535,size:65535))))))&requestType=heatmap&geoFieldType=geo_point&searchSessionId=1" ) ).toBe(true); diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 7ed24b4805997a..86dd63be4b67b8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -142,6 +142,20 @@ export class ESPewPewSource extends AbstractESAggSource { }, }); + // pewpew source is often used with security solution index-pattern + // Some underlying indices may not contain geo fields + // Filter out documents without geo fields to avoid shard failures for those indices + searchSource.setField('filter', [ + ...searchSource.getField('filter'), + // destGeoField exists ensured by buffer filter + // so only need additional check for sourceGeoField + { + exists: { + field: this._descriptor.sourceGeoField, + }, + }, + ]); + const esResponse = await this._runEsQuery({ requestId: this.getId(), requestName: layerName, diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 776d316440a561..82d2162986503b 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -24,6 +24,7 @@ import { } from '../../common/constants'; import { + createExtentFilter, convertRegularRespToGeoJson, hitsToGeoJson, isTotalHitsGreaterThan, @@ -254,21 +255,14 @@ export async function getTile({ } function getTileSpatialFilter(geometryFieldName: string, tileBounds: ESBounds): unknown { - return { - geo_shape: { - [geometryFieldName]: { - shape: { - type: 'envelope', - // upper left and lower right points of the shape to represent a bounding rectangle in the format [[minLon, maxLat], [maxLon, minLat]] - coordinates: [ - [tileBounds.top_left.lon, tileBounds.top_left.lat], - [tileBounds.bottom_right.lon, tileBounds.bottom_right.lat], - ], - }, - relation: 'INTERSECTS', - }, - }, + const tileExtent = { + minLon: tileBounds.top_left.lon, + minLat: tileBounds.bottom_right.lat, + maxLon: tileBounds.bottom_right.lon, + maxLat: tileBounds.top_left.lat, }; + const tileExtentFilter = createExtentFilter(tileExtent, [geometryFieldName]); + return tileExtentFilter.query; } function esBboxToGeoJsonPolygon(esBounds: ESBounds, tileBounds: ESBounds): Polygon { diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 6b24ef000b6951..48ceaefb239394 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -24,7 +24,7 @@ interface JobSavedObjectStatus { }; } -interface JobStatus { +export interface JobStatus { jobId: string; datafeedId?: string | null; checks: { @@ -68,7 +68,9 @@ export function checksFactory( if (type === 'anomaly-detector') { jobExists = adJobs.jobs.some((j) => j.job_id === jobId); - datafeedExists = datafeeds.datafeeds.some((d) => d.job_id === jobId); + datafeedExists = datafeeds.datafeeds.some( + (d) => d.datafeed_id === datafeedId && d.job_id === jobId + ); } else { jobExists = dfaJobs.data_frame_analytics.some((j) => j.id === jobId); } diff --git a/x-pack/plugins/ml/server/saved_objects/sync.ts b/x-pack/plugins/ml/server/saved_objects/sync.ts index a59687b9c3cbf4..d6fa887d1d68be 100644 --- a/x-pack/plugins/ml/server/saved_objects/sync.ts +++ b/x-pack/plugins/ml/server/saved_objects/sync.ts @@ -14,6 +14,7 @@ import { InitializeSavedObjectResponse, } from '../../common/types/saved_objects'; import { checksFactory } from './checks'; +import type { JobStatus } from './checks'; import { getSavedObjectClientError } from './util'; import { Datafeed } from '../../common/types/anomaly_detection_jobs'; @@ -45,6 +46,12 @@ export function syncSavedObjectsFactory( const tasks: Array<() => Promise> = []; const status = await checkStatus(); + + const adJobsById = status.jobs['anomaly-detector'].reduce((acc, j) => { + acc[j.jobId] = j; + return acc; + }, {} as Record); + for (const job of status.jobs['anomaly-detector']) { if (job.checks.savedObjectExits === false) { if (simulate === true) { @@ -141,8 +148,16 @@ export function syncSavedObjectsFactory( } for (const job of status.savedObjects['anomaly-detector']) { - if (job.checks.datafeedExists === true && job.datafeedId === null) { + if ( + (job.checks.datafeedExists === true && job.datafeedId === null) || + (job.checks.datafeedExists === false && + job.datafeedId === null && + job.checks.datafeedExists === false && + adJobsById[job.jobId] && + adJobsById[job.jobId].datafeedId !== job.datafeedId) + ) { // add datafeed id for jobs where the datafeed exists but the id is missing from the saved object + // or if the datafeed id in the saved object is not the same as the one attached to the job in es if (simulate === true) { results.datafeedsAdded[job.jobId] = { success: true }; } else { diff --git a/x-pack/plugins/observability/public/application/types.ts b/x-pack/plugins/observability/public/application/types.ts new file mode 100644 index 00000000000000..09c5de1e694c82 --- /dev/null +++ b/x-pack/plugins/observability/public/application/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ApplicationStart, + ChromeStart, + HttpStart, + IUiSettingsClient, + NotificationsStart, + OverlayStart, + SavedObjectsStart, +} from 'kibana/public'; +import { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { LensPublicStart } from '../../../lens/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; + +export interface ObservabilityAppServices { + http: HttpStart; + chrome: ChromeStart; + overlays: OverlayStart; + storage: IStorageWrapper; + data: DataPublicPluginStart; + uiSettings: IUiSettingsClient; + application: ApplicationStart; + notifications: NotificationsStart; + stateTransfer: EmbeddableStateTransfer; + navigation: NavigationPublicPluginStart; + savedObjectsClient: SavedObjectsStart['client']; + + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + lens: LensPublicStart; +} diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index ddfd8ebed4f8f7..191a1b2890ada3 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, EuiButton } from '@elastic/eui'; import React from 'react'; import { ErrorPanel } from './error_panel'; import { usePluginContext } from '../../../hooks/use_plugin_context'; @@ -25,36 +25,29 @@ interface Props { export function SectionContainer({ title, appLink, children, hasError }: Props) { const { core } = usePluginContext(); return ( - -
{title}
- - } - extraAction={ - appLink?.href && ( - - {appLink.label} - - ) - } - > - <> - - - {hasError ? ( - - ) : ( - <> - - {children} - - )} - - -
+ + +
{title}
+ + } + extraAction={ + appLink?.href && ( + {appLink.label} + ) + } + > + <> + + + {hasError ? : <>{children}} + + +
+
); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 3265287a7f915c..3e02207e262725 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { TypedLensByValueInput, LensEmbeddableInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { DataViewLabels } from '../configurations/constants'; +import { ObservabilityAppServices } from '../../../../application/types'; import { useSeriesStorage } from '../hooks/use_series_storage'; interface Props { @@ -20,57 +20,88 @@ interface Props { } export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { - const { - services: { lens }, - } = useKibana(); + const kServices = useKibana().services; + + const { lens } = kServices; const { getSeries } = useSeriesStorage(); const series = getSeries(seriesId); + const [isSaveOpen, setIsSaveOpen] = useState(false); + + const LensSaveModalComponent = lens.SaveModalComponent; + return ( - - - -

- {DataViewLabels[series.reportType] ?? - i18n.translate('xpack.observability.expView.heading.label', { - defaultMessage: 'Analyze data', - })}{' '} - -

-
-
- - { - if (lensAttributes) { - lens.navigateToPrefilledEditor( - { - id: '', - timeRange: series.time, - attributes: lensAttributes, - }, - true - ); - } - }} - > - {i18n.translate('xpack.observability.expView.heading.openInLens', { - defaultMessage: 'Open in Lens', - })} - - -
+ <> + + + +

+ {DataViewLabels[series.reportType] ?? + i18n.translate('xpack.observability.expView.heading.label', { + defaultMessage: 'Analyze data', + })}{' '} + +

+
+
+ + { + if (lensAttributes) { + lens.navigateToPrefilledEditor( + { + id: '', + timeRange: series.time, + attributes: lensAttributes, + }, + true + ); + } + }} + > + {i18n.translate('xpack.observability.expView.heading.openInLens', { + defaultMessage: 'Open in Lens', + })} + + + + { + if (lensAttributes) { + setIsSaveOpen(true); + } + }} + > + {i18n.translate('xpack.observability.expView.heading.saveLensVisualization', { + defaultMessage: 'Save', + })} + + +
+ + {isSaveOpen && lensAttributes && ( + setIsSaveOpen(false)} + onSave={() => {}} + /> + )} + ); } diff --git a/x-pack/plugins/osquery/common/types.ts b/x-pack/plugins/osquery/common/types.ts index 11c418a51fc7cb..ab9e36ec335db6 100644 --- a/x-pack/plugins/osquery/common/types.ts +++ b/x-pack/plugins/osquery/common/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { PackagePolicy, PackagePolicyInput, PackagePolicyInputStream } from '../../fleet/common'; + export const savedQuerySavedObjectType = 'osquery-saved-query'; export const packSavedObjectType = 'osquery-pack'; export type SavedObjectType = 'osquery-saved-query' | 'osquery-pack'; @@ -25,3 +27,31 @@ export type RequiredKeepUndefined = { [K in keyof T]-?: [T[K]] } extends infe ? { [K in keyof U]: U[K][0] } : never : never; + +export interface OsqueryManagerPackagePolicyConfigRecordEntry { + type: string; + value: string; + frozen?: boolean; +} + +export interface OsqueryManagerPackagePolicyConfigRecord { + id: OsqueryManagerPackagePolicyConfigRecordEntry; + query: OsqueryManagerPackagePolicyConfigRecordEntry; + interval: OsqueryManagerPackagePolicyConfigRecordEntry; + platform?: OsqueryManagerPackagePolicyConfigRecordEntry; + version?: OsqueryManagerPackagePolicyConfigRecordEntry; +} + +export interface OsqueryManagerPackagePolicyInputStream + extends Omit { + config?: OsqueryManagerPackagePolicyConfigRecord; + vars?: OsqueryManagerPackagePolicyConfigRecord; +} + +export interface OsqueryManagerPackagePolicyInput extends Omit { + streams: OsqueryManagerPackagePolicyInputStream[]; +} + +export interface OsqueryManagerPackagePolicy extends Omit { + inputs: OsqueryManagerPackagePolicyInput[]; +} diff --git a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx index ffa86c547656cb..23277976968a98 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx @@ -23,6 +23,7 @@ import { import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { PLUGIN_ID } from '../../../fleet/common'; import { pagePathGetters } from '../../../fleet/public'; import { useActionResults } from './use_action_results'; import { useAllResults } from '../results/use_all_results'; @@ -130,7 +131,7 @@ const ActionResultsSummaryComponent: React.FC = ({ (agentId) => ( = ({ policyId } const href = useMemo( () => - getUrlForApp('fleet', { + getUrlForApp(PLUGIN_ID, { path: `#` + pagePathGetters.policy_details({ policyId }), }), [getUrlForApp, policyId] @@ -36,7 +37,7 @@ const AgentsPolicyLinkComponent: React.FC = ({ policyId } if (!isModifiedEvent(event) && isLeftClickEvent(event)) { event.preventDefault(); - return navigateToApp('fleet', { + return navigateToApp(PLUGIN_ID, { path: `#` + pagePathGetters.policy_details({ policyId }), }); } diff --git a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx index 8419003f57715b..b28471a907e04c 100644 --- a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx +++ b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../fleet/common'; import { pagePathGetters } from '../../../fleet/public'; import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana'; @@ -22,12 +23,12 @@ const ManageIntegrationLinkComponent = () => { const integrationHref = useMemo(() => { if (osqueryIntegration) { - return getUrlForApp('fleet', { + return getUrlForApp(INTEGRATIONS_PLUGIN_ID, { path: '#' + pagePathGetters.integration_details_policies({ pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - }), + })[1], }); } }, [getUrlForApp, osqueryIntegration]); @@ -37,12 +38,12 @@ const ManageIntegrationLinkComponent = () => { if (!isModifiedEvent(event) && isLeftClickEvent(event)) { event.preventDefault(); if (osqueryIntegration) { - return navigateToApp('fleet', { + return navigateToApp(INTEGRATIONS_PLUGIN_ID, { path: '#' + pagePathGetters.integration_details_policies({ pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - }), + })[1], }); } } diff --git a/x-pack/plugins/osquery/public/fleet_integration/navigation_buttons.tsx b/x-pack/plugins/osquery/public/fleet_integration/navigation_buttons.tsx index 808718c55d1994..d8169c25ad929c 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/navigation_buttons.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/navigation_buttons.tsx @@ -9,16 +9,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; +import { PLUGIN_ID } from '../../common'; import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana'; interface NavigationButtonsProps { isDisabled?: boolean; - integrationPolicyId?: string; - agentPolicyId?: string; + integrationPolicyId?: string | undefined; + agentPolicyId?: string | undefined; } const NavigationButtonsComponent: React.FC = ({ - isDisabled, + isDisabled = false, integrationPolicyId, agentPolicyId, }) => { @@ -28,7 +29,7 @@ const NavigationButtonsComponent: React.FC = ({ const liveQueryHref = useMemo( () => - getUrlForApp('osquery', { + getUrlForApp(PLUGIN_ID, { path: agentPolicyId ? `/live_queries/new?agentPolicyId=${agentPolicyId}` : ' `/live_queries/new', @@ -40,7 +41,7 @@ const NavigationButtonsComponent: React.FC = ({ (event) => { if (!isModifiedEvent(event) && isLeftClickEvent(event)) { event.preventDefault(); - navigateToApp('osquery', { + navigateToApp(PLUGIN_ID, { path: agentPolicyId ? `/live_queries/new?agentPolicyId=${agentPolicyId}` : ' `/live_queries/new', @@ -50,7 +51,7 @@ const NavigationButtonsComponent: React.FC = ({ [agentPolicyId, navigateToApp] ); - const scheduleQueryGroupsHref = getUrlForApp('osquery', { + const scheduleQueryGroupsHref = getUrlForApp(PLUGIN_ID, { path: integrationPolicyId ? `/scheduled_query_groups/${integrationPolicyId}/edit` : `/scheduled_query_groups`, @@ -60,7 +61,7 @@ const NavigationButtonsComponent: React.FC = ({ (event) => { if (!isModifiedEvent(event) && isLeftClickEvent(event)) { event.preventDefault(); - navigateToApp('osquery', { + navigateToApp(PLUGIN_ID, { path: integrationPolicyId ? `/scheduled_query_groups/${integrationPolicyId}/edit` : `/scheduled_query_groups`, diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 6dfbc086c394a5..2305df807f1c84 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -15,8 +15,10 @@ import { i18n } from '@kbn/i18n'; import { agentRouteService, agentPolicyRouteService, - PackagePolicy, AgentPolicy, + PLUGIN_ID, + INTEGRATIONS_PLUGIN_ID, + NewPackagePolicy, } from '../../../fleet/common'; import { pagePathGetters, @@ -27,6 +29,7 @@ import { import { ScheduledQueryGroupQueriesTable } from '../scheduled_query_groups/scheduled_query_group_queries_table'; import { useKibana } from '../common/lib/kibana'; import { NavigationButtons } from './navigation_buttons'; +import { OsqueryManagerPackagePolicy } from '../../common/types'; /** * Exports Osquery-specific package policy instructions @@ -51,7 +54,7 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< const agentsLinkHref = useMemo(() => { if (!policy?.policy_id) return '#'; - return getUrlForApp('fleet', { + return getUrlForApp(PLUGIN_ID, { path: `#` + pagePathGetters.policy_details({ policyId: policy?.policy_id }) + @@ -128,13 +131,13 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< replace({ state: { onSaveNavigateTo: (newPackagePolicy) => [ - 'fleet', + INTEGRATIONS_PLUGIN_ID, { path: '#' + pagePathGetters.integration_policy_edit({ packagePolicyId: newPackagePolicy.id, - }), + })[1], state: { forceRefresh: true, }, @@ -146,7 +149,11 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< }, [editMode, replace]); const scheduledQueryGroupTableData = useMemo(() => { - const policyWithoutEmptyQueries = produce(newPolicy, (draft) => { + const policyWithoutEmptyQueries = produce< + NewPackagePolicy, + OsqueryManagerPackagePolicy, + OsqueryManagerPackagePolicy + >(newPolicy, (draft) => { draft.inputs[0].streams = filter(['compiled_stream.id', null], draft.inputs[0].streams); return draft; }); @@ -205,7 +212,9 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< {editMode && scheduledQueryGroupTableData.inputs[0].streams.length ? ( - + ) : null} diff --git a/x-pack/plugins/osquery/public/routes/scheduled_query_groups/details/index.tsx b/x-pack/plugins/osquery/public/routes/scheduled_query_groups/details/index.tsx index a1203542613058..960de043eac6e7 100644 --- a/x-pack/plugins/osquery/public/routes/scheduled_query_groups/details/index.tsx +++ b/x-pack/plugins/osquery/public/routes/scheduled_query_groups/details/index.tsx @@ -125,7 +125,7 @@ const ScheduledQueryGroupDetailsPageComponent = () => { return ( - {data && } + {data && } ); }; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx deleted file mode 100644 index 3879a375b857c6..00000000000000 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiFlyout, - EuiTitle, - EuiSpacer, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiPortal, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, -} from '@elastic/eui'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { CodeEditorField } from '../../queries/form/code_editor_field'; -import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; -import { Form, useForm, FormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; - -const FORM_ID = 'addQueryFlyoutForm'; - -const CommonUseField = getUseField({ component: Field }); - -interface AddQueryFlyoutProps { - onSave: (payload: FormData) => Promise; - onClose: () => void; -} - -const AddQueryFlyoutComponent: React.FC = ({ onSave, onClose }) => { - const { form } = useForm({ - id: FORM_ID, - // @ts-expect-error update types - onSubmit: (payload, isValid) => { - if (isValid) { - onSave(payload); - onClose(); - } - }, - schema: { - id: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { - defaultMessage: 'ID', - }), - validations: idFieldValidations.map((validator) => ({ validator })), - }, - query: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { - defaultMessage: 'Query', - }), - validations: [{ validator: queryFieldValidation }], - }, - interval: { - type: FIELD_TYPES.NUMBER, - label: i18n.translate( - 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.intervalFieldLabel', - { - defaultMessage: 'Interval (s)', - } - ), - validations: [{ validator: intervalFieldValidation }], - }, - }, - }); - - const { submit } = form; - - return ( - - - - -

- -

-
-
- -
- - - - - { - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop - - } - -
- - - - - - - - - - - - - - -
-
- ); -}; - -export const AddQueryFlyout = React.memo(AddQueryFlyoutComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx deleted file mode 100644 index f44b5e45a26e54..00000000000000 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiFlyout, - EuiTitle, - EuiSpacer, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiPortal, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, -} from '@elastic/eui'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { PackagePolicyInputStream } from '../../../../fleet/common'; -import { CodeEditorField } from '../../queries/form/code_editor_field'; -import { Form, useForm, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; -import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; - -const FORM_ID = 'editQueryFlyoutForm'; - -const CommonUseField = getUseField({ component: Field }); - -interface EditQueryFlyoutProps { - defaultValue: PackagePolicyInputStream; - onSave: (payload: FormData) => void; - onClose: () => void; -} - -export const EditQueryFlyout: React.FC = ({ - defaultValue, - onSave, - onClose, -}) => { - const { form } = useForm({ - id: FORM_ID, - // @ts-expect-error update types - onSubmit: (payload, isValid) => { - if (isValid) { - // @ts-expect-error update types - onSave(payload); - onClose(); - } - return; - }, - defaultValue, - deserializer: (payload) => ({ - id: payload.vars.id.value, - query: payload.vars.query.value, - interval: payload.vars.interval.value, - }), - schema: { - id: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { - defaultMessage: 'ID', - }), - validations: idFieldValidations.map((validator) => ({ validator })), - }, - query: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { - defaultMessage: 'Query', - }), - validations: [{ validator: queryFieldValidation }], - }, - interval: { - type: FIELD_TYPES.NUMBER, - label: i18n.translate( - 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.intervalFieldLabel', - { - defaultMessage: 'Interval (s)', - } - ), - validations: [{ validator: intervalFieldValidation }], - }, - }, - }); - - const { submit } = form; - - return ( - - - - -

- -

-
-
- -
- - - - - { - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop - - } - -
- - - - - - - - - - - - - - -
-
- ); -}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx index 8924a61d181b6a..64efdf61fc7359 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx @@ -24,13 +24,22 @@ import { produce } from 'immer'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { PLUGIN_ID } from '../../../common'; +import { OsqueryManagerPackagePolicy } from '../../../common/types'; import { AgentPolicy, - PackagePolicy, PackagePolicyPackage, packagePolicyRouteService, } from '../../../../fleet/common'; -import { Form, useForm, useFormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; +import { + Form, + useForm, + useFormData, + getUseField, + Field, + FIELD_TYPES, + fieldValidators, +} from '../../shared_imports'; import { useKibana, useRouterNavigate } from '../../common/lib/kibana'; import { PolicyIdComboBoxField } from './policy_id_combobox_field'; import { QueriesField } from './queries_field'; @@ -44,7 +53,7 @@ const FORM_ID = 'scheduledQueryForm'; const CommonUseField = getUseField({ component: Field }); interface ScheduledQueryGroupFormProps { - defaultValue?: PackagePolicy; + defaultValue?: OsqueryManagerPackagePolicy; packageInfo?: PackagePolicyPackage; editMode?: boolean; } @@ -89,7 +98,7 @@ const ScheduledQueryGroupFormComponent: React.FC = { onSuccess: (data) => { if (!editMode) { - navigateToApp('osquery', { path: `scheduled_query_groups/${data.item.id}` }); + navigateToApp(PLUGIN_ID, { path: `scheduled_query_groups/${data.item.id}` }); toasts.addSuccess( i18n.translate('xpack.osquery.scheduledQueryGroup.form.createSuccessToastMessageText', { defaultMessage: 'Successfully scheduled {scheduledQueryGroupName}', @@ -101,7 +110,7 @@ const ScheduledQueryGroupFormComponent: React.FC = return; } - navigateToApp('osquery', { path: `scheduled_query_groups/${data.item.id}` }); + navigateToApp(PLUGIN_ID, { path: `scheduled_query_groups/${data.item.id}` }); toasts.addSuccess( i18n.translate('xpack.osquery.scheduledQueryGroup.form.updateSuccessToastMessageText', { defaultMessage: 'Successfully updated {scheduledQueryGroupName}', @@ -118,7 +127,15 @@ const ScheduledQueryGroupFormComponent: React.FC = } ); - const { form } = useForm({ + const { form } = useForm< + Omit & { + policy_id: string; + }, + Omit & { + policy_id: string[]; + namespace: string[]; + } + >({ id: FORM_ID, schema: { name: { @@ -126,6 +143,18 @@ const ScheduledQueryGroupFormComponent: React.FC = label: i18n.translate('xpack.osquery.scheduledQueryGroup.form.nameFieldLabel', { defaultMessage: 'Name', }), + validations: [ + { + validator: fieldValidators.emptyField( + i18n.translate( + 'xpack.osquery.scheduledQueryGroup.form.nameFieldRequiredErrorMessage', + { + defaultMessage: 'Name is a required field', + } + ) + ), + }, + ], }, description: { type: FIELD_TYPES.TEXT, @@ -144,19 +173,35 @@ const ScheduledQueryGroupFormComponent: React.FC = label: i18n.translate('xpack.osquery.scheduledQueryGroup.form.agentPolicyFieldLabel', { defaultMessage: 'Agent policy', }), + validations: [ + { + validator: fieldValidators.emptyField( + i18n.translate( + 'xpack.osquery.scheduledQueryGroup.form.policyIdFieldRequiredErrorMessage', + { + defaultMessage: 'Agent policy is a required field', + } + ) + ), + }, + ], }, }, - onSubmit: (payload) => { + onSubmit: (payload, isValid) => { + if (!isValid) return Promise.resolve(); const formData = produce(payload, (draft) => { - // @ts-expect-error update types - draft.inputs[0].streams.forEach((stream) => { - delete stream.compiled_stream; + if (draft.inputs?.length) { + draft.inputs[0].streams?.forEach((stream) => { + delete stream.compiled_stream; + + // we don't want to send id as null when creating the policy + if (stream.id == null) { + // @ts-expect-error update types + delete stream.id; + } + }); + } - // we don't want to send id as null when creating the policy - if (stream.id == null) { - delete stream.id; - } - }); return draft; }); return mutateAsync(formData); @@ -164,7 +209,6 @@ const ScheduledQueryGroupFormComponent: React.FC = options: { stripEmptyFields: false, }, - // @ts-expect-error update types deserializer: (payload) => ({ ...payload, policy_id: payload.policy_id.length ? [payload.policy_id] : [], @@ -172,9 +216,7 @@ const ScheduledQueryGroupFormComponent: React.FC = }), serializer: (payload) => ({ ...payload, - // @ts-expect-error update types policy_id: payload.policy_id[0], - // @ts-expect-error update types namespace: payload.namespace[0], }), defaultValue: merge( @@ -182,10 +224,11 @@ const ScheduledQueryGroupFormComponent: React.FC = name: '', description: '', enabled: true, - policy_id: [], + policy_id: '', namespace: 'default', output_id: '', - package: packageInfo, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + package: packageInfo!, inputs: [ { type: 'osquery', @@ -205,7 +248,15 @@ const ScheduledQueryGroupFormComponent: React.FC = [defaultValue, agentPolicyOptions] ); - const [{ policy_id: policyId }] = useFormData({ form, watch: ['policy_id'] }); + const [ + { + package: { version: integrationPackageVersion } = { version: undefined }, + policy_id: policyId, + }, + ] = useFormData({ + form, + watch: ['package', 'policy_id'], + }); const currentPolicy = useMemo(() => { if (!policyId) { @@ -288,6 +339,7 @@ const ScheduledQueryGroupFormComponent: React.FC = path="inputs" component={QueriesField} scheduledQueryGroupId={defaultValue?.id ?? null} + integrationPackageVersion={integrationPackageVersion} /> diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx index 34c6eaea1c2656..0718ff028e0022 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx @@ -11,16 +11,20 @@ import { produce } from 'immer'; import React, { useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { PackagePolicyInput, PackagePolicyInputStream } from '../../../../fleet/common'; +import { + OsqueryManagerPackagePolicyInputStream, + OsqueryManagerPackagePolicyInput, +} from '../../../common/types'; import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { FieldHook } from '../../shared_imports'; import { ScheduledQueryGroupQueriesTable } from '../scheduled_query_group_queries_table'; -import { AddQueryFlyout } from './add_query_flyout'; -import { EditQueryFlyout } from './edit_query_flyout'; +import { QueryFlyout } from '../queries/query_flyout'; import { OsqueryPackUploader } from './pack_uploader'; +import { getSupportedPlatforms } from '../queries/platforms/helpers'; interface QueriesFieldProps { - field: FieldHook; + field: FieldHook; + integrationPackageVersion?: string | undefined; scheduledQueryGroupId: string; } @@ -28,29 +32,53 @@ interface GetNewStreamProps { id: string; interval: string; query: string; + platform?: string | undefined; + version?: string | undefined; scheduledQueryGroupId?: string; } -const getNewStream = ({ id, interval, query, scheduledQueryGroupId }: GetNewStreamProps) => ({ - data_stream: { type: 'logs', dataset: `${OSQUERY_INTEGRATION_NAME}.result` }, - enabled: true, - id: scheduledQueryGroupId - ? `osquery-${OSQUERY_INTEGRATION_NAME}.result-${scheduledQueryGroupId}` - : null, - vars: { - id: { type: 'text', value: id }, - interval: { - type: 'integer', - value: interval, +interface GetNewStreamReturn extends Omit { + id?: string | null; +} + +const getNewStream = (payload: GetNewStreamProps) => + produce( + { + data_stream: { type: 'logs', dataset: `${OSQUERY_INTEGRATION_NAME}.result` }, + enabled: true, + id: payload.scheduledQueryGroupId + ? `osquery-${OSQUERY_INTEGRATION_NAME}.result-${payload.scheduledQueryGroupId}` + : null, + vars: { + id: { type: 'text', value: payload.id }, + interval: { + type: 'integer', + value: payload.interval, + }, + query: { type: 'text', value: payload.query }, + }, }, - query: { type: 'text', value: query }, - }, -}); + (draft) => { + if (payload.platform && draft.vars) { + draft.vars.platform = { type: 'text', value: payload.platform }; + } + if (payload.version && draft.vars) { + draft.vars.version = { type: 'text', value: payload.version }; + } + return draft; + } + ); -const QueriesFieldComponent: React.FC = ({ field, scheduledQueryGroupId }) => { +const QueriesFieldComponent: React.FC = ({ + field, + integrationPackageVersion, + scheduledQueryGroupId, +}) => { const [showAddQueryFlyout, setShowAddQueryFlyout] = useState(false); const [showEditQueryFlyout, setShowEditQueryFlyout] = useState(-1); - const [tableSelectedItems, setTableSelectedItems] = useState([]); + const [tableSelectedItems, setTableSelectedItems] = useState< + OsqueryManagerPackagePolicyInputStream[] + >([]); const handleShowAddFlyout = useCallback(() => setShowAddQueryFlyout(true), []); const handleHideAddFlyout = useCallback(() => setShowAddQueryFlyout(false), []); @@ -59,7 +87,7 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu const { setValue } = field; const handleDeleteClick = useCallback( - (stream: PackagePolicyInputStream) => { + (stream: OsqueryManagerPackagePolicyInputStream) => { const streamIndex = findIndex(field.value[0].streams, [ 'vars.id.value', stream.vars?.id.value, @@ -79,7 +107,7 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu ); const handleEditClick = useCallback( - (stream: PackagePolicyInputStream) => { + (stream: OsqueryManagerPackagePolicyInputStream) => { const streamIndex = findIndex(field.value[0].streams, [ 'vars.id.value', stream.vars?.id.value, @@ -91,39 +119,61 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu ); const handleEditQuery = useCallback( - (updatedQuery) => { - if (showEditQueryFlyout >= 0) { - setValue( - produce((draft) => { - draft[0].streams[showEditQueryFlyout].vars.id.value = updatedQuery.id; - draft[0].streams[showEditQueryFlyout].vars.interval.value = updatedQuery.interval; - draft[0].streams[showEditQueryFlyout].vars.query.value = updatedQuery.query; + (updatedQuery) => + new Promise((resolve) => { + if (showEditQueryFlyout >= 0) { + setValue( + produce((draft) => { + draft[0].streams[showEditQueryFlyout].vars.id.value = updatedQuery.id; + draft[0].streams[showEditQueryFlyout].vars.interval.value = updatedQuery.interval; + draft[0].streams[showEditQueryFlyout].vars.query.value = updatedQuery.query; - return draft; - }) - ); - } + if (updatedQuery.platform?.length) { + draft[0].streams[showEditQueryFlyout].vars.platform = { + type: 'text', + value: updatedQuery.platform, + }; + } else { + delete draft[0].streams[showEditQueryFlyout].vars.platform; + } - handleHideEditFlyout(); - }, + if (updatedQuery.version?.length) { + draft[0].streams[showEditQueryFlyout].vars.version = { + type: 'text', + value: updatedQuery.version, + }; + } else { + delete draft[0].streams[showEditQueryFlyout].vars.version; + } + + return draft; + }) + ); + } + + handleHideEditFlyout(); + resolve(); + }), [handleHideEditFlyout, setValue, showEditQueryFlyout] ); const handleAddQuery = useCallback( - (newQuery) => { - setValue( - produce((draft) => { - draft[0].streams.push( - getNewStream({ - ...newQuery, - scheduledQueryGroupId, - }) - ); - return draft; - }) - ); - handleHideAddFlyout(); - }, + (newQuery) => + new Promise((resolve) => { + setValue( + produce((draft) => { + draft[0].streams.push( + getNewStream({ + ...newQuery, + scheduledQueryGroupId, + }) + ); + return draft; + }) + ); + handleHideAddFlyout(); + resolve(); + }), [handleHideAddFlyout, scheduledQueryGroupId, setValue] ); @@ -148,6 +198,8 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu id: newQueryId, interval: newQuery.interval, query: newQuery.query, + version: newQuery.version, + platform: getSupportedPlatforms(newQuery.platform), scheduledQueryGroupId, }) ); @@ -160,7 +212,9 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu [scheduledQueryGroupId, setValue] ); - const tableData = useMemo(() => ({ inputs: field.value }), [field.value]); + const tableData = useMemo(() => (field.value.length ? field.value[0].streams : []), [ + field.value, + ]); return ( <> @@ -201,12 +255,16 @@ const QueriesFieldComponent: React.FC = ({ field, scheduledQu {} {showAddQueryFlyout && ( - // @ts-expect-error update types - + )} {showEditQueryFlyout != null && showEditQueryFlyout >= 0 && ( - diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts new file mode 100644 index 00000000000000..3345c18d07b2cb --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/constants.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const ALL_OSQUERY_VERSIONS_OPTIONS = [ + { + label: '4.7.0', + }, + { + label: '4.6.0', + }, + { + label: '4.5.1', + }, + { + label: '4.5.0', + }, + { + label: '4.4.0', + }, + { + label: '4.3.0', + }, + { + label: '4.2.0', + }, + { + label: '4.1.2', + }, + { + label: '4.1.1', + }, + { + label: '4.0.2', + }, + { + label: '3.3.2', + }, + { + label: '3.3.0', + }, + { + label: '3.2.6', + }, + { + label: '3.2.4', + }, + { + label: '2.9.0', + }, + { + label: '2.8.0', + }, + { + label: '2.7.0', + }, + { + label: '2.11.2', + }, + { + label: '2.11.0', + }, + { + label: '2.10.2', + }, + { + label: '2.10.0', + }, +]; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx new file mode 100644 index 00000000000000..4e433e9e240b1a --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platform_checkbox_group_field.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, pickBy } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiCheckboxGroup, + EuiCheckboxGroupOption, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../shared_imports'; +import { PlatformIcon } from './platforms/platform_icon'; + +interface Props { + field: FieldHook; + euiFieldProps?: Record; + idAria?: string; + [key: string]: unknown; +} + +export const PlatformCheckBoxGroupField = ({ + field, + euiFieldProps = {}, + idAria, + ...rest +}: Props) => { + const options = useMemo( + () => [ + { + id: 'linux', + label: ( + + + + + + + + + ), + }, + { + id: 'darwin', + label: ( + + + + + + + + + ), + }, + { + id: 'windows', + label: ( + + + + + + + + + ), + }, + ], + [] + ); + + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const [checkboxIdToSelectedMap, setCheckboxIdToSelectedMap] = useState>( + () => + (options as EuiCheckboxGroupOption[]).reduce((acc, option) => { + acc[option.id] = isEmpty(field.value) ? true : field.value?.includes(option.id) ?? false; + return acc; + }, {} as Record) + ); + + const onChange = useCallback( + (optionId: string) => { + const newCheckboxIdToSelectedMap = { + ...checkboxIdToSelectedMap, + [optionId]: !checkboxIdToSelectedMap[optionId], + }; + setCheckboxIdToSelectedMap(newCheckboxIdToSelectedMap); + + field.setValue(() => + Object.keys(pickBy(newCheckboxIdToSelectedMap, (value) => value === true)).join(',') + ); + }, + [checkboxIdToSelectedMap, field] + ); + + const describedByIds = useMemo(() => (idAria ? [idAria] : []), [idAria]); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/constants.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/constants.ts new file mode 100644 index 00000000000000..4f81ed73e1e7a7 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PlatformType } from './types'; + +export const SUPPORTED_PLATFORMS = [PlatformType.darwin, PlatformType.linux, PlatformType.windows]; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/helpers.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/helpers.tsx new file mode 100644 index 00000000000000..362fa5c67e6f90 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/helpers.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; +import { SUPPORTED_PLATFORMS } from './constants'; + +import linuxSvg from './logos/linux.svg'; +import windowsSvg from './logos/windows.svg'; +import macosSvg from './logos/macos.svg'; +import { PlatformType } from './types'; + +export const getPlatformIconModule = (platform: string) => { + switch (platform) { + case 'darwin': + return macosSvg; + case 'linux': + return linuxSvg; + case 'windows': + return windowsSvg; + default: + return `${platform}`; + } +}; + +export const getSupportedPlatforms = (payload: string) => { + let platformArray: string[]; + try { + platformArray = payload?.split(',').map((platformString) => platformString.trim()); + } catch (e) { + return undefined; + } + + if (!platformArray) return; + + return uniq( + platformArray.reduce((acc, nextPlatform) => { + if (!SUPPORTED_PLATFORMS.includes(nextPlatform as PlatformType)) { + if (nextPlatform === 'posix') { + acc.push(PlatformType.darwin); + acc.push(PlatformType.linux); + } + if (nextPlatform === 'ubuntu') { + acc.push(PlatformType.linux); + } + } else { + acc.push(nextPlatform); + } + return acc; + }, [] as string[]) + ).join(','); +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/index.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/index.tsx new file mode 100644 index 00000000000000..b8af2790c6f368 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useEffect, useState, useMemo } from 'react'; + +import { SUPPORTED_PLATFORMS } from './constants'; +import { PlatformIcon } from './platform_icon'; + +interface PlatformIconsProps { + platform: string; +} + +const PlatformIconsComponent: React.FC = ({ platform }) => { + const [platforms, setPlatforms] = useState(SUPPORTED_PLATFORMS); + + useEffect(() => { + setPlatforms((prevValue) => { + if (platform) { + let platformArray: string[]; + try { + platformArray = platform?.split(',').map((platformString) => platformString.trim()); + } catch (e) { + return prevValue; + } + return platformArray; + } else { + return SUPPORTED_PLATFORMS; + } + }); + }, [platform]); + + const content = useMemo( + () => + platforms.map((platformString) => ( + + + + )), + [platforms] + ); + + return {content}; +}; + +export const PlatformIcons = React.memo(PlatformIconsComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/linux.svg b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/linux.svg new file mode 100644 index 00000000000000..47358292e08a80 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/macos.svg b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/macos.svg new file mode 100644 index 00000000000000..baa5930800aa9c --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/macos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/windows.svg b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/windows.svg new file mode 100644 index 00000000000000..0872225da3a117 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/logos/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/platform_icon.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/platform_icon.tsx new file mode 100644 index 00000000000000..1126dfd690c193 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/platform_icon.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { getPlatformIconModule } from './helpers'; + +interface PlatformIconProps { + platform: string; +} + +const PlatformIconComponent: React.FC = ({ platform }) => { + const platformIconModule = getPlatformIconModule(platform); + return ; +}; + +export const PlatformIcon = React.memo(PlatformIconComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/types.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/types.ts new file mode 100644 index 00000000000000..94953a6a854ea2 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/platforms/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum PlatformType { + darwin = 'darwin', + windows = 'windows', + linux = 'linux', +} diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx new file mode 100644 index 00000000000000..62ac3a46a2d773 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCallOut, + EuiFlyout, + EuiTitle, + EuiSpacer, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiPortal, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { satisfies } from 'semver'; + +import { OsqueryManagerPackagePolicyConfigRecord } from '../../../common/types'; +import { CodeEditorField } from '../../queries/form/code_editor_field'; +import { Form, getUseField, Field } from '../../shared_imports'; +import { PlatformCheckBoxGroupField } from './platform_checkbox_group_field'; +import { ALL_OSQUERY_VERSIONS_OPTIONS } from './constants'; +import { + UseScheduledQueryGroupQueryFormProps, + useScheduledQueryGroupQueryForm, +} from './use_scheduled_query_group_query_form'; +import { ManageIntegrationLink } from '../../components/manage_integration_link'; + +const CommonUseField = getUseField({ component: Field }); + +interface QueryFlyoutProps { + defaultValue?: UseScheduledQueryGroupQueryFormProps['defaultValue'] | undefined; + integrationPackageVersion?: string | undefined; + onSave: (payload: OsqueryManagerPackagePolicyConfigRecord) => Promise; + onClose: () => void; +} + +const QueryFlyoutComponent: React.FC = ({ + defaultValue, + integrationPackageVersion, + onSave, + onClose, +}) => { + const { form } = useScheduledQueryGroupQueryForm({ + defaultValue, + handleSubmit: (payload, isValid) => + new Promise((resolve) => { + if (isValid) { + onSave(payload); + onClose(); + } + resolve(); + }), + }); + + /* Platform and version fields are supported since osquer_manger@0.3.0 */ + const isFieldSupported = useMemo( + () => (integrationPackageVersion ? satisfies(integrationPackageVersion, '>=0.3.0') : false), + [integrationPackageVersion] + ); + + const { submit } = form; + + return ( + + + + +

+ {defaultValue ? ( + + ) : ( + + )} +

+
+
+ +
+ + + + + + + + + + + + + + + + + {!isFieldSupported ? ( + + } + iconType="pin" + > + + + + + + + ) : null} +
+ + + + + + + + + + + + + + +
+
+ ); +}; + +export const QueryFlyout = React.memo(QueryFlyoutComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx new file mode 100644 index 00000000000000..344c33b419dd6c --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { FIELD_TYPES } from '../../shared_imports'; + +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; + +export const formSchema = { + id: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { + defaultMessage: 'ID', + }), + validations: idFieldValidations.map((validator) => ({ validator })), + }, + query: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { + defaultMessage: 'Query', + }), + validations: [{ validator: queryFieldValidation }], + }, + interval: { + defaultValue: 3600, + type: FIELD_TYPES.NUMBER, + label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.intervalFieldLabel', { + defaultMessage: 'Interval (s)', + }), + validations: [{ validator: intervalFieldValidation }], + }, + platform: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.platformFieldLabel', { + defaultMessage: 'Platform', + }), + validations: [], + }, + version: { + defaultValue: [], + type: FIELD_TYPES.COMBO_BOX, + label: (( + + + + + + + + + + + ) as unknown) as string, + validations: [], + }, +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx new file mode 100644 index 00000000000000..bcde5f4b970d43 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isArray } from 'lodash'; +import uuid from 'uuid'; +import { produce } from 'immer'; + +import { FormConfig, useForm } from '../../shared_imports'; +import { OsqueryManagerPackagePolicyConfigRecord } from '../../../common/types'; +import { formSchema } from './schema'; + +const FORM_ID = 'editQueryFlyoutForm'; + +export interface UseScheduledQueryGroupQueryFormProps { + defaultValue?: OsqueryManagerPackagePolicyConfigRecord | undefined; + handleSubmit: FormConfig< + OsqueryManagerPackagePolicyConfigRecord, + ScheduledQueryGroupFormData + >['onSubmit']; +} + +export interface ScheduledQueryGroupFormData { + id: string; + query: string; + interval: number; + platform?: string | undefined; + version?: string[] | undefined; +} + +export const useScheduledQueryGroupQueryForm = ({ + defaultValue, + handleSubmit, +}: UseScheduledQueryGroupQueryFormProps) => + useForm({ + id: FORM_ID + uuid.v4(), + onSubmit: handleSubmit, + options: { + stripEmptyFields: false, + }, + defaultValue, + // @ts-expect-error update types + serializer: (payload) => + produce(payload, (draft) => { + if (draft.platform?.split(',').length === 3) { + // if all platforms are checked then use undefined + delete draft.platform; + } + if (isArray(draft.version)) { + if (!draft.version.length) { + delete draft.version; + } else { + // @ts-expect-error update types + draft.version = draft.version[0]; + } + } + return draft; + }), + deserializer: (payload) => { + if (!payload) return {} as ScheduledQueryGroupFormData; + + return { + id: payload.id.value, + query: payload.query.value, + interval: parseInt(payload.interval.value, 10), + platform: payload.platform?.value, + version: payload.version?.value ? [payload.version?.value] : [], + }; + }, + schema: formSchema, + }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts similarity index 100% rename from x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts rename to x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index 6f78f2c086edf4..36d15587086f2d 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -22,9 +22,10 @@ import { PersistedIndexPatternLayer, PieVisualizationState, } from '../../../lens/public'; -import { PackagePolicy, PackagePolicyInputStream } from '../../../fleet/common'; import { FilterStateStore } from '../../../../../src/plugins/data/common'; import { useKibana, isModifiedEvent, isLeftClickEvent } from '../common/lib/kibana'; +import { PlatformIcons } from './queries/platforms'; +import { OsqueryManagerPackagePolicyInputStream } from '../../common/types'; export enum ViewResultsActionButtonType { icon = 'icon', @@ -303,12 +304,12 @@ const ViewResultsInDiscoverActionComponent: React.FC; + data: OsqueryManagerPackagePolicyInputStream[]; editMode?: boolean; - onDeleteClick?: (item: PackagePolicyInputStream) => void; - onEditClick?: (item: PackagePolicyInputStream) => void; - selectedItems?: PackagePolicyInputStream[]; - setSelectedItems?: (selection: PackagePolicyInputStream[]) => void; + onDeleteClick?: (item: OsqueryManagerPackagePolicyInputStream) => void; + onEditClick?: (item: OsqueryManagerPackagePolicyInputStream) => void; + selectedItems?: OsqueryManagerPackagePolicyInputStream[]; + setSelectedItems?: (selection: OsqueryManagerPackagePolicyInputStream[]) => void; } const ScheduledQueryGroupQueriesTableComponent: React.FC = ({ @@ -320,7 +321,7 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC { const renderDeleteAction = useCallback( - (item: PackagePolicyInputStream) => ( + (item: OsqueryManagerPackagePolicyInputStream) => ( ( + (item: OsqueryManagerPackagePolicyInputStream) => ( , + [] + ); + + const renderVersionColumn = useCallback( + (version: string) => + version + ? `${version}` + : i18n.translate('xpack.osquery.scheduledQueryGroup.queriesTable.osqueryVersionAllLabel', { + defaultMessage: 'ALL', + }), + [] + ); + const renderDiscoverResultsAction = useCallback( (item) => ( ({ sort: { - field: 'vars.id.value' as keyof PackagePolicyInputStream, + field: 'vars.id.value' as keyof OsqueryManagerPackagePolicyInputStream, direction: 'asc' as const, }, }), [] ); - const itemId = useCallback((item: PackagePolicyInputStream) => get('vars.id.value', item), []); + const itemId = useCallback( + (item: OsqueryManagerPackagePolicyInputStream) => get('vars.id.value', item), + [] + ); const selection = useMemo( () => ({ @@ -477,8 +512,8 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC - items={data.inputs[0].streams} + + items={data} itemId={itemId} columns={columns} sorting={sorting} diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts index e0f892d0302c0c..93d552b3f71f3a 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts @@ -8,11 +8,8 @@ import { useQuery } from 'react-query'; import { useKibana } from '../common/lib/kibana'; -import { - GetOnePackagePolicyResponse, - PackagePolicy, - packagePolicyRouteService, -} from '../../../fleet/common'; +import { GetOnePackagePolicyResponse, packagePolicyRouteService } from '../../../fleet/common'; +import { OsqueryManagerPackagePolicy } from '../../common/types'; interface UseScheduledQueryGroup { scheduledQueryGroupId: string; @@ -25,7 +22,11 @@ export const useScheduledQueryGroup = ({ }: UseScheduledQueryGroup) => { const { http } = useKibana().services; - return useQuery( + return useQuery< + Omit & { item: OsqueryManagerPackagePolicy }, + unknown, + OsqueryManagerPackagePolicy + >( ['scheduledQueryGroup', { scheduledQueryGroupId }], () => http.get(packagePolicyRouteService.getInfoPath(scheduledQueryGroupId)), { diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts index 737b4d47357772..8a569a07616567 100644 --- a/x-pack/plugins/osquery/public/shared_imports.ts +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -12,6 +12,7 @@ export { FieldValidateResponse, FIELD_TYPES, Form, + FormConfig, FormData, FormDataProvider, FormHook, diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 291b0f7c607cf1..76e26c770cfe02 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -12,7 +12,8 @@ "common/**/*", "public/**/*", "scripts/**/*", - "server/**/*" + "server/**/*", + "../../../typings/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/list/remote_clusters_list.test.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/list/remote_clusters_list.test.js index c91732019f79fe..209c224618f78e 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/list/remote_clusters_list.test.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/list/remote_clusters_list.test.js @@ -181,6 +181,10 @@ describe('', () => { expect(exists('remoteClusterCreateButton')).toBe(true); }); + test('should have link to documentation', () => { + expect(exists('documentationLink')).toBe(true); + }); + test('should list the remote clusters in the table', () => { expect(tableCellsValues.length).toEqual(remoteClusters.length); expect(tableCellsValues).toEqual([ diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js index 1706be8cfbe2f6..2b2e7338fb6b05 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js @@ -5,62 +5,38 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { remoteClustersUrl } from '../../../services/documentation'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import { EuiPageHeader, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; export const RemoteClusterPageTitle = ({ title, description }) => ( - - - - - - - -

{title}

-
-
- - - - - - -
-
- - {description ? ( - <> - - - - {description} - - - ) : null} - - -
+ <> + {title}} + rightSideItems={[ + + + , + ]} + description={description} + /> + + + ); RemoteClusterPageTitle.propTypes = { diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js index 124d2d42afb789..f62550ca5aa107 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js @@ -9,8 +9,6 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageContent } from '@elastic/eui'; - import { extractQueryParams } from '../../../shared_imports'; import { getRouter, redirect } from '../../services'; import { setBreadcrumbs } from '../../services/breadcrumb'; @@ -58,11 +56,7 @@ export class RemoteClusterAdd extends PureComponent { const { isAddingCluster, addClusterError } = this.props; return ( - + <> - + ); } } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js index 18ee2e2b3875dd..1f3388d06e54c6 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_edit/remote_cluster_edit.js @@ -5,27 +5,17 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPageContent, - EuiSpacer, - EuiText, - EuiTextColor, -} from '@elastic/eui'; +import { EuiButton, EuiCallOut, EuiEmptyPrompt, EuiPageContent, EuiSpacer } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../shared_imports'; import { getRouter, redirect } from '../../services'; import { setBreadcrumbs } from '../../services/breadcrumb'; -import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; +import { RemoteClusterPageTitle, RemoteClusterForm } from '../components'; export class RemoteClusterEdit extends Component { static propTypes = { @@ -92,56 +82,50 @@ export class RemoteClusterEdit extends Component { } }; - renderContent() { + render() { const { clusterName } = this.state; const { isLoading, cluster, isEditingCluster, getEditClusterError } = this.props; if (isLoading) { return ( - - - - - - - - - - - - - + + + + + ); } if (!cluster) { return ( - - + +

+ +

} - color="danger" - iconType="alert" - > - -
- - - - + +

+ } + actions={ + @@ -149,10 +133,10 @@ export class RemoteClusterEdit extends Component { id="xpack.remoteClusters.edit.viewRemoteClustersButtonLabel" defaultMessage="View remote clusters" /> -
-
-
-
+ + } + /> + ); } @@ -160,23 +144,50 @@ export class RemoteClusterEdit extends Component { if (isConfiguredByNode) { return ( - - - - - - - - - + + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
); } return ( <> + + } + /> + {hasDeprecatedProxySetting ? ( <> ) : null} + ); } - - render() { - return ( - - - } - /> - - {this.renderContent()} - - ); - } } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_list.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_list.js index ccf4c7568f7ad9..b94ae8f7edbc04 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_list.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_list.js @@ -5,32 +5,24 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, + EuiButtonEmpty, EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, EuiLoadingKibana, - EuiLoadingSpinner, EuiOverlayMask, - EuiPageBody, EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, EuiSpacer, - EuiText, - EuiTextColor, - EuiTitle, - EuiCallOut, + EuiPageHeader, } from '@elastic/eui'; +import { remoteClustersUrl } from '../../services/documentation'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../shared_imports'; import { setBreadcrumbs } from '../../services/breadcrumb'; import { RemoteClusterTable } from './remote_cluster_table'; @@ -82,41 +74,6 @@ export class RemoteClusterList extends Component { clearInterval(this.interval); } - getHeaderSection(isAuthorized) { - return ( - - - - -

- -

-
-
- - {isAuthorized && ( - - - - - - )} -
- -
- ); - } - renderBlockingAction() { const { isCopyingCluster, isRemovingCluster } = this.props; @@ -132,16 +89,28 @@ export class RemoteClusterList extends Component { } renderNoPermission() { - const title = i18n.translate('xpack.remoteClusters.remoteClusterList.noPermissionTitle', { - defaultMessage: 'Permission error', - }); return ( - - + + + + } + body={ +

+ +

+ } /> -
+ ); } @@ -150,80 +119,84 @@ export class RemoteClusterList extends Component { // handle unexpected error shapes in the API action. const { statusCode, error: errorString } = error.body; - const title = i18n.translate('xpack.remoteClusters.remoteClusterList.loadingErrorTitle', { - defaultMessage: 'Error loading remote clusters', - }); return ( - - {statusCode} {errorString} - + + + + + } + body={ +

+ {statusCode} {errorString} +

+ } + /> +
); } renderEmpty() { return ( - - - - } - body={ - + + + + + } + body={

-
- } - actions={ - - - - } - /> + } + actions={ + + + + } + /> + ); } renderLoading() { return ( - - - - - - - - - - - - - + + + + ); } @@ -231,10 +204,35 @@ export class RemoteClusterList extends Component { const { clusters } = this.props; return ( - + <> + + } + rightSideItems={[ + + + , + ]} + /> + + + - + ); } @@ -242,7 +240,6 @@ export class RemoteClusterList extends Component { const { isLoading, clusters, clusterLoadError } = this.props; const isEmpty = !isLoading && !clusters.length; const isAuthorized = !clusterLoadError || clusterLoadError.status !== 403; - const isHeaderVisible = clusterLoadError || !isEmpty; let content; @@ -261,13 +258,10 @@ export class RemoteClusterList extends Component { } return ( - - - {isHeaderVisible && this.getHeaderSection(isAuthorized)} - {content} - {this.renderBlockingAction()} - - + <> + {content} + {this.renderBlockingAction()} + ); } } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js index 3da8bb505fc543..1404e51d98a6d4 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js @@ -330,6 +330,19 @@ export class RemoteClusterTable extends Component { )} ) : undefined, + toolsRight: ( + + + + ), onChange: this.onSearch, box: { incremental: true, diff --git a/x-pack/plugins/remote_clusters/public/application/store/actions/load_clusters.js b/x-pack/plugins/remote_clusters/public/application/store/actions/load_clusters.js index 343237e70a120e..1a6459627c9a13 100644 --- a/x-pack/plugins/remote_clusters/public/application/store/actions/load_clusters.js +++ b/x-pack/plugins/remote_clusters/public/application/store/actions/load_clusters.js @@ -5,9 +5,7 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { loadClusters as sendLoadClustersRequest, showApiError } from '../../services'; +import { loadClusters as sendLoadClustersRequest } from '../../services'; import { LOAD_CLUSTERS_START, LOAD_CLUSTERS_SUCCESS, LOAD_CLUSTERS_FAILURE } from '../action_types'; @@ -20,17 +18,10 @@ export const loadClusters = () => async (dispatch) => { try { clusters = await sendLoadClustersRequest(); } catch (error) { - dispatch({ + return dispatch({ type: LOAD_CLUSTERS_FAILURE, payload: { error }, }); - - return showApiError( - error, - i18n.translate('xpack.remoteClusters.loadAction.errorTitle', { - defaultMessage: 'Error loading remote clusters', - }) - ); } dispatch({ diff --git a/x-pack/plugins/remote_clusters/public/shared_imports.ts b/x-pack/plugins/remote_clusters/public/shared_imports.ts index fd281753186665..c8d7f1d9f13f3d 100644 --- a/x-pack/plugins/remote_clusters/public/shared_imports.ts +++ b/x-pack/plugins/remote_clusters/public/shared_imports.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { extractQueryParams, indices } from '../../../../src/plugins/es_ui_shared/public'; +export { + extractQueryParams, + indices, + SectionLoading, +} from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap index 04190fbf5eacdd..b1bc14cc79e641 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: expected value of type [boolean] but got [string]"`; +exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: [2]: expected value of type [boolean] but got [string]"`; exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.foo-application]: Payload did not match expected actions"`; diff --git a/x-pack/plugins/security/server/authorization/validate_es_response.ts b/x-pack/plugins/security/server/authorization/validate_es_response.ts index d941b2d777c4fc..223aee37a90471 100644 --- a/x-pack/plugins/security/server/authorization/validate_es_response.ts +++ b/x-pack/plugins/security/server/authorization/validate_es_response.ts @@ -9,6 +9,11 @@ import { schema } from '@kbn/config-schema'; import type { HasPrivilegesResponse } from './types'; +const anyBoolean = schema.boolean(); +const anyBooleanArray = schema.arrayOf(anyBoolean); +const anyString = schema.string(); +const anyObject = schema.object({}, { unknowns: 'allow' }); + /** * Validates an Elasticsearch "Has privileges" response against the expected application, actions, and resources. * @@ -31,7 +36,6 @@ export function validateEsPrivilegeResponse( } function buildValidationSchema(application: string, actions: string[], resources: string[]) { - const actionValidationSchema = schema.boolean(); const actionsValidationSchema = schema.object( {}, { @@ -45,9 +49,7 @@ function buildValidationSchema(application: string, actions: string[], resources throw new Error('Payload did not match expected actions'); } - Object.values(value).forEach((actionResult) => { - actionValidationSchema.validate(actionResult); - }); + anyBooleanArray.validate(Object.values(value)); }, } ); @@ -73,12 +75,12 @@ function buildValidationSchema(application: string, actions: string[], resources ); return schema.object({ - username: schema.string(), - has_all_requested: schema.boolean(), - cluster: schema.object({}, { unknowns: 'allow' }), + username: anyString, + has_all_requested: anyBoolean, + cluster: anyObject, application: schema.object({ [application]: resourcesValidationSchema, }), - index: schema.object({}, { unknowns: 'allow' }), + index: anyObject, }); } diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx index 740437646f61a4..2fdb7e99d860e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx @@ -8,14 +8,10 @@ import React from 'react'; import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; -export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({ - date, - showRelativeTime = false, -}) => { +export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => { // If date is greater than or equal to 1h (ago), then show it as a date - // and if showRelativeTime is false // else, show it as relative to "now" - return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? ( + return Date.now() - date.getTime() >= 3.6e6 ? ( <> {' @'} diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx index fc5c19e95fb770..d677a4a9fd662e 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.test.tsx @@ -140,6 +140,22 @@ describe('when using PaginatedContent', () => { }); }); + it('should call onChange when page is empty', () => { + render({ + pagination: { + pageIndex: 1, + pageSizeOptions: [5, 10, 20], + pageSize: 10, + totalItemCount: 10, + }, + }); + expect(onChangeHandler).toHaveBeenCalledWith({ + pageIndex: 0, + pageSize: 10, + }); + expect(onChangeHandler).toHaveBeenCalledTimes(1); + }); + it('should ignore items, error, noItemsMessage when `children` is used', () => { render({ children:
{'children being used here'}
}); expect(renderResult.getByTestId('custom-content')).not.toBeNull(); diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx index 890b21624eaf67..a6b2683316efe5 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx @@ -16,6 +16,7 @@ import React, { useCallback, useMemo, useState, + useEffect, } from 'react'; import { CommonProps, @@ -152,6 +153,12 @@ export const PaginatedContent = memo( [pagination?.pageSize, pagination?.totalItemCount] ); + useEffect(() => { + if (pageCount > 0 && pageCount < (pagination?.pageIndex || 0) + 1) { + onChange({ pageIndex: pageCount - 1, pageSize: pagination?.pageSize || 0 }); + } + }, [pageCount, onChange, pagination]); + const handleItemsPerPageChange: EuiTablePaginationProps['onChangeItemsPerPage'] = useCallback( (pageSize) => { onChange({ pageSize, pageIndex: pagination?.pageIndex || 0 }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx index 7ecbad54dbbece..356d44a8105287 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -70,6 +70,7 @@ describe('When using the Endpoint Details Actions Menu', () => { ['View host details', 'hostLink'], ['View agent policy', 'agentPolicyLink'], ['View agent details', 'agentDetailsLink'], + ['Reassign agent policy', 'agentPolicyReassignLink'], ])('should display %s action', async (_, dataTestSubj) => { await render(); expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull(); @@ -80,6 +81,7 @@ describe('When using the Endpoint Details Actions Menu', () => { ['View host details', 'hostLink'], ['View agent policy', 'agentPolicyLink'], ['View agent details', 'agentDetailsLink'], + ['Reassign agent policy', 'agentPolicyReassignLink'], ])( 'should navigate via kibana `navigateToApp()` when %s is clicked', async (_, dataTestSubj) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index f8574da7f0a03d..c431cd682d25ba 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { EuiComment, EuiText, EuiAvatarProps, EuiCommentProps, IconType } from '@elastic/eui'; import { Immutable, ActivityLogEntry } from '../../../../../../../common/endpoint/types'; -import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time'; +import { FormattedRelativePreferenceDate } from '../../../../../../common/components/formatted_date'; import { LogEntryTimelineIcon } from './log_entry_timeline_icon'; import * as i18 from '../../translations'; @@ -144,9 +144,8 @@ export const LogEntry = memo(({ logEntry }: { logEntry: Immutable{displayResponseEvent ? responseEventTitle : actionEventTitle}} timelineIcon={ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index 16cae79d42c0f2..38404a5c6c11ff 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -8,10 +8,8 @@ import styled from 'styled-components'; import { EuiDescriptionList, - EuiHorizontalRule, EuiListGroup, EuiListGroupItem, - EuiIcon, EuiText, EuiFlexGroup, EuiFlexItem, @@ -23,17 +21,14 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { isPolicyOutOfDate } from '../../utils'; import { HostInfo, HostMetadata, HostStatus } from '../../../../../../common/endpoint/types'; -import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; -import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { useEndpointSelector } from '../hooks'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; import { POLICY_STATUS_TO_BADGE_COLOR, HOST_STATUS_TO_BADGE_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; -import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; import { getEndpointDetailsPath } from '../../../../common/routing'; import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; -import { AgentDetailsReassignPolicyAction } from '../../../../../../../fleet/public'; import { EndpointPolicyLink } from '../components/endpoint_policy_link'; import { OutOfDate } from '../components/out_of_date'; @@ -44,8 +39,6 @@ const HostIds = styled(EuiListGroupItem)` } `; -const openReassignFlyoutSearch = '?openReassignFlyout=true'; - export const EndpointDetails = memo( ({ details, @@ -56,19 +49,34 @@ export const EndpointDetails = memo( policyInfo?: HostInfo['policy_info']; hostStatus: HostStatus; }) => { - const agentId = details.elastic.agent.id; - const { - url: agentDetailsUrl, - appId: ingestAppId, - appPath: agentDetailsAppPath, - } = useAgentDetailsIngestUrl(agentId); const queryParams = useEndpointSelector(uiQueryParams); const policyStatus = useEndpointSelector( policyResponseStatus ) as keyof typeof POLICY_STATUS_TO_BADGE_COLOR; const { formatUrl } = useFormatUrl(SecurityPageName.administration); - const detailsResultsUpper = useMemo(() => { + const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { selected_endpoint, show, ...currentUrlParams } = queryParams; + return [ + formatUrl( + getEndpointDetailsPath({ + name: 'endpointPolicyResponse', + ...currentUrlParams, + selected_endpoint: details.agent.id, + }) + ), + getEndpointDetailsPath({ + name: 'endpointPolicyResponse', + ...currentUrlParams, + selected_endpoint: details.agent.id, + }), + ]; + }, [details.agent.id, formatUrl, queryParams]); + + const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); + + const detailsResults = useMemo(() => { return [ { title: i18n.translate('xpack.securitySolution.endpoint.details.os', { @@ -106,55 +114,9 @@ export const EndpointDetails = memo( ), }, - ]; - }, [details, hostStatus]); - - const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { selected_endpoint, show, ...currentUrlParams } = queryParams; - return [ - formatUrl( - getEndpointDetailsPath({ - name: 'endpointPolicyResponse', - ...currentUrlParams, - selected_endpoint: details.agent.id, - }) - ), - getEndpointDetailsPath({ - name: 'endpointPolicyResponse', - ...currentUrlParams, - selected_endpoint: details.agent.id, - }), - ]; - }, [details.agent.id, formatUrl, queryParams]); - - const agentDetailsWithFlyoutPath = `${agentDetailsAppPath}${openReassignFlyoutSearch}`; - const agentDetailsWithFlyoutUrl = `${agentDetailsUrl}${openReassignFlyoutSearch}`; - const handleReassignEndpointsClick = useNavigateToAppEventHandler( - ingestAppId, - { - path: agentDetailsWithFlyoutPath, - state: { - onDoneNavigateTo: [ - 'securitySolution:administration', - { - path: getEndpointDetailsPath({ - name: 'endpointDetails', - selected_endpoint: details.agent.id, - }), - }, - ], - }, - } - ); - - const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - - const detailsResultsPolicy = useMemo(() => { - return [ { title: i18n.translate('xpack.securitySolution.endpoint.details.policy', { - defaultMessage: 'Integration Policy', + defaultMessage: 'Policy', }), description: ( @@ -198,7 +160,7 @@ export const EndpointDetails = memo( }, { title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', { - defaultMessage: 'Policy Response', + defaultMessage: 'Policy Status', }), description: ( // https://github.com/elastic/eui/issues/4530 @@ -210,7 +172,7 @@ export const EndpointDetails = memo( onClick={policyStatusClickHandler} onClickAriaLabel={i18n.translate( 'xpack.securitySolution.endpoint.details.policyStatus', - { defaultMessage: 'Policy Response' } + { defaultMessage: 'Policy Status' } )} > @@ -223,10 +185,12 @@ export const EndpointDetails = memo( ), }, - ]; - }, [details, policyResponseUri, policyStatus, policyStatusClickHandler, policyInfo]); - const detailsResultsLower = useMemo(() => { - return [ + { + title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { + defaultMessage: 'Endpoint Version', + }), + description: {details.agent.version}, + }, { title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', { defaultMessage: 'IP Address', @@ -241,70 +205,23 @@ export const EndpointDetails = memo( ), }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { - defaultMessage: 'Hostname', - }), - description: {details.host.hostname}, - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { - defaultMessage: 'Endpoint Version', - }), - description: {details.agent.version}, - }, ]; - }, [details.agent.version, details.host.hostname, details.host.ip]); + }, [ + details, + hostStatus, + policyResponseUri, + policyStatus, + policyStatusClickHandler, + policyInfo, + ]); return ( <> - - - - - - - - - - - - - - - - - - - - ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index c39a17e98c76ab..7892c56fef8069 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -166,14 +166,14 @@ export const EndpointDetailsFlyout = memo(() => { style={{ zIndex: 4001 }} data-test-subj="endpointDetailsFlyout" size="m" - paddingSize="m" + paddingSize="l" > - + {hostDetailsLoading ? ( ) : ( - +

), }, + { + icon: 'gear', + key: 'agentPolicyReassignLink', + 'data-test-subj': 'agentPolicyReassignLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${ + pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })[1] + }/activity?openReassignFlyout=true`, + }, + href: `${getUrlForApp('fleet')}#${ + pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })[1] + }/activity?openReassignFlyout=true`, + children: ( + + ), + }, ]; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index aa1c47a3102d90..86f1e32e751eeb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -516,7 +516,6 @@ describe('when on the endpoint list page', () => { describe('when there is a selected host in the url', () => { let hostDetails: HostInfo; - let elasticAgentId: string; let renderAndWaitForData: () => Promise>; const mockEndpointListApi = (mockedPolicyResponse?: HostPolicyResponse) => { const { @@ -546,8 +545,6 @@ describe('when on the endpoint list page', () => { query_strategy_version, }; - elasticAgentId = hostDetails.metadata.elastic.agent.id; - const policy = docGenerator.generatePolicyPackagePolicy(); policy.id = hostDetails.metadata.Endpoint.policy.applied.id; @@ -738,37 +735,11 @@ describe('when on the endpoint list page', () => { ); }); - it('should include the link to reassignment in Ingest', async () => { - coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); - const renderResult = await renderAndWaitForData(); - const linkToReassign = await renderResult.findByTestId('endpointDetailsLinkToIngest'); - expect(linkToReassign).not.toBeNull(); - expect(linkToReassign.textContent).toEqual('Reassign Policy'); - expect(linkToReassign.getAttribute('href')).toEqual( - `/app/fleet#/fleet/agents/${elasticAgentId}/activity?openReassignFlyout=true` - ); - }); - it('should show the Take Action button', async () => { const renderResult = await renderAndWaitForData(); expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); }); - describe('when link to reassignment in Ingest is clicked', () => { - beforeEach(async () => { - coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); - const renderResult = await renderAndWaitForData(); - const linkToReassign = await renderResult.findByTestId('endpointDetailsLinkToIngest'); - reactTestingLibrary.act(() => { - reactTestingLibrary.fireEvent.click(linkToReassign); - }); - }); - - it('should navigate to Ingest without full page refresh', () => { - expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); - }); - }); - describe('when showing host Policy Response panel', () => { let renderResult: ReturnType; beforeEach(async () => { @@ -1139,5 +1110,12 @@ describe('when on the endpoint list page', () => { const agentDetailsLink = await renderResult.findByTestId('agentDetailsLink'); expect(agentDetailsLink.getAttribute('href')).toEqual(`/app/fleet#/fleet/agents/${agentId}`); }); + + it('navigates to the Ingest Agent Details page with policy reassign', async () => { + const agentPolicyReassignLink = await renderResult.findByTestId('agentPolicyReassignLink'); + expect(agentPolicyReassignLink.getAttribute('href')).toEqual( + `/app/fleet#/fleet/agents/${agentId}/activity?openReassignFlyout=true` + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts index d4e81fd8126687..fef6ccb99a17a2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts @@ -59,6 +59,13 @@ export const getListItems: EventFiltersSelector< return apiResponseData?.data || []; }); +export const getTotalCountListItems: EventFiltersSelector> = createSelector( + getListApiSuccessResponse, + (apiResponseData) => { + return apiResponseData?.total || 0; + } +); + /** * Will return the query that was used with the currently displayed list of content. If a new page * of content is being loaded, this selector will then attempt to use the previousState to return diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts index ac2b16e51603c9..9d2d3c394c4166 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts @@ -17,6 +17,7 @@ import { getCurrentListPageDataState, getListApiSuccessResponse, getListItems, + getTotalCountListItems, getCurrentListItemsQuery, getListPagination, getListFetchError, @@ -120,6 +121,19 @@ describe('event filters selectors', () => { }); }); + describe('getTotalCountListItems()', () => { + it('should return the list items from api response', () => { + setToLoadedState(); + expect(getTotalCountListItems(initialState)).toEqual( + getLastLoadedResourceState(initialState.listPage.data)?.data.content.total + ); + }); + + it('should return empty array if no api response', () => { + expect(getTotalCountListItems(initialState)).toEqual(0); + }); + }); + describe('getCurrentListItemsQuery()', () => { it('should return empty object if Uninitialized', () => { expect(getCurrentListItemsQuery(initialState)).toEqual({}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 00ee80c5d70223..0975104f02297f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -32,6 +32,7 @@ import { getActionError, getFormEntry, showDeleteModal, + getTotalCountListItems, } from '../store/selector'; import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; import { Immutable, ListPageRouteState } from '../../../../../common/endpoint/types'; @@ -66,6 +67,7 @@ export const EventFiltersListPage = memo(() => { const isActionError = useEventFiltersSelector(getActionError); const formEntry = useEventFiltersSelector(getFormEntry); const listItems = useEventFiltersSelector(getListItems); + const totalCountListItems = useEventFiltersSelector(getTotalCountListItems); const pagination = useEventFiltersSelector(getListPagination); const isLoading = useEventFiltersSelector(getListIsLoading); const fetchError = useEventFiltersSelector(getListFetchError); @@ -235,7 +237,7 @@ export const EventFiltersListPage = memo(() => { diff --git a/x-pack/plugins/snapshot_restore/public/application/app.tsx b/x-pack/plugins/snapshot_restore/public/application/app.tsx index 0c064d448105fc..a7993300079ab2 100644 --- a/x-pack/plugins/snapshot_restore/public/application/app.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/app.tsx @@ -10,14 +10,16 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { EuiPageContent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { APP_WRAPPER_CLASS } from '../../../../../src/core/public'; + import { APP_REQUIRED_CLUSTER_PRIVILEGES } from '../../common'; import { useAuthorizationContext, - SectionError, + PageError, WithPrivileges, NotAuthorizedSection, } from '../shared_imports'; -import { SectionLoading } from './components'; +import { PageLoading } from './components'; import { DEFAULT_SECTION, Section } from './constants'; import { RepositoryAdd, @@ -42,7 +44,7 @@ export const App: React.FunctionComponent = () => { const sectionsRegex = sections.join('|'); return apiError ? ( - { `cluster.${name}`)}> {({ isLoading, hasPrivileges, privilegesMissing }) => isLoading ? ( - + - + ) : hasPrivileges ? ( -
+
@@ -84,7 +86,7 @@ export const App: React.FunctionComponent = () => {
) : ( - + = ({ children, ...rest }) => { + return ( + + + + + + + {children} + + + + ); +}; + +export const SectionLoading: React.FunctionComponent = ({ children }) => { + return ( + } + body={{children}} + data-test-subj="sectionLoading" + /> + ); +}; + +/* + * Loading component used for full page loads. + * For tabbed sections, or within the context of a wizard, + * the component may be more appropriate + */ +export const PageLoading: React.FunctionComponent = ({ children }) => { + return ( + + } + body={{children}} + data-test-subj="sectionLoading" + /> + + ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx index 6443d774c9ac7b..06c65a17136927 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx @@ -30,7 +30,7 @@ import { useCore, useServices } from '../../../app_context'; import { DEFAULT_POLICY_SCHEDULE, DEFAULT_POLICY_FREQUENCY } from '../../../constants'; import { useLoadRepositories } from '../../../services/http'; import { linkToAddRepository } from '../../../services/navigation'; -import { SectionLoading } from '../../'; +import { InlineLoading } from '../../'; import { StepProps } from './'; import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -174,12 +174,12 @@ export const PolicyStepLogistics: React.FunctionComponent = ({ const renderRepositorySelect = () => { if (isLoadingRepositories) { return ( - + - + ); } diff --git a/x-pack/plugins/snapshot_restore/public/application/components/section_loading.tsx b/x-pack/plugins/snapshot_restore/public/application/components/section_loading.tsx deleted file mode 100644 index c1548ad960bb08..00000000000000 --- a/x-pack/plugins/snapshot_restore/public/application/components/section_loading.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { - EuiEmptyPrompt, - EuiLoadingSpinner, - EuiText, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, -} from '@elastic/eui'; - -interface Props { - inline?: boolean; - children: React.ReactNode; - [key: string]: any; -} - -export const SectionLoading: React.FunctionComponent = ({ inline, children, ...rest }) => { - if (inline) { - return ( - - - - - - - {children} - - - - ); - } - - return ( - } - body={{children}} - data-test-subj="sectionLoading" - /> - ); -}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx index e4a23bac636d8f..211d30181c25c6 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx @@ -9,18 +9,7 @@ import React, { useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPageBody, - EuiPageContent, - EuiSpacer, - EuiTab, - EuiTabs, - EuiTitle, - EuiText, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { BASE_PATH, Section } from '../../constants'; import { useConfig, useCore } from '../../app_context'; @@ -100,79 +89,65 @@ export const SnapshotRestoreHome: React.FunctionComponent - - - - -

- -

-
- - - - - -
-
- - - + <> + - - - - - - - {tabs.map((tab) => ( - onSectionChange(tab.id)} - isSelected={tab.id === section} - key={tab.id} - data-test-subj={tab.id.toLowerCase() + '_tab'} - > - {tab.name} - - ))} - + + } + rightSideItems={[ + + + , + ]} + description={ + + } + tabs={tabs.map((tab) => ({ + onClick: () => onSectionChange(tab.id), + isSelected: tab.id === section, + key: tab.id, + 'data-test-subj': tab.id.toLowerCase() + '_tab', + label: tab.name, + }))} + /> - + - - - {/* We have two separate SnapshotList routes because repository names could have slashes in - * them. This would break a route with a path like snapshots/:repositoryName?/:snapshotId* - */} - - - - - -
- + + + {/* We have two separate SnapshotList routes because repository names could have slashes in + * them. This would break a route with a path like snapshots/:repositoryName?/:snapshotId* + */} + + + + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx index 2bad30b95081d2..0a283d406e5aad 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx @@ -40,6 +40,7 @@ import { linkToEditPolicy, linkToSnapshot } from '../../../../services/navigatio import { SectionLoading, + InlineLoading, PolicyExecuteProvider, PolicyDeleteProvider, } from '../../../../components'; @@ -318,7 +319,7 @@ export const PolicyDetails: React.FunctionComponent = ({ {policyDetails && policyDetails.policy && policyDetails.policy.inProgress ? ( <> - + = ({ values={{ snapshotName: policyDetails.policy.inProgress.snapshotName }} /> - + ) : null} {renderTabs()} diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx index 7b1c10ec59e8ab..3927b73abf0936 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx @@ -9,13 +9,13 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody, EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; import { TIME_UNITS } from '../../../../common'; -import { SectionError, Error } from '../../../shared_imports'; +import { SectionError, PageError } from '../../../shared_imports'; -import { PolicyForm, SectionLoading } from '../../components'; +import { PolicyForm, PageLoading } from '../../components'; import { BASE_PATH, DEFAULT_POLICY_SCHEDULE } from '../../constants'; import { breadcrumbService, docTitleService } from '../../services/navigation'; import { addPolicy, useLoadIndices } from '../../services/http'; @@ -87,49 +87,57 @@ export const PolicyAdd: React.FunctionComponent = ({ setSaveError(null); }; + if (isLoadingIndices) { + return ( + + + + ); + } + + if (errorLoadingIndices) { + return ( + + } + error={errorLoadingIndices} + /> + ); + } + return ( - - - -

+ + -

-
- - {isLoadingIndices ? ( - - - - ) : errorLoadingIndices ? ( - - } - error={errorLoadingIndices as Error} - /> - ) : ( - - )} -
-
+ + } + /> + + + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx index 0ad19028457706..4ab0f15cc55230 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx @@ -9,12 +9,12 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle, EuiCallOut } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageHeader, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; -import { SectionError, Error } from '../../../shared_imports'; +import { SectionError, Error, PageError } from '../../../shared_imports'; import { useDecodedParams } from '../../lib'; import { TIME_UNITS } from '../../../../common/constants'; -import { SectionLoading, PolicyForm } from '../../components'; +import { PageLoading, PolicyForm } from '../../components'; import { BASE_PATH } from '../../constants'; import { useServices } from '../../app_context'; import { breadcrumbService, docTitleService } from '../../services/navigation'; @@ -106,21 +106,39 @@ export const PolicyEdit: React.FunctionComponent { + return saveError ? ( + + } + error={saveError} + /> + ) : null; + }; + + const clearSaveError = () => { + setSaveError(null); + }; + const renderLoading = () => { - return errorLoadingPolicy ? ( - + return isLoadingPolicy ? ( + - + ) : ( - + - + ); }; @@ -139,8 +157,9 @@ export const PolicyEdit: React.FunctionComponent - } - error={errorLoadingIndices as Error} - /> - ); - } - }; - - const renderSaveError = () => { - return saveError ? ( - } - error={saveError} + error={errorLoadingIndices as Error} /> - ) : null; - }; - - const clearSaveError = () => { - setSaveError(null); + ); }; - const renderContent = () => { - if (isLoadingPolicy || isLoadingIndices) { - return renderLoading(); - } - if (errorLoadingPolicy || errorLoadingIndices) { - return renderError(); - } + if (isLoadingPolicy || isLoadingIndices) { + return renderLoading(); + } - return ( - <> - {policy.isManagedPolicy ? ( - <> - - } - /> - - - ) : null} - - - ); - }; + if (errorLoadingPolicy || errorLoadingIndices) { + return renderError(); + } return ( - - - -

+ + -

-
- - {renderContent()} -
-
+ + } + /> + + + {policy.isManagedPolicy ? ( + <> + + } + /> + + + ) : null} + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx index 343c0b60a2253d..100d345a49c4d2 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody, EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { Repository, EmptyRepository } from '../../../../common/types'; import { SectionError } from '../../../shared_imports'; @@ -79,25 +79,27 @@ export const RepositoryAdd: React.FunctionComponent = ({ }; return ( - - - -

+ + -

-
- - -
-
+ + } + /> + + + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx index e27dd255f3bdf2..9ecd1d0e3fafe9 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import React, { useEffect, useState, Fragment } from 'react'; +import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiCallOut, EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiCallOut, EuiPageContentBody, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { Repository, EmptyRepository } from '../../../../common/types'; -import { SectionError, Error } from '../../../shared_imports'; -import { RepositoryForm, SectionLoading } from '../../components'; +import { PageError, SectionError, Error } from '../../../shared_imports'; +import { RepositoryForm, PageLoading } from '../../components'; import { BASE_PATH, Section } from '../../constants'; import { useServices } from '../../app_context'; import { breadcrumbService, docTitleService } from '../../services/navigation'; @@ -79,12 +79,12 @@ export const RepositoryEdit: React.FunctionComponent { return ( - + - + ); }; @@ -106,7 +106,7 @@ export const RepositoryEdit: React.FunctionComponent { + setSaveError(null); + }; + const renderSaveError = () => { return saveError ? ( { - setSaveError(null); - }; - - const renderContent = () => { - if (loadingRepository) { - return renderLoading(); - } - if (repositoryError) { - return renderError(); - } - - const { isManagedRepository } = repositoryData; - - return ( - - {isManagedRepository ? ( - - - } - /> - - - ) : null} - - - ); - }; - return ( - - - -

+ + -

-
- - {renderContent()} -
-
+ + } + /> + + + + {isManagedRepository ? ( + <> + + } + /> + + + ) : null} + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/restore_snapshot/restore_snapshot.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/restore_snapshot/restore_snapshot.tsx index 685f3c9346f49c..0f950ef3234ba2 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/restore_snapshot/restore_snapshot.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/restore_snapshot/restore_snapshot.tsx @@ -8,12 +8,12 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageContentBody, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { SnapshotDetails, RestoreSettings } from '../../../../common/types'; -import { SectionError, Error } from '../../../shared_imports'; +import { SectionError, Error, PageError } from '../../../shared_imports'; import { BASE_PATH } from '../../constants'; -import { SectionLoading, RestoreSnapshotForm } from '../../components'; +import { PageLoading, RestoreSnapshotForm } from '../../components'; import { useServices } from '../../app_context'; import { breadcrumbService, docTitleService } from '../../services/navigation'; import { useLoadSnapshot, executeRestore } from '../../services/http'; @@ -76,12 +76,12 @@ export const RestoreSnapshot: React.FunctionComponent { return ( - + - + ); }; @@ -103,8 +103,9 @@ export const RestoreSnapshot: React.FunctionComponent { - if (loadingSnapshot) { - return renderLoading(); - } - if (snapshotError) { - return renderError(); - } + if (loadingSnapshot) { + return renderLoading(); + } - return ( - - ); - }; + if (snapshotError) { + return renderError(); + } return ( - - - -

+ + -

-
- - {renderContent()} -
-
+ + } + /> + + + + + ); }; diff --git a/x-pack/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/plugins/snapshot_restore/public/shared_imports.ts index c38f0daedf9964..759453edaba5db 100644 --- a/x-pack/plugins/snapshot_restore/public/shared_imports.ts +++ b/x-pack/plugins/snapshot_restore/public/shared_imports.ts @@ -12,6 +12,7 @@ export { Frequency, NotAuthorizedSection, SectionError, + PageError, sendRequest, SendRequestConfig, SendRequestResponse, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0c419982bdfbc8..c59b1c85869d99 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8930,15 +8930,6 @@ "xpack.fleet.agentStatus.offlineLabel": "オフライン", "xpack.fleet.agentStatus.unhealthyLabel": "異常", "xpack.fleet.agentStatus.updatingLabel": "更新中", - "xpack.fleet.alphaMessageDescription": "Fleet は本番環境用ではありません。", - "xpack.fleet.alphaMessageLinkText": "詳細を参照してください。", - "xpack.fleet.alphaMessageTitle": "ベータリリース", - "xpack.fleet.alphaMessaging.docsLink": "ドキュメンテーション", - "xpack.fleet.alphaMessaging.feedbackText": "{docsLink}をご覧ください。質問やフィードバックについては、{forumLink}にアクセスしてください。", - "xpack.fleet.alphaMessaging.flyoutTitle": "このリリースについて", - "xpack.fleet.alphaMessaging.forumLink": "ディスカッションフォーラム", - "xpack.fleet.alphaMessaging.introText": "Fleet は開発中であり、本番環境用ではありません。このベータリリースは、ユーザーが Fleet と新しい Elastic エージェントをテストしてフィードバックを提供することを目的としています。このプラグインには、サポート SLA が適用されません。", - "xpack.fleet.alphaMessging.closeFlyoutLabel": "閉じる", "xpack.fleet.appNavigation.agentsLinkText": "エージェント", "xpack.fleet.appNavigation.dataStreamsLinkText": "データストリーム", "xpack.fleet.appNavigation.overviewLinkText": "概要", @@ -11965,7 +11956,6 @@ "xpack.ingestPipelines.deleteModal.multipleErrorsNotificationMessageText": "{count}件のパイプラインの削除エラー", "xpack.ingestPipelines.deleteModal.successDeleteSingleNotificationMessageText": "パイプライン'{pipelineName}'を削除しました", "xpack.ingestPipelines.edit.docsButtonLabel": "パイプラインドキュメントを編集", - "xpack.ingestPipelines.edit.fetchPipelineError": "パイプラインの読み込みエラー", "xpack.ingestPipelines.edit.loadingPipelinesDescription": "パイプラインを読み込んでいます…", "xpack.ingestPipelines.edit.pageTitle": "パイプライン'{name}'を編集", "xpack.ingestPipelines.form.cancelButtonLabel": "キャンセル", @@ -11988,8 +11978,6 @@ "xpack.ingestPipelines.form.versionFieldLabel": "バージョン (任意) ", "xpack.ingestPipelines.form.versionToggleDescription": "バージョン番号を追加", "xpack.ingestPipelines.list.listTitle": "Ingestノードパイプライン", - "xpack.ingestPipelines.list.loadErrorReloadLinkLabel": "再試行してください。", - "xpack.ingestPipelines.list.loadErrorTitle": "パイプラインを読み込めません。{reloadLink}", "xpack.ingestPipelines.list.loadingMessage": "パイプラインを読み込み中...", "xpack.ingestPipelines.list.notFoundFlyoutMessage": "パイプラインが見つかりません", "xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel": "クローンを作成", @@ -17685,7 +17673,6 @@ "xpack.remoteClusters.form.errors.serverNameMissing": "サーバー名が必要です。", "xpack.remoteClusters.licenseCheckErrorMessage": "ライセンス確認失敗", "xpack.remoteClusters.listBreadcrumbTitle": "リモートクラスター", - "xpack.remoteClusters.loadAction.errorTitle": "リモートクラスターの読み込み中にエラーが発生", "xpack.remoteClusters.readDocsButtonLabel": "リモートクラスタードキュメント", "xpack.remoteClusters.refreshAction.errorTitle": "リモートクラスターの更新中にエラーが発生", "xpack.remoteClusters.remoteClusterForm.actions.savingText": "保存中", @@ -20144,10 +20131,8 @@ "xpack.securitySolution.endpoint.details.endpointVersion": "エンドポイントバージョン", "xpack.securitySolution.endpoint.details.errorBody": "フライアウトを終了して、利用可能なホストを選択してください。", "xpack.securitySolution.endpoint.details.errorTitle": "ホストが見つかりませんでした", - "xpack.securitySolution.endpoint.details.hostname": "ホスト名", "xpack.securitySolution.endpoint.details.ipAddress": "IPアドレス", "xpack.securitySolution.endpoint.details.lastSeen": "前回の認識", - "xpack.securitySolution.endpoint.details.linkToIngestTitle": "ポリシーの再割り当て", "xpack.securitySolution.endpoint.details.noPolicyResponse": "ポリシー応答がありません", "xpack.securitySolution.endpoint.details.os": "OS", "xpack.securitySolution.endpoint.details.policy": "統合ポリシー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 59046e98a8f3e9..529a24042d209c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9010,15 +9010,6 @@ "xpack.fleet.agentStatus.offlineLabel": "脱机", "xpack.fleet.agentStatus.unhealthyLabel": "运行不正常", "xpack.fleet.agentStatus.updatingLabel": "正在更新", - "xpack.fleet.alphaMessageDescription": "不推荐在生产环境中使用 Fleet。", - "xpack.fleet.alphaMessageLinkText": "查看更多详情。", - "xpack.fleet.alphaMessageTitle": "公测版", - "xpack.fleet.alphaMessaging.docsLink": "文档", - "xpack.fleet.alphaMessaging.feedbackText": "阅读我们的{docsLink}或前往我们的{forumLink},以了解问题或提供反馈。", - "xpack.fleet.alphaMessaging.flyoutTitle": "关于本版本", - "xpack.fleet.alphaMessaging.forumLink": "讨论论坛", - "xpack.fleet.alphaMessaging.introText": "Fleet 仍处于开发状态,不适用于生产环境。此公测版用于用户测试 Fleet 和新 Elastic 代理并提供相关反馈。此插件不受支持 SLA 的约束。", - "xpack.fleet.alphaMessging.closeFlyoutLabel": "关闭", "xpack.fleet.appNavigation.agentsLinkText": "代理", "xpack.fleet.appNavigation.dataStreamsLinkText": "数据流", "xpack.fleet.appNavigation.overviewLinkText": "概览", @@ -12126,7 +12117,6 @@ "xpack.ingestPipelines.deleteModal.successDeleteMultipleNotificationMessageText": "已删除 {numSuccesses, plural, other {# 个管道}}", "xpack.ingestPipelines.deleteModal.successDeleteSingleNotificationMessageText": "已删除管道“{pipelineName}”", "xpack.ingestPipelines.edit.docsButtonLabel": "编辑管道文档", - "xpack.ingestPipelines.edit.fetchPipelineError": "加载管道时出错", "xpack.ingestPipelines.edit.loadingPipelinesDescription": "正在加载管道……", "xpack.ingestPipelines.edit.pageTitle": "编辑管道“{name}”", "xpack.ingestPipelines.form.cancelButtonLabel": "取消", @@ -12151,8 +12141,6 @@ "xpack.ingestPipelines.form.versionFieldLabel": "版本 (可选) ", "xpack.ingestPipelines.form.versionToggleDescription": "添加版本号", "xpack.ingestPipelines.list.listTitle": "采集节点管道", - "xpack.ingestPipelines.list.loadErrorReloadLinkLabel": "请重试。", - "xpack.ingestPipelines.list.loadErrorTitle": "无法加载管道。{reloadLink}", "xpack.ingestPipelines.list.loadingMessage": "正在加载管道……", "xpack.ingestPipelines.list.notFoundFlyoutMessage": "未找到管道", "xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel": "克隆", @@ -17924,7 +17912,6 @@ "xpack.remoteClusters.form.errors.serverNameMissing": "服务器名必填。", "xpack.remoteClusters.licenseCheckErrorMessage": "许可证检查失败", "xpack.remoteClusters.listBreadcrumbTitle": "远程集群", - "xpack.remoteClusters.loadAction.errorTitle": "加载远程集群时出错", "xpack.remoteClusters.readDocsButtonLabel": "远程集群文档", "xpack.remoteClusters.refreshAction.errorTitle": "刷新远程集群时出错", "xpack.remoteClusters.remoteClusterForm.actions.savingText": "正在保存", @@ -20442,10 +20429,8 @@ "xpack.securitySolution.endpoint.details.endpointVersion": "终端版本", "xpack.securitySolution.endpoint.details.errorBody": "请退出浮出控件并选择可用主机。", "xpack.securitySolution.endpoint.details.errorTitle": "找不到主机", - "xpack.securitySolution.endpoint.details.hostname": "主机名", "xpack.securitySolution.endpoint.details.ipAddress": "IP 地址", "xpack.securitySolution.endpoint.details.lastSeen": "最后看到时间", - "xpack.securitySolution.endpoint.details.linkToIngestTitle": "重新分配策略", "xpack.securitySolution.endpoint.details.noPolicyResponse": "没有可用策略响应", "xpack.securitySolution.endpoint.details.os": "OS", "xpack.securitySolution.endpoint.details.policy": "集成策略", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index c34da1bc097bc0..01fa320bbc789c 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -111,7 +111,7 @@ export class UptimePlugin return [ { label: 'Uptime', - sortKey: 200, + sortKey: 500, entries: [ { label: i18n.translate('xpack.uptime.overview.heading', { diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts index e861dc528b2375..e8c940d6b29b63 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/sync.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { cloneDeep } from 'lodash'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { USER } from '../../../../functional/services/ml/security_common'; import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; @@ -23,7 +24,7 @@ export default ({ getService }: FtrProviderContext) => { async function runRequest(user: USER, expectedStatusCode: number) { const { body } = await supertest - .get(`/s/space1/api/ml/saved_objects/sync`) + .get(`/s/${idSpace1}/api/ml/saved_objects/sync`) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) .expect(expectedStatusCode); @@ -32,9 +33,19 @@ export default ({ getService }: FtrProviderContext) => { } describe('GET saved_objects/sync', () => { - before(async () => { + beforeEach(async () => { await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + afterEach(async () => { + await spacesService.delete(idSpace1); + await ml.api.cleanMlIndices(); + await ml.testResources.cleanMLSavedObjects(); + }); + + it('should sync datafeeds and saved objects', async () => { + // prepare test data await ml.api.createAnomalyDetectionJob( ml.commonConfig.getADFqSingleMetricJobConfig(adJobId1), idSpace1 @@ -51,18 +62,6 @@ export default ({ getService }: FtrProviderContext) => { ml.commonConfig.getADFqSingleMetricJobConfig(adJobIdES) ); - await ml.testResources.setKibanaTimeZoneToUTC(); - }); - - after(async () => { - await spacesService.delete(idSpace1); - await ml.api.cleanMlIndices(); - await ml.testResources.cleanMLSavedObjects(); - }); - - it('should sync datafeeds and saved objects', async () => { - // prepare test data - // datafeed should be added with the request const datafeedConfig2 = ml.commonConfig.getADFqDatafeedConfig(adJobId2); await ml.api.createDatafeedES(datafeedConfig2); @@ -89,7 +88,73 @@ export default ({ getService }: FtrProviderContext) => { }); }); + it('should sync datafeeds after recreation in ES with different name', async () => { + // prepare test data + const jobConfig1 = ml.commonConfig.getADFqSingleMetricJobConfig(adJobId1); + await ml.api.createAnomalyDetectionJob(jobConfig1, idSpace1); + + // datafeed should be added with the request + const datafeedConfig1 = ml.commonConfig.getADFqDatafeedConfig(adJobId1); + await ml.api.createDatafeedES(datafeedConfig1); + + // run the sync request and verify the response + const body = await runRequest(USER.ML_POWERUSER_ALL_SPACES, 200); + + // expect datafeed to be added + expect(body).to.eql({ + datafeedsAdded: { [adJobId1]: { success: true } }, + datafeedsRemoved: {}, + savedObjectsCreated: {}, + savedObjectsDeleted: {}, + }); + + // delete the datafeed but do not sync + await ml.api.deleteDatafeedES(datafeedConfig1.datafeed_id); + + // create a new datafeed with a different id + const datafeedConfig2 = cloneDeep(datafeedConfig1); + datafeedConfig2.datafeed_id = `different_${datafeedConfig2.datafeed_id}`; + await ml.api.createDatafeedES(datafeedConfig2); + + const body2 = await runRequest(USER.ML_POWERUSER_ALL_SPACES, 200); + + // previous datafeed should be removed on first sync + expect(body2).to.eql({ + datafeedsAdded: {}, + datafeedsRemoved: { [adJobId1]: { success: true } }, + savedObjectsCreated: {}, + savedObjectsDeleted: {}, + }); + + const body3 = await runRequest(USER.ML_POWERUSER_ALL_SPACES, 200); + + // new datafeed will be added on second sync + expect(body3).to.eql({ + datafeedsAdded: { [adJobId1]: { success: true } }, + datafeedsRemoved: {}, + savedObjectsCreated: {}, + savedObjectsDeleted: {}, + }); + }); + it('should not sync anything if all objects are already synced', async () => { + await ml.api.createAnomalyDetectionJob( + ml.commonConfig.getADFqSingleMetricJobConfig(adJobId1), + idSpace1 + ); + await ml.api.createAnomalyDetectionJob( + ml.commonConfig.getADFqSingleMetricJobConfig(adJobId2), + idSpace1 + ); + await ml.api.createAnomalyDetectionJob( + ml.commonConfig.getADFqSingleMetricJobConfig(adJobId3), + idSpace1 + ); + await ml.api.createAnomalyDetectionJobES( + ml.commonConfig.getADFqSingleMetricJobConfig(adJobIdES) + ); + + await runRequest(USER.ML_POWERUSER_ALL_SPACES, 200); const body = await runRequest(USER.ML_POWERUSER_ALL_SPACES, 200); expect(body).to.eql({ diff --git a/x-pack/test/functional/apps/maps/blended_vector_layer.js b/x-pack/test/functional/apps/maps/blended_vector_layer.js index e207410eb22818..3cc7d8e07d623f 100644 --- a/x-pack/test/functional/apps/maps/blended_vector_layer.js +++ b/x-pack/test/functional/apps/maps/blended_vector_layer.js @@ -29,7 +29,7 @@ export default function ({ getPageObjects, getService }) { it('should request documents when zoomed to smaller regions showing less data', async () => { const { rawResponse: response } = await PageObjects.maps.getResponse(); // Allow a range of hits to account for variances in browser window size. - expect(response.hits.hits.length).to.be.within(30, 40); + expect(response.hits.hits.length).to.be.within(35, 45); }); it('should request clusters when zoomed to larger regions showing lots of data', async () => { diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts index 52ec81b8bf7dbe..0872abfcaa4f86 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -253,7 +253,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); }); - it('allows configuring http advanced options', async () => { + it.skip('allows configuring http advanced options', async () => { // This test ensures that updates made to the Synthetics Policy are carried all the way through // to the generated Agent Policy that is dispatch down to the Elastic Agent. const config = generateHTTPConfig('http://elastic.co'); diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts index 63326448ec1e51..777e6fd598f454 100644 --- a/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts @@ -67,7 +67,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { let testJobId = ''; - describe('anomaly detection alert', function () { + // Failing: See https://github.com/elastic/kibana/issues/102012 + describe.skip('anomaly detection alert', function () { this.tags('ciGroup13'); before(async () => { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index e55307ed5ef66f..1a5158adbd6951 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -124,8 +124,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('when the hostname is clicked on,', () => { it('display the details flyout', async () => { await (await testSubjects.find('hostnameCellLink')).click(); - await testSubjects.existOrFail('endpointDetailsUpperList'); - await testSubjects.existOrFail('endpointDetailsLowerList'); + await testSubjects.existOrFail('endpointDetailsList'); }); it('updates the details flyout when a new hostname is selected from the list', async () => { diff --git a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js index b678e88bcf0dff..502c950d2b1131 100644 --- a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js +++ b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js @@ -15,6 +15,8 @@ const ARCHIVE = resolve(INTEGRATION_TEST_ROOT, 'test/es_archives/metricbeat'); export default function ({ getService, getPageObjects, updateBaselines }) { const screenshot = getService('screenshots'); const browser = getService('browser'); + const find = getService('find'); + const log = getService('log'); const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['common', 'dashboard', 'timePicker']); @@ -28,7 +30,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { 'dashboard', 'view/Metricbeat-system-overview-ecs?_g=(filters:!(),refreshInterval:(pause:!t,value:0),' + 'time:(from:%272020-09-29T19:02:37.902Z%27,to:%272020-09-29T19:06:43.218Z%27))&_a=' + - '(description:%27Overview%20of%20system%20metrics%27,filters:!(),fullScreenMode:!t,' + + '(description:%27Overview%20of%20system%20metrics%27,filters:!(),' + 'options:(darkTheme:!f),query:(language:kuery,query:%27%27),timeRestore:!f,' + 'title:%27%5BMetricbeat%20System%5D%20Overview%20ECS%27,viewMode:view)', { @@ -45,6 +47,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { // await PageObjects.dashboard.clickFullScreenMode(); await PageObjects.common.sleep(2000); + await find.clickByButtonText('Dismiss'); await PageObjects.dashboard.waitForRenderComplete(); await browser.setScreenshotSize(1000, 1000); }); @@ -61,7 +64,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { ); expect(percentDifference).to.be.lessThan(0.01); } finally { - await PageObjects.dashboard.clickExitFullScreenLogoButton(); + log.debug('### Screenshot taken'); } }); }); diff --git a/yarn.lock b/yarn.lock index 9d78a16dd3ee58..0418c2e2c39713 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9293,17 +9293,16 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^90.0.0: - version "90.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-90.0.0.tgz#1b18960a31a12884981bdc270b43c4356ce7a65a" - integrity sha512-k+GMmNb7cmuCCctQvUIeNxDGSq8DJauO+UKQS2qLT8aA36CPEcv8rpFepf6lRkNaIlfwdCUt/0B5bZDw3wY2yw== +chromedriver@^91.0.1: + version "91.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-91.0.1.tgz#4d70a569901e356c978a41de3019c464f2a8ebd0" + integrity sha512-9LktpHiUxM4UWUsr+jI1K1YKx2GENt6BKKJ2mibPj1Wc6ODzX/3fFIlr8CZ4Ftuyga+dHTTbAyPWKwKvybEbKA== dependencies: "@testim/chrome-version" "^1.0.7" axios "^0.21.1" del "^6.0.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.0" - mkdirp "^1.0.4" proxy-from-env "^1.1.0" tcp-port-used "^1.0.1"