From 3571100bcce63ec3f338a893bd9c549007f7255c Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 29 Jun 2020 08:31:59 -0400 Subject: [PATCH 01/49] [CCR] Fix reducer function when finding missing privileges (#70158) --- .../cross_cluster_replication/register_permissions_route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts index b8eb5ae14750e3..008828d264a2b7 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/cross_cluster_replication/register_permissions_route.ts @@ -43,13 +43,13 @@ export const registerPermissionsRoute = ({ }); const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions: any, permissionName: any) => { + (permissions: string[], permissionName: string) => { if (!cluster[permissionName]) { permissions.push(permissionName); - return permissions; } + return permissions; }, - [] as any[] + [] ); return response.ok({ From 7e5cff4be988e95165513c2620d333bcf1ae893c Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 29 Jun 2020 15:17:00 +0200 Subject: [PATCH 02/49] [GS] add application result provider (#68488) * add application result provider * remove empty contracts & cache searchable apps * fix types --- ...kibana-plugin-core-public.publicappinfo.md | 3 + ...-plugin-core-public.publiclegacyappinfo.md | 2 + package.json | 1 + src/core/public/application/types.ts | 7 + src/core/public/application/utils.ts | 5 + src/core/public/public.api.md | 5 + .../global_search_providers/kibana.json | 10 + .../global_search_providers/public/index.ts | 11 + .../public/plugin.test.ts | 33 +++ .../global_search_providers/public/plugin.ts | 29 +++ .../providers/application.test.mocks.ts | 10 + .../public/providers/application.test.ts | 204 ++++++++++++++++++ .../public/providers/application.ts | 39 ++++ .../public/providers/get_app_results.test.ts | 119 ++++++++++ .../public/providers/get_app_results.ts | 58 +++++ .../public/providers/index.ts | 7 + x-pack/typings/js_levenshtein.d.ts | 10 + yarn.lock | 5 + 18 files changed, 558 insertions(+) create mode 100644 x-pack/plugins/global_search_providers/kibana.json create mode 100644 x-pack/plugins/global_search_providers/public/index.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/plugin.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/application.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/get_app_results.ts create mode 100644 x-pack/plugins/global_search_providers/public/providers/index.ts create mode 100644 x-pack/typings/js_levenshtein.d.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md index c70f3a97a8882f..4b3b103c92731d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publicappinfo.md @@ -11,5 +11,8 @@ Public information about a registered [application](./kibana-plugin-core-public. ```typescript export declare type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md index cc3e9de3193cb8..051638daabd12f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md +++ b/docs/development/core/public/kibana-plugin-core-public.publiclegacyappinfo.md @@ -11,5 +11,7 @@ Information about a registered [legacy application](./kibana-plugin-core-public. ```typescript export declare type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; ``` diff --git a/package.json b/package.json index b1202631a0c026..6b4c8ee7858148 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,7 @@ "inline-style": "^2.0.0", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 6926b6acf24115..cd2dd99c30c116 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -269,6 +269,10 @@ export interface LegacyApp extends AppBase { */ export type PublicAppInfo = Omit & { legacy: false; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; /** @@ -278,6 +282,9 @@ export type PublicAppInfo = Omit & { */ export type PublicLegacyAppInfo = Omit & { legacy: true; + // remove optional on fields populated with default values + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; /** diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts index 1dc9ec70590017..92d25fa468c4a5 100644 --- a/src/core/public/application/utils.ts +++ b/src/core/public/application/utils.ts @@ -120,12 +120,17 @@ export function getAppInfo(app: App | LegacyApp): PublicAppInfo | Publi const { updater$, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, legacy: true, }; } else { const { updater$, mount, ...infos } = app; return { ...infos, + status: app.status!, + navLinkStatus: app.navLinkStatus!, + appRoute: app.appRoute!, legacy: false, }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d10e351f4d13ed..a65b9dd9d242a7 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1143,11 +1143,16 @@ export type PluginOpaqueId = symbol; // @public export type PublicAppInfo = Omit & { legacy: false; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; + appRoute: string; }; // @public export type PublicLegacyAppInfo = Omit & { legacy: true; + status: AppStatus; + navLinkStatus: AppNavLinkStatus; }; // @public diff --git a/x-pack/plugins/global_search_providers/kibana.json b/x-pack/plugins/global_search_providers/kibana.json new file mode 100644 index 00000000000000..025ea2bceed2ca --- /dev/null +++ b/x-pack/plugins/global_search_providers/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "globalSearchProviders", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["globalSearch"], + "optionalPlugins": [], + "configPath": ["xpack", "global_search_providers"] +} diff --git a/x-pack/plugins/global_search_providers/public/index.ts b/x-pack/plugins/global_search_providers/public/index.ts new file mode 100644 index 00000000000000..bc66994aa393a2 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { GlobalSearchProvidersPlugin, GlobalSearchProvidersPluginSetupDeps } from './plugin'; + +export const plugin: PluginInitializer<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> = () => + new GlobalSearchProvidersPlugin(); diff --git a/x-pack/plugins/global_search_providers/public/plugin.test.ts b/x-pack/plugins/global_search_providers/public/plugin.test.ts new file mode 100644 index 00000000000000..a2880acae440b6 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { coreMock } from '../../../../src/core/public/mocks'; +import { globalSearchPluginMock } from '../../global_search/public/mocks'; +import { GlobalSearchProvidersPlugin } from './plugin'; + +describe('GlobalSearchProvidersPlugin', () => { + let plugin: GlobalSearchProvidersPlugin; + let globalSearchSetup: ReturnType; + + beforeEach(() => { + globalSearchSetup = globalSearchPluginMock.createSetupContract(); + plugin = new GlobalSearchProvidersPlugin(); + }); + + describe('#setup', () => { + it('registers the `application` result provider', () => { + const coreSetup = coreMock.createSetup(); + plugin.setup(coreSetup, { globalSearch: globalSearchSetup }); + + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledTimes(1); + expect(globalSearchSetup.registerResultProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'application', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/plugin.ts b/x-pack/plugins/global_search_providers/public/plugin.ts new file mode 100644 index 00000000000000..9f18c06608b01e --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/plugin.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, Plugin } from 'src/core/public'; +import { GlobalSearchPluginSetup } from '../../global_search/public'; +import { createApplicationResultProvider } from './providers'; + +export interface GlobalSearchProvidersPluginSetupDeps { + globalSearch: GlobalSearchPluginSetup; +} + +export class GlobalSearchProvidersPlugin + implements Plugin<{}, {}, GlobalSearchProvidersPluginSetupDeps, {}> { + setup( + { getStartServices }: CoreSetup<{}, {}>, + { globalSearch }: GlobalSearchProvidersPluginSetupDeps + ) { + const applicationPromise = getStartServices().then(([core]) => core.application); + globalSearch.registerResultProvider(createApplicationResultProvider(applicationPromise)); + return {}; + } + + start() { + return {}; + } +} diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.ts new file mode 100644 index 00000000000000..4fdf8a75a4bc23 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.mocks.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getAppResultsMock = jest.fn(); +jest.doMock('./get_app_results', () => ({ + getAppResults: getAppResultsMock, +})); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts new file mode 100644 index 00000000000000..ca19bddb602971 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAppResultsMock } from './application.test.mocks'; + +import { of, EMPTY } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { ApplicationStart, AppNavLinkStatus, AppStatus, PublicAppInfo } from 'src/core/public'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, +} from '../../../global_search/public'; +import { applicationServiceMock } from 'src/core/public/mocks'; +import { createApplicationResultProvider } from './application'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createResult = (props: Partial): GlobalSearchProviderResult => ({ + id: 'id', + title: 'title', + type: 'application', + url: '/app/id', + score: 100, + ...props, +}); + +const createAppMap = (apps: PublicAppInfo[]): Map => { + return new Map(apps.map((app) => [app.id, app])); +}; + +const expectApp = (id: string) => expect.objectContaining({ id }); +const expectResult = expectApp; + +describe('applicationResultProvider', () => { + let application: ReturnType; + + const defaultOption: GlobalSearchProviderFindOptions = { + preference: 'pref', + maxResults: 20, + aborted$: EMPTY, + }; + + beforeEach(() => { + application = applicationServiceMock.createStartContract(); + getAppResultsMock.mockReturnValue([]); + }); + + it('has the correct id', () => { + const provider = createApplicationResultProvider(Promise.resolve(application)); + expect(provider.id).toBe('application'); + }); + + it('calls `getAppResults` with the term and the list of apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); + + it('ignores inaccessible apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + + it('sorts the results returned by `getAppResults`', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + const results = await provider.find('term', defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); + + it('only returns the highest `maxResults` results', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find('term', options).toPromise(); + + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); + + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('|'), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); + + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + + application.applications$ = hot('---a', { a: appMap, b: appMap }); + + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { a: application }) as unknown) as Promise< + ApplicationStart + >; + + const provider = createApplicationResultProvider(applicationPromise); + + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; + + const resultObs = provider.find('term', options); + + expectObservable(resultObs).toBe('-|'); + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts new file mode 100644 index 00000000000000..e40fcef17f73c8 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/application.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { from } from 'rxjs'; +import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; +import { ApplicationStart } from 'src/core/public'; +import { GlobalSearchResultProvider } from '../../../global_search/public'; +import { getAppResults } from './get_app_results'; + +export const createApplicationResultProvider = ( + applicationPromise: Promise +): GlobalSearchResultProvider => { + const searchableApps$ = from(applicationPromise).pipe( + mergeMap((application) => application.applications$), + map((apps) => + [...apps.values()].filter( + (app) => app.status === 0 && (app.legacy === true || app.chromeless !== true) + ) + ), + shareReplay(1) + ); + + return { + id: 'application', + find: (term, { aborted$, maxResults }) => { + return searchableApps$.pipe( + takeUntil(aborted$), + take(1), + map((apps) => { + const results = getAppResults(term, [...apps.values()]); + return results.sort((a, b) => b.score - a.score).slice(0, maxResults); + }) + ); + }, + }; +}; diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts new file mode 100644 index 00000000000000..1c5a446b8e5645 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppNavLinkStatus, AppStatus, PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { appToResult, getAppResults, scoreApp } from './get_app_results'; + +const createApp = (props: Partial = {}): PublicAppInfo => ({ + id: 'app1', + title: 'App 1', + appRoute: '/app/app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + chromeless: false, + ...props, +}); + +const createLegacyApp = (props: Partial = {}): PublicLegacyAppInfo => ({ + id: 'app1', + title: 'App 1', + appUrl: '/app/app1', + legacy: true, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.visible, + ...props, +}); + +describe('getAppResults', () => { + it('retrieves the matching results', () => { + const apps = [ + createApp({ id: 'dashboard', title: 'dashboard' }), + createApp({ id: 'visualize', title: 'visualize' }), + ]; + + const results = getAppResults('dashboard', apps); + + expect(results.length).toBe(1); + expect(results[0]).toEqual(expect.objectContaining({ id: 'dashboard', score: 100 })); + }); +}); + +describe('scoreApp', () => { + describe('when the term is included in the title', () => { + it('returns 100 if the app title is an exact match', () => { + expect(scoreApp('dashboard', createApp({ title: 'dashboard' }))).toBe(100); + expect(scoreApp('dashboard', createApp({ title: 'DASHBOARD' }))).toBe(100); + expect(scoreApp('DASHBOARD', createApp({ title: 'DASHBOARD' }))).toBe(100); + expect(scoreApp('dashBOARD', createApp({ title: 'DASHboard' }))).toBe(100); + }); + + it('returns 90 if the app title starts with the term', () => { + expect(scoreApp('dash', createApp({ title: 'dashboard' }))).toBe(90); + expect(scoreApp('DASH', createApp({ title: 'dashboard' }))).toBe(90); + }); + + it('returns 75 if the term in included in the app title', () => { + expect(scoreApp('board', createApp({ title: 'dashboard' }))).toBe(75); + expect(scoreApp('shboa', createApp({ title: 'dashboard' }))).toBe(75); + }); + }); + + describe('when the term is not included in the title', () => { + it('returns the levenshtein ratio if superior or equal to 60', () => { + expect(scoreApp('0123456789', createApp({ title: '012345' }))).toBe(60); + expect(scoreApp('--1234567-', createApp({ title: '123456789' }))).toBe(60); + }); + it('returns 0 if the levenshtein ratio is inferior to 60', () => { + expect(scoreApp('0123456789', createApp({ title: '12345' }))).toBe(0); + expect(scoreApp('1-2-3-4-5', createApp({ title: '123456789' }))).toBe(0); + }); + }); + + it('works with legacy apps', () => { + expect(scoreApp('dashboard', createLegacyApp({ title: 'dashboard' }))).toBe(100); + expect(scoreApp('dash', createLegacyApp({ title: 'dashboard' }))).toBe(90); + expect(scoreApp('board', createLegacyApp({ title: 'dashboard' }))).toBe(75); + expect(scoreApp('0123456789', createLegacyApp({ title: '012345' }))).toBe(60); + expect(scoreApp('0123456789', createLegacyApp({ title: '12345' }))).toBe(0); + }); +}); + +describe('appToResult', () => { + it('converts an app to a result', () => { + const app = createApp({ + id: 'foo', + title: 'Foo', + euiIconType: 'fooIcon', + appRoute: '/app/foo', + }); + expect(appToResult(app, 42)).toEqual({ + id: 'foo', + title: 'Foo', + type: 'application', + icon: 'fooIcon', + url: '/app/foo', + score: 42, + }); + }); + + it('converts a legacy app to a result', () => { + const app = createLegacyApp({ + id: 'legacy', + title: 'Legacy', + euiIconType: 'legacyIcon', + appUrl: '/app/legacy', + }); + expect(appToResult(app, 69)).toEqual({ + id: 'legacy', + title: 'Legacy', + type: 'application', + icon: 'legacyIcon', + url: '/app/legacy', + score: 69, + }); + }); +}); diff --git a/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts new file mode 100644 index 00000000000000..1a1939230105b2 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/get_app_results.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import levenshtein from 'js-levenshtein'; +import { PublicAppInfo, PublicLegacyAppInfo } from 'src/core/public'; +import { GlobalSearchProviderResult } from '../../../global_search/public'; + +export const getAppResults = ( + term: string, + apps: Array +): GlobalSearchProviderResult[] => { + return apps + .map((app) => ({ app, score: scoreApp(term, app) })) + .filter(({ score }) => score > 0) + .map(({ app, score }) => appToResult(app, score)); +}; + +export const scoreApp = (term: string, { title }: PublicAppInfo | PublicLegacyAppInfo): number => { + term = term.toLowerCase(); + title = title.toLowerCase(); + + // shortcuts to avoid calculating the distance when there is an exact match somewhere. + if (title === term) { + return 100; + } + if (title.startsWith(term)) { + return 90; + } + if (title.includes(term)) { + return 75; + } + const length = Math.max(term.length, title.length); + const distance = levenshtein(term, title); + + // maximum lev distance is length, we compute the match ratio (lower distance is better) + const ratio = Math.floor((1 - distance / length) * 100); + if (ratio >= 60) { + return ratio; + } + return 0; +}; + +export const appToResult = ( + app: PublicAppInfo | PublicLegacyAppInfo, + score: number +): GlobalSearchProviderResult => { + return { + id: app.id, + title: app.title, + type: 'application', + icon: app.euiIconType, + url: app.legacy ? app.appUrl : app.appRoute, + score, + }; +}; diff --git a/x-pack/plugins/global_search_providers/public/providers/index.ts b/x-pack/plugins/global_search_providers/public/providers/index.ts new file mode 100644 index 00000000000000..d71c30d41d46a3 --- /dev/null +++ b/x-pack/plugins/global_search_providers/public/providers/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createApplicationResultProvider } from './application'; diff --git a/x-pack/typings/js_levenshtein.d.ts b/x-pack/typings/js_levenshtein.d.ts new file mode 100644 index 00000000000000..812bf24bf3dd78 --- /dev/null +++ b/x-pack/typings/js_levenshtein.d.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; + * you may not use this file except in compliance with the Elastic License. + */ + +declare module 'js-levenshtein' { + const levenshtein: (a: string, b: string) => number; + export = levenshtein; +} diff --git a/yarn.lock b/yarn.lock index 0a7899e4ac1023..8b13f3bdacb635 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19523,6 +19523,11 @@ js-levenshtein@^1.1.3: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.3.tgz#3ef627df48ec8cf24bacf05c0f184ff30ef413c5" integrity sha512-/812MXr9RBtMObviZ8gQBhHO8MOrGj8HlEE+4ccMTElNA/6I3u39u+bhny55Lk921yn44nSZFy9naNLElL5wgQ== +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + js-search@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.3.tgz#23a86d7e064ca53a473930edc48615b6b1c1954a" From 8e57db696aefd4342d0dd18bfaf0047787d6e861 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 29 Jun 2020 08:23:52 -0500 Subject: [PATCH 03/49] [APM] Use licensing from context (#70118) * [APM] Use licensing from context We added the usage of `featureUsage.notifyUsage` from the licensing plugin in #69455. This required us to use `getStartServices to add `licensing` to `context.plugins`. In #69838 `featureUsage` was added to `context.licensing`, so we don't need to add it to `context.plugins`. --- x-pack/plugins/apm/server/plugin.ts | 64 ++++++++----------- .../server/routes/create_api/index.test.ts | 3 +- .../plugins/apm/server/routes/service_map.ts | 5 +- x-pack/plugins/apm/server/routes/typings.ts | 3 - 4 files changed, 30 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index eb781ee0783075..deafda67b806d7 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -4,46 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; +import { combineLatest, Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; import { - PluginInitializerContext, - Plugin, CoreSetup, CoreStart, Logger, + Plugin, + PluginInitializerContext, } from 'src/core/server'; -import { Observable, combineLatest } from 'rxjs'; -import { map, take } from 'rxjs/operators'; -import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; -import { AlertingPlugin } from '../../alerts/server'; -import { ActionsPlugin } from '../../actions/server'; +import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; -import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { createApmApi } from './routes/create_apm_api'; -import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; -import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { ActionsPlugin } from '../../actions/server'; +import { AlertingPlugin } from '../../alerts/server'; import { CloudSetup } from '../../cloud/server'; -import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; -import { - LicensingPluginSetup, - LicensingPluginStart, -} from '../../licensing/server'; -import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; -import { createApmTelemetry } from './lib/apm_telemetry'; - import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { MlPluginSetup } from '../../ml/server'; +import { ObservabilityPluginSetup } from '../../observability/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, APM_SERVICE_MAPS_FEATURE_NAME, APM_SERVICE_MAPS_LICENSE_TYPE, } from './feature'; +import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; +import { createApmTelemetry } from './lib/apm_telemetry'; +import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; +import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; +import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; +import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; +import { createApmApi } from './routes/create_apm_api'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; -import { MlPluginSetup } from '../../ml/server'; export interface APMPluginSetup { config$: Observable; @@ -135,18 +131,14 @@ export class APMPlugin implements Plugin { APM_SERVICE_MAPS_LICENSE_TYPE ); - core.getStartServices().then(([_coreStart, pluginsStart]) => { - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins: { - licensing: (pluginsStart as { licensing: LicensingPluginStart }) - .licensing, - observability: plugins.observability, - security: plugins.security, - ml: plugins.ml, - }, - }); + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + plugins: { + observability: plugins.observability, + security: plugins.security, + ml: plugins.ml, + }, }); return { diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index f5db936c00d3a7..3d3e26f680e0d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -9,7 +9,6 @@ import { CoreSetup, Logger } from 'src/core/server'; import { Params } from '../typings'; import { BehaviorSubject } from 'rxjs'; import { APMConfig } from '../..'; -import { LicensingPluginStart } from '../../../../licensing/server'; const getCoreMock = () => { const get = jest.fn(); @@ -41,7 +40,7 @@ const getCoreMock = () => { logger: ({ error: jest.fn(), } as unknown) as Logger, - plugins: { licensing: {} as LicensingPluginStart }, + plugins: {}, }, }; }; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 3937c18b3fe5e0..a3e2f708b0b22e 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -35,10 +35,7 @@ export const serviceMapRoute = createRoute(() => ({ if (!isValidPlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - - context.plugins.licensing.featureUsage.notifyUsage( - APM_SERVICE_MAPS_FEATURE_NAME - ); + context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); const setup = await setupRequest(context, request); const { diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index f30a9d18d7aeab..b1815e88d29178 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,7 +14,6 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; -import { LicensingPluginStart } from '../../../licensing/server'; import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; @@ -67,7 +66,6 @@ export type APMRequestHandlerContext< config: APMConfig; logger: Logger; plugins: { - licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; @@ -116,7 +114,6 @@ export interface ServerAPI { config$: Observable; logger: Logger; plugins: { - licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; From e91594aeb9ecdd718190c20818493e6bc0f3f128 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Mon, 29 Jun 2020 15:24:11 +0200 Subject: [PATCH 04/49] [Ingest Manager] Use DockerServers service in integration tests. (#69822) * Partially disable test files. * Use DockerServers in EPM tests. * Only run tests when DockerServers have been set up * Reenable ingest manager API integration tests * Pass new test_packages to registry container * Enable DockerServers tests in CI. * Correctly serve filetest package for file tests. * Add helper to skip test and log warning. * Reenable further file tests. * Add developer documentation about Docker in Kibana CI. * Document use of yarn test:ftr Co-authored-by: Elastic Machine --- vars/kibanaPipeline.groovy | 2 + .../dev_docs/api_integration_tests.md | 113 +++++++++++++ x-pack/scripts/functional_tests.js | 1 + x-pack/test/epm_api_integration/apis/file.ts | 151 ------------------ .../packages/epr/yamlpipeline_1.0.0.tar.gz | Bin 1996 -> 0 bytes .../packages/package/yamlpipeline_1.0.0 | 32 ---- x-pack/test/epm_api_integration/apis/list.ts | 124 -------------- x-pack/test/epm_api_integration/config.ts | 35 ---- .../apis/file.ts | 96 +++++++++++ .../apis/fixtures/package_registry_config.yml | 3 + .../filetest/0.1.0/docs/README.md | 5 + .../test_packages/filetest/0.1.0/img/logo.svg | 7 + .../img/screenshots/metricbeat_dashboard.png | Bin 0 -> 94863 bytes .../kibana/dashboard/sample_dashboard.json | 38 +++++ .../0.1.0/kibana/search/sample_search.json | 36 +++++ .../visualization/sample_visualization.json | 22 +++ .../test_packages/filetest/0.1.0/manifest.yml | 30 ++++ .../apis/ilm.ts | 0 .../apis/index.js | 2 +- .../apis/list.ts | 38 +++++ .../apis/mock_http_server.d.ts | 0 .../apis/template.ts | 0 .../ingest_manager_api_integration/config.ts | 67 ++++++++ .../ingest_manager_api_integration/helpers.ts | 15 ++ 24 files changed, 474 insertions(+), 343 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md delete mode 100644 x-pack/test/epm_api_integration/apis/file.ts delete mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz delete mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/package/yamlpipeline_1.0.0 delete mode 100644 x-pack/test/epm_api_integration/apis/list.ts delete mode 100644 x-pack/test/epm_api_integration/config.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/file.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/ilm.ts (100%) rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/index.js (90%) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/list.ts rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/mock_http_server.d.ts (100%) rename x-pack/test/{epm_api_integration => ingest_manager_api_integration}/apis/template.ts (100%) create mode 100644 x-pack/test/ingest_manager_api_integration/config.ts create mode 100644 x-pack/test/ingest_manager_api_integration/helpers.ts diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 46a76bbb8d523d..f3fc5f84583c9c 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -21,6 +21,7 @@ def functionalTestProcess(String name, Closure closure) { def kibanaPort = "61${processNumber}1" def esPort = "61${processNumber}2" def esTransportPort = "61${processNumber}3" + def ingestManagementPackageRegistryPort = "61${processNumber}4" withEnv([ "CI_PARALLEL_PROCESS_NUMBER=${processNumber}", @@ -29,6 +30,7 @@ def functionalTestProcess(String name, Closure closure) { "TEST_KIBANA_URL=http://elastic:changeme@localhost:${kibanaPort}", "TEST_ES_URL=http://elastic:changeme@localhost:${esPort}", "TEST_ES_TRANSPORT_PORT=${esTransportPort}", + "INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=${ingestManagementPackageRegistryPort}", "IS_PIPELINE_JOB=1", "JOB=${name}", "KBN_NP_PLUGINS_BUILT=true", diff --git a/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md b/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md new file mode 100644 index 00000000000000..612d94d01a2d0b --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api_integration_tests.md @@ -0,0 +1,113 @@ +# API integration tests + +Many API integration tests for Ingest Manager trigger at some point a connection to the package registry, and retrieval of some packages. If these connections are made to a package registry deployment outside of Kibana CI, these tests can fail at any time for two reasons: +* the deployed registry is temporarily unavailable +* the packages served by the registry do not match the expectation of the code under test + +For that reason, we run a dockerized version of the package registry in Kibana CI. For this to work, our tests must run against a custom test configuration and be kept in a custom directory, `x-pack/test/ingest_manager_api_integration`. + +## How to run the tests locally + +Usually, having the test server and the test runner in two different shells is most efficient, as it is possible to keep the server running and only rerun the test runner as often as needed. To do so, in one shell in the main `kibana` directory, run: +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr:server --config x-pack/test/ingest_manager_api_integration/config.ts +``` + +In another shell in the same directory, run +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr:runner --config x-pack/test/ingest_manager_api_integration/config.ts +``` + +However, it is also possible to **alternatively** run everything in one go, again from the main `kibana` directory: +``` +$ export INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 +$ yarn test:ftr --config x-pack/test/ingest_manager_api_integration/config.ts +``` +Port `12345` is used as an example here, it can be anything, but the environment variable has to be present for the tests to run at all. + + +## DockerServers service setup + +We use the `DockerServers` service provided by `kbn-test`. The documentation for this functionality can be found here: +https://github.com/elastic/kibana/blob/master/packages/kbn-test/src/functional_test_runner/lib/docker_servers/README.md + +The main configuration for the `DockerServers` service for our tests can be found in `x-pack/test/ingest_manager_api_integration/config.ts`: + +### Specify the arguments to pass to `docker run`: + +``` + const dockerArgs: string[] = [ + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/package_registry_config.yml' + )}:/registry/config.yml`, + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/test_packages' + )}:/registry/packages/test-packages`, + ]; + ``` + + `-v` mounts local paths into the docker image. The first one puts a custom configuration file into the correct place in the docker container, the second one mounts a directory containing additional packages. + +### Specify the docker image to use + +``` +image: 'docker.elastic.co/package-registry/package-registry:kibana-testing-1' +``` + +This image contains the content of `docker.elastic.co/package-registry/package-registry:master` on June 26 2020. The image used here should be stable, i.e. using `master` would defeat the purpose of having a stable set of packages to be used in Kibana CI. + +### Packages available for testing + +The containerized package registry contains a set of packages which should be sufficient to run tests against all parts of Ingest Manager. The list of the packages are logged to the console when the docker container is initialized during testing, or when the container is started manually with + +``` +docker run -p 8080:8080 docker.elastic.co/package-registry/package-registry:kibana-testing-1 +``` + +Additional packages for testing certain corner cases or error conditions can be put into `x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages`. A package `filetest` has been added there as an example. + +## Some DockerServers background + +For the `DockerServers` servers to run correctly in CI, the `INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT` environment variable needs to be under control of the CI environment. The reason behind this: it is possible that several versions of our tests are run in parallel on the same worker in Jenkins, and if we used a hard-coded port number here, those tests would run into port conflicts. (This is also the case for a few other ports, and the setup happens in `vars/kibanaPipeline.groovy`). + +Also, not every developer has `docker` installed on their workstation, so it must be possible to run the testsuite as a whole without `docker`, and preferably this should be the default behaviour. Therefore, our `DockerServers` service is only enabled when `INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT` is set. This needs to be checked in every test like this: + +``` + it('fetches a .json search file', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/search/sample_search.json') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); +``` + +If the tests are skipped in this way, they are marked in the test summary as `pending` and a warning is logged: + +``` +└-: EPM Endpoints + └-> "before all" hook + └-: list + └-> "before all" hook + └-> lists all packages from the registry + └-> "before each" hook: global before each + │ warn disabling tests because DockerServers service is not enabled, set INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT to run them + └-> lists all packages from the registry + └-> "after all" hook +[...] + │ + │1 passing (233ms) + │6 pending + │ + +``` diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 6cafa3eeef08e3..29be6d826c1bc4 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -52,6 +52,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/endpoint_api_integration_no_ingest/config.ts'), require.resolve('../test/reporting_api_integration/config.js'), require.resolve('../test/functional_embedded/config.ts'), + require.resolve('../test/ingest_manager_api_integration/config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/epm_api_integration/apis/file.ts b/x-pack/test/epm_api_integration/apis/file.ts deleted file mode 100644 index 7cf07e2cd99ae4..00000000000000 --- a/x-pack/test/epm_api_integration/apis/file.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ServerMock from 'mock-http-server'; -import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; - -export default function ({ getService }: FtrProviderContext) { - describe('package file', () => { - const server = new ServerMock({ host: 'localhost', port: 6666 }); - beforeEach(() => { - server.start(() => {}); - }); - afterEach(() => { - server.stop(() => {}); - }); - it('fetches a .png screenshot image', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png', - reply: { - headers: { 'content-type': 'image/png' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'image/png') - .expect(200); - }); - - it('fetches an .svg icon image', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/img/icon.svg', - reply: { - headers: { 'content-type': 'image/svg' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/img/icon.svg') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'image/svg'); - }); - - it('fetches an auditbeat .conf rule file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches an auditbeat .yml config file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/auditbeat/config/config.yml', - reply: { - headers: { 'content-type': 'text/yaml; charset=UTF-8' }, - }, - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/auditbeat/config/config.yml') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'text/yaml; charset=UTF-8') - .expect(200); - }); - - it('fetches a .json kibana visualization file', async () => { - server.on({ - method: 'GET', - path: - '/package/auditd/2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches a .json kibana dashboard file', async () => { - server.on({ - method: 'GET', - path: - '/package/auditd/2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches an .json index pattern file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/kibana/index-pattern/auditbeat-*.json', - }); - - const supertest = getService('supertest'); - await supertest - .get('/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/index-pattern/auditbeat-*.json') - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - - it('fetches a .json search file', async () => { - server.on({ - method: 'GET', - path: '/package/auditd/2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json', - }); - - const supertest = getService('supertest'); - await supertest - .get( - '/api/ingest_manager/epm/packages/auditd/2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json' - ) - .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'application/json; charset=utf-8') - .expect(200); - }); - }); -} diff --git a/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz b/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz deleted file mode 100644 index ca8695f111d023b24c6ebf0e5c230a6cc79dd4a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1996 zcmV;-2Q&B|iwFP&aOPbA1MOPtZyUK0_vig9SRDe%ZM>1JSD_$v4|fOjbxG0Om%=p! zYL}8&Q_D5Ub>ay6-#aAt?bRz$bPbL_AhARaXGqR)9u%MOip4Z0j7H?D=Xd??tBX^k z3m6ZF<}aZB*L?2vhvVL03?By<-QM+RJib7~lh339iBwo1bRjrbyXf}yf1`MMuKyK| z=$uI9KdsnFWM~DC27|5oAN2dF{zv0s?;7+!ydGR%pzcYe@4;_e|8p)@SWO>^kd#Lg zWK6*GBD^9KR5lJzQN^I`-_VBsnKq&r2lseEypYI1&{!EBfASFeWl3e$ivk`gOe2Y~ zVTm%HzE_hQU_};OP$DPjmhpwW^8{f8OAtIG3VR--0g234ENS4Wrx-rd2!;u)rF$^o zA)$h-NTen(5yG%kG>`;~V5u7rN`?9>3WCRW!QY{`98s94^vwSg&-=Aia~3q5{}3zK zaCN#kaJc`^&OdmBk@NrOzz+Sx`8$mb9IyXjujk_bS+Ga{74P}E)^NQ3$K!s_>Hi!! zO8+!kKwfy2(I09LN9+H(-@P6>{htG0r2l;2ei35(|33$= zuCA)dd!E`uWq?8|B?%Ph9sR%s`SI<0^tbo#-Xfiv`(7+~K&0eC>b&|231Z3ylVc+^ zr-X$Qv;qoUA=pOP>jhEMw2wSOlI}ykzn~FjDG6OfAZj|tlqCX^dnFQL*lQ!JF>hp0 zm7zzO;ptjx9E{~w=NMz9h=8qVzgQ~@eG0GQ4Z3}?hGqKi+f)Q9Lbmqr5gj-os?QU z`I3Gr@y`0-AFOrC@AZJ2Su!_dtyGR6zzmA0X~090311Q%5;2`KypYcsW<#vJvZTb; zT^rfnZUYUQTvbxFZ>t4tXMUf|KxIS`*~tG{_YM&&{#X-{e#vZb7YiQc4Tc@~(YnbB z!9{H|9x+RRLug3&akMVn3Q^fl>e{6CyRu*GcwV2}SF4Tqz;{~z|d&i|hS zyZwKpZ-*HDqyxa;^D~skg61%gSw&{}bUs0W`k2|gA1tx>UUj;c=*=6{(cdmRt##`% zCAU{k?e+Sv#@h1vv?|#~3ywqkNO8aWJaI9@`hw}BDrDOI$|N!zEhZ2)Xv9Ef+GqoV zy$rL^ld=IT5Ckg{qBpwjw*BWk%CzrZm&vPL8TmG9-}O0o^r@$M!K zczQ3Rgt1+Vy=~iOGxLaiK!3q<`7@3?m<{98{9>PFXkG$8GEK60P%b8X=jZ*ltQ? zJdHx~@f|R3-?dAkvLUQt2qV!#Eju-8O_dVhFoX~&8-~hcCY68#(&@cK@pcY6eD53{ z|Ka*2VkHI}M3^MUExOa5oOR9JFI{u5w&rGoCYdcaMbvBu;%Cvcx)2>ds}^nhZ}OEE zMt!C4tRIQkB17&eXzQ0&W=8I3_y6xQ8-I@OzJbKT89 zHJTsa9qZoWL^G1EDm1Kg zit8Jy-vq14rNg=BnOO{Wo8;x*OJ$ z+_3)k{FB4i#UB5ElDB|+{67SMVg2|20gyZYe+Hzl`u@Qe_GMrG+|SD{2B1Xttk;-A zr@3iG)r)`UHojdnoPTqb=<7@N6Uo^7`~P@Qd;dKc_gwyW7U;66rL&H)>2Zbdk%XK{GTWl3%*B>Dn0DA9mNA-=)N`iO-s8deIs~h zC`!+Nfy7pYt$RAd5!T;z;|?%&~FQ&$BsSr=hv^qKYQNu zmDpFR$3m0Ur|EU`vSJGTW%Ykyvd>~#I{lKt!z7Ew^rg9OFB&sTG)9)U7V*$B) { - const server = new ServerMock({ host: 'localhost', port: 6666 }); - beforeEach(() => { - server.start(() => {}); - }); - afterEach(() => { - server.stop(() => {}); - }); - it('lists all packages from the registry', async () => { - const searchResponse = [ - { - description: 'First integration package', - download: '/package/first-1.0.1.tar.gz', - name: 'first', - title: 'First', - type: 'integration', - version: '1.0.1', - }, - { - description: 'Second integration package', - download: '/package/second-2.0.4.tar.gz', - icons: [ - { - src: '/package/second-2.0.4/img/icon.svg', - type: 'image/svg+xml', - }, - ], - name: 'second', - title: 'Second', - type: 'integration', - version: '2.0.4', - }, - ]; - server.on({ - method: 'GET', - path: '/search', - reply: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(searchResponse), - }, - }); - - const supertest = getService('supertest'); - const fetchPackageList = async () => { - const response = await supertest - .get('/api/ingest_manager/epm/packages') - .set('kbn-xsrf', 'xxx') - .expect(200); - return response.body; - }; - - const listResponse = await fetchPackageList(); - expect(listResponse.response.length).to.be(2); - expect(listResponse.response[0]).to.eql({ ...searchResponse[0], status: 'not_installed' }); - expect(listResponse.response[1]).to.eql({ ...searchResponse[1], status: 'not_installed' }); - }); - - it('sorts the packages even if the registry sends them unsorted', async () => { - const searchResponse = [ - { - description: 'BBB integration package', - download: '/package/bbb-1.0.1.tar.gz', - name: 'bbb', - title: 'BBB', - type: 'integration', - version: '1.0.1', - }, - { - description: 'CCC integration package', - download: '/package/ccc-2.0.4.tar.gz', - name: 'ccc', - title: 'CCC', - type: 'integration', - version: '2.0.4', - }, - { - description: 'AAA integration package', - download: '/package/aaa-0.0.1.tar.gz', - name: 'aaa', - title: 'AAA', - type: 'integration', - version: '0.0.1', - }, - ]; - server.on({ - method: 'GET', - path: '/search', - reply: { - status: 200, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(searchResponse), - }, - }); - - const supertest = getService('supertest'); - const fetchPackageList = async () => { - const response = await supertest - .get('/api/ingest_manager/epm/packages') - .set('kbn-xsrf', 'xxx') - .expect(200); - return response.body; - }; - - const listResponse = await fetchPackageList(); - - expect(listResponse.response.length).to.be(3); - expect(listResponse.response[0].name).to.eql('aaa'); - expect(listResponse.response[1].name).to.eql('bbb'); - expect(listResponse.response[2].name).to.eql('ccc'); - }); - }); -} diff --git a/x-pack/test/epm_api_integration/config.ts b/x-pack/test/epm_api_integration/config.ts deleted file mode 100644 index 6b08c7ec579559..00000000000000 --- a/x-pack/test/epm_api_integration/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); - - return { - testFiles: [require.resolve('./apis')], - servers: xPackAPITestsConfig.get('servers'), - services: { - supertest: xPackAPITestsConfig.get('services.supertest'), - es: xPackAPITestsConfig.get('services.es'), - }, - junit: { - reportName: 'X-Pack EPM API Integration Tests', - }, - - esTestCluster: { - ...xPackAPITestsConfig.get('esTestCluster'), - }, - - kbnTestServer: { - ...xPackAPITestsConfig.get('kbnTestServer'), - serverArgs: [ - ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), - '--xpack.ingestManager.epm.registryUrl=http://localhost:6666', - ], - }, - }; -} diff --git a/x-pack/test/ingest_manager_api_integration/apis/file.ts b/x-pack/test/ingest_manager_api_integration/apis/file.ts new file mode 100644 index 00000000000000..33eeda1ee274d1 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/file.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { warnAndSkipTest } from '../helpers'; + +export default function ({ getService }: FtrProviderContext) { + const log = getService('log'); + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + describe('package file', () => { + it('fetches a .png screenshot image', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/png') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches an .svg icon image', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/img/logo.svg') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/svg+xml') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json kibana visualization file', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/visualization/sample_visualization.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json kibana dashboard file', async function () { + if (server.enabled) { + await supertest + .get( + '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + + it('fetches a .json search file', async function () { + if (server.enabled) { + await supertest + .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/search/sample_search.json') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + } else { + warnAndSkipTest(this, log); + } + }); + }); + + // Disabled for now as we don't serve prebuilt index patterns in current packages. + // it('fetches an .json index pattern file', async function () { + // if (server.enabled) { + // await supertest + // .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/index-pattern/sample-*.json') + // .set('kbn-xsrf', 'xxx') + // .expect('Content-Type', 'application/json; charset=utf-8') + // .expect(200); + // } else { + // warnAndSkipTest(this, log); + // } + // }); +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml new file mode 100644 index 00000000000000..0060e247827dae --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml @@ -0,0 +1,3 @@ + package_paths: + - /registry/packages/package-storage + - /registry/packages/test-packages \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md new file mode 100644 index 00000000000000..0d19532bae2d72 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/docs/README.md @@ -0,0 +1,5 @@ +# filetest + +This package contains randomly collected files from other packages to be used in API integration tests. + +It also serves as an example how to serve a package from the fixtures directory with the package registry docker container. For this, also see the `x-pack/test/ingest_manager_api_integration/config.ts` how the `test_packages` directory is mounted into the docker container, and `x-pack/test/ingest_manager_api_integration/apis/fixtures/package_registry_config.yml` how to pass the directory to the registry. \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg new file mode 100644 index 00000000000000..15b49bcf28aec2 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/img/screenshots/metricbeat_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..76d414b86c4ab447ba7334e7a4797ae7e137a4f4 GIT binary patch literal 94863 zcmb??byQqSvu}U^Nw5G3F2O@^XV4G`E=fpmAKYPZcbDMK1P|`+?hNiQxDGnFKF+$| zJtybBKi+$5y7CH-vx=Rfm7$%ZuC2i{BTFj_111pA*1*6LWNc-3 zgw!hZ?Ag0#Qeq-o*Lh`(=H$JeK=GIGYz8qW~pW8U&vX}T0V>Mk|LQ$G5;`Y z)txO}E51MrTPm(;lo@ZPsis+w+gWmDTUw?z;W}*YQe(3i&HmJY9sS}OL4~6#fNc&l z*k)A!cb|?v%FiQmFT{=^uXh1W;7C+Y-tE-=o78=cZLb-ll6}APi;D}&f2C9GO$*_d zZDI5gqGEq0{ZH?&>HkGJ;S&x1R{=ZI|1$|YsndmGQ+eWjdOoHY_4AlXZI-AFBh+qT zX#1(CAbuUB+b+3R+TrJQfM)?x{d3YBPP(M?TQ^10KI)S)kUhJz>p~viYwZ6@QP}9@ zDM^PwF8qn!Y9`^5ZNZgqgjhwutfhcq?w#J2sD@t)`fHjW-1P_`9!#KJ(OS{s9@mQ z%SYI|Q#yz(Ji=E1-kuX3bZ5-_;}P_5h}vj_x>OmwR^+Yy&4K&kF<`!x4I_mq{0{rE z&)5ihQ$=wbnCX7xa_SH;o~036GD7VM&4IIG(Ok4}6B`fe!&D3VS?pQMjj&x7V1o)m z#0v7eV_z>9TG_&j)^andW4Co7L*P+Vub}@*8p#0>CTEY?7W$9hQ7_n5KnFS27I-H8 z+~)lz`ddW?z37CHIQlFi`Ia+J$2sswId+PZorxTYz z&y8+ky9Me~u5u3Hn2jL`>#!<|byN06^oK4ZRS?vVsdWbA0Zu&T zyZoA%(%;~d_uJIGn*i8N1ngQ+=~hJE?;B!5=SvqHSGf{TeRFU(&TjPYQsP9p0RJ;LlHnTxAiiF=B!4F zrJ(og2Jq;HvnC<~1=8Yn8lo#|g1w9Tw&uSFe4G)W{$7(DQXj=bl`zmZRJB={^}h6( zgc=2nKfTKFn#1&%KD(T~e#~GcIJ}lKs>g5k|JFca^Gr}!RCwt!xX`i_O zfxZ$(JvR9Qgs%mYSjh4Y%rRE2*95umDpCPhmUO4R5X=QkF~81W$GW40%^_!N)F|P6 zeYs94KGdU$sdf&CQq*w_sh^;VxJEyNIS8DRLB!jG86x8WK{`rbDJN|;8u8_?yRMFC zk;@Z&b95yLN6{-Z&H!vovW-r+p;>8nZd=t0bRnU$c9eQ0R>`iB8hl3|^dMhSmRFRf zHQBx=b%=G3ETZ9HB2=$POFhrwoky6T(@vBT`OI+sc`iFAC+a*1iC21ek0gXt4mJd# zU;-pf@kb(Rk>t?CVm+8f9KClX@26sT`rel4BK6aVhe*55b4O%q zH;V3QZMO+|?vexW2e3IEgE1_tDtk=K&W>|z>zX>-YUP0u*Z!8oSMMbW>H(%S98^ZP zASpjcSz3p3`BR3Y$ztAQJ_)k-m#+3_&w=_DzVYbS?5d>059}jcERqG4 zO}jMQLz0G%u;Vjl=#M_wPd$#;a3lZDKy3>B-Ye^x!P%*#%qkD@tk8}Xt| z;MCP02s)gVt`h+mk~hJvalXQJDI?^IJ;@Q`hpzYfQ1MHOLywG5u1o!i>ll*cmbfky zGfvP*{ETRKs3!&UI%dSZ)7#!@m0h>;0MrNe-vfut1KMtp{*KH!2fYAJ=skUMdZA&b zw#!KT*ZW1ZA^>|r@2Tw$(-k|vB}{LkhRDH5Gzi;EQJ`|)K0R2oukbNet~>mz7uaY( zTQBC4DE&IGapx|N#(c*kw6}+~@!D5eYb6chk)91`zr-;1-~;r_4+IqPtY&5o$+^fA z1yGc}J70C8j-+&??RGV@hAj-J0qOVAF4icLyODD?@(3kISn_gn1C}lqZX!XIRzQj$ zC;RlpzkgA?W|szD{bo?>6^-EKj#Vo2gvv{wf=2GyHZH$a8<3w|^XKQ6W@Nr6RsKr$ z*}mnq-ae31RSL2oJJ;f}cWFVpp!i8Q2@SD_EExpJ)3K?lJjytabz+~>_$qO~jH7hx z@J)&Q*Kuy~MuJm6^PEg`R={cTb&-5|#C}TBft-5ZU@4t5h^_vmFSx*attL8&G&0ML zJYD2=CSe8_2MZS;owRO?EK7!Gpb;h&A0Ig}Q}r zpztocbg-T1Mr87BaaJ3)f`|!5M6zv2MRV-NW~Jl1*yAbsHkI@whM%L7qWVtzz7P)#GqAXO9n?@82Dx8u40YBrmAbR|7VgIu?42D#%EvP{G$h9Ch z=xYG6!-Tx_2e)hkx=WgmzTIP5qH{vHfb0FTr0~~*;BMm6GE~joF0Zjs&%^0Acndv3 z5(`HkHXoXX>lCLeb+43RK3#&s9KVN z$4@VjCHpjgf9-kY6OIth8S%!vi6)zk0qF$b%$NohW;Gb0=4cn4K#ssbHjvf5-?INw z5j`|TJXbN}ZR96jt6xetW0-3-`yr4d#nITjy{Eco!ZN0NB5cs89ZxtwU&^z!kAC*! zfEyLT@T?NvPov_DD!ml}UyaUuKB!05vi3R|RxCvmSraXXXbKZiO#a)W-!H?zri+U7 zS|bVM(PowgEYdMwB%1NNqM_0VmH$+(3Xv#OwYpO7mG1L5M;!_#*<*H%FaBoyi{FU`!a-#&afNE@y9)T##;0=NfLbiE9RGU>^>PX^Xau% z)EZnVBQpwPC__(%Ywn@(G`gy)10EVM%b0H*Qw%u*+l!E~X=LbU(^fDpC+@Jj?_e6? zaK~T>V&D2FT{)o*W8$AdC@`Eb^3L2O8e5-T^7bHYb|AZ8`m+Y`?}1DyZ%p62>-qHf z_0lGvVIa?vx*y3r=h^iCJX?#GD%YYZaNDnN%m1Otiy{X0@&ngOa{**1F#W2gM*b&lRXq%;_!5tUD)-yVGlkN64!|2#r5* zybq#4ep%{+CgYKAyscyOW{?F{zN!1vLVm5nYIBO1nF`0)<>ZploN#uoEqbA~t(;h) zY`qOUcUEXi*0Lmjez1)b9>pP=Uxj)TQ*7;F%W2)zu169?<<`~NyI-83mBDb$zsSl~ zufUr7(}JhW!-(|y#ADz>wU53Mk*Y}Dq6{uMY#1MLGDwE9F6PGH)w_ah2x$Z$bvxM6 z5uZwzn=i`leq#`CtELG|wYhV*>i6(ta(+p$v{?ixY}2nszwc!KLLx`yhWGtx^Qie2 zx74+KS{z%XJ`i6`W3I_lgYVpH@YdSX5>W!fOjY$u+j7M)BkA2o3@3}>*-G;_aY{Cv zJgCZ_N!fO3sAYBB{oU^P89$RQbMc?sqkfUAnD(oXlzc+afvYyV$?1>4UhmibamWWB*3AZcRu%Rmq^{xkwLXl{v1fQ zZVk!`&PvOzX(vz5+!cF)aEo-37_#V1MQeIRI(|@ebrJD*ZhQ;M73lb?^GmC zcOKE!?4jXFtiF~Mi6-GyzbBFAOJ{j&4bVs|if_rm^y%6HO|Jw=(bYa?pa?}YjDTgy z#_!G9IiTh}=7Tx!PzPQ~L*&Y_rp56X;5Ms7?5InEFlZJlU74LGwt>3su_fuj=VgR5 z=nPyL?`z`PO2%E<_Vj10)p_x}&7X@+2_KY6-`eMj$Sli$`Fp~V(FrgfbbTo;HHvY+ zoJj(aDZ&ua=ysfBjDboCC{)w>#B7I3ixdIGdv(*!xdobG{j)I{hGxz?M;n#1Wki9O zXjt-j#Gy=GZ+dg)v6yuPj&b;7@(rq^Rva-8sN~LhgL?7sSL&X&llFA_ETKHw?9zxQ zQ>Z=BOdGN4_3f`q8cUVNsQZ|k)5YuAN?f)pv-fivJOks|2a^`dvf^6DE!EsjJAA|KGkddV?QsG+p|8l+ zjSbyHxMr^DJ!*oLn*~RwM9xFQpX?rw-;cxizguD4mW_ty+k~-PF|azKkdPF~enNvi zD?7gWq%T@3lFl)K4%+-=%A~HBd^hCcV=8*GoXS(ylc+7_c56GFN=7d942*`4V-9Wy zWEz;L5~y*j%uw0t0Rl7wjG3Hxm1^jjj2Q?ynE(Qkk1_ z6I<;2d%In=0fyXqL-%+2ArIh-iwAkZ^piF;LZh^jFp~}abTbKXZ+m)smVWEh(3W6*VtyfpBvBrd~Qr}OLtj(LVYFm7*=dgi)J8#!$qwYDL} zKhWrv%Im9}@-9UIEXd0;mu{TXz_}s zXN;iEwcA4A^$C9b;~9_G!#QF& zrrLkA4kd)UD@HldzmPZKY~u|(e7|ff;iu%$CF5ZRo`6Lu=o zzhFXL`u+$uzp{>#_Q_*R?M@v|?{M`gsPS>JV%%E!hwg|hc~wyxb=QAe-T1*#XXpO2 zOIT4B5NZyFgip$N&PiKk0_V zbS!e3o6MQN=P9q*h{`Cncx7yQ>)i-Dl2lL-zO8gJp}6%v2}jlH#Ph@`=ZH*CsTTq$eqFTa$cTD^ca5_tM^ zbH~1kdb{1J>LAQ8G0`{FPeDf)Q*{=%sJ+#NpYZu>f50zoGMj{ZQW?~x$lbZmlgRGz zD3)-v7P@fpJA0ZU$gsYR^s6K@8SA_^6$X<+ZsmR#j&0Z5>|hQ?#VjR0;QcntnI@ z2apX4(%{>hO{|Gn^2JrlWvrPL5R-1RIJH>=#VLMK5mghRn8S^ge5JAab#Fk1o~g8B z4Avg{5|oPJ>cCt-M~{2|K{v^ewxQK!K><$I|2ATo!4V)pv`VdBhp z{Qtxl{(4;hCXn<9Xo3}Mm=}Lv1dAsNhDwkql7`C0-V<|_=jAAV0U;c%zKA;7(F&1H zmefkf>B{??*PtLbM&qq*nt_?+h?yf)Uj#)v&SXL%xvP1T%bOEmn@$L=%Qt3o+-^X9 zY{T%Z?JN4_pX}XcKSTehL6(`xmEm8ZmkFQv=C^;ah#h6}xnJSW0_-Hb*t<7>mPDC| z{BIWcb1ZImc9A^iWzmweCJZMc{leIcZ#Q7!d5%NdJ`l{@~ntm*Fc4uRJkcxv7Ds>Kv7vy!PB5EbfA1lLhuA7((3QPfu;~`j7xxZD`q7P=*lZAh zE>>$lmf0+EN!1*-VN{Rvw(rY#{q0hLvtBWkos4?0u(J=f>@p6a?9rp4=X$$OZSNgw zqZx3zHd`GUpZ!B$g|2jj!omdRX-SE`?+4t>2Jdz6*GMCtE5Dr5Bu@rsy4D`PqWYwzu*^5_Ixs?Y| zTn69*FZDn33(88L!i|J)2~)T&){a*@@79GM*7d)$1*H^dLe?W1eM{$=5|7u<>#T_} z+Oh6~;Ps~Y=RK7$92qwstV>;^hVUP8uoA8`IWOb|AtFa02f?rMF<%Qm_wWb90<$F*7@kkgt!#gx zN}OrW4t({zX3ex{^ZGy`s?HoaKR#F572Ir2~ z1r%MCPYJpJ@~Km5z8*;Gchfk6|JIT|3C-KLz*-zVNVI zWsj1Q`nrdbBkhUCK%4i0z)hD-lfyo#!1>%R7?dPC%=OsafiM5j&%3Trs1wKlgYCG* z=b-Y5@_8(8RtB4!WM(4$Lenlo6567rxla1|vkg~4p#8G-(Vg33zU!}NW>ywm)`w#k zsD_zHuh?F5={Mr{ zmMqyZ_P;C0h*nPK52`o#;YpcQx;Wp1r-q<7i5Op022N?-w*T7LnB0TM@>vvL`JnKK7_ydD!KOLis5zKtgx%)q{GE3*$KgKcU!EMFxN8mk*WRJ=J2g6@8?s- zHY%)ZIS=_9y#ZMNeHP$=w-~ANawyx-AeA3E%YQOkg~)8lsa&Vy^SUllaA9^-0GkYRV?w&9ZgI`!Vk1mF zuT#Hwuhhpyy?p#QGaT92;vGM^AyTP&d&FggR_wvr&G^nfIL!(4`yAs4t#e`AZ_Jbq zD8iw@(@5blSxC5%oT@GFW=m2X7w%-9eE-j>0o)zG(9(wrZ^t$ByTO_0Ium(b3yWe{ ziS6-qv{L$rx|n$1%P+z#E!WC?f#~7lwu%FJ^*u#h+x~m73s6NR+Rj=y4^*eq+w6DC za@a|m>wSIFoIccf18)Qs6z7Z$ z>`y;Blh%DGWb0l~t)@otEoVtvE1BvH2IQQUY`-A9$&nsRf!GG zOCQK%$gh5`VCmmABMXvwg9h`()5mZ~7j?>qYKHUJAYabPuGp8kx>CjeD3ro9@JY_| z9zY=vW|-FDAUttis=Co*)$7vJZpS&|PPH)$V2&gDl_nw7146JlV@pJTp8Onk?$S=; zi_kXV)2l;ab9|k$UiRg4C~DF3IU#Ao!F}^zG!6)aN@%3juEy3JjPLkq{ z)lNO-Vg&&Dq}d)9OxJI0k=E92ExJ-PRF?d4Q_PHnO4dIV4xRqm>@w21%Su%pW1iY=G#O5IJdJ=L7K;5Wi)M#*v=(4N=VU0M%DQ+d}D33@>r zZz6?GaOCLGj_*%cORMe2dg%JfSk~}8apkBzJ~+0vH3-|=R^%hX$#s@H7Oo}|D4h@D zgyEU)2y+R6mp?}BLr#S`k6RzkghocJOOklNJ_b^5RNN#Z(=pFH-9i)Q@2czrL7hxe z0`~iFhjt@xbu^J4;hVswvFQmO#q)T7-x^Q1)D$?tH|0>{(}@P%pr=|lv6zkW1x~uE zO|pN^(Kl69xm-tjxfyQu(ld>UH-?pN-IU41n6_{b9q;rUVO7z3`Ap?HoP++s01)Zw zQa5gHxLR}}5 zeCP9?Ppx@7-&fzw?cHO_8a@&#|#t~3c?b}JWaGNXzQlOj=3 zTCYVFuk38aq3-9?_k?udlp7RbG@M&p2M=-&->8ghHY9OTR3Xn73lbaMbFCSbCBsh`g9w)&MPb6eht(~FwcDhP&Vp?NtTs5W0lcjMePln{qt-I$tqU;p}v}$8+ZUoH>BPdK%QTh#mP;((L@%DP; zH^Gf}Xj;NL&%wPS7M;3_=?@!ztw@Z%xNpVv)&iDxmhl7g4ah7Av z$yS+$x@0B&qXmwhHb*x)zjyn+C*zDom+P=(7$9UCj|B8l;YA!Dx zqpJ6k3p1wT8tFEjtl&A!Wm!1DRyI*~#p2xdA^aaNh#iwO&-n1+x#RFlavOLmfOgA1 zHM1VgGWjK|FYz~=zC&{H@Fq6|VO6KZ(uWiGhe)pYkARv=dGgIMVSo66tNEf3z78Hw z4)xd3E0V~E-8YU)>|Y?;b!L{H8o7n4fKqRX$TY~EvzVVZ z$nuPtEV^?CtL3Nov&V+Y(4Ik{WTJuLwQ%WLc{{4Tb0U8K3Vmkhne}n8E-s>P+>~w5 zt*Irj7iwe!&8T&*yI>knZwQE;UmO?8ju^50v`?P!Q%b?(;W$vZYuE0y3s*tbR!&?N zkNAR-xIbdaywv=irOx#ipupfE>1JA(7HXUVk6r^+=oF#5V{`2TeN8PUi#tpzR7wtX zb*H1Z!`St48<5)XLsQ@_9*AE!{9KD}WLjlS9DK+VhAm@@cS-GH&BguY_Kce-x`pp% zsNwYjfLioa9vFu73@c*E)tJ)=eF>t|wQ`9eLv?dYEZ|NPr?ZLsM(i1Y-l2atXx2(gd%Z||~-vXcM!BC1Ad*&hck8d3nYHmHgmkba! zI_mBn$t^w&WYG-JH$;U@pB+mA-Klj_O@DXI^s!Qu;`WonGDc3d62%XKy1bZ|IN zebjBMXmC2o!4DE-4Q#Z$BS~pV8b)GKD&NSNt~{}G2)A357|dpJU07ZosckuDdY10A z7ToU)-F`2N$MKbPxh02Qt~}9Z0!C(Fq=3fsRSBnf8;3;8>II1Msr+~pr^Ae%&VTIL zvIu>m&bBidM?T?+llI(5jDk?VVxu~%k$#v>4TTOTP;Nx$Tp9O-i<8xTb363woHF*|4T@UU8sSNGZN?c!X-t6{OYmSk z_EB-pIDk`;XX8UyeJS$m0eg;aJ)t8l_}Jd=JJYTJth9Dx-rCr6>iBhHlZ)!V-M&7j z&%?CLYof{X;>(NETbnAA=3B!8m#TE4xCUuJ*((1J>?>9LNVQPY?dDd6^~QwSuU8wO z?B+$jn~ffm%t|=tk)56Goh0sMGv=i`m-m%-IGz0Hq+kM#vfRf3=E(}a`Kw_WuDjvV zWxbJum-_Od_Ii+{*bO5^Mclu9YcxE@6^W<)c!O4`*)o-xhpZGI3<-qn=eA5sNx%>p zpNTUBqpeu~cq<=?MecrEhq8=e%C5U(JpnsG5NayD7tYTYf9$Fy;E`?y_u8j`p}Vy^v+rIpRxtV|(yPd})R0)CZ-azIXP0MUb}h zb^CQ&tk{Fjg}rVo)^nClvo3x1(fLI;=9XUv0v&9L!Ca!16Ydwps9F+C@<+eAG}?Fr z(tIJJ_a%b7axyB>#$89}YX^_J{L9j7h{EANFmF1}zGyE5d5Y*g-m>^wkPmE|X9GS& z>6<}e?cU@dJscYky6`MoEV)^jl#`j6P59{B`!i3Op6$+ko3GmSp@M;O)CSZXdLLN* zT2_k+x{1Le1>NHbZ%T`^kL;7FG!lMo`oCMT=NEp+7P~pBZn+nXFf~;NN|CZH@ay?| z-@>y`*Umr5PWscB{sRt;Bs&8b)?H>b9b@5eaNexyr4SsaWGg@f+%jQnI@;>uv>uco z38xEavf|Lev6V+Z!Pe3uW*>4%=7~t7JS=>jD41dz8q|x1r}$zU@W~k^#(R42 zKtM~-TAFqZhof%?j7?bP;YLzYuyKvaA{3UWwUBD07N~2lGfjh*SbfYV-(R ztQ@#Ku;CTuJC1()(SJdQ;Ok9b_Ce~Tm@|-|_b14jXLRLa4RoaIy1oKpi>g~AgT=RS z%N7^tLuyQ$63ZCt{0$rif400az6EHdhBI~18l{3qIn<=2i?(*f@$=)q;ze-H^-1y# zUF^Xpf|qZec*N5A3lZXGjxs1TH@aKu(8?tE(&0Um!2G z9uUZ)B!I)t5}~;OXwrGh(9@*Eug5M@JTx`PPmJFov+Y z(ls38uGIHv9frQ0>)Y<`1~o^$ z;j+MK0`18`Vb9|-Bt9Omq+zcByY@U4;M-c5qHt$CQ#Y89f4PdKtdmG6auwEwHveD9 zzeo19Mx1#mSGl?U9zo0pA&th`F@n}7bgaE{OyIkqYOu|m>9@EX?4)Nxsg1Q?tyH`B z^xd;Q{te^ic1TL~?~3+AcCCu~AyUrUc^V|*PqI%-S3NQc<(&7VriCA0nz`7_%#53t zReTIqhzedU$yrX348ZyGA_abTb`xBY84tIn$h9ndT@p0T9&K7UcdpOnUMdns*@L4X zu2d1f^(N^(9)F>-g8l110BCY@X%7z#!r@sMLr)y1?e3$bhKJ}C&5b6SjrT-m*NQ&?1SV;y{>X1lr zCYkE0Y>&{Iht0_U4$`iuE>@Kb^v3%1Umt>)F=z>T@*aPxR zOib$-a(~axQ)VOcDO_<($RU}^6F5+scT%lcx_&)c`qrAoMpxJR8Ybzm!MMf&4x@GJ z31b_6K;J(g6&zk#Sdg)@V)1ANmy|G%qyjpVIE-j1E&ulOo-$EvDQrL#4#Odxt)V0W zbd-*cju*rf{L>2ygGtL;4GoQe12|}YyLV>lv$7+W`z~9tzpKeE4I^V%680*A(TINh zSQ}%OcM>-C5Wyx9*4ESgo99W_fG>SRNgPiHQh(*;8Tt798Gynj1RozCS4u&~qV;rj zb%&l&c625gaK07-qKJ3Oj->8x%9hgJYK9p44eRuTu^I*h0-r=(%*jbI7}EYp?ZTla ztVP*3B1h+6z36BC+4zc)zR76i~DC>E2z)!De!^;pvS|*6L(S};OKC931&m;c-N**Verb|-(SQ^8^^9G zhFvuI+PQ}PUv1NKr#Jn8;?x^GvXvwBch$Ga;LfN1i#_ZpnbiODeWOoZtDS*)d?-(o z68Su9EC{llvd8)~^+Ml-PJ$JVR|Coal6Fc;a{8OcC*J#JyGI&vyvh{w`882?bD zr|kOl#1gXcocf=PVkgo5UqsRVFGwhvH&$@-8n2d8B%BYe(H`$-Fx8sU?Cj2EznfET z2PYHC7tu#6c(RAE^!8Q==n8?1hEw;-{w2*guO0R#^OwuX?5gfLA8%}N6#x+{M*t2& zn_$1L*FwG;3sVBtW&-dmBRPBCKfA_`?9$NaWkW3twy!DMay(BxoJLouw;Kw1VKjJ3 zU4hG}NcuPa|CgPtp^wxLVQYrNJ!eR^4l8D2uVQ6Jb`Qyvj$G0n6>q1%2mV<*r6Tpl z#x{pl!`yfKAhyyd2HU7JM6APGj#fcCy8_WaM>C54^|#Z?lPjs|xEY2M`h+H}74=49 zYZ89IRL0*1`ctOwGv2q;zgo|&ydBc-rDUhzYlIvhm9`2_>HZHo&g)&%+n<_h;@^ez z(rroFr!H2%2U7gWAO6?TjcqHeW}aOUeQ320)ZIZqqUUtqN4-CN$4dyY2WprV$8l}p z%(jo{G_sq%LG-Ud|AxbPU4O)Kq#ee4Bi41gaXH=>(d6_)tAe?TNzZC!_-L^WBTBil zc15XzikXoFXfDutJEClS-LC8ePvMEiw%6T625s8&oc8+gU(d{nkVl-PJ{&{w^Nwno zzlLts_VzXlQZJF81}6Kuxn7Dq+^5M$)Y^}~^a?zBxcmIje9#esl?_i5w|uzPDiVf5 zT3u;N$^IDA`Y(d8>jRLAr)!gNM^k0L&yB^)%#YZx7Mk4$!GsnZWSngHnCS3boMwxygbd7LFs3Yxry zJxHhT7P~vJ_d_>lppEfcqSo*qQDVvHRivgXhXa>@mt9aNjs?qBA7|`Uv2+hGs>9?l z`MnhV`bYEx;$IV;xpaljiyS*}?D?%!5ew|!!<53^M=g#>atrC}s7$_?M*?!{bXE4Z z0EZ69$CIpD!H>K=PzQz-%jChPATrG9Nt;WVBM|!q5xYXGTy9}0ycx9edJ5c{KGf%U z9r=uBq;78w-9iF0>e_`r$qd$o!E7B1B9c39A#Q8y^ftV((~=sA9T7k5vZ)GHS1ew< z$Zt`=b7yhrx=(lCWgX$yQ_Y^6_(OTf`H$|dH6wcIPK_@S$nHRN04ZM_6bq(}qiYU> zUbm~ne*9KDw0a4wFUKQNSLdE5<{Jig@R_2frdtT()y;vM(MUb;!l_{=EJ%MWJXj#v z@DTiNd$mzmc*r1>(uU;WwYDQ&FI7qQ4nwdT+p87P0S`|v@ZDu#mHp*w09|}$sy`!t zW5W(1AHTwB=)tY-lwL?l5`efm5$!Kq{8`hk@cZ*KqYGItzvyU_P#wN&L*gZ04 zXl3BHwYVlPrydFqV?(&!1h}8k9UOzO2enywQ@ZSg+(UJ=juu<9mL zPxY!@3l^96jxzZi`h@G-Lu?`HPa1TnUPSNw)@U2uvaj#g+6BW^WFGV=h3WcyKV!{V z4+`_^OCsREo!?p=n>U<4R&7m=(APHY&eVrpjy=f8wv^8X~bjKmuiC*DuEQnfPd*Qyk3(JU6D1$|$IB|Md zZ=H%>2tfNF5%yy>R>Dc8-KXnuh9r&9%>jNKRKa%*SDo-u4A+l2KPQ$xJ*0~d2nfD* zCaiZ{lw8fDLcoGD+1TtS2=$jhc~01s;j&tkp*J%89z(wC-?b|m4t75BUs&Iz;XRCc zYKA4aM=uQF!r$Tivz$etZ5kwG1*Vp|MSs?KQVK^KTBdhoUNLWCNxPia^_K4%g8L5T zBXIlU!0M8R{HQAo4zGrvk!cC3FH5}|AabU&XY;&Uqzi%x^|wCWaJ699Cx&DzoE?nc z3qjJG!j)Sdq$qdW%S%HVwUQwz+Bej9j=0yw%J4ntWijpi^aVxOnOb*xu$&i)<^?zB zfq>GrgXbD=B=_HF+Z*;v54QZ~0?b zmdk%?R0c1Wx<4(M4F0L_S%ew*gWxynPHRQ4~>S!P-sF5o$>mK|(|+<1RT%FA;tj+`jv7_*1RxyHwx z-vzFZj|zRV9%0nJvL8Alv2nP{pMRRn=4$teE+{8w3!zaGE&YC?W{kF2@myMtDO|g- zZs*f{@(nVR2Xi@7&AWk1q6K2)0+E#86gbkED6Ah*hM#eG-zC4ieu$Dv^t(&+^Fv=f zR|z%P8WM7Cg+vWKon?bHd-t6T@wl~z7IXB&0_U`Z)4|Z!^6$MUV#fsKqN>OZ8;#Iq z)@7GLLqiI|*+pHB9g8mx!^o#jg+!Dv`qBRTEI?Nz$GU*(LWN7-+>l?4_HiS`jQ3?2 ziAm<*lwC^doyQ;@?9_<3m+XueK0%^)21L;$MRkv{qUuQGHALhm3$@^Xvu6`!^i2FY zM@7qs(X~l4oaSvF zW@WkQD|iXvh&mnJ&n-aLWdTBiS!w>#Lo}Tk(Mt?8+UHa+p^`eE-NXhTi$-6Fr}Ig_ zA7!Nsi#ng$9+8JJ*OXkjLR`jyg5k67mNxtL+^)$lxSn6fqESwcin%*_BHYd@y*N|< zHbFApYlZ40Oma$z(8B@!(iVC_prnp)i~X=l2tl)1w&DyU7>-;UzN@xRwpKWDd`$!h zaXNxf(P@atx=#T(Qbk3>{fK;Ca1k^r@H*;0`pBIFoifgShg@htRKlC363L0UXgV9y zyEe=D+XvT;7RK*jfgk?Z&E)RZvpQqR9pLBMo@;^*C~%3XyFLCWzjVu$jTe=U^=iUt zmMi+kx1dkM6k$R?2HA8Fz1sVBK(trR%3WbiAv0TkWroVw{f``}Ryne|=NMUh3SKBl z*JChx*&{i!Y&1b7HW#rV&%wYO+79_C-O@WhVjee#!*A7jL;ymffVWMmtM9}eu)E6# zASz|697#2zc zK8XNSMeUiOeFl6wMao1ko*1jTFu!9Lht!?Nni~-J_6|0MJ8xkg7V0H{95YLTz~mM@ zH*tGVM?Y5y=E~t(zLz0>e`3cCZH$!Xv4(wri}i3ZSlKJk8GKpA+)4aNHg8z7y_CJg zXu6+v&-+FcT(Ni)L%6(wn z-N!nPypc52G}O2!@W=*PN>7*(2Cc;-&mJ`F6G>Oc7|W|QK1%9y!ZUqF zih*L!i`B{bTKU=^Zr&Zm&PNHj9Gz#^(pV4yocB0c9;*{$CM@-B^sx?*Fi?dIdS__M zFOqvI2j?87$eu;rStlcNeB60kbhTx$5%-;goiTRwh7U&8W=(jhDZFJ~)3Ep2c~!Y` zjmU~A;LA_|nI8Z4yxRWWR=T~typc=Oswl2(j(@orh0eEED`W&y_WVuaqSvO(ak zVLHqx$Gt^Y*JX?IETQ`)i(Biu&~;(>!*!|Hny_%2@SgWzSXfbM_bw=8!cp8z;pmq8 z{F*Kszb(=*uk674P0esi$d$*Hc;}qTCjvoRO3&+li@U~ii04wf_F5A;nP$yfG1w1f z_RE0xTmOr_w~nf6ZQn)-1rd;vl9EQcyOfp`l#tFvcXxLPNT*WL-Q5k+-3^O|MJ(b> z-*@k=`}ci+eCM2T{yU7pU;^`*Yd&?ybzk@Wte|(N7QS1Eln=Jx;q=;%8%omI@74JY ze~JQW6>-Gb1|4yW`+N`*-sGFC!Z`(Hv}wBf3F|}-H`As<-Uc4!l_z-k7C-XtRH?B@ zi&a>iM6t~vlQpRuHWlAi@ycU+t`md>K5Z=VV!o3|yotx(U!XE}i3);Ptz+FK-te&9 zK6fMp8!8TL=yZE|b<8(fZLD7TBIQKiQ_s4R2y@4K@ zwP>q1F%gLG=2(nKoHD@Q1Yky4&i1LwH_8XgUu9lrU0pBdfvf{PJ4i}eqt`QJ*9?Y{ zIFAZe@25Fr1hgykl<>Xku3pHwGdFpNrmL4YtIgW6F<7_#R@Vqb4`jb-A%NOQl1bh|?Y8E@ zfMEn9H+|;xPxTolikkA?wBEsp;xI1&zy30CjDFt1Uqq>Nea9}h3_QH#et&HF3Waph zbwRd?5TrM9!D8-u?-ep{xV>Yi=RSf4+DpwhXx3_TXdF=(wO-#S+1awX<>A@?h#gkj zBCY0u{w;7#rq1;FX!k7@Pw&Z@R|L;-;j!`T-b7wXBc1iC0Yj|U)s?4}vo$J6{5I{b z;_P_w?1OwGPUM=rn#%bO*sJ2ux69E6sov-nwACfpUp8oA*b(K4o0mJm2XZ$yzVbj0 zuf^eSN%=)7KRr2Z?{`Q}FEx!E?^bEW7tl_237~Thw81|yjJCC(63EGIu2ia1xy)Rh zcoK}-c-G#KPZRQq7#lxZc^{&;*&2CTED@79)#AW;cg6TVccrHR31*7^*5VDzN=AE> z#O(u0uOJiE{UvQ=K=RE^>dS3{$mmO2ovYwE-9qP7HqaIyW#|ow$ zF%ZSz&WhL+`54&iP(%nbV?0*CY70?geZl~RjE}>ztn=^cIlqK*H1besQU;7VglG+_ z{v45)IQpCwW!CX4r66DpNG(}D8VyooxRPrnz%@i#RB7!+Hd>XAFJ!x`3UaA9QLqgqdH z43n|>^P+p=t#M33_S7L~0wL&hb$Hk_06c%5yTAq-S+%VPgxn4Qhu>q@1_w*F z5eT|-lai7q4jTockt|P-If6B&{(M0puU=!mSB|$Ado^E8dSMq!TW&+6$t75AHzj?2 zeGwqffkgu+2}Izj7DtpqfCFwgXLTg?ppe`kU&?YVG<8=yUQN;j(L{gLVbbi=T^& zGMbu1z?|VynF4za#*i~5zp&7329PHo{?)YMRo3Bv^$KqB!6qjM$Jpd#HYF5T4KcVc zU;6iMe9Oj-QT8UG-v; zKAg|4-=pyB`yc{+|G3>rl7i~;g4mI~`(@}(JN&FlMR?5)^>j`# zjXW9S?-8plvEu94w}+)E8yw8%G-cP~Rh;#m@Ou}<$%>>57>6m^Cp8z-v;^Wbd*&A! z?r2s_7O!+>Z`ldLG$yfy+GC2*qnm=YmvB*!Ohap%I)J3b$f;N5vzT>kY_Ts>Fn&-y z?h&TqpO=*?g< z!1T6W<&31_WT?co`JK;{I%U;AIzPZaZ9zkA)UK##A)ev1`0>t=D1|7W-q{p`lknws z2{}mrNfeU|8B2E$L1UOr?ob<;L0d~fu4^(!RAu`#?9d^b(IXUFnOx58X6JGhY&3@x*?nJ6ByXENU()>^;-$w&+N$_eJT|2d6iL=CBP} zT*d*-2Ip7hlxgeeNIDu^-{;FNA?OeWPX!=K9U-;9q*j7)rlD$C}Q*jDQ zsNA$bb66d=TDF=`f7+$|ly}N_)6$SuSt=-NxQ^}&V7vPie;9Q=_#O5=U2!?SL0T=} zsnlIkCSqH60z*n)_5S^@DpzqT zr_a*;OlQl;PDBo4P8F{$Sd}aBW6KK%0FltKSAqtPI@9vJR02LcLrgKd){mJw+Q3fH zWp*KodyuADA{wlD+iomNHfNMq^?gS`hN1uJ%PWiAoWk^xi{1Ik%RjW%yVjvQ`>zX0 zyk@*345eQhuWYtuAyVG+bY(L{I-?a5E;*WmAL>kf`kusO)mE6vbFe9r+a%y9zKU*B zBoKa;l+R=CY)jdQnHw9=oAyK?YzXL2=fTB*=D_?AHj!IaLig2Rdn8!{j7Z=442 z6y8hVOzBt~_tK0ScS>oIC~thWA*8z2;qqfkNlG2x#GXTPc|@&FX_-g?HnUDxm^hC- zpcKY5TgOSZJZ|RXllU&`#opiYJzi2K(Y43i`OrT8$2YW3!P#r8A+tK-zig6gW*j_2dNszN0ARWk0M=z zt5qB>6_!`7-THMt?kkB>SYIq6rfc84xZBM`S^D%6UpCy3ukksUVyMj5gRAjyjH2fv z6)3x-^^e&St+kr~=oX1j>}k!g2`zD5-KQ3f-bw3;#5|5sF`1dZc$~p(zk|%xEd2N+vUV^;Pi@GUCtC9UYI-Xj`kG ze27pRJCIgyUfUZJk2k0Ggp7>Uo8LjMAWbgS!pn42_POH%qT`v`u|&pP&N4(P%~mPC zm0$v#!~bx-QKWkNtda`K2tOt@^n20W7iq^gTlBt^?jbkfZ#EK8*Q=;wnwK=+mRBn? zWK*9h-ZV@=6zMY7i)9OuHWlcXG`A&WdUWK%xEk69Ylz}@t8;xZbNV2GhoesDG?F1^ zzhEN1J{|)k`UdvW64uFnpfF@4BcJCRClCljRI+n+8TKllB5J?(=j$Jm(G$^CFOwP3 zc)z`^q2+SMLg;|bA{c;}AdY2nL2aqM)D7rBSK1ZLMRYe10uoO>|LKOt&;;)3MN zD+aygc$Gd6Bcxu_2vu{W#TJ^Z`?D^<@p#Macpsl}e8z(wKO{fBJooM6mB%Vi#%1l* zPmp6_?fu6cnfPb4TtfX7!^`l2&-4^`Ds>#`b4T<>s_a$L{;@SAOHuheZtd9s8=7r+ zc$l4oW9`)0nwzkzt4pI!(iB$!RceXB!6>O`XSVb> z$OwKunVGKxQ3(*3>+X%b^BGlptsHwk~)YJvPSAQNd@2dz1QZl7MpYNn%UV)0pIMWCktA%(S#szz2)>e>&`71|yJJ+WW zC5Ps_Vy?gVnE(40MjQAVutjGySN~@S?U0(R<857K@0FF&T&lWcxV*u#5GU44wokX6-Q`$*|S{urF(kKzctz$Y~d~967@|{;~rss#OIyON1d)$=0_j6ACK1 zjS|C@xfPh7Z4a_x{W9k@u@LmpCiNv8KeI=pc*Es)dhYxowTs;wRw_p?Qop@3|r`P+ao1gQJ0<0g%^Id zrXO3UKWEp}a7c+GLG+r7E(jB5rm;xtgd%`+ugLRMY?{$6v1+!@Qjg#Z-yiN3u(j^Z zh2xtA8Z0=NI5;r{8J?_kTM|6i-XW5pM`P%JE%;oOIpNxZr%4HpYk26j$)#xxc6}nx zIgQIPks+92PlKc->7)m9+0#yF0%x(EXr{)B&U2)5k)08UOQ=dr`=KaICA_dj{{ z`V8kNxh9#qwmHbm9N&s0RdRmc`CUnxq@3fk!WpCJ(|uFS9fL~O8`-K!JQ+#ny4qZH zGWxZ$*5p@_Cy@E6&CNLLs$2}?%;@Y&mM0Rdj^@zB#t48RNb!Y;Y3tDM!%)3YBnGz3F86fvj9+Ws@oN?qD8|uZ@YHOg()o=C3zQ2~zS>W3Yt$m5%r8tn6=|wN zxVqjqX8MdodN)PB0-R&IZ8_f)bNx(X0moXYrndgX+Lr*$`hsSa?Yt5?`wO>F4GvKl zm6CJO@&D#Cfl3;VvUkJ&k1B}%W~2kTd-B>h+c3&b<(iK_>7HszSaO`uvi$4me%P^1 zRc>_8D@4QN!`?NbOjN04kNj7y*mDCC%1tiAnlx4W8m%n#)TKnC$^q8m zlcK?rt;hqj*%lK%uiFVBZcHcSH(3aLG=INp{nxre4e&h(`{_;ZEYt-4NGnyn$rEW5 z7anYQAG7m@I1>+XMxmvqjrBE~(1Pei56W~)JHHOQ!&FxMdyY!NbKlM8P8L= z^M!a^#2i?;J_B~)9y!(F&4TGjsL&e~DWo8(#V-G!m14BCw7deB_mBBTU%NZ53&k(g zxy7@}<#&kkf2<89RhC+)pgo)+YZH}ZwJc9Sc+vGM<{3NjSiM(3=OVp{C3qk*3VLI7 za^L*|-hDYlWf?{Z_z|oKi&FAle+zjsaC(eg@+Y>4_LLzt%$uoaC`M$p!o5Wgp)S45}?@wB7Gj0jpwNJu!j99uZ9-NxTs0A~Hr zI{w7Hy_&a?KekAKfZ#o*F+RU!8k_DHayTe%^~+|>r{3+{sgpcd%m`?fF|)d*`cBJZ zHHk)xhUE0_*0C8rcuJZPL3gc0rfzAj$Nt{ICOZdl&Wj8yKPBY|!n%3;;1wf4eO{Le z>M|j0y*fmj262t2D2+9rYCKL~qwB%__g^+r(W2d`4}EuXI$inJY+OOwRS^%C%mTzM*F6&Y)(2l*Mgu7R09bFn_it{JdeqBD4# zmdK`ZYF-q*?@Q78Z?|8GMFlqC8{)wTb$~I?zQY_Qd(C?7^3)#3-Z>0t%ixLJ}-&_w$~p)J;(Y zMx(B~rlF5Fl+}5Quq?)i%*gY4@2{tgWyld8$4qb-;6q=yX}0~^ohGPp8TD|qxm80n z144pwXHGp&Wn}X0s1zSgZ7^FLp2oj@2y`h_6EeP8Le5T9WTT>?5*m`M{ctzR6jE>b zvc?R-%Nymjg(Z9AT>(DoOI#x9&R&gYVC z-7l?2>3s08d~TXBgIS)gpT9Sg@tNHDb@AEgqj$GGhnM?sinDRxSf}82O^_#Z9jOfj zpY}^SD%_1rx1)n-TX*(O+!1hpsMjlQ&$96Q43aGa5omS}iypg*xb`52KGV6`iYQe3 z$|3pM+-{&howH<;A^r`8BGnPK3e3X02s(-=Mm)bjG0A+Iiypff>5;@=kXWzUH&PHY@NRqS6WVNQ9?32TW_7WEq?@PrKInAi(8zvX=%o=02Y z0=n!pNbtuJa$=Zs5=C(o2Gj*^#Lq%YioI3F2dKPG8$8C0?ZM&0WzL6FpXz#t*AF73 z==#sUdO6=#XkrmFKT2BVB;M)cV~Lygtd34!0>5Advm}Y*!LjBFOFgSrlzBkzN`yO| zdkwVe+KCeme}kTZ3aox%@HreqZozjlJcIIz8Ua4d*QU5#- zZG9R02;6I##?!wO5b%}fGbru_h#O=qtKFd4d);(R7x_uMmuo{};vT31<;!K(zyP~C z=j1Q&iBqw+ic_&?8}+Fwu}1fIRjeZbvE|p4?z*TvPN?+7mY-v8pqs0p3L4%}iB{Xm z9C@`9D_X4+_9J)v#e>n)r5vlWrL>+6c}ks*h>1PRB#wPxubsQ#u@Udg^;0cQ1r$9F zBb*h}Nmj#^*uGF#DL1acPvujqWDPp3tW+(Zd|usMtqrQadTqW1F64-lKNS zKibq3dnyh3IWvJAA-vzcShHXYCGUlo%-RlPL(&A&unD<8pMzOz$PbsX7)tPPe>pWt@s$cE1H`H~mls?21w4aUgmiLx|CsV(?`?y8?B6Vqg<}opQl%4%I`N6A_*v*2FBbrX)75+%vqVJm3hS?ShIREnamxs$F6HxJ7bL3aw8PWgW_aC zgojE5dF!xYL%?i5OFo%?#Xe{C5l(!4TcEpxB4fb3QjOh@WZGhD_y5@0;fjmy{c8zoGN1Ng6>Y+-_N%^fOu<>)0WRu@ z4(B;%OGzIzWOvL=5TU!x1-putgBq?wAQo4DTZxmv5|@DKOB80EXJ;q0TJXQF)!saWWN4#sym=9_$lF7iS4CvS?-1JRPK_$>Ay8WkCEBoTvO&*! z8NFOkT6$d8YIvN=C%Ez5e5TRevp~O}xrb^|($}d0)T!cjv)akTy_I&v;|@k$jAw4> zH56U?)ceNy5?u@`w_(n%(_%}}zlSB+z&KPor6=Y(K8I&_oP!f)1T1W1Rii*Ia z`+$(e)UBLD;Cu4l7mN;z%&UnneSZbV>}vY1@yb^sTxa0w(JyM|vGwHxga@dE$LKQ2eD7001uyexPqn+^XZa3Z~jR-4)IdxDX;==4vbR+S@kScp9{ykBRB*_J|qE^2r zeD9c6Ow{06VL`zwF#2E4JL7vuln~r1pam_U9=FN-S5UCW7Pg%G?VGscmaxOz6h`}C zGEC+`Hq&)cvKI+!XT&U?7MQW>SQ)+VuU9uZ1w-UtcMWv0Xfis}VV2!V&HfHuN_ywh zo*i8!CC=_a1Iw6m?|QvpPIO0(nN&3ZNR#Y)a4YS129{01ngld7;+@Skz2}19FV__Q z<*}_J#>HxMxTJqp{bPJr+%Ip23s8aTj+f+v=Y~S$g|>c165-OYVj#(Mh6Ky56R1R7 z9CgmMsX&+WGB;@)4nre!%G0%>U$})c3JBXmAfe;F&r12sGo(lJ0L_!;m~Y zIN?%oVjvlihKI`P-pV3ra5z$s3aWiyBL&7gF1FsLN0uVIRR)Qo-uQAMJp%x-??1%> zsFz-HN(w%w`K-?0xm31e#!JT&u1l8!@o8UeNHXqL5^RWh``RDxwW&Oz3-CmM6sr3Y zT;4?$PKm?4;W+WqpzDWu9ty@iL`BVSnv85*W&iI3#$5CEJCClQ7uYW`J0^pO5mvKs zADA<3Li)Nvbss|H@jqY8sQm-P09f{?*>lf~mh~eqZ|1P4JKy|1Zf>C(7z1{eU%^Uz z^QDBoKDlEfQB)I{GEwg{JD5D=Hc5Q9e&qrY%fnWy5<+mW0 z$GIu@iy*@96XOW9{22A3E|KxNOhS^H`p0RY8QDKoXivV==%$LK0Y zQY83S7MTa5Q0u*=3FPFvs)fUF0A?i$Ko1cH8VeQsDW-IOQHYZA;Wp4e(5)$R)ec@VPM_^Yu?qI5HZ&%rF!}d zDfexpA&llvLn{H|(J${GVEz65uQ^iA6mvM6R`m zf-K=jpghg(TGm~m$U0W$=|pQKG)U-MRns>o2gf*TJC!oSd$}R}LTAWap6XZka1~o* z8wll*x037_q&EKt(W+7SShis<#)RzpxoFDBPQV7R@y@% z*K>zsAzlg33kq%8anYaA)v$!GnUwiOt|*|-o@DnED`!Y*?EAV9GWUR!Q!SNkpE7j5 z_Wyy0AX>w@qDDXkc`$@`!Hz4VO3~b=U`>cb%nAy+4NbV(3Abp`OP=y(Y!@iSTf;%_y|X}$em_6^pmyl;)XvQ zKk6K8fOWM0Zu-#({$?6G`!lQxPf&58D*FP;NLrCvQYU0itw_~0U|w%ps64G>Q!G1C zeVIkE{TA$O42WdI_xPLCBi~tjv2ES>%WS%hW2ch7z|Dv z%xcBAHO*Si!z==YhTb#4Fse7!;$C(5U#6-u3gX)T%G<@~K5l&!1jt{sIXO8N zA@VSQLNzgY^Xm^J?kDZ9#s8)s?i3dMK=-!k@AoL#NLF*g5OgHWYvu2GXuYp)eTNk- zEr*>~a7X8~SgY}W%aXTzB|F7hb^cuMMHHU@hGT+qYG0Y%rFo(!-vyyJLHHY5n9%-A0Jl)JM!)A?cu(r z{cF4LSpl0Ju>5&TT}z;AXmgig^r2iuJM(U>3nbq{`Y5SWS22X6I%C?OKp7=>3Ua1j zw$DigPz~v}BsDO*pIcMe;T_HMGQYBJ^k7S~>yJ36 zDRDQT6_(E-b{i4|MAfZm48?VgR#`d9^bbR`;C(F=SlqW~mHwdd?X%@nizY!Qp|Cu| zvBY~~9w&bkM;+X@9QQ-A_0p>qua(j+5M5AbJM_49G=giH+xx@c=Q;@+FC3o-E+1d* z9TZgYU09jWg3p%?lIDXH5B#mxD1~d^2s}Bn@^&Kr>vgyb^x;yfYl^D z+mL^Gov!f9+^_Wie{b&RgLSD42dl+&B3oj&q(Rnluf{SsdH0KkkLGuen&j+VnF56$ z>?9f74rpDXpE}8sv#>;01y#5}C9k%wj#pOluHfPdtr?Og*6S%7%X%wB4(G)fKNY*V zL#A6ipg;2S@x6op(l`d0lRSizgo5Fu^x=I{u_KKw1fF;LFz-1ZqH_ZS&r9S*89CNu zoHw<^pE{{fC6)?sN1e9VHJB@f*kFIr9*a4#TX3pu9C8u|{l_KPR&1c_TiJTe$nwfu zilPp=kE!tK;z!&Tv3knrx@C%kR7dbKe^x&)u~}kcPTKBE%q2b-v(n0~;r(}KkVHwT zGRSw^w)n1(Ai;$z-e$YEOWcKNo>jUX=JvL;YrU^pma4}HG?&7byD&2ueUmbF_Ajn} ztZ6DUc}BvYMpbgsrCv=7_E!|C)--=@uVdVQLq|u>#gzaCgB^P-D=H*qWH#r`N)L~J zj;ilukfMTDn!+gyrHxPQ6nDFw56Nsx#>dAUOa1)(>TkAFc$`mOZ*4K8MD%ubG25(* zI)Z24>g($_+PNj)C#9qe76FGK1_$rfqz-QBK_Q3yCvET^O@9Z#_xhu+oZy+MPVbC7 zYC>-z#qM_<$lPc|n=4)(as5el&>Ss{djGCF|D@I*>m;qWd?P)PRtWm(_b=IwGiD#k zY*_Kph>bdXsGW!ODTa9C{GT94PS+N{k>P4?pP)nAuq7y?6zaR{7jNq+lE|&JxsT=F zw$-nq_`=efG8dI*{l7RPME`f3k+ww(0%gnz^P)ku{()do;-Sz3eS&)%Cf*)flJe#z zR6mRV(DPvdGB-aTDXQ16bL}-1Rjx|hCuL<;qoG8`mC_$SP6|i%Ycq6hAcbG2 z{IOEvN9w-If-}zd=W?`n=wDwqCpaI|gs0UfPR-$qVhfH81t6-P7%|%Qz7^5sG(m=( zpVY+9XwKDG5QvG1SxB`_Z)#gXI_a30sCjuC7Fgk34rc-hbn4(C-XD3viV#&ja*XAsnrw8YV^zpZJ4;9<5xs-+B)`ci!TF^P;aH3bDb-} zoSJ+Lc8aLnHLPhzR^qO#eV}MJrWi3a>Rp18&^L=!Av!wb%$fdHKBugf(W*7QNx z7bkj5$;dySppf*K~R#i5A_@hb^S!jivl=sRY#rAqGGRHvZEtEdJZwtTZa& zQLfX!+074p8VWF}kAMwMwP=f+plydrEqMRc!|MS{i8yt}$|0;Tr;{0iP;$SAYAh>- z^aQqc2P87a*QA0qxJYSN|3)x9X)wWBI%wE5Z%9){T#^6OiC({b$a-MeGiucI6)CrW z%`xTEAoO?e5uWBI3F+x00C$_)cC#;&>had~P{7<`zF4^45wKdfwze#!mmMJuwaUex z#bjhqq6BYg7!wK)>;6eZz?B;wBcCJ@lCyT^&boEk`bOYchwd#vz_p%VcrerOi@sdd zCha*r`4f+@f400>f-D5(YH5!(@tO32{UJIT!RS8HS>c76#Ryb(^mk<>Y>mI9 zjJ!OxfIzba&Zf@pK{Ygb6x z7Lc=n32rI>yl`A+QLZ{4-?&t+FVW~B#DF7Ry93GQfW2*lM+2l88qMD`YYY3yL-k2p zVple7zR+H87|gZ71l23~v{zp^fmP4w@4ax&M)80Xp z!UbW9{*JB1C8zDO-|SN<(HLGYO#I^&ro4Y+{ z8|Cjj+ZsgNrRAI1F*_Df_i;QV8|SjIu>s88(nAMXSyV(c;?31|zr*;3G;SL*z!2U^ zT~IGnE?&Q!(MDV?X*}B3pZtqSFs?4~c*&IM1vru%j66wwQZ^rt>2faOG)kbO5{TIV z?>^cG$GTsh?0EHbN{oI`Yq-;{GbyIUKPWe@thiX1-j4XyAfFntbHyt?kWsm->rc<*DK@3AB2ejphRSNBypJ| zrq_l3nP-)EbILZgaKdgk~-J0DvPMMwO_J zcuTZQOuc|R!HoCp`E$M5GF_ORB`XBqm>A6O-)|ve$t4#Y<$s-SkVdy~#`Qe?f#btU zOI%9E?WK3kWls}g{<6yZyCxFr&JGe(V|B9e>f2k_MN6lQ(V6RT6pplrv6{$<8q>3? zyOAf0=|GLEaW|(G53X1$+5PgxY^hi{+P_yGnE>DCkI#UW=c9Hutunse4fC}nM%wRu z)4LV`zs6U7K^ypFmQAPPKypJT&=ilu^y9)Q&?^>`af)Am`(@_^WBkPslhh`>UxkP1 zp`b!_R(}+7b0Wc=^4Ph5e98tt^aF(zPu#MJ6k%NWE1sn9uqoC;kE2=X)Ka36NujLl zyz}AKc7a8#gI%S`ym>=@eMQX}ByTDCprF6U+(F$!KRM=OtSQ-YCM;I)I_f|K^L{w9 zHyeDb5Dby!laHH(tM~5w?)<4dK!A~r7Q{~?xDo}8-=o-C7T>*ddC_!t#vzmuj6(8F zY3IaUL3Ycn+BuRPt08;_r`W~+lNd~Qk&6Mg*bL3a7JeVS(QQMZ(D^TuKAMixXb2CR z`!5!1V07hH5RcapAe#e`L*vf>ZrHWn=k2osXTY?o2q?Tjj23~IlacM&sq z#(TW=GuG`UTY3%awR2VAICU7e=ccWzu#Yr06mH90qY`z;H_zqa2Bem((n)uKe5xgN zL_AgQHn{K=iU{@=sLWxZ_Ik0`{8A8+VUU^QM_NX7bTj~sVi6GuB<3~e0tQL>XQ(HE z#M#X6<2O`tP36^3?U=Vc0tat09^3lU__(h#N?9G&-hoT}-wLz_qoAUy3vSPj#REb7 zE1PTFPvLHiPu;F3w$#-cOT0rf(`LV#kNd4*kG|dl0#GhcBzIW4{K|7Rerk9LF;dD% zHTq%W!dFy4tnh-tN^yPEP`ostYXokJPqoz&nKB(_c4>1+yU%0pMX0v=;PlyZ)l-y( zwE9gZX;}3yXTBWfOO8+E$?Ga`%eaM7{N;LKQ|Mx!hMo7z!O$2ju~8a~qE?Cb{g#jOfkzOxgbSnD07 z%Ziu1!YC7&NIOr#gT~5^7DL9z4{o#pNHf(!7&L~R^F(zxg2Uq|h(4jkMb0Dg;GwXE zLFe$^blGm4A37VMQ|6K`$#>#t5l1qN4>W;cle4_E1>aWU+J$)tR4lq{R&CNpZP926ow{|$-pTb3cR}TKI{On)6hDf+To5|pW#T=usKbEJ|s0=>x=j@n4R!YjdjAj zY423T#SPaK+V%kiL7^&L8Nqm@$0UTYG#XBs+G&zY84S4Zd*K+L%gja&o2LXXi0C^_ z|2q)G@-4DtbP_*a&{T-wh zL*U4qf!0bMabh$u`vkFtcZIUTX=L1S8s^&Fnbz^V3ic|6HxX znLwSh*x<&M%LVx?DI-UDv@mD=cYr4t37_MbZLt`QX>FFzO6hhbB@r@gRRC2I?FjFQ zv)tM&5$w6|j_8gfgm6#$bRKsiA4<<6oH+4{l4}Xsf26rz7B_#v_o9}-?JcTbH&^ilwO=`y-yHQ^hFFRAF=T1UtXdk*K9Tm^*>i+&rV9S=Ce%?e#Ik$~uGmiVWG* zkt(%Krj+icP&nmMciC`pU6{XAGw@?vQ(^}+e&~c3-_B!#87p+<0t2~U z#|_*<$6%{qv3$##<;HL(hRJ6|R`R1PPcbk0XyNT!7xi)$h343_oHe4fEpw(HuVK(> z@^l)5hEmxjhbj5W{CpLK&@)R;WCt{O47B9FFZ=Bae$2?GO9{cbiX@YjJ1YGcC6r-uGAdS==Sq*?LcV8AtsX1q zg>{|Y(C2!ZeB8;iP@`9M_N_ihUw3;;;CRJeQK{+7%h?EGo+wY+iO*=mEsII6{nD+* z`8uv1ZNy+HndD4B8zzM=ce|-*us?5r(sEl2-dM|)Nyfz z36=buK>_jxTDQzK?AuI9gMpVqMmL{@U&Y`NcH8ApRfx||iGl8)A zM!`m-`l#^1TV9EpU?4MA*?W3g`Lnt@Qoy?>esB4S(ExBO!J#ZP^m$JdiI=P|kkoPV z@zWBEp&%j34Xf=Wz3FX8Ui#(;WS|&mb1?TSgAVr_ML7o>yW6^-6?#iyyJqnCcOl&NV*8{(gus2W!6*zngVgCe( zB!-KMi(}&BhYTB$)6#}cPfuG)zx?a@;UDt^`C)_?H~Rxep(sv+B50@m+bcGk#X`1& zerRi{?ESlvz|L4gQ9Z5P$gLhW>NKs!d>(+X{}XsFN5Y0eF*1TV*_19ZSf{4xqds7USL3QfH zuEFE71U!(Dg;Yj5azTNm|1?cQF+Q!#TAiDp5AD{&?-L0N?qkl%vHZ(Aa-AN#N%|UV zW*u_`E_ov8a11Bt>H+WOb!sQrxc)SY8~_9DZDr>z?b(3(bGP5k0{;8Cl1@`l2plsc zkRUiTf`eOjG*{pR1_t}sa>rgRsoew^SKf(azu)qKYHaM1XW0PIq;r}`2Fs6EFksI; z5JDaRv+l`s{}JhkWj+TcdxF>*M*8DRYNw;jn(SF%De1v6T=p7sKM1yvyBTwP#K#Xh zTZ|=3sD65)FqABz{J}ngRV&?RNH4e4>{{vccNT9Kg(>6^eu(FoO{0n}GUW*R9L7zh zn3X#s7;pcMbfoE?M#A&_N!)SKcOz)4QoYL?3C}A`kUSU4^FE%;d_}ol8(Ktd zRbpsc8%b@fFv~%@hDh|v6ScrM*nbL>%nEB*f(l}pN* zU}%iWaC}ed9Z&Yvt%c{83KS*fEck!ccJQ;0$Clb>(_O%jit%mqH9a2BJr+J@T5q+U zcWW21@WuqARGcHu*Z_yN9ftUHIy<)X?zXlxD#=c0x5QN+Jpb`*K18;kN&v5gL-J<& zllihIoOav$eXu*uXn&gzDs) z+MJ=(8+){DDM|XF;$_*>H%zLX(-smZ?7nnvuwmzwQoHtqW%aCTW!xFyC*FXCtn?1D z_*#7icU?-ms}e0RVPjyf$(663iIJ2~VL0;gU{-P44@Q-Gr89=Ie$ru(HK>a2&Q*f! z-@|O{M5^=PlCf6N0&#e+P&pAs}hXcRnseLshCuM*?fm5j? zHqT6rD6G*_Kj9t)@jRQvuGkK!epyGN5Ai_mdo$PU)w{44Wej zt(}4@ZP42eP*=Nr8r5;?2|q&sp4DGpT{>@DGv?8Rp0uTTgteR8GMQc&TRzOsz|P)} zkD|ztllc`!3VS?4%wDXN>TwyCSJqKySuCljn-aUE&A*!H8a~%6c4v%oa-hMx$0R2D<~r5#`*R<9^CHqyAj0!aCu? z%uKM!)GeqL>}5-#gCR%?OE;9VoNad(yo+S-NO_}=MR>jMp1}QihaLtNZuNXEe|8vo z5SdZX$}ZOB8z~2liWe`Z(oUuQDQAuO=l-)x%QKu!HX-Tabd|#1bafzA$qO0aNf z68u*TZhkJhVgn8{Pg!kYp-sHLaL*ceLVwMo%i7g1bXh*z}!;>(JN3gK9xLSCw@5JjZ?M@_+ z#?6TXnon@HpQC3~D*%IZK=brx3B!6?Vz7lOEklvL39KTQqlMoMt^N|tz$!pduFlK4 z=CS|frrizPi}IzV-fUvgF1w8aGLe-=oP`(4_}#UbeJ;5EMmoI0Um%V*!hYq&^MQrX z2%r5lFgF$#5E{Q{TclROQ2l&75 zu6yr#@6KAfJj?Ikm|j^@)fViW;UHW0Ugl+%KN z0+n5|rJVZRFp8Aq&ZX5?1Za1DhdFZ>d+O^IL;zdk>Qg2#`56U>E#QL?q1g>pMUnG4v0igvSS7 z%zI;PJ8u}i_Xr4j6P5}Vm^O!!_OHJowS!zt$VJHmJT|$``46lfYnFzd;q7J=PKVB{ zG`nuU6S_U7N?igJspodd=M%+lS{-|I*vQG!okiNf(QfeSg?Eu^equpb#x}@2=j+&> zXHTK#HA;hvJ3Uo!KiR|Se{D0nwq&P*2io1J#7{8L*7LMw0Nv>qUJ<~Dg-6V@SN!~* zlai77`};R-o#2Ow@bkAa_a||S0T4By@lftw9+%#tO=WeR6VCm)wY+Gr8x~;&j<@@E z++($Qxox#HH{+TNFLm2ed>HQVQb{GCTJQ@9Sg-ctZEkLkJObi5$s|AGrd3p67mfw? z8En}FGZXE-J@sL%G7V^1|CbeJ1M%sp9VoE47_EHdwC6jp--Jx_Qqp#iN{#iXy|SSV zkv?z!s>)Wd6TvQEX-W$ zgy+`;gr=xpg9nyvp^&^sM-LR`YqZ+FdV=dXECKr2&t|(1)=FQTam2HqvG*}}!Frhu zKi382ob56QWJudGl_QEx5^sBVqw&09rGWhv&rg1p830ZzPk(ya+(pD?C7GaSMENc5 zTEHCg?KZA5w0KO*(ki7NVq~s=D%C5uj)T{gw}8gE8mmGZE!j{xVfOP&GBTTs#KZ|O z7@Mf(LR;)W@6JwPU;&F&^KlA-iKhCKlV%-%#DwhOvpFSAmaosEnMQ ztG?gO8E9!#5}8uA6}5z7b}z>y5)JGU9>L0)1{_m<*iuR1MrKGn$!~1C9foo_H}8wv zbuEJx0vjqu|FHjMK@UzEW=`TUS58P`cCC}&n=-WvlKiBfE-3UW%poxVO2lT%WpVjM zZ7r}(CmD!l7?zmZ55Xy&c>)yi+6XNeE7{*t?>H%3Qqt2~_BJ=r4Gy8SxWWwA1?7=T ziT++#=a|S$d-&850$k5<9lwS*ueo?pb#lGoqx($VuI`{toAlWnXfA zNq826t{=W`i0ey(KD)c2C{U7>pQBlix+WYYze{PK=1*Wsx>1Y>82!{7GL54Wt^sJ{ z6%p`7bf39U@0V+Zed^U%;02vWWx5qA=e1Hyee8h!h$pKwo)K zo8ERlR3coCqFJ`^yJL~+?+!rLHTfr4cLc38O~^)u?g&a%M-)mha{jY+j+Ih7y!Otm z^BlO4*!1ylTJ-D1RI3-1SB4Tfh%k9<@!F z$<_Nwr4B%kccUtcj>h;Sd1_oJjuz1TCN-jBrHDV)c4}k#|5M)4+5cDFy3`8c`zBGI zR;4$(6lfbbJL#9}x1aouWs$Pzh%#SleT}AR2^!0D?tQtv+=ou`s1T9-Nt~% z{)RqcXmADh{}xUaP+>(MVg%9u6>BQT`ToCX*H4~DmRiFU&5()LE@%mh#8GG|C#2;&wmHlpHYHOuzxw56 z)w9_%tEPy#WQA>=vriu-eGw%rRgQyI6y7QLW0q@vp4dn+%8RGrkmUe-8V?^FE;Uuv z{R=msB*Wm)5aap+AVGov5Jbezj#)A@qO-FTxlJ1D^FPhm1g4}Qqov{xvj801o0hAA zez>~KiUfbB$2?@r;r9C%2V6Q;sFIU=LAT9d)WVegsDN7AbnWh zf_nu9O32x^wf}cWbmhqdr24Gq9#YkUlhK4oQdJI8+Xw+wczrW<*yw5L|NLoNr7W!! zgo){g(~M@HPn7SgsHusms}nADgaU3mTy&tsSL^d+X+NOmm$Z^nhy9p_WAc9X+TI)1c zVK zhcN=igqnoCE8XydWRp2sSt8Z{fK;cH|ASQN_s+Ac#E-Cx(#NM}k?|YV)^^;#Y@{5k!Np6T z*d@`QAg@@U*?%}rh|Ob7qWZ4RlN28xUo|=Zd_o3<^5pTz=xBISl7$ga{_8ccwWP(J=nvv*Fg z4QHaeA3TS4t??uNyCuc1u&lhimoYuwFq1Z&V!+rPG>@v6 zF4~zBuY_j<_Dxxt9nApX}Q+bdyLgRdDQu*Y2?1nKXVcwZh!@80AM~_Iq#j6l59!V+*Jq*c6(X6CURyJ#NeUqc3=b zFuze(`NKnyDV~wS-Hs-;>9YrhZ-Z6n`V*zN=e+4^1bIMVPq{0A&f$>6{g>GqEUAh8 zrdc8H1VA=MU)_)bBCTxm^v1`2&(#R^(bK}i2y-H}FU)MdjbWle-!C4htf#F2Xcf$R z04BPux>>WG5FyOWeHBmC0Nhi8heMNolcGR7H?Ol%YFlisHgL%% zmbL$+sI6KgzV2I_Eu+;jW}^Xu)Y~m3v*e;_F#wL3KTk?4>LBIvj+3L|`HK0$?29 zr+DI9f1W$C1C$0$z6H9&Iy0h37;FqfTbun|vJ_(+cMB z%3~GH%eKHzb!LwoGizKwxIKP?KhaR`7x^^wXi<#*aC^}#hb>Q!&lq&csg#Mp(b?@8 z^c129+|SV$2t@g#?e^@zPrl<L zK)m&0hid`7Dy)g&P1}IB{UcK)i{=QP&PgzpBtqFmW3AsTQjceM8CQ^2#fl9UUqUk7bKd$=-n*_I~C4fVoRc z@!7Kj22+CX{q{#`61AK2kXyl~e31t71B^aPvNC3oU&|lLu1OkhEWVed>9uRy(xO z8*y;){ioUC5Nol!CsC;Z)vI^ZY2>W&`q?1cPH0_RIas~_bQ{tO;f~lInH}?$5Vm)8 z*-qLfEgEPlT=_2+V8)BYiqn-#34FYC0z|wWUC3#ibM?=SxhAIrV4L7?P6tf4&l7ma zsqNLcL>~GTL2uiO)c+k#(w|5?#fyr=7^tboYo@^f$P)IQ1t4#0t=n#VqG+Q1Q?Q#e zk#{KlTee!>Vb0(-0L~s89nMYETT3ra)&|>`8p>)Y3!0tEPHy_6GOp4tUDUpK;b@_M z6tH5JSQ@EsJG6cv4SMp%%=Gtg#cV%}#Jz!M{N(EU2jBIRx>v8m!a7375d9rzdlsjQ z1$`exMwsbu6DfFjC5P(aU0SXxK`aEi<6ZkxB~T^Oz+KHr<>``_(Cwj#Ps1*M{`qshTW=-Giz4s zi^iKguhP&lUuZ*t6GHq8^JRyG2JjwWwgr7uOjuG+n5hiI8N2qmJ-f7|znT0K6CwD| zff<&oSZ7HOTC%l4kpxzn@d~B0q%x76QF(u!FiEZ0CwN23N-Hin=xT3&za7O)wJIUT zoj0%8UvGIO!g)c}l8lT#BF3{iV%+(tN?Gbyrp%abE7zS_rz0w^D+YUjVn=qZBAwCk zD={qIqxY$0xW346 zGzF(?^Du$V9#yKAooXBMq-2%;t?5mAsIHh;hk5=RCa%1M*k3(fQpc6=V;g~ZB~!F0 zF%NpzQ>OxAG)PYhm5s{C+!*5H`EE*270b0f_&o#aF&;&K`dpOBVwsPv^Nc(6elz>M zOhZ26u`OQ)YAJVW*+f{CdmM_hsUm@|H4>f)O*Uw)rWDJ)Z}Xh4@E=`wuIq-cwGmb94RD zPJcQJP`-hrK;cRSV4%HkD5L%5bRb4{s1IMpdxf*=zG~D3}%r-`VCH`+g#=V`P0O}0P;QhS=@1;UV2wSD?{7Yq88D)F@hkE*W z*pfT+56X#N(FS%dz*)&xPVV#FRWWUzQNNKNN)vZ}v^u;#dxRSU@rEsY1kf;G3DJ%1 zKzF31`(76L?nj@-f0$9&+g#n*Of|7P=fq1+7NA<7A8t3V!7k?Pb>ZB>;sX1kpvni$ z1UeD7-5V~5k=}o#GrQA8BjoQ%ZFfIz+)bda3sEcbJUFm}q@s(_htf;--#HeAba_1z zjfwEf4o>vI8_xG{GK_GTNP2Cp(jUPFD48Y!AI*KUQOG&lc{*QhJn@VQpeG)L`3T_m zR%vT-LXxM7Gz|Ykl;$eJ>X&x_gpP)kROk76jB;$Xg?hE_<5W zle4Sc`ymn|tVcfme(ii@@BH4a(k$DPN+Jl25II6HMnxC8Kpk)P!;&l?CZ1jFM|!;c z23NdalAz!BE168mWZ8xA#EaXkM70dF;C^>^z8YfKlk>gPfk6}Bci+}cy%rmh6D;#+|;0z6A@$BIH94XR`^@tqK>z}AIPtx80B3960W{Rz1Po!%3sIC}2Xbr5EAoGkOVD2+` z#VS)u`s=3*a{ODuk~{pUk^5Tleky+Vy?7P)5E0C+S!IRJ&_3{nSNMM_ZREWWiS>PW zuz#QnBZ_dZ^c+v%`uFm~)(*=}zGt7+`U+ec!&nJeqgoR ziUc5_1q7DNoFTxYewp{|^9W#!yst_N8FE7GPTS-#O~kP^NqwDl+LJ-+)^$-JTm@)4 zCRB5CNS}wyEAM3!|L6OdkCsF_XD@rtPofAQ-68`?iq%GJO>XW)%QX^5??~Qb*27}81^pQvC zz8fa#hn>;K%&aWj+pD+8?g|fo$@{|{6##wjTWVtNCz0WeVg6vemT_vippZM~)x&kB zUTtB7B#ZfA%N4XtLMOhyCt^3vt1X#DK{a9gsZ0OHNTGm*{rG`ZDF3fnB_{2G@TvWG z+ssR)Lu!oxO?+Y8U-Z+>D{LV`3@QqY%-lO;7HHWDyOU2Jb{OR4#8eKD&B4tx2Sm98 zp-Po&z;J=u2Gyg*{LAkXxN(-(y98@b%u7*qg;a2vHizai;sz>Z9Z#>kI~Zhui^R=h zrop%->Fu`Gw?=B@NV@cO;KnYwzC>awEu5LHo6JvZNIzBKb)GX2P~Z1xwr@8Hysl?&)%jj&@vS#60fpHzRvpfBnTsWlH?Se<|#jZH`yCfa?bXgCx{;i2TlB=OfI|AA;JF>= zz_t{Cy}HWF$__m}l|;l`>Gmt)b@8Ue7U*!zR_FoSb!1x6tae4NSbc4?PWmjy-7wpd z{#k}Zk!{Npp+fi`w^PXEu=$)EC3)MtB|dEHh9rv3s ztT-J6knBLSK~dPTP&;I$K6}6ng#Q5Z2M}SyvKCP>oS*~Mn4T{^wmFMX7&BE zvpS0mB|;@Svz;WEUnOYQ4&89YJTlz#TZ>ro$jAuYRZ?0tJX++CR-D_r21hVO&(+KI z+=Om9-+mkWwYa8lYMgc|ObsQ>Up2E8%aj-_77?$0>(%A&Acs ztG<|A_1SUJ{lrLzyt-$+zdcBuV8|{)VCQosm$ZBYMmmZNEpI81t2jBdi)gB_*|6iL za%Ov5X`!^moQ(^vaK3G2d%5m`!}BOZ+{$XIum3KVA{-<2LVS1JgIVP>mSf1nVR6q)~ft4tHp6=vP5nrcS^1#kZUcrf*jV zDGBafI(Ap;M^qRMVkNoG6M9^;J*o2qy|KCh?k}fhV1F%tRSdd4Q1UB#YGm=Jg&ERM z;uXMXb6ckFakb2F+HHwb4m6}Wm+1|U&9Sk#KS7{pz z1~Y189HM|JDQ}o1GMB0@JR6Mc(kviD*q^$ymJb9wbZ({U19hjV`{6ne=3A&lSoZqd zjlYaPl|+>BTmyI4dTZ*FE7XFTohkC^ICHx+Nl3f8Cw(>)YHml4T6>PMt&8>-y=WnJ z|16_ZzSab5Fm*PQLc3-2igauaUI~oM3HIJh#ua4bpU{;_tZZaOdcF?Uq%6_r(oPzn zA%Z6No;s&5F-1~?3lplptJRnSq{p7f5xEdU|MasPoxa|hN#=|uT&e&AA*vd3i=6+@GgqVF(CabGSTI05-?Y@BoY zTvqcSAj4qyw6;yBY2o9|;(OAV(-AS>gCyj`8G#64>$AcLxq^IX0l0r5K=806ahk1z z+h~p3aaX-^tvv{Dc&F;K#B0x4IT5FgVeA;*U7|4z9Hg>@45ukLq2=~Cdq_5utw>dG zgQmW|KHxXVQ=|dB@qT_49Jxv!GoM;Qv~H>PMu4MSAKkO^7+LSQSnWEwa$MaW`RqpF z@u&~FraQ)J|K6vg(%RbEjxnFv>lhCM;+Of2>4xL@%|T(RZ%Jw8p5&L|6PB1#%B`cn z|4{*!BrC6|&^*6+wA!iX^U@tis4DP`7F<8Ak3AqsL8|w4xF$Akuk3ocC+E0|tRKy9 z&@~7_l7ldWBZ2uK2k!VUfHiJ>xhJO8`>WXW8Pk=@CX@W_I@3rLut1GJtH6XsOF-({ z>*r4{XAjU_s$(b_BTwu?E4J(@FwNj>cV{JoH~xTg?b?r^5;ggA<}g>_BD3j9vTfQ! zMj9$&$1jnCVa-pnI5cOf_BX2ir);TS6CjnHv#b7D(*ZJUqKq9?<2&Im=8ps*GO&1T(rfIR99K61b&-eow_a0>P0FjcBiihcDF)XCWn?g%a90zdW zZe)f>$beLJcyoi7@liu}S%`1U`c-YD!5*#|5)&TyEKtd6H%Vhd3R@A7LVFzq{$)@7 z{gS>0r0+B{m6u5}^)cEQ2D0fEv&B)$%_NaZ)?b^Nr4GC{t8c;-n_@}GTl`By+^ z>=q)Dp*;EFlUyp5|F#ei`y{KDadImu?v`)N_-OmKvcu26Fn!~gJ;VK$BXtQReY`|f zRP)}LlRJd^;?^jD!hl|nlPQ|ov!NFgawl$5ggo`C#Yfkrp0iBiflb4xqZjEk%L6+7 zu7Be1wCeY<^Z`-Rui?f>%GR^&bHFCl}aP zK#SYYe@g8o`+Ik;q>=^Ge1=aSxzUKSFSbj<+FT7kbWdCDKt*-^gN-^XbU>myZ*i@W z-gQcWiAwCng!S(q_@2*mTiSm1ZcKBt6q6)EHPj6bj%3IBa+CEH9t?~ixg$-Kw%bhH zM$D(uud5gVQz@;q2f%69+ktXGAkonCM}Le(J-b;LV+k3;rjLwqKn>2h?eokFDgM=e z->D1?x(mox&|8~AfAyG0=(yH)s-&1_)SN5e+(h4=b)Zq_@`$;6kw~4$oMK%?YvGJ) z+%yU5+jxSJ|9KY}uK)-|c=Wda@%;H-h+K8W$UHxAD0}soop}oAJ=s(~bZ9=u-5$KG z&=Yi=BOVx6rm;7CTw8gTH)uWCwuo<9b6!*&oFgu zup-Ao#Ocb?^#xyNvkkthqn4vCTfV+objkk#yZ9uua$!I&ud_i3PD@Wn@^3mH`WI4o z_L^Z@$d_f$_T4#&qZVT0Ugg-($n@}!5-+-N>xghJM|RdRtNCkg!UkzjpUw|%vl?#i ztr^zdbjYd!E;kQkIfTVR7rhI2@7wATGhW<32bNEcb!DjTyp70d2TV-Ypp0qtX2O%ya=xZ-9lnMn#%Ig zZx^-l7l66UNE*9~UE~);YdG*Un;YW|C1aSc= z3|Pyj60zoC|86+JQ(eq#bA0~|yFuE_iWItwL_y``QV?NYmx_TdS2YrIoZp8b_w{XwH% z2vKg+Fk$neu=Zja|Ds*g1LPF;=oOeQX+ugXb6rSalH%tl_RD}N}2i_gYqV07i?n!oqOoz?sL5JwS!&9nHjqN#q$$e$-tn2U|N7f ztXAt-rsZ1-=5C|4>MjX9PA)Yi@e_skpG{MB04X+Q;dUC#sYKYROP%`XW=VlE4#-rd zaTBh84eb)%eh{u36LOEhGspr^GN5%@t=)H$sasr$_4$muZs0z11fe^vO$rRZEeehc z8aN1s-$q+bb0z8lNgYr4s9{9O(zC9*hw*=0rS)(@D`t!A(vTw*qymPICbgeSAX%oq87+fg4svTe|d>Pn7WjqZ`Xu4l!a6{mY8V(b2!#R@U=pmR5NjKUsJp2HXu8tpyP=!XC-(p~)yri35YD#@H6@2&1vv zEhm3fL3RAIZVCHfL+f4hAg+P^NN}(zSU`$qTJZ$ zy@9VNO|bM^A1s=Qtn6N>UIQ1i-L-;Xne5cg3mH_`r14=z&~;4R^x-m{vJP{SFyQq1 zV2cgr>l=ahGg;5##J-~AhBaimEot(~@D{=y$^KYzgWK;ys zfanMK^YUb}eHEMaCfnyW3cR2yNch-f{9w&X-Urk#>r85PpeBZSBG~rek`85+1$~6e#T_}*AFr(qY@c$lvD4ywl<9WKv!=3~0Ke`s$B(u*w z2?C;8yk4O{7vn`WZ^XqYDqg{z(tCz31<}Y1XV1oqVRPKAvp20Yp<;^f`UIP)vETb- z=3wApz!+inNq1oU8C6KrBLak`Z{D;zU%Q@@9e*PqaWUgA8=*^7oCD({iL56-o!N|b zYRJj@l0V0>mb`6L_UGlE z5lZ0{x#IBHLu_SMd{rM7#!V#$!P=ql)FZ~9^F&TM>8PKI8P`vzSkq0WGDbcMmL2~w z3EAxEz{kQxOQcOL6R+6RT89z)YH}*K$Bo_f|4ra)Pl<64^l3&Tkr|=<%AV3@6WB%C zn%dTL5D)at2)$gvH?5rOAH-}(q%kOFlIqY-qR={aE#H@CgCnh33jEdpN0`8+c=lXImB%x4J^3yMv$@fQSoE^3yB0J7S{qxY~#J4X^e z?B1z(S5qc`p@PKL5T)0hVEtP9?h~Nqfw-@*Xz&!NYk>xzIbY6IooMhMK~f@Y5QsPV z97$US1@*$NZiY-`Qu*@%#;>8s%H~W{sySGgZSdxi^Ge=@w58ViFA5r^ynq*X-}C!< zUgl(eG5qC2Bd<2cA~@?pHa{*Zm%An~3UdRWR4TzIhbN zCP@xl5j+OH;{B?8RH_B*K;8Ix&2UYESfb#5{wt&sPA+1omNe+tP)q58ga7oE1ICs+ zw(QlDcD!7|%h5m}vC>%X?3Wa_Yuw@3Ds?acbc zd^T*On@+b1RdiJ@rw5E!tgP!sbyDI1py`@8OQ+?75Na0S+G-c|w3bIHLRzJg3768a z;15;q;j~EInrayx#EyQvLw7xWGtw?rTR#8Jr!ZG2dkgbWxLFhBKJP&5PBIL&GXjCc zU;BhXCnkSEf7f3I=IkC*r<&D>Vx;d9)XC~RHjR07R@B3mSvymfMDxf>tl@n~ZLaOf z2j@KF?*1-mAAA1(%h2P?0n_CGJEyzeK+}4~Phb4bZp+5sAdp!(d%se@8)B#YuNy@y z&x>!G%B~tSf*GushCRQO+D&{f}hiY`OsZm-|lJRm`wCZvG}~-F6ki)P{4r_Y}Gh5PZXt z!^w#7L7?zv#=}wt!?{95N1X!EfKp2jKT`!wYY3s~FrOzoNUXWYQ5+@zd-@f+LPz01g;HB4&vv_s1Y;2?s`9gN6$2r{P?chkAZ!2b9sT_KUqne#O_y! zBs~YdsOgyL`~bH!a({Sv(g}O-xr?+irlBC=9$%nwDLnSUom{M26w7ivt7M22tDwl| zrU;ZWc^+9w;T?4OB<*c^MOpR)+lh>&rSEjz64*ZXRJ>f2(D}XQ_QO7VKD4@LIa+vo zJhoY|sR%pEExSNtrLosFNo#ownr>;9zG9B7FsZA^S1mLT74>2>Id19-%rV}RHrMwx zp#jS;y6aEg9y>4`QCRJrA0P3G3|()9lM*7mLU~bkyMm+N&z3h_Bp&wy=Wc%A){oov z++|?5QstcBO?env&i(HVEo~%b&}Y4$`jo1}GwGL?xjFf!((Zv`nj+Y)KCGBH)Ge+G z3gtK_m-NHOL*Ii_UOuMvG*8s5@^u##FM3DC#51%2l4<&w#~6!7eAiErc;t0viQ3b@ zdrDmg2#8OmL7(}5F6fRuBf1%^sa}jobJEAL2v5zEr!XD14AG>PtJfSfwI5r-Y?GST zw7OW0tc+*f4?r|ntddDxGR%REEG1UAXBG>>>+6KP!(P0+pUcyCa4G%E<6F3VKU(5< zYOC8GgM24#fqS~?($2wW&u**^5j+~SfU<1(#Kek%g6%}xHlHQN1j{dzh06A_JIYG! zf)&###B)l3>r06tNLBCE#C>OM-Ti@NanAmY>Yj1zg%Bsk~z<|+E1BV;LNH#djlQ=r*g|vw> zcm!Wq-pEz&&dL6^s+Xu$JYwta^!Qv{oa|~oyHUj`glM0Cx(6G!<~?0zVO%7=^+vL_ zlQs{w%*{VAr%1;Z&=6;J4OuiFJmCEzb{#K65b-*OJf53sXhAr9GTt*g`jU-81bFpz z2<01m-OXxi!9Hlid)txxw2>5HUovV=T^svr)!-OU(t%%c)HI@`L?LPGLB>UUIR&tr zx=(zP6A@$YM$s>BjF{V(@l2GKR-jp#OFv{4BVP^Erj$3J2Qvt$c~ao(hNqO?|Ioer z5fFXT-R-Mn0-QZf8N3{YiLz=_Tll-{3I;O9tvYdeE6sSYq$`3d{P?nx@z)zx4eJW>j%CZ5jh4BfGe`m}{0N9Gd=U z;p4DjSp+yUiu|^3Ogw06=Tz)Fn&q`egBuT2d|2Mmu@a~;Y}LW*#ECCIHIj46Q}d!S zntE<+=r@Z5#D5`iX|FHG@nrc@$ij76r?}Gss%m0RSeyKzP)v<+7tLfY*o$ps*-l=?_&BF)rT{L7{KG!q`bSN4@~iM>Uh1h{@DuE>XVuJ5;zm^4RE^*Vwy(@Ws%Q8(C!{lB z)OeE>lz&1F;-Q-I0W7>LTWrr@H|J|HusxztrG_GotW&JMzUtYzd?Dn3s4({1vDes> zH>Ibj*RX+H{XfGew;p53=YL{3rhMUqv}JXePDJA<@uITGKTHP7^*xR>)Sf+iX5mW9 z!NHN`?%NVYqZAny_O|7tUa>NzMNV>+CF|C{Rvp>e>e_K4P{P8H=aM7AZHqxAh#z1B zZ!;)%;7Uk^?HP-aT^W558X0ewaogL#4M`LfX4BRwGLeAb;LrfHL*JSFLsUo6a2*|5 z(Y|ra1;586lCD0J6i;93`a|UuBY&24dQu72a^vG0C7pBN z>);b@MAS5VU0ho((@_cf`CJh5+{&eBZ?kn^aK8-!%8~a-X{L1mVCsNM!Bf7Y;^NmQ zXCsnVgmerj$D>_)XCmR~;fH$V`U1?94Re_{Nt6Duc}Oav!o7h;;^8A>?#84UPFn$4 z=YMM(G8J`oztjJ2YfFF6&{=QEj`C-7naP(zL{ME;-t9me*ap3Wyds0cT99eqJEmUd zkuN(|$~a3-$!|c%(3P=qv2KMw6H7+JLb;CGaL+Q@E+o&ueD{He<;QE0UBwu|{9p`l z2dX}GdklK96S)XOhMR~)r3YcZbRGn|=^ME*?JK$7KGHF(INH@jw<^vuHxZOdox`+F zM*InnMDmMmiSU)x1eIBG6~PiRf?pZ!m~8t`xerfniA&sFKM@Cqgv=hChF#I3Jokn0 zq$X-!9Yem4i32KLL0szPL9yUdN&xyBU4trFpU}9JcO=K4MpGwLMyyHQ*1y>1crO9D ze7k!b24yKQt)9mDGpMgJTt;KK`$cBTDzo3-?Y)R-u-FJj)zqrl+=~5%JX}CTRy`;= zbJtb*pt#gCja$#?$x4mP(fxhZm|F)|+ z)+PZ;LKGs?-q>VoRYRR)^>qqo-3O7O_7S(JjK1rpb1H7=*5>Cxa%wPxkQ(FKhpXuw zj`66aSfC~UB6hrM3!2;3ZpdHF~+gYw<0-Z|D?wyN9>-n zJJp)@;nthjHGBOrLlYZBp1<^>^-^eg%3wpdH%}qK%+BSXEhdqkrAhQ&j#xMI3zHjN zmk+lGQ*=+ulawXJ9WgCsy`VgC9!t!XuxDTJ0rs;Vt7i0ulxAb z)2r~5?a5ccV>CK1B+FVc>8!;C#hd)wV^a<|k=`7p9JmOG^WuvRr{3;bbvg(gV8p&} zZj;IkxnBQeGE6db5wfN8WU!r)rxbT^IVnO9+p^O zila;F>m7n}{IX1HxQSI^6QCP}gjsrK9YSqM-~+PbS6K$W<0g;#+toF9b44@a*v zN^Bg3*h#ZbCcoNOBu0}1##@Qa=qIcNzl`?Cs2VZnt<$^zF5QV#Ev%!U=7iH^)71a| z5pF5Oo{~POj*(#J{$Y;_IjC=9`W5EWc+8bn8VFZ$S5$ldw6@D|*B zxf)~&)g<~nsU{Fohf8Vc-?2~~rk)pi+^k_Rq=ynow{OlH*db?LRyRb0H+;yc_3a83 z3uay~_@%Y?6n2WE23du)$IA8<8Xb+@4E)-OU!?k|@G}tF$9bi+*L}2Nxvu@SRP%Z( zH1c5jXwqTH@RDRr4mFn8l0zC0kT#sL| z(VjckDxo=xb7TtnPM@n&|Da-okgdp3XaznaH|MP4+oM%^k*1*KE0v=S|I?-*C)dKA zAD0%AYo(Ncd|1Qla;{hkw|MC&63&>PJ(EuUz*0iVyqyWp??)0ia~&!$0OX4^nn=v2_D zyAy>uE!^KlCZOBPZXOU1Pbhaz+zhaMdG!%H_vA)GG?pwA=uC7-7c#X?A5yv>*ol5!^iG_)3G zy(K?t_9eMFL0v#!lpM+P1jLA~{Wa%%Kv&CF?-0-xUmDiN7@kfkwtNM=V0*8{Wx%bN z+xa~y*pql-steq5mJ?v28blM#Yk!P_L|){<@q!uJs4|T7MO4!GAXfBk03dQFe+E1h zY7YlEcl%+h=rwUP@lk)Wg>woOwgNORcWu*O7&^`iMOki6q|B74WqerW8jgbssSb3T zqi$`~3jI)J+e<4IO-$0{j@x-|LR5=9Q*HvNWJ6{4y!lwlQ)9pJ$5`?uRMYeEjtU0{ z*`4@ss!gNx#l})6j_7~RY;3?p45QG4pKvF#-B#*MX9x+Yg5^`fe$PyVINL6>a&xZa zsY*+B4Dc3l5P=cZm_&c5s(p;>Z}Jayu{$^O613e?2!|%m&3M4&p~LnyzQ^F)StZ#7 zja!f5mZO5=i5=o0uoh=#)8O0dkfnp)qsv#hM|-ALcESVZ(*-9gM*}mrQY4;P1bo~s zq_^LHkJ7m>$wn3YQ7vaNbeGB&JX1V4T8)EJXbmDuEqk93b3|-z znqXo6bdaFuyoakmAV=T3E!R4B#cV0Rk?9MTjdvXKI#rL_^Awy??B`Ks#591va?Dty zDpL0Oq~x74J|2~A<8H0^OmfGDXHg~VsjA zS&@d4R@0APWF9?yu~8P&XE9S?vo38+)7p6NCUaTXZgZO+{C6Yc(S@ui^X;Z%zFg;d&lJ z9*<>hi5T&4X${+Zs$;|_DWkB}x3G~KEW_4y$c#!DD&prz#+SK4ZJOKnxF0a8=;R`| z5fe3)46gyIt+p;bd9g*CJ7x z2_gd{14g(_Mj+zD+`hf>&N|;1nc!Pf*dw@|AZ;PVJ7uQG;WJ;GgGp`egzJ}|5?Z~Z zXGVOQGI_DdQ|=i2j-u;tlCL5^Cm~3NMpLmyLj>bYT8{XvsJ8yeN;D=lS$Oh)bVJ8T zSzP<2sx~>Y9ltWB855_b9!{a@mCW(x6--d@`m(JHAX#ZmDW+j7KRYRCkVZ<%^T<0J z4N{lG?j~$lCo8M>g))w?cW&XYa6(e=`L|`~AwlFveBH`>Mi=sjPbWOagdUDEzpbs+5cn z<=gt2eA@thmd7EEpBsXSIo>bX(Zlq&4^#pvgSw+W`&}>UWr{A9C7C1x3%P$bJt5Gl%!k*)7xLwQyPqNjFHBPqrKrD zrhc57=3FcJ9*eqWYq}K3izFjqKd+LDs=EU^M8lIc)P~52P`^==ilWS3w5 z2uaLUh$k1*VLapcZ^6F(>Ju{s&P6Jy3Vl~@h8B_1%%%-@m4nNP?blv{wG&>?>JqwS z|JTnELmkB%J`Dq+ppRQ6fJujx@b$O!j>z0%)eb4eP0>QB5`!$gGqU&eJ*nqN|Gtj= z%13}Bzx!6UDku)w^aj+87Y; zC`WiIQ6*p3Y`4z5?`3+;D2!dTe8dT+M8uEs*qVdb|0#!xCV8r82=>jEypZOy$7aAw zw?{rRGyyYlo;*qJ8|k^Z{S{0pi4tOlsf3P5fM48J`pS=$Xe6ij;5cFYJ=KbL8K_h> zF*1I0CQQv!aL9Vs&K$3q- z1H9B=Ol1p;n4;KZW{!X87)E36&o;XLGp49c0P=6MYbgn?M?7^l8*bIDx6WPP=M~!_ zytXZez<49-k7^EcWXfbA&6{1U*|3cVY3?pnhJbe{+6V%Hgipm$&4Mf2_%lTHnAcLL zqOVKWdCBL?ohZxKx2kCCUQ8HT(*+%=sV_hLEpw@+fil%VrpdZS0V?5FRk8dZZ$Xc| zz{AuXaByL?Uv>4;vy%ez&(>KcME$o?2AWP5sGJQdYhk)Uztug>lt7awOD04nn#IH% z_gSJSZby7sC1EjD)|!keFGQ zP0TQ^;RPTWe-%y@TCi*HP=A0zeL+My5Eq^<@&oGxeC`u4tn$q+@3ouWehL!gBY-^ymHeY`v-FK|57|b1((tQ zXG&l+{nhpYWeSp)Kf@Glu3y_e{j(NN=V`+F<%7ym+*&R{s7Z1s646mkka+pee6xAx z6kRVd?7=r-p8~M5w<-XdQz@6Pr$ny<9+{{`A<*QBm-njFhKFym<@A$@}Rr z1)6Bz=P459BrJ8Fm%Vi7@0*CN;S!wD8xn0i^$*t#kbw@WpL|mIJhCV2^zc0_qOl+S z9%bFNQ!8Spo)B}YqwGcPX*KbBMH-fCdAXw7$7BZ`b5rTV&^Xz=*Y?eI-YbTWAN`uv zKGG!XDX&3hD4*OeXM?w!OlXiS+$6}PfTU+;zH;g%V<Hu^68Bz`Cc zG?62Fv&;_gY(CHxs@L_Jk@FJo+M022%m?WE<9hn?@74}Y%(zQbxlb~;EOKx554AL5 z{u5_;`MfSXo3d5b)&a&M;F%dN@~m`wP?8zGDW?pVr_ab9c&Otn8x0Qg;b7*l>1%8iC>)=ky7ZysB6wmRj_2%zzgfmw`!BnNrwY=j ze%>wa->3f%4`JVz&D#Dy`^?T#{&-e)1G<(l#rdn^4>r#Nv`;aV3@WFS$w_G{H&uq` z|3qwZ0p*w*Y&d`M>a(`1If0M{p{^)q9cu<= z+R^_br44~KF{f}xq$r`e8zc7m9O|Lx9ygn>rKaPg;Q1H!Xk>r&J5{a$kQ8pNZ~el_ z^?_$iphp*eYtQ>*`^U%k0iWJKR}VWcQKdhIfk_sGDr8<85|xPog!76t_j!P8?p}wk zZNLf}PW?*#(%<|J%zI&3KoeO9Dc7H(zYxiensqR1)Qrsjm4@oG!1;ns)~zj7dZVmC zpQc3mtek?DTXrkoyQG&v7SH98<)wf+{yX``%L^2KVeD#?Z`!0iFa%Y5cKx$6?yqkIvk@f$wgoFUs-R)1(-V?5Biw7EC(T*@2~n< zyw~ED_lkL@oqcMD{mwgvu(4nII2F|;ck|-Ke6C<{gVpu&P}p2c?)7X^)r*ZU46)^>yCsXo>*lP(7~Vhuo>wGi0w&UPX?4!8(Jw8dd~M=yg99$j}n;*Z@^>!-`R+X(nPBY+41zH@!mqX|()$ zC4j83wVLJvgtKf7_4WASU#>cIEAxyDwA%)GPcwrFHQyT^BjbVBr;G}pY zTBPGwEP(V*geuZwwr^p^12{K6`|PS}YDJ@COOeZH$K(BBB=BI* znplXpVC%#n)#V=2s{IxumzaRD*)upu2sB@mv%zpvvkVwuD&Oe9+e@ytGwAN_3U{S# zBOOQ4y-i~ssSJ39*>Nmk4Wg#@=8{c1Y#7QyT}MfUI@o0vH6BEMMm@s&=D2TfCm5-U z)kzI9rx~R5m)$yB#ldsaAId5fO&qH}B1=1nYI(W*T}{H0|5F@U6yx z2F!b7q$K}SVR&~T9m>Me(i%I-Q0rU!4z<&_r38;0#ethFR|QKh@U{BSd77z=Mpxcv z3iS6E_eP*6wVC|;OMtmnXkg0oYlM@*+;y+}U+--##(T#^lW74}B}5aW<36=q)Gj$S znzQqI(L07)hF{@KR)^lK)NOI7^Z?prw3uySY{Fn8wo(dKW#knj2hSIfPDKmQ^_%>4 zFt3x%Z@ieomPE^GKN{kp`B8F@k!FS+z%SA~s$5sTFMbbTg44mM;VX2~N1F)o{%1ZP zFxdyC;M;jHgK5jHPaJe7HvNIEs+mzA%XftZ6p?1}p<@Mz+boF2Qe#a_X0SH?uBh_DV&z?QQGplN7#6DpA1A;czR!|5#-}>sKMcQ>NbNarG6&M0R zzIk&qe?NMr_NPXoPc6k9r=X~~<{KcB{mxw&a2!u(55{gB`^$hxBI=To^*Z!kp|n%4 z)|gBCL8zuOdJ!anyZ>gWPmY|!)|ngKtGV#Tay9-1yz-Vc@4Qk7-zW9ufIsA0CGE4# zqAt`G`*5zXhc;3kdDIQ{{~q{Mxr%^^aSFGCC%h8ZKB(*{Qwr;4*nBs@2p!&G%!&IE zvG7A2w~%O)MP;4uDfIoDS>KqI7bXE-*GNh))bx=|W7m|-;yUNBX!KivQ|Z6FL?;_Q zg!44nnyox}olaTCuTiG+R#FMCXZXLG$LtZg&Jc&-@;gR8-2W zd@#fRyOl|LvHJAiHr!=>@v;9O`|#7se;W6k?#9Bu$*6A9{6a#Hf7GY@KX*Tg^Z$hs z(Mo6T3WH2US>{)w8sWSuJa|-)b=qeJAc*dAThK3@G~bSCZvj2{IjR%4f8e@cB#_91 zSxw~jzr{oYtcs1?=lG{rlp+VFipB0}dhmPwP>;WXDjPSG>KMcK_&GR90RN6{wJ zry6_QVpC-M1Z{+j)~UzEuATHdOZ`86m}~zRKWbFYM)VIn5s2!tcZ~rflfJlvR|?s- zkX3!igMFPJF`n8Hze(`tV`7IUyUUPi``?WeT!s)4q|@oL0;OMg1GG@*cMBirRMN?^ z7tpSK9*w%q1aODy$sP57sQ7Zg{$G56$}H!H6o((cnt{)M17j{N>;mts;enumm?gO| z^A3x*cQ`l{;5ENsqLy2!3&Obde*5;l9&P35e~mC8j_W@r8C4nY^>}Kko#(LO#vSzJ zs=%PnMW;md{{`|eJH%;4n3M2E=xJ1QVF1iL2#-DbEUE%8L8o8b-2MoJl@%ndj5L+o zTV&=D~Dkp1YC51OeR{oXZ_JLON~+Xz^>m1uOIpiZ)U)? z-*9Go@P;_**lo-pWDwN|W1)7uU$_;WEOJdIUE1D~WYvKzJlF0ug@C{{6-M+{0&*YN zgJNrGHrPVhAAuRf{F-hEC)kJ%`pGIU`vs*qqxJB3rFD-gH@R* z^}F~f>KEUn_A?G-k5W7iZ+I{zZ$!)MKp=B#i;JXsWB#L-mZ!l_p1j(((XEei94K`j zKZG{#VGgr{SF(IlGHx8jy>H(=zdz1;5{iAh)%+|$S%He~o!{MVo zcz*(cCDu6RUiRh@r5`=s1I|9079$w`LqEkM_v9cAg{SZn#W$+Hl2BL?f_@7+`gFF{=HXxpgkVcv!ffGU1b@hUA8UoQZ~%< z(N-bbZF&0$F?@Zd!xjgRq_&VzI^X64@~0rpSR( z!zn-b`*LRWd*sL zEm+KmyzeWcheN6haj9sG#_;5cB+M%-^awh0tgC6SM+B2Ss;OxL_G#9w*{7%FR0&zV zYyfig@^tpDALnb;nL;_+ItmvXRDo4Gj#dNIi~B3aWzs035(i{(T4AC=oe9x;tlTZ@ zk3YY;pp}KOf*1)R*0z|Ondd+vTb14c@8z9RF`639*P630-;NS3PogN8b=@I}@M+$_ zj=CAUHjvJ-8#b!ACpr=;ol`8b@;}XWjb!t>#(+YsPpRlMFD}*3Xt2~4WrKEfj_%L= zB#le=U?3h|B+ksKIY^rMFR~uJ7Ct&5y?!V*pS)uN1lq&_N55Qmk=Jhjfi_7?SFJ9tP z@181*GQRHp4Nb{LZq}bxuYO&J{sf~!;y?Ti(((7#$QR-xk>V|QGjl=Z1pXe9a z+yhDgw$%o)HN(n;>Ku7DU>&Gl7a}#`-|%qGupH|=w<~7TIhc5_5QG(#>+(wEA_N%F z4i7tY5lRB_LVXFIwzY=!Cd5pZ#T7YGczofbfb!Cab$EPvpojd?{rf&c&98|o0)FY@ znjTK7SttC`_uNP#;w<$IMPPFRdQOiX9x%}4E3hD@|37ES!?N*@#$?JU+#xn$N1Nm^G}Cvh(D zXMwzlu6(2KPWCT;g;eq2=Rh_iLnW)pVDvGk&``P!UKQoMsbbfZdnm1uRR@Ny==xCH zD6MhOVL}t^p*r8S&8{2=tfqrT*6O>RW}noe0JJ9m#Fj@SZ0|e=aUp9dg|NO6A+yIP zS22k{y@mxu2=NZ=mQW8rm}yVTBo=T*;UpH;O@~LtMelTa?sN;UN5MozskuBtP1+E{ zDo(WsZ{hF0&Y6(CodUHn@2YzhE>YiWqBeL3C5h`~q^Y&-n0P2gDLWual*mx2$aGekf2x+tkro+Y`4?)Qak$CIU$g?LG|iok)#BwuxUy>3!Z0?qTe zq25`hVTp+fg;;PKsfU&_{dHXZ$bBRTUQ|N)zE{nzxh+mo%yJ>#mb=L4`;b7Heb~6! z8{)rEi|T52MVj8%Dr6+(2W(T;CdSuR3uJ0(o(C{fNPZih0$bg3L*coaF4> zfGem98|5F@AT$^3rl0CaUR(%=vqcHrlPw4d9rYrsrFhfY@YEORV6r{BY(I40;wrI< z+eV4fq{bb+n1rpUr;&hPs<$zTLq3l9{0|1Wo+`fgQ|LYqMXaf;9w+8RTv5{U$mH

pKP@oJqhb%^lSm%Gaw$mvo9c$gO z?D%N`1V%V7=w(Ax)cV0=c>$HSC=O+$F3 z#E-(Ma4srRzR>u4DOTO{&1errjgr3Gos`$wdI9+%$B63cDWDE2zv=)L|8Q#qu}YanlskPMWvNCk&iqZO47&dv zMTCb}Ht*hUTtAc-Z>p6?_$y+d46k&Nki!POI@grLogg}F(+?WZy1kg z9?hOiGhzn80t&bd!>sZ#BU$a;Gqq0)ZH-Rne=uJ0Cv!O)(fZZ}xacm;?}HIbjLGrk zIv*u>>|EXI&R3{rACMAdiq~Elt*i7J<}t;|7LF&=Px+#ig1|*y%NEGjlvcR3+(#oH zN6hVjZ-Ns}1PJMs3fU3tWvQf~_jX&R(dc8z;J~%Vc!Q&+4E*ByN%!&l1=C&wRo7su zl6vFgJ7>e24)N6VVQguL^~1bL1PkD1@iSc1T}yKJNpWm$Mf*4Ajyf#q;=gJ2bt$j+ zrnb6^>-Ak8_3hzA>Jr3x2bDmRBhnHzqDD4~F|kd-+FO}&LMVG~Z>d}o%1@+1#tKeU{rT3$5k7JDDS2%gTr54bZax6XXL zvQ7Ul1(=0fEe4T0sWsL*h7cz`TSQSa9kgxu3)w1}Nb4NV-m>1TM$~GSqdN4$z;LgE zDw!tLewW@P=j4ehFsyi_RA_}uIbjT6oafq*hx=f$!rh%r#w>kElenM)FM6mLe3;`f zk%rrz;ZG_!Urg?(mzZGnTkKn!Pof?>%kp_Lv!JWGtvmg`5u=_#pea|8Qh>K?{-Zo2 zY>~#iQ#;cKPY~1@`&v8$i)53t2|!KCZ0(ggl6r97vCXc=OP$#ZD|MIIt*v=lWxSfh zz73l}n{k*?1UXD|yFC0&i`YWQ5JXYH6V zQ39<^0Fyk^ND$wXu4bsP^GK7hK%tbpozs*v4X_VkBK6OVpd5d2dV{Kx`oE- z;hs5fwNbPFw0)c0nFY>=jKPE~)HC&cEj5g5W5lCiwS3V<=5Rbk zh~1~|^wDZ%x+4P-cZtO=Q3uhenaHJB>plz}t<73}pkc%?w3t!ZpJhd9)s#8gP?CLH z10d#2-1lR${*)thVNKk0Nef#lBUWRjdImFK=+Ublnj*9(IUihT%-8E~Ae@vUbktO+ zqy|c7qgVKv(Hqfuwm|@^(J+?C*8k~MApEth>1eRDu)W9HOQ(-E$mWARmH+n^kP> zB}WNjtjGtU6+v=hjk^2WMEfTlC5z{-ZAC%+i9z3xhPH2kHLG*=dvp5@a9*KO(9x?g z_f$beC>=kK39|Ml^^zuvyOBltn-iju2yrFw^6J+hZ$ z0>2W`n6WIbY{VkH<++q8X|H*2jM2lpe&wxfd6Dvt`F1IO#CdvR*jY(O-5R9+;jzVs z(+Il{EGK)YgoRqq3!j{e`{Pe~7}wncUul+$%ss}kO`~&lVKXG*?4^AQgH?B$STSne zy)${NUdk(Lpa*_nx;K>Z)o3sPh>05T$AI*8TCOcS3pe`I`<2rVW!JSKiQvG&1-M4`fT>+|GcbJJYWZpW<(LPPGg#9VpRlsm}PYI1OYALttn zT=)6T1?3ltLRKKl9@$l^3i;E@2GzlO7irS=eeUTGu+xJ#OJu>?_6d_m6B+)fLpi4V zwZ>3GL6O+l1TZdOId>o#W*BTB0gcD0tVrQt>VjJvbwm7}Io}kC!H~8ZjZSe8^ z80XZQ(@K4xVpN6Lh&+vZi3~EHOP;FwJnjS5TJ^>#Q~S5_tEwsN23FPS7>5>y4F;H? z*KQ3w(}tcVzI`dhv!GE#)ssbYi8$|0u=e_s-CBS6s4;|f`{)B4l;=!tNMW~DYSQp} ztPr=M%G0(Va~Yt*-P(>Eg$LB^?stG2vpbR5sXA@TQhY;ez@`z8gV#&KH-pDlAT4$g zGH!KbX0HyRn+EEUGVx1Nc?bf72%$=gSZzAD?%H4=pIjkf;G@Q(pVE=@#+WZfwAm9o zut+_RRfqx(OgDi)JWX#UqHuJzRw5SPV=m9e!{4EARKy4wy6DC4m1;C?6mD0h z4(pC`8MqFY-#A_US+GZxQ4l@OZfIvWRNIz;AYQY&l|hhzcB5;5mUW6+kUwhTBmZ67yBW))!4VX z%N;c2MlPmqAQWck5m55ez8w;DA(jzzr*3#1G(bCJjj-mB1;s4LV( zqpo?9cEb^9N4U_nrOAF2$j_#7;>-x4;Dl=ZqK2vNO^*m>{otKf0(4=GVYfdf`B&Tv zT=k~ar$|t~g-gI>iZ^e}DtAV0wuA*>r6hCXG-1UOYf`i@QU$Io@Vq7+K60}K$DZ!m zP&&wYgVxkL&V{86|2ERuJPpZaHtQRXTcY*Z@U ze8x=*P{SsbKeC}r54pxHv<-!VV}Y7d-K7x+kZ3BZXxg+ZQys-Kx=1)^n(0S`LC>Ev z$I~L!U52p6Q&eU&=L60i-vZaVaF)62)#=MOf871}qbBknha#2DssG>aLQ_BXXJuuT z#Ga&jYVVvMaQin3oN8=IVNTBaH>l!&tWN0RlPOfbh`u?wz+Gk=c+PD?l@Yd{|uX{0;IKw&^z|tO&riH#avSssaA~ zR(G!h{mdcuh>EJPR*CIa-sk)C%;MjRTVYGNlTuT!Z$#W$&G?7Rcl{LhW49dy!yDC=l(cko;J{%TGw!wXohPVL zJ>GvS19tnIS@*RPN;^Gqd;iIA1FcgqdCksJ891~jgd7Ug@ea*dF5q(|;`qz;^N9NT z039Gw;psv?My%+wUE({lSDjXi&F`o`{B9w~q<(YW%GJNa*_7L!o!f8g5WRjr#56;V zL;hC$;krlrKmiTa(=?l}L49U-j~fFV*GCZ>MXrVXOWUdF9Z8)2X(7YZe{xH^Y3LMH&tnLo`}E`-c{Mml_V@)$r&kAeGJg99qWiaCiZ`Ox7f%IU zfSRbUEPcLJ((DFoS&-0_<8UUH$@T8_)3oB4e|;|;ZP-A?04(dr{w5}%Qxa2Ci(kBW z@o*#gLTSqhs=FDR6FX=aVQ^4*NJO@l;U|AD_|9ROU7)Ku$31v= z=lt@7_>;r_^-Xf3!W|g3lbzX=@|7RkR(KdeP4C~OXG)cgO-_XW9Ca48lYtw) zxc}DDx*p_*+pwbliRO%HBmW32sJZiJSU;qGB(S0%*p{J2S0sPke7p zAJ6qvJy!r~3T5<`k2$HRsV!@2U^JA@%w_-{ZIXZE**{M#uCB%^emPG0)ZV3ZZ}!38 zGTE4BV}Gw(xPgJ(o*p#=lpg*0uuj<@v2`ucS0f$+r`vE~Mv<>yGb%G|CZcI132K^} z5PyG(J&xn|+$?-MlR?3U$$ciCjLIHJdb~*@cB?2W1K#R3-4E2qNm9Q7Cy&#fKb@4E zJaY7S^})=KLH^WH(6EWigbT*8%{QL`EvTHo1y>Lyy{R1gQvyHU7?R7uL=5Pk)VC^7>f^uP1!_ zS;wo+m7MNBnpQ*CN=%pYhglT5mH3Z(2W{{`KSP?DOp;>OW9R&9Dv!o6^X;PRfXD-` z!n1xqJiKab(~m`I5cq<%WxFAqZI!+A$YZR;)%)1seQHr%se*PDUJz{aQ-m}Qy!2^y zuDayI6r@9>i9#8c9qB$)$CcymbtSJ$|AL={s$?^IVgyCbi1ACf#(i9i=!ZR1bGvh9e*;^pD6mF(mLaqq=XA?0!G{QN zx#Se+(xyl)ZI=ns`kr#RT_9GodC{@&j_&uP{ag~0kv%K(`fx{Jn zu8mJNV>@d@Sqw#t7p3!@krgx!QpG0qJIJcfe8C;J_RG&YuM#xRiX*X(bIij5a-{mk z#YDCOJK|^)radyx`C{-mD`#gk?W=!)p74u8l_$>x%O)WMBH){8GSBeY01pBl_JOSIV|4fprdX zcHuj0xK?tQUWnUurEqddXr$l#&m4sn!dtm;cb3tS@N-u5+c^27K8H~m4C*BCKdh9~ z2ahH0Z3$i2-#Z?JsUtQ)O@2xDXC88NC!MiZ=jgOji3iSxuX)$8bo|_mA??*v5z@bz zfz-9BWdBSmz{f6lLXP{+J!N-0>sq;hxREZ9DcVkA7Sj9e^jJ3wT#i-rz#`ncespnt zuKJFq3wRBebIr$aYv^i0BfcJ7;gM3_C1!`0^D8&ZRaNJrPAUy}bmJ^zc_@Q!t!213 zm~U{9mReb3CD^{Y0ohbA2eIXvkt$}wBP*TogNP?e41;cG>`I4J_a(62OB=Y^yPeZc zT6u)a^-pGtKUJ+kHk;PxKgG^f%2peHwoPdFGY-|Q-e;+-a4TRw8ky!U-y42nmts1( z4p-BZ&KZa?nkkb;t_`6^3DVkE>PP3w-WusP*k>@i2ZO?00~Cz*!yCOWc=Vdc;i*MS z9H1y>O4~i>E+OSCIr6#(#!mBa1E-rW7)X8Ft%O`~4NIs~CivGpNY+MAbdjW1q>XQD z?k!F(UDu)H_#3$55zpIHf&xmjSpd>LwYMIb%LwUDmii00qe6qGw#2>-edaDY+ofU+ z%IZ?p(KQ0!4qn5R;=7VYpXg(=CQltQFgZUd4lIy6JB%Qe$?v!tqNJjf%kS&f!IDa` z;b6k(gJMwTsijR{`L~SsGI)b%n@Vpb(TH0KE0@#QS(liswoRVMEYiUC1tujaa)t-3nXU0F}G-yk>GnY%Z%Z;jdoW3`d@ zoOQL6m4(K1%ls2Y#2v6lcF`6yWo`8PPe!E_>T8iBND-vZNuv^#4JijH*|vUjedb}x zl>EwS1JvkV%HxDxZM4Zqmk_N8vz|J^`tM;x9k6ze)WrI>?dZptd|6!!cU75rcubYM zg6QTs+t!ER+H=?(Hu6DnUW4(rEmC)dYuF(pzch)v8;@)iWlq9Wb%H!}FU0ClMtPNE zF1UMVeLfRRXPFPi%4Y*uVJBVC4(p}^ZRu|`Saz- z&FXKA-EL<(ke>>%0*l1CEM0IXH@MWK0N$XTm9nMlILe%&Cv#XsB2!CEPS@Qao)_1Z zDNe~(_p9|5zwvqiR~m0wGx8<8cJ}R;TQ3F!Ycb&=@OJDU9eO z1|2xJ{JAuO+)^;?SV4n8>7lTXjW*a84AI97_p~PC++N{h<{xr2Tnz$EvQ5{dTx-x&P#Qi`|jLP%ZA+QDOez8TDv!>im(Ik#*? zOt1r9S^}$I1ojI#D^WwN32e5+3jeCNK0$iV&aUk1dUce*hp9XL^usex85-Qb`T0hx z8>Xzd*aCToF4Z0m=fUpOYa+@mqN?=HLvC&s`;WSaLuAnHXVEwd2!vqC^1AJ2k1zz*gk^>HAwm|rFF3PXbK6NF(%TG~ zobPBED6eTrZcB-)&4CG*mTJeoh4zgi-Wld%8zYKo;N1&MW%5}guKWqMJKoZJGYS6N z1rkJAQnB8Sb=;`0@fblJ*%j@IF5o)Z)Y7`M`v1(annj5x1LpK zC@z6cpWhmCjQ)qm%`}J^{|seqG;(*L1g+B;l*FyV*wxjwc*La-&apns!$C(gzy>P1 zdhe&y1?0pWsp4ehet;AP7PWDoNbrLnLoeIkhxf?ZymE$Q`)Rh z?;{Q_x6ebbZi=r?8zz_Jsv&S&h(%*ot{Ls=cP6+wUY(C!g6I7t9hT|FP;}z5#YsGD zp&U|wGZQ_oM-(Vsls4q(&&ou2IyjB?m8&6>YrzzUBnK1`5_XQcf9}|xG&4*U29!%- zsf%ZQ{AWnMGYd!Snp3S+z$2yPTU zUzB$785F5!sBF1!J-xK7C%OvL2yU!jQ$ukZ?-w%@^x3)rJPh2Mm4fYF02k?4^&1s> z#i1b&qxqzV_q~RelGi`FB4ims5zLhOUT)?cH+61b->7@dWDIWW{pj$FSZ5X_cfVRH ze?V~Ll44Y&9WV|0xurC144agraJh40V~S4B4GM>h3O3wdihGe&j_s-#hq0;{%NlPE z+9bX~9@+c6OJOl55Bb{m}U)1_S#=)|mj*1z|ZWgFP_YZAoHKzoRS>mh1 zv2v~m2atrJ!NEKww%jN8d8_fL_wV2Pw)?0XhkMVnPzwy^#bLnDJf=*&PIL^&6zS$$z8FKvfYovib5C|$mC~Qk-5Z~vx_IIr=q*hV1&6=B){`D=; zk@ySaVO%3dJCxQs*Fylv@ian=LQ?-(jq6T&)B!xfdDN?u1*49cBvp8+J593VMA%u{ zgbNy(T>Fd+TZ~Z{1mY>#Im$pv!_5ED)a+BXC z=%-Ke(lrm?zTeUMK$9>38@o?MRdM3~+#UDd{{~7t2QbDTCEwk8?|wLkBww2oqPPY( zso6tN9>YxqjqNx#`oyL{?1(@=+-8rCKhoGIOuaEYs4Erk) z0gU@_SBu=9IxZ#PTv-eLi>E`l@7%fWA!)O5*WfOYDkEv1Qxydpmkc}*i5(mcK% z@6Ej4EoSDnIK@aAPJR@`A3QY>qBRX`tgO(nZP1CdEMWHEG^fRLl=^*5_j-*OxZFKK z-nbFXu*veC?FK(2Nd#?73%vTT@Ycx3xAI)XuzR!0nS{3{ws9idQf6=6)*HFMkS_3b zXl9hjsoOLylt{9=K7313Yh^lk;Vl!O&yHsXMOddgU-GFy?}{4__#W)Kd3nW9(!76= zw}$!K6MX=e7+PuDUh8`$(J15PeVSH9gp1udtCXwN+M;!yw8*JBJa20oPG=Qlo3Yhc zbYjsT{rKW+oaK{L^MrS&8_5F?$L>kzHS-8{dd5^oFz2n;{z(saeXjw}cOBY%tp1hEPjMb$%_I+6V9r|dH zmmpOVR@8*{u$7b33+->YYejWUr7|!^7gHu;tY0|=XMr3kLHIs zq57YoRB*OnxdPM4T4>BS8Uqhi-RsP$&%%ba_AFOW5(2Ir#SPeuO*{E`-k4zowsa&p z*X~{_7}k7trx9%!T+*d{=`w_FqH0~h%ZOkEMZvb~Dq+(?1%8Lc1jYotpv9{y^{kpD z!$mXaZ9C037p+XR#L!h;sS-^@puH`dqHcSI!E3S~cmqwxzFu_LZC9~w{4UOZ(U=hv zC_FQiVM{ zdo*s}B1xtB=!J^sBY~j(vSs4Ho+0J{7MECU8t1|fPJZMdRRgL=yw>SJwsByrdWUiC z*&_<4VQHQ}7TrBvD6TgtLL$L)tExAzCKaz9?&&<5!8Led;Er4`B{~f!4HYI8TesqCTf3+E#iwCbI$Ndho;jMHTEAOsRgy zsPfxF5!i}^oGn;E@K30G^N2hSGB!Ic;Z-OnOD+Pz^Dzh2?1n-QH#k1=uV~2EUypjj z&m6F(a?3#lqHSM7Qcu~Ol;j+`vW!O?G0nx5NMMbLVg`!7x5RGRZs^C}_*7z}9Mowy ztvzf6S-R1vIh0Q7($y=2U@7=(#ihIg5)AQNnvUJe{;PH&k3!AU)J|r95$&`)%OmwZ zq~(EeKK~%pDY~iQiA?VMf{lGh!8O$9Yf)jVLX$tolhEhALeMioVups7T0-EURW%Q> zhm{H4J&efZ9do*UVPomj;ss=Q_DS7S+&n8hA8okA`tuE|p#y%k6=wr>D(+ObiNyh?W^wh&af%R1x?TAGlqEE`@G@PWFCYDZ`mYGX65LfJ9_cjX@bv- zEC&j1;{wfE@TrJ#X-9dsPee#;_qmB)P*Q3PJ_zX(s8Nm5vADQsB4nPaAqIRJA(@Txro~Lr>dev4+Yi(6@^_pTC<#2Xn}zYm+=S z$j#vDflX-~)2XE1{CV#n4sn8~p37)Ozzz1aN~ett|6I=pmVP&|#LpqPYTNLvaiZ3k zTt?+v-x_5ZRC(O}G`mP%2$N*~tC^0JR&btwaES^pb7si~k84s;e%Re-Z9>Mv0Q_Qu zlQuayxus^-E#2BsI8i(4VtcdH_OMBB>0ToW;}QTpBDu04SBHpyOVOeKDj^ytJU^Kt z!ZcdXX+#T8Oif2hcBMETwz!4YO-T5B7JHOoUsv(uy!_Ip=Y)6}*%I3^wbLbh^Iov; zSL#gCE~a54K*G8_zhmmjosVN|kRultQGe&I+x^G3*wg#z(SaTjLgzq=`UIZF$7!Hc zR|)Q_1w~nMJZv*>?IM5Rb~$b7NFgdX&z6~IAPr*rnYI6wI{Ny`!%6)mXCh8(bp$Rv?+@O;;x6*_RKo0?L6(wW!(-_Edn=3k+< z-^r4!AiA7e%#$_M1A-8-UZ#{yX#brF8y$P)!d2t=k^#8dSm}Y>A$f0R+f>?Av%0}H zZhm4_(i7rVrN&iTmnWqyx;8X#Ve1(c>m65@B+)Y%qYy+ocg}P0t%rp4vetu$I_v3M z6KNu5+LO-Qo2#D>!-S-b&9plJ+jr@a%0fnn`~?J#P)v$B79^F;T`21tXY-|^R31rU zo6%|)ucgf(U?y8YP~Y9D!E%6HfU+q{zh{$P-t=9(fN4KXBCJ{*#52l?Bd)JrZs4Wdw^;c+1df}N7Qn>JrJEnMid68J0^ho-~+P^w=Rb#{pzseFWbEbd;M zga<1%*~5v4u1H@>E|RWYfN;Lnv2ue*aSvA$O4CUUKC(%|vvuYq(E|IyOeOda)ak?l zM`SaiO!?%gjY3TKW+9c`R`5T@GzE2NoUB z(gCd7^tT6~h7jlG;{5jgHfGf=L;$G1K6^#HDEq)8WVMD*F8C;Tl5XwE-S*+^rcxh* z>u~*AE!b#|sgj>>_zQt&6QF39k|sl8(pX7F1?bxs1L94&qlpEu$uM4G!fh*=%TUX` zT7~X0r{UjKuRqnVWr7raJe{YWOL{^(547j7^A0W$;^c;e)*8eyMLVrl4?LcY6#dwl zZj^nALp3O5wZ|uCUqb&1H<{<>Wj(b?VeQ^)Vj`cX;-S)n_=)jPI+8 zj%I@(kaW0vN|)PFNEerP&s*{9at^y6y;r(%rHZ4oowt+^C01}vM%$cOdLKW?Hp|$4 z=n~s^*HDgpkvHt)4{U*TxAlOGJqOR<+TnZY!S+}DLG}b?@dfgD%HURQ1oe$$PPyBw?$VT&3d%hN<-bhO z51nyZO!P`Kp-l6HHjugt4-$RbM16kFf#Tcd_34JsRjSh}>!c(nzfpzss0uGm2Dk$U zGl;|ULADa)fKb@(nK+Hig-<2IAuIwY@y}=h*RIj223@{R%_zk$gIc*l*94Nc0yD|v zf%NheM2)~@X9=`VTqet_mnuS%mAhf)XKwyjEeZIcKp%4lF6}E5x@>8ySEgevq!I;{Tu4X~7*sP(2sBFE#2KJy(Rl*a8bWzSJyz0MJs+@f{ zYw+##wPA5P%L+OgLhfGS?u#Gpr__fXVnx78c`P%&N@CKy$KY6TN@ZstMELGB^8M@qMb%U(4obICkQPBY7^S{K@h5-4l%)c?PEnL?8}aVX9wPE=kwB4VQA@| zGzfm7uHX~G=qxHEdH=vXQ|1QW3s~S}QCM7FsCJxKe%)XS*oRDd1)Np(PLXn|J7P00 zSVA=PWB;8BuV;}ebj3l~ z`rL0xRKZ@y29~IIr{GTW?U+&{zfH#;GUsBLYVx&;i+r5p@?ZQN`BlZJgs$~Zte^80 zb&mMs_+kJXVc6raxq56t^iTyg%CPKi4sG8*Sttwy6sbfGf^}Z=D;owMouGU#3QlAT z+Um}JIaa@E_*!C6u-N1{Kd`es^v5um4LmG^84a^3lPk{9wTUyBkqzP5g=?}t`_aH5 zOh2xxai%>$dJkZdh3C_D3ya7~iuZl~`3x`#(!YIQqVuYG@S=QD`ge7zz3DSzi;Q{F|xW6QCe&m`{xPq~$@J{nhD zxuc)K7BY~686gu51~=KLsA^#QlMQUj2>m5M`utOYyA)v4`kaT`c%IDy#tItmg_izc z%(NJ#MDP02Cho1D|JQTb@vZa2@xcKgMR!>YsmHhd997GQjlEWX3Ti$udr79^=6{+a zCJh8h^bN#_&(1qeOO5fFpSq>9dmw(w)%aGpHoVl(&}wL*V$xUPELBUs-?Wc9JRk3> z4q1Jkcpq3U`SThiZ1TdWXG#Xw&6@Ie2lIZ#0-O~ppoXUO+*3V5SVhUpxY{y?oTM@% z9XJsDln2QB^S`%XvHZW-d+(?wv$k&(E2E;!GopY}bOezm9i*#_V(3z(1W-!oK{^Bo z>HuQ{4ZTLBcOejZ5eASNX(BC=mPkv0&_YSR9dw@eIqUu2^_@S?I{%!r*RbN`&b{xw z_tk&bb?+^u8`-dY&S~I@Lf`kCN_3ot;Vze-vy)TeQXX*he2{rt3oY4tqr@a1ijem- z??GN1JiF(Y!K!U;zxhva{QYh6$vDyM1xS$e(Zm00z!HQZ79DS@nDa03T0nuoYw>bS+57m^uE>VbWg)}Uf>pyMO=>1{tF>xPA)TtSZqc1aU)Fj9b9C- zXwZDEB^2EfO7N|GsFRZWM+^k&UX04xJS*qLuPU}E)v2Q8 z3hr8!6MxB+M-$)N>x(^#XhZSi#WV>vICJ2(AByVKggY9xx@lh)%kfr4Dg2CxAb~qU zFW)^x>rd&Se^*1dJkbkRzAn17B|I=sff6$QB9wk;(zpyDTMlxy%IppC$TJ0VpFdvi zJ{@nIB(p3Ha}*W6Vni%*1LNpF?Cfaj1Kr%awOfJCyN_6A?W(2nWzR_@#V$U5PEuM! zKn%a6ZT+qI8w*Q2*dP-~A}?c{ zQ9{0%`&&#;*Z$JoJ5LY!R@R*JspD|-75?sG`ZpzpK#5`O)%1YJB z!4EBG8zq&KBivAJ|F^pi)+fnrKJ$#tMW3o`-~IB7iFti{OK1VeiQ}0?ceKydE}57W zYCp8mr1*(nP+B3M1BZi|5qW!Go&Qq%><~0>uTr2BRd=tuX&)&{E12?=s{cU9-JGEm zF1Omb?0!?FNmvcXC`jF3Ajb#QW z(lx5f(o7Vx^LIj1LHMGYI(IHBOXAFrc`PmRuKDpc9BebyS+E;FH-bMWWFX(o(*|`(PW1(2BZu(BrV>AWqq7tYwhM{inuB}50 z4*@F2SqIli!|OEWtMF>}`_mF`QKKcDE-{@hMhnv^cU3|w-c5RCOD_23uZ}bs=-&{7 zXvuO7oqhhQc&k)+zTkach(SgLU>r62T+=nhj*Mw{T-2BhH;d#CXXe7T@7yuS06KUl z;s)5p4zxjS!rq(s`nLV<^C6)zeU$y|!@YVAlN*q1&V*~feK$5kq|}aC3=iYC26GG$ zmW;*5xk+dyr$%0tm`PR#HgAZ>9B7k${#p=_o@7bnW%B5+uqQYFfY;9f&xTA7e;jpCtL7LnvB7>O2!2oR{);PjgfrI#;f0y5C>uW=c3_kLUfl5f;a(7|3rQB)IxWfL65#KopxCOy$o zBm_Y_70-y%;5OW{dlD?RJ51;DW-4z$9aH_pOH>N`f1FhdJgB&R!xnFx{lE|?1OLx! z^|<{SwXrhjWt+&gFY0-~B8`q8=`Sw9KlCr{@t$8mr^wE3{DDZtRz?La)gt%O7wD^q zvtE3vsNas)<7!CO*%D3AB*AQqOP?XIws*A)#A+1Uttcu6`7-JRVQS&w!oZ^kp0C z@F(|tXh5Eg>FE!&lflFSUAoEFTOryRe?3P0ip4cu+!7^jDQL^RCThW)tI4jo-7wvz8IkPa=*i==U&e1Zk>9b7k`V?a$7HF1Qnk(Ug zS%!yzBE#YJd3xt@NdILADS2X1s#NcO;G^BDxu7lexI}wXGE70_qtg=a)f#{LS3JIX z5_;)lkQE}w+uFy3q#PwoKX23Y%m2(}iG9_5k@G;ACZg7*lhGnfMvUf9l-eG4b zZK;_dJA4Wia*S>?IlMPEg71Jd*~f{kV7AV|0oG~?f& zdz(@5=;1#>!pM8Lo}qZpMc?t87$XXNw*XKR(8+ZwWN|p=9lpjsvz2l@Iqe0Ijlk7< zC5d!?+E+m2z8;jYd-|9@UxGl|K22{9zEds@x8xR&9m8CmRXJpv^X@<<>gn-DYREsT z@Q?n>B!k$=+$&1MpKhD0)R$`L6=nC8FL^4eyPsya`|^E5>G%#Y&NshsINP(s0r+F= zql}&s^aA+XY|`)g!2bQ#*m#O;W|e|7hVv|U@L#G@3GS=r+kNXf+}+8slC7CyRbq*3xVZ*XLWj`g=^F%+)gs( z+zFp95tRp?mm{?QM!Ej)`mI_$>Ad>+ED$6ATAU>K|00K`eRb{SO!X9 z9>@29i*)Z`J}g;(c$bTvqS~7$jx@RTM>M^m-psb5G>Ug5pJZA#h#{~cwPWK$bIwL) zmW8!ryP;!2NQ-kW@EFR>ahjS3*J?HJ^L%Ct883nUvgYd99+NlfAR8aJe@kgqs+9iwXboM@j$*b-$rwiCW+ zGvI)%q9wz)4*(J)H$n=hAY_qj`RhNEzH0Wu?ujAKv!)}RP{upG{q@KNH$Rcx5`7W> z{bx)^7O1TKNw2xex;ux=sDDx4ZbBq444*xtjY=*EEd-XaYx==8Z_}tAb49Hriotuu zz=6JV&Fjn;tHXqS=#O&M@;0sk-4uep5Lj~hV4=gnu5!saDOGM4D~jOOhT~x8$2uEQ z+udV9r!sGuVGfr#9?=EEaaX|@YJ<~tA@khQ z*}XBq7I>TL^#m{#%e!odxj$Z}jPpoh>%lng;DhSSfi`MWu#Bs_BzO=NgN5!<0@D4j z$Q)fQoPl9b1W{c0f?tZQ_TalIUx|7;1x)xwiO#(@A5)9&y)y#QAox#-v6}cuTUMys zzEZgJg7}-`me943iA#{K+Qk?V{N~bEeU9c2Kc2Z0Q4jUES*p46Lw0^e5|?PwFdPMm zYzOy&1_Y!T;{`X`C;F;%xTai&Bms0m1LWnd2O8C$4=R z5ODU4HUu%a{;K(54CetAZyIUe>`-$vD`;yD21bS1Wd4$p(^Y_v%G(DTI>w`ol?-a`#* zFEOYrO+ITeO+D%5zBhh00JU4QIx~Ovn6pUr@7tB(RHp=i zhW!$41@ZL6-^b=LYQEbo70Sn!kJr@-W&N{b)J-=q0^5l1qQP2*krGAhE~;lk*~Zc) z{BUVo`T_sEcoQZH=+Mk|A3IResyT_uGVzDGw!%2|?$sgaGfxZf&KZ-!PFvJPum9HZ z0|5PkTP*wur^j{z`v0u7G)yf4n6&@|V8%}V5f%TuQpNS+VC4Lv9J~>}zUF;@R;ilb zQDlzfCl1H*Ot4)Upc>z6YioVW`$QoC!+Pxvav49Gjy`iD$G5)^IY9dP{*33)Nd0F^ zWft2YrgJIN9EtqvOdw)W9Q!k_0C)ZX{v5;3G70gcam5|f+}z!||9| z+X(93eK?Ow{#Ba?AMgD^oT#guGI|*+@09t$suJ!@DU*jB&z7($-sr~I^ZYE5F}}ii zB+Qw-q=;5J@~>W;yr?LZ0i)pG7dDbrJ|fSiUAp|_SF=OXe_8%|3v2jAwk+-9i8GK7 zGX)>xhF+K{8qL`U`Q|8zx|-rzReHu%dKVIsBGXHf%%PJddbKgv&88P@FLvB$^bJ1Y zm-NSj7k7SDIC3|v`WFH1?bHF4U)cOE5#P7=&rt)Xm#7TJmOkQPR8=F4qFOuvlGZwI zzOQLRll=M`P!t<+LzAz2GgBM$@T1@en-6R;Drt1R_qF+h@3>7(ti!H2GFjiu#%?$D zStT`Hum?VE>hQH|T=pRdEYSnfm#e|5c5KK<93(iU|@)HaEo2 zucoUax^{xWv+kZyKh$0}Bh)wdsXs%fG{39M!IwW2=xDRj94S{bQ&PX(vrg2@LRr|* zh~xJ1M}f>j#mM8VMo{(rN88Zf(?5zsvvhuePme0TF){&w&E{U%oKW+P zO?)7_MV}>1O3G)YQf18xt`+W_uO)4_95GQ<^k$kosrY8I=N_t7ZghyQgWcN}63&U` zb{&CHp9<7`@dK0cke_09(;iM$3kMp;?$O=u2$i9=-Q~zL@#R6_rION%7V@)}2{mMk zalp{kHGiU6W`=6K8L{Ma83?Mf&^_fii-Df5&9+6d2$We;tmic@%txPN7hj~2vbmgoqPY|UZSBd?xIW+LF!1_kww~MJib_M@t z!6aPL3NdVkOQTz#ziyMu-s3AxPIG9&czl?{)A<+m>s_wPB)*`dfqg zj@n4eMt?@~kN_lzI@D^35gis55H^;;<*<@<9aA@MFvc#Y0f3J~v`+OHJ%fRSfM9g&d>(*?JU1jR`D|JR< zSE&}1o_1_I)pGi@fB+M!k5`6lp^kZ?^Sy&W2w`v zka3r6DPI=K7A9KrVQ#tDbwSkXhik5*a#lf3tfz0xq;g(GDp9PC>$jy*OzzV=hb9sg z@CVF$LFB)FE4n9k29{?K5}GnjUti+Wo2eb!f|fJc9zWLKe}8k0#bXGtnfs3zoMpu0 zQ8-#iiTcTClDn_MSkU=Fomxt~P7(j1Z|w66Wc^3umIq5y%{Qa0m^vIENp70mc)A(9 z_;K?^+z|skIGwE1*RZQ^9;EHQ01GW*Y9Y{?|1`p`cz^$n_gPkty^`yS@q$_=Yv+1l zYcEq5+Wqk-4TL$d^$opDJp-C2vYZ{SJ_x^tjFK-ixAepEPZNtf`|{})N&y`9%F zkbii(q3hSsGcenCBCm^!ozvC#_XY+Yy=a^M>VHfU!lSmPw}ay_Qpdb$JC$oz`y)0< zeAD$|*D;fhN7!{9-QhhsXP(_NhpyHGeM#>x#{8RFRt^tn z=IG5j%BSgpsigpS{~a%|b*10j&GWFMOjb&(|MR9DpQ~!(Uf+F-=s@2%?03cY&O<8bQfty|=!i zNvUuIynJ3p;jnF4P^WsF;o<42hz^l;Ra3+Kn;R@?Icv$?g1bLlnavZlyt-G%uNFh#;k|CA zz;YIU@gG^2x{o!wK`Nj(|L@sh+5GZbH_9CpH$1lJt-bn2%Bw8y&7 z!A_bajg+e~W^5)>B=CkkQ+AZtV$rbYOD)){|I$QPx<0I7F1!0rzRd_9&Xib;Ck}3@ zzd4syN}LPXNRry*J*@Kp@Da48=z8T~p@`Lw;6TUC;TLsSuEXRKdxZ(?;UWxEg>I7Zi5*!z3oAUSYPy|nZWogG5 z+L)`(tG_Rc8w!cyV4KQ7E&RZN>`lYI~7K zditQM^1$Pw*cx~LoXYF-h+TU_1=Y|t_BTK)7(q~2(?VdB)Fy1tEF(l!HCg|CH@2Zz zuiK1_d)AHDvUz9?7BlC~6i0joY_LGk`2tgTQ@8qdt*(Ile6>Ye41$`{Cu0`i@0e_v zGT><>3M9t1URYy0Da!y&{PIP+Z?j0xNIT`**>Ck}O}@b9HdkIytCor8t#3&&>0a|? z>a*rpv-Fj-VWLX@Wb_)5a@S1k0qq2f=SBf%NiWIL(T3i8TjK)!2gx*A^utn$T+NWE zEQh%sofOlE%!ETMSMyu;OlZb+s^MS>s>`MKzU&)V@6aTuJB=~i7j`3$=mfx132U3C zO!v$x+c~Mu=~gn)*sPZMrfqwTbkUwlg2tpWrqC6JHC3>0TZpfyEP-^tx9HY6Gz~OQ zn5)@6U0+|9lO}Q3fpZb|xP&cm7Uj0ro-i=cpq!=}iGh(y%fX+aNz*0q!jY_EUlY?E z>~+a-=r8KUf3AFjc`NhZE|joFA;O2D|ikQ1c}OU<7gx z2Df~V^fq%oXxXXO8X@IdRpCHY_0OsCSVFLJ;es|#&!%MU4H>TFXgm{M1w+K~U z`-Ho=Hn}@ue^>D&3s){afVvlmKG4dZ#S!e}9y`|3702Q7U?_+(SS07-F7HjGXflYM zOCo}u6|yDu;>+&E=5=m|0yXZ%Og^_Tk+4d^Rare8 zAO8<30{v8IsDL#8Qi;t8*%;_4r0riPl~~1$7n0gb=}+>BEnT?rA|{VHhSLhy@;7s| z0GU~uZRdss%P>wq7Si@;46Bd6_=8Xp!2r@>A6TM; zu9upNwz06#DSN=PTf%bn%_(3XfkVw~*Ir7yA;#b2C}z7M$ek*WE97&vTZR1^6U9|E zjpUHJ7djd>!XCp{(hU75swo2I7#GLzLQp#J^Sdr6poQ zb)Z|xC$SG5A299oJyK>$rVZ_FVXf z*n+VLSqay!S5N4vx(zHGiC*}C!9d_?E0idV+l(g265g^>L}tS^m3}T*P|#q)fv=cT zEiI-VBx=b|a|qJfaJH_z3K)(yTM)>-5PFqCR&Pkuup?YG-dfrIvyr>l?nC#3QZqraxK_P!)hq_9fZc0<~W-Y#coeHD2&}D>&OLcph z4imZIGT=C_Bd48{jk>U;^q#YtuHLn>sgFo6M+dxRO{gPTWdn174GkUx{!MV48BveH zL^mcDtJ5D^i`2&qwhj(dZ@#3B?Z}zm0H*nsYh6&^>_i;DAwRPPs5K(BtT)m_lpdo z?odl-6@*?0$7v;1`f12l;dYxh+IT3z@|J$7I)-r(*o~S}ck7csLqz$z-en(olmc$B zYMBKfwvEpKU1Y4=Jz6&Zt8L5IGOGP=qCgvZOeC%QPc(>N9q>?bGD5Ld=2 z+EhH5O`y|NdB7WYgmzbZ`CR(_xSA@Y9J_d)-ir=uPqd9rN0&NeH=9u?|2eH#+0k)O zLBa81hWa@FX#8^3p0gS>3}5=kHKnOw!F?Cg+JU7Wie zwkGqE5{ReH4z~346pK54J7O~{&ZqKbis)%NGA2Am9E;8>bJvI36jRIHtIAGi1c9wgO-P!+s!sN2#jj-e|GyrbUIkl~)5G$V9; ze4DUJe@f%*+7u~U&p+2LAW%NkHk>&6#5+IT_wk&QuDkp3GmSBr8tmpDOr`CKc`kaW z%Lz8}GvM)@$;mDWl8mg|azwhw__)D$w4QO3B?PmRxJ9nE#iH29f~Y-42+SH8 zf%&lHD}>lwsyWdB27PJo=(WsKqUvzyf$@L*+Sz9OP1HzwAG?1Pc1YYRb-D~cG`vY1 z)vU^KT`6Y6(O|S^j4czD^kw~e)O4CnZ!YH^y&~Xlh z)U>GDP5AbGur_5T|8wLtTiiS(xveLRU}v)*>rhLs6vwI@VX1x`F19zZPa~H%jCwCw z6%b`}97gNyZEu86RLeU7E)R zJQ~*iUN9fw3m^`X1K=I#WUI5a4f-}98wl_{sd0kORfcQi*wrf~jzCaJ!WG!;oSCw{ z?|&c~cW;hIqU#Pzp78NW(?S)|wtJY7gJcqip^;gNd22XFq<&8B?yQsd@RHkJH^rVB zIT2DYLvx$-zlw?4mt{DvxA!#c7t-$TlX>nT90eeg$!;#ltCpdAF&su+uPPTYfwxdr zt^g%uQDBB5csHbj%|NgwUwz>lPQLkjhMy+xV2l>#b!~p%JDT&SLg*sXWhwL9+d$7w zk)j>9pTUibyGELtq{R8Xz`o*WBGQ$yzo~(XV?rx!iwDR`;MYnlbE4kB%<~-RQT6d; zB8|1r-{7ZJYA;J8wc;!f@R=;2x>b`O?Uzpso2KlSHpX#RiEBR|Y3zBxps}A;W4y_c z2o~41Gi5>?acli$Nc)30lEjQ7yLY{SQpNXiV&;Z3^OKuyyyg#5Z=U_!FLxmnTU?3f zWwCW*a}2oQY*D3l@k?=6eSx#H%f1`gQb9GXY2xin+;w)qB`-uZw*Tiav!80jar~=FLFpunRu#E4fyMEQ=!!4>K zWqi^5ZWgA)xwMAOM^O~AR>ydoteANm)?s4~Vtq(tsS_48Tgp#{p!Npug}($k*!V?K z<&Mng1854juzfox{RaE>u>_6mB=fSMKpuIppf|bKI%MYO@ktsTLDZ!cW#J)-&e}Tl zm@jnBwN~*-;Jb|YWs%ZZ*IvhusjsBcebo2uvFlZHh^E?Vvo_5K#Xx!%6xt$X%CHljHn!$ZV)D5snSJ_}9Hh#Xd^N6CX z!Mp{SGgoxix+U%ZhH5-H{@ZZwMmG|rf5v}~6%KX#w=(8xaCFzc98F%t<&uUHNF6NI zXS7om$}tRM;7!jW88+}j9}pJC+!L0284r7`Z$_VjBV(7Tt`d4Kh4J>S`^(M+_JLq5 z#>^PYIlqvvXNue~iXLO*9ZzW-u#MQ&hB!M$ynsk$XKh~&ALc+!ZQ1x8;J(lak*XiK zF9e8adt_q_shnDs8xt&m+%ILAI}Rg@1_TeW==|OgIA2r3b6v1ApGPgAGxWB}pcfWk zZ@{2?#9rQW)7c%1@TuSQ39a_7$o80;=7`?NDZwh>)N19R&)^OLed^ssWfT>lvV~iX z4wdxHjN|oC!Q{Tcr%Z6RN+pG{FfMFO?yd+g7@AlQVy-J3;6mriSG>1Kb={kexH9kj zfh_p=j7wnLaN<4p>6V9K>yWxvW zxh(JcEtC(rFBG^<%jBkiyJ%9lwn2gGbZ@9td+5qns>g_{(;+W}9ook>M(e+|yBy;Z zjaCo20a@#?DO)(p@=hh_-!nE0D|HoV|Maxe6iALAb+W%hXGwDa)mNx@Do} zWS7ETx*4JC)p&e%wX2O|5o53N7#cIS_IaNIcX1kh1BdztoiyOf-6iyTOYmuIBzy9b^SP4aEVkt?_Xbl9H&wRm(*pu+^b#Tl#{rO69|XKw&fNd- zYGFAgOt2u}MZTWzl(CXE`Cdn(jPM4!bN--XtcqOi+;W4Bh4GXY&<~iM3POnH`GhmD z4%fQD1aHL;7*W>p8yCM++28#~4}Mr)uCKs~ci$|Kj*Y009U%^xj)&t)5I zedJh7*O50JalCsnX*FEE@7c@EIRj8>TV$OcanLazLB4judoba=DM0YUI>`oD#$GXp zg+;mFaI)+Vl(Rie`cHHuW*N?}KeC6LwNd*VA8|V**>YS*>j}i!DKAzC&8-Fe~`lxrAw$+5Uumi#lYa3OTkhI5YZLvv^l%isiG~bhCDPt ze@0A2W9V{T95H@;j~`~nlVQT8Y*R2eH~?ETQXAqW63L!kyKOoQusMM2 zh&;m4pZHMJVrs__iHlt5=f(}xcSz{fe(weL+KD$s0!{H~(#LNBxN zv5I21E|nN&y4Em$MMjvTz(}P`0lLdR_vt_5`FpzNK}V};=|SXss99GVEC`*Odks}# z5D%Mils;BW-)OS!gyUmd$+lXHpCD$}Z+HG@9KDxYtohHab!r1QmzYCu2h8z~H}qhZ zzsD2PSrZz!3T}9+sH(zqGQItSqU0Q@TMF6PPluTyU!a2`)q7WT7e!QF`vwiKU%97= zx=p;EoMxyG-}w!o9#g9HbwCM3lRXn&?e)35va~RfK)wPqR3ccbBK;hVE0_gy?au(m zwQ_UiY0bbupvq{boq2H}-GHgLz8Zw?x91NTBV}!N_MRR9e8<>`2AY~!e{v&NfT^xN z)K^-v{>DYdG(OlRO6DM9)ZQNbKcE9C3ov~?J(SzFm7R-A0TU%|HvXKFkUstv8eU@_ zGR|w>;NonGmzD^@siyM#>2`~KWIAr7xQ7w{148i9{jabSlvVdkOMIyN%=-g>w~cMf zfkA(1qbAB`_5nyKOM7;CuqyU{AEq|GI;G<&;h~;B2*dY^zsx0_EnF03qFEeez)DR= z4>q{^E7khvGI2FmQfXh4zX+5UA>%rS5-XCK2N+!9LYJhBO6;;aQZzh1z6e@*wbzbt z=w=&Wz3&A$MyQd|K|pJ95|cboTOwS%_yzWTJ!5@oK~Ix}Us4Pdfi?w5LviBWz%LdS zkGl?bOAUJ=qOl!#eYeiv-fSewtvEg99Dwo~+)Yfc=ZWK_jlxp0D6=*7YtbP<;5;5Y zV7in!CCOV)Kihx*JHuzoCpWXMD-{@lFt47|3HE~ul-@4#5I5->sSAi27dY~svu)W#);p97TXjbVtK_X=bgOPsi;evt<_?tADROUA_#a+ zX}gaD6%r4UH*ta*LYu_w1RH%#+>}qfig;l-y7}nFHntV8p282C3zoilE*3U#Sf(Bc zHblbg`j1vpob(b<#=815<=IIl@n;ToE%GD1r5idT25&SeRS}4yKkL!>fIO?b(PS*o z%!>#@Ouz=Sadl%~a@-w^h{LZ!;4BKQGvjxw>i1_#WletOMZalvm9Jrf`cM;%SV6R# zd3sLmODG5HaigNb*K8b#_l_Tb8MxV&c`&{LrWR$6FAlgfR>=rHs~vpd0ZLh)6xBC5 zulOagmq3V(2U(nVZdm#>5POV&Pj}Hyv`O`k19}ghM!o%Qbht)hQnN41r?DZ3zSMI~ zGm^z&-3F}^`**<)+>DFTF1g3klMxl=MwtkaS}96SuU)XW4R|b1Z)>0prIyI(Mo$bQql8U z@ZqzcXU5M6K^h%_z%$z`gdO_c8Kw{vyyRtz4rFIhX!~q1-#}HP&`?b00JQcxDK_;4 z7}aL)ajpNdp5_*LeQD3cZ+UN+wxyR{Xa{#JwnBybnuZ-d+d3%0ql+rq%~W(wwJ~Bc z8)#wdGC2_Yho(jrBXZfklRIo_YO(3i$@%XSDXK#H*^8X|Ig2$8v>l+h9K@85U5n{J z^G&X8S~H;q(3<}=z&a$#QI2JFetxboPoCG)^f#d?9p%zMY_iYx64E-0lK8uK^l_z3 zSD1N&Nwu)KeQ#<`u$P{ZS`FE8{kZ4MfnlWM05C0Z=IJkLGV%^d9L+rSkl&JQ3{r~Fx;NlN2wf8685)DAgGU2qa_y8-c{w6`&HW6V}<-J);Xg=mBFBjpH+Ic~!Wm-Y^~-%V*s7N89+dmEZr za$#V==LJoHpn3fG@w+H{dwYQ^SFQluPWb%G`NhTVh2cU`C?SzHO(cmx4@*Y%Iw!`X z_agPpYS~z>zV*ik3bj32)hJC-^d{=&9rs9w*7-S*Cu+bx!}$dTU7BiZZv~QusJ$1< zV7T<62W9V>$t=kyiEwEXs7GVq!@eYe?CCm4&gU-N{VNVZ@uZ*ry ziRc0Y@HM9gTLr=n^TuGBi49y46m2WC$p4{Za@KBq{Qna4_EH*bdRF^j7>ABd0TX8@ zOZ4s-G263unZFMU^6yrnRNWkrai)YcQ|58-P^>r6Zs?EQyx%h#la5Cgu;g8bU#+sk*yUTEh!?Dn*|O z#x=(p=5fDPEv>l!^#HIl*~Kp=kBzadH+laoA~N$n3Vz4pd1P~G)M%brb&_+GnIL1a zdh|boW;Q=SGlzpZg&X^IDytI|FL}tre$)`}MB0@$fq8wiEze|leiP9Tbmh@_0=tG$ z&{wUOt20cyjk>+D<`(p=Pu8SX3|zfhFs_MR&=k(GKRGgEI&BVJ1EhRoC|+zRj(bNd>h`10VcC2TGr*7G7iZVG$s^vM06&D1@Q9WV&EVrvLQ`o}L~K#|aoe%lfLLmxV;2%tHW-l4}5mHdjk! z00I1XgxkY9E<9WyA2$|%J7{ym>_G5yB)v_5rdqYT9gc>v_GJfw!$eD6U0h{8GxUxf zWc3SV%#H!PNd4;MzXX^$0k_>(+%jHEU&{(Z`zoz~K#webEweK(dvqte8`PC)c@MKA z08m?!K)Y%*{O3vaf0rW?@^~-s==kohZ$Z7Y8E>|hMrAVSSI|xyda~_R^{?Q8+=Mmh z#yN-J?(S_po!jh*+gBH?hDmps-%DYCd>3paGwPu(+)#06&L*sUu0tI_bUn;EFHYmyYhZzLWDh3)*bbC}K`t=i zaPhbjrpR8GKA1EA)gh*xIA~^TZ^2pJZw0uyxjR37x~G+G_@eF6&dyGG(8hw+2rF3U zqvhb(_ntImh49;#O-xK^pET7_0>l(>_r#m-UImFB#h5JPV1?&#KdKFZ_G6|U58Bo6 z+u5fQRdxpV*JDNp2=SE3rmJ!g>7kSnG*Ylh@xD&JaaU30#qydE{4VQ0J5**UWw|Lp zFm1mr>w}67{VpOIKLV5LP)7I0^9scnXJ@#FkX=&}TF#9Nm6!1mrk0Stmg%Fasadft zd%iUk?e4*qMzz?e!a8vinBX4e?(TX_J^1KI1s{st z9l>W4TbK?A^RF*mRoRQ!ll0aoSMN2CdT4MKIH=&yLf@d-5yl2ZGqiTU<}H#DLx`&P z&Y-2)UD*wayvx`lm%X*w`^JBlzB*rPeeVeZ%siS>m#e9l z-mvn>c9x$(ZhE!%-hXz%!McV7&U;Q{)&jJD+O}qFTlY4!Z|!t!wTwqH#y=rLm-62- zwqs5)Os!geu0S5!%p&zP7c1C!UNeecUCnD@DF3YttnXE-D&kA9cTXZBJ~|K3a3{eldI^1xp#N;(HF{=mlN~!7ZVr)DQ37(MwN*RF+1?q z#&`578JQ|ziBpknv7=~>C^3) zvBV&&V_QW5HYZERn4Ezb|J zq|+m#K`!%#%$NePk$u87SYnm6y=wc5DD5M)Zg2!$WQBxVEP&~(Zf&iVJPs};TF)S! zy8YTuePMU3AY ze2~QWsk*izXG8I9pfH=5-(ViN)nJ||5I$-?x)xc=EpnRV#;B2n<74$lIK02tpdDRd zc6?3^0hIidU87evBT|jRn>mHvk}t={2UPYPvHm{xNGfd5$}Sj*i+lxt*qIji{iAYB z2EM++!7{y|NgdrdfH~Q|wPn?@jmKoIWB>`Ime>WWtMA>$j^LBvVj==Z5>&dk>5YqA zgwtw-68y&A?uR@apo+2M|3nopY=d~!^r{>+u`y(R{=2RX4!c(uiHv2C-kM<; z-wJ6{`pRCjsP5PBl3fZQL7`6?Q;2}p-U17Lu>4FaNRh-O02QPNM{`)FV2bCWNgG1e zJ*%6qX=|tj?J|*=tCclhfS0dJ z$WyWeLuA_~gmk=JA-o{=cwa-+a_eVLsqh7n8AN^|7h(D)G6Ijt{HFw7H2Upo!}sIu zTP|d|#u4R4Z))c*f6o?u?Md*K1pg=hrn2k3RYl#;wyGR5zz^@zLsn>;Qy#MfRy3T& zD5eI;_<_VT1wUF5$iH`dH0`S7ov)kNS9-oG=;GZz`!8i^A_452BE7X)N_-Lrp9{AE zn=6~i=LBTCj;W{X`fm{X`5DlN7~@`ecSvz%=lmpPQaSU#CoC>;Pa^ldmSeh4zlM*e znO9N5_r7UDF1KZc>dJ>^t8ssqY$QQ@x8?bQ#iQ#dQVbtj6rg}1yu2kYI{U)TXGeSn zfq(sql0Txp2c+c+5?N~7ylw4i2Z*xVI`L`y3r9%fA)PzK^f|j95)TAl4M@C0ANWXV zHeUUI$~J;GF%Ar3807Zx_m#aOO8-4XN6wpb=LR{(QzqlitdpjnLHj2$u5z zYzTYa6&$JuYXCm5T~(CJ$W7P zM#=A5*vxjmhq~?V*z5dhVrML6mz~7AnE2<5+$~}_$wq(VV`D0x-h!iq-Z-bceQWuc zK<8+cb!WVdk0TN(EJrnSm@!jBe^FFx1OqtH9_%wA#eXcUv$IRzc7aPNHaB$d6N7-? z-@ogN59C1(Dn5!RQ{5JlCq`h2!=J9Sw@pR5zmjTiVr5spLJ5EU>{-W?SpK@rU0(-Y zRgxlsB&8G?jfVAkf?d{z<5hXKJNEbNDUL3;(`1_%+EG3^xhZ~V_3nPE8CNax(lyu! zT|se0izahAg?(6x{n+TC65mNdxP#n9*RB&0%8LH89G#DCmvFND9#h@MmsbM>Z`q)t zqbj_pft=zH6xc1YRb>Z>3N%J#(K?H@Q0M=W?X=UZ`E~5?CzJQDr>gYTDNyvt6~&`H zjobXRl?SIb)JKnUsu7>PymIsD7G3U;=tUZ-;qsXV@286<%Cz~5i_c%#Oi%jjy&0{D zDM+zd2{e20&gop8--uKwUABpG;Yq#L1)E(pG z`oFT+J83h7_Q%Wj8E|WDoXNK>$|PwyVmeL0aWJrMVr*@ zx}qqkT)6%5l7YRc4uQ34*)MUeo{3#wVfF#$z*` z>Ypb>^`4#X ziKLVhXV5#|eU!JIqkd7VlOrK(W;}ZMD@{eIsP8?Y&o1?T|17DXe70iK{)E}^+9N(T z{DsTikF;Le-MmR#J8b(W{z=a(s(0^Hs_$!?sg(?jz)nk1-ssKAu501_ho7#B$|3`j z|FHS1Ao;>{*}r~+sSynpSJpRzB+_%M@}i(AB21osT=aUJ#m=3F?vBFy95PuHIZ<2U zIddNzGXp`%B9ks6wa!+iniZujpLm<;KFl*SJ}epN zBLAQb9F+6NvfESvNktvW_+8^>-Zj0!!eNfXlcQq4{w}L{=V*rH8^M^yj#ruwBzJin zL5*+W<55AS0fTa9c6fu~&Tv!QtRTv$W2?#Vq}`oY^j+tYDyW0!bsu{)-sFMt>VHb7p;X_ccZH*)^j6_Q^v}ZM_sctPjPQi>UCvVQDuK zF26}hV&5K3V%5_pT=|XA-k6S%t-7ihbggs##pxwNRa(I)cFXa&ld^ZbH*XcU4y@Cj zd?+Kyov$p%dFs3Iiq-9h99()b$qzS${)obQzlK|kUid;BGqEu4#y5y^>23@JHHL$n z@6DZM{?$j1(k6eDb((`0CxfP|ja6vcs({GRF%b#+G=<-Eow;6R%Pkv%Q zlK-$h@nm*cOMc)39r}=pL)dEBx-mN3X!+!W3y+lC)V@$GfQ}rfx8)0|&T%I8bB82p z3POUUt@dIoE*%Yg_|)e`zo%T?!q_(DO4B!(*h61kx$I8dc2(iAqVdUlCM@jXRW&L@ zvWXk*7fxuNIFx;{N#96CCFFmtNF$1aqoqMV=OACJp0xy%giZ|b1wJh`Sc1ad3iHF||1o$S;*Z%V)!}|LVwxTOv8$1L< zueu(P;>wSGcDnm-$(Pg>@nIevj(PVb+r$@r`QF12=#}(veW}3gQa=7CPuFP|8T)kT zI3L!0u=?Mp#gm`&+?AYZV`p|KLrSB|aPCy+&Grt;m6L1xc>5V#4a%lpd}DsSa>KvE z|ECmzW!B68+@D{_oLM{r6bzpr!H~K-;$UQy^`<>vU(Nn=dYZ^%$C9GyPx^9uglZKM zbm!f1t@_Nh+@3pdUkPip`JdM7>!bW_s;0VZYPCPQYNf*2xBCQ7NtaESZ{t^-K4sO@ zR)-D&#ioQ$-~N2H`e!vqk$ZyNL$`4Iliv6>4a`J2O{0|2&Xnnf$_sg@~M-Qe&Wi1ZR%(Xnl{xX!M)^nY3Qt4Z6 zk#j$eO=)-4x>j@b`3)^4ThF`?q36zp!ZL{!JERz2{=oYDZU)aPwNI;V{w!j=-uJid zaPHlnO*8%|9DcF(bxqx}sqZ9vldtUA(|GEI?K<^ITfQ$j`Df|%{xym3_szW`+CRaf zc-FIE-7WVUJtUe6Iu`bY_0?51Exx{K%5{@VA;NRF_I=o;yJt@k=VICTfdreH$hqdSWrpK9*^wsS%;po z3Pk!|20CzR>dt99Jc@QSl-}Nde%~(l8@#(s{4T%CI@K&_B{VlBYx~BJjhBo5uLDzKJK`uwMtJ5S&7 zxLg0-yQ3g-PHp}E=B?astM!iQe|%@Hz9Vky-@0#ZTP#hl`3FizYfF>%lH*mj_dN@k)Vu;f#*&E5Ac z45akSksr6BK`o|Bmp`mnzxVIC&sqT>aR{2HB5n`zHUypJ)~f@X59ZGj2j&yFN)!DZ zKQ!SgGvf+A2E()g3z`RyK#2;1mQ)@*>JQ_ncz$l`o)1?!=~-)}xV^fX+PqR(kl>Qa zq;)=-_rTWFM)yzB{QCsAL5IFPdcMfDd&b*zu<3HguYX=z33kO76&tU8f8rgV-F))! SqSbLwN5Rw8&t;ucLK6TtSdwJ` literal 0 HcmV?d00001 diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json new file mode 100644 index 00000000000000..0370f58706a65c --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json @@ -0,0 +1,38 @@ +{ + "attributes": { + "description": "Logs Kafka integration dashboard", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"highlightAll\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false}", + "panelsJSON": "[{\"embeddableConfig\":{},\"gridData\":{\"h\":12,\"i\":\"1\",\"w\":24,\"x\":0,\"y\":0},\"panelIndex\":\"1\",\"panelRefName\":\"panel_0\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"kafka.log.class\",\"kafka.log.trace.class\",\"kafka.log.trace.full\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":12,\"i\":\"2\",\"w\":24,\"x\":24,\"y\":0},\"panelIndex\":\"2\",\"panelRefName\":\"panel_1\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"log.level\",\"kafka.log.component\",\"message\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":20,\"i\":\"3\",\"w\":48,\"x\":0,\"y\":20},\"panelIndex\":\"3\",\"panelRefName\":\"panel_2\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{},\"gridData\":{\"h\":8,\"i\":\"4\",\"w\":48,\"x\":0,\"y\":12},\"panelIndex\":\"4\",\"panelRefName\":\"panel_3\",\"version\":\"7.3.0\"}]", + "timeRestore": false, + "title": "[Logs Kafka] Overview ECS", + "version": 1 + }, + "id": "sample_dashboard", + "references": [ + { + "id": "number-of-kafka-stracktraces-by-class-ecs", + "name": "panel_0", + "type": "visualization" + }, + { + "id": "Kafka stacktraces-ecs", + "name": "panel_1", + "type": "search" + }, + { + "id": "sample_search", + "name": "panel_2", + "type": "search" + }, + { + "id": "sample_visualization", + "name": "panel_3", + "type": "visualization" + } + ], + "type": "dashboard" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json new file mode 100644 index 00000000000000..1b34746cec89e1 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/search/sample_search.json @@ -0,0 +1,36 @@ +{ + "attributes": { + "columns": [ + "log.level", + "kafka.log.component", + "message" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":null,\"disabled\":false,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\",\"key\":\"dataset.name\",\"negate\":false,\"params\":{\"query\":\"kafka.log\",\"type\":\"phrase\"},\"type\":\"phrase\",\"value\":\"log\"},\"query\":{\"match\":{\"dataset.name\":{\"query\":\"kafka.log\",\"type\":\"phrase\"}}}}],\"highlightAll\":true,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "All logs [Logs Kafka] ECS", + "version": 1 + }, + "id": "All Kafka logs-ecs", + "references": [ + { + "id": "logs-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "logs-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index", + "type": "index-pattern" + } + ], + "type": "search" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json new file mode 100644 index 00000000000000..5d5162436e6de7 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/kibana/visualization/sample_visualization.json @@ -0,0 +1,22 @@ +{ + "attributes": { + "description": "", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[]}" + }, + "savedSearchRefName": "search_0", + "title": "Log levels over time [Logs Kafka] ECS", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customLabel\":\"Log Level\",\"field\":\"log.level\",\"order\":\"desc\",\"orderBy\":\"1\",\"size\":5},\"schema\":\"group\",\"type\":\"terms\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per day\"},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Count\"},\"type\":\"value\"}]},\"title\":\"Log levels over time [Logs Kafka] ECS\",\"type\":\"histogram\"}" + }, + "id": "sample_visualization", + "references": [ + { + "id": "All Kafka logs-ecs", + "name": "search_0", + "type": "search" + } + ], + "type": "visualization" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml new file mode 100644 index 00000000000000..ec3586689becf4 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml @@ -0,0 +1,30 @@ +format_version: 1.0.0 +name: filetest +title: For File Tests +description: This is a package. +version: 0.1.0 +categories: [] +# Options are experimental, beta, ga +release: beta +# The package type. The options for now are [integration, solution], more type might be added in the future. +# The default type is integration and will be set if empty. +type: integration +license: basic +# This package can be removed +removable: true + +requirement: + elasticsearch: + versions: ">7.7.0" + kibana: + versions: ">7.7.0" + +screenshots: +- src: "/img/screenshots/metricbeat_dashboard.png" + title: "metricbeat dashboard" + size: "1855x949" + type: "image/png" +icons: + - src: "/img/logo.svg" + size: "16x16" + type: "image/svg+xml" \ No newline at end of file diff --git a/x-pack/test/epm_api_integration/apis/ilm.ts b/x-pack/test/ingest_manager_api_integration/apis/ilm.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/ilm.ts rename to x-pack/test/ingest_manager_api_integration/apis/ilm.ts diff --git a/x-pack/test/epm_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js similarity index 90% rename from x-pack/test/epm_api_integration/apis/index.js rename to x-pack/test/ingest_manager_api_integration/apis/index.js index 3dc4624d15cf42..ef8880f86078b3 100644 --- a/x-pack/test/epm_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -9,7 +9,7 @@ export default function ({ loadTestFile }) { this.tags('ciGroup7'); loadTestFile(require.resolve('./list')); loadTestFile(require.resolve('./file')); - loadTestFile(require.resolve('./template')); + //loadTestFile(require.resolve('./template')); loadTestFile(require.resolve('./ilm')); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/list.ts b/x-pack/test/ingest_manager_api_integration/apis/list.ts new file mode 100644 index 00000000000000..200358cb6f8f03 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/list.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { warnAndSkipTest } from '../helpers'; + +export default function ({ getService }: FtrProviderContext) { + const log = getService('log'); + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + // use function () {} and not () => {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('list', async function () { + it('lists all packages from the registry', async function () { + if (server.enabled) { + const fetchPackageList = async () => { + const response = await supertest + .get('/api/ingest_manager/epm/packages') + .set('kbn-xsrf', 'xxx') + .expect(200); + return response.body; + }; + const listResponse = await fetchPackageList(); + expect(listResponse.response.length).to.be(11); + } else { + warnAndSkipTest(this, log); + } + }); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/mock_http_server.d.ts b/x-pack/test/ingest_manager_api_integration/apis/mock_http_server.d.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/mock_http_server.d.ts rename to x-pack/test/ingest_manager_api_integration/apis/mock_http_server.d.ts diff --git a/x-pack/test/epm_api_integration/apis/template.ts b/x-pack/test/ingest_manager_api_integration/apis/template.ts similarity index 100% rename from x-pack/test/epm_api_integration/apis/template.ts rename to x-pack/test/ingest_manager_api_integration/apis/template.ts diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts new file mode 100644 index 00000000000000..bbef12463ed089 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { defineDockerServersConfig } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + const registryPort: string | undefined = process.env.INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT; + + // mount the config file for the package registry as well as + // the directory containing additional packages into the container + const dockerArgs: string[] = [ + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/package_registry_config.yml' + )}:/registry/config.yml`, + '-v', + `${path.join( + path.dirname(__filename), + './apis/fixtures/test_packages' + )}:/registry/packages/test-packages`, + ]; + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + dockerServers: defineDockerServersConfig({ + registry: { + enabled: !!registryPort, + image: 'docker.elastic.co/package-registry/package-registry:kibana-testing-1', + portInContainer: 8080, + port: registryPort, + args: dockerArgs, + waitForLogLine: 'package manifests loaded', + }, + }), + services: { + supertest: xPackAPITestsConfig.get('services.supertest'), + es: xPackAPITestsConfig.get('services.es'), + }, + junit: { + reportName: 'X-Pack EPM API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + ...(registryPort + ? [`--xpack.ingestManager.epm.registryUrl=http://localhost:${registryPort}`] + : []), + ], + }, + }; +} diff --git a/x-pack/test/ingest_manager_api_integration/helpers.ts b/x-pack/test/ingest_manager_api_integration/helpers.ts new file mode 100644 index 00000000000000..121630249621be --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Context } from 'mocha'; +import { ToolingLog } from '@kbn/dev-utils'; + +export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) { + log.warning( + 'disabling tests because DockerServers service is not enabled, set INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT to run them' + ); + mochaContext.skip(); +} From 64e87cd6b5300ad229ce640ddc754decf3b9eb83 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 29 Jun 2020 15:36:59 +0200 Subject: [PATCH 05/49] [Uptime] Use ML Capabilities API to determine license type (#66921) Co-authored-by: Elastic Machine --- .../__snapshots__/license_info.test.tsx.snap | 44 ++++++++------- .../__snapshots__/ml_flyout.test.tsx.snap | 29 +++++----- .../ml/__tests__/license_info.test.tsx | 8 +++ .../monitor/ml/__tests__/ml_flyout.test.tsx | 51 +++++------------- .../components/monitor/ml/license_info.tsx | 54 ++++++++++++++++--- .../components/monitor/ml/ml_flyout.tsx | 12 +++-- .../components/monitor/ml/ml_integeration.tsx | 4 +- .../monitor_duration_container.tsx | 4 +- .../contexts/uptime_settings_context.tsx | 20 +------ .../uptime/public/state/selectors/index.ts | 2 +- 10 files changed, 122 insertions(+), 106 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap index 2ba4eda82a391c..09c58b6336871c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -26,22 +26,24 @@ Array [

In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

-
- + - Start free 14-day trial + + Start free 14-day trial + - - + + ,
In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

- - Start free 14-day trial - + + Start free 14-day trial + + diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap index 7a61eb7391a10f..5c7215edcbce74 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -17,7 +17,6 @@ exports[`ML Flyout component renders without errors 1`] = ` /> -

Here you can create a machine learning job to calculate anomaly scores on @@ -67,7 +66,7 @@ exports[`ML Flyout component renders without errors 1`] = ` > In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.

- - + - Start free 14-day trial + + Start free 14-day trial + - - + +
{ + beforeEach(() => { + const spy = jest.spyOn(redux, 'useDispatch'); + spy.mockReturnValue(jest.fn()); + + const spy1 = jest.spyOn(redux, 'useSelector'); + spy1.mockReturnValue(true); + }); it('shallow renders without errors', () => { const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx index 31cdcfac9feef3..4795042ed845fd 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_flyout.test.tsx @@ -9,47 +9,21 @@ import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { MLFlyoutView } from '../ml_flyout'; import { UptimeSettingsContext } from '../../../../contexts'; import { CLIENT_DEFAULTS } from '../../../../../common/constants'; -import { License } from '../../../../../../../plugins/licensing/common/license'; - -const expiredLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'platinum', - status: 'expired', - type: 'platinum', - uid: '1', - }, - features: { - ml: { - isAvailable: false, - isEnabled: false, - }, - }, -}); - -const validLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 30000, - mode: 'platinum', - status: 'active', - type: 'platinum', - uid: '2', - }, - features: { - ml: { - isAvailable: true, - isEnabled: true, - }, - }, -}); +import * as redux from 'react-redux'; describe('ML Flyout component', () => { const createJob = () => {}; const onClose = () => {}; const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS; + beforeEach(() => { + const spy = jest.spyOn(redux, 'useDispatch'); + spy.mockReturnValue(jest.fn()); + + const spy1 = jest.spyOn(redux, 'useSelector'); + spy1.mockReturnValue(true); + }); + it('renders without errors', () => { const wrapper = shallowWithIntl( { expect(wrapper).toMatchSnapshot(); }); it('shows license info if no ml available', () => { + const spy1 = jest.spyOn(redux, 'useSelector'); + + // return false value for no license + spy1.mockReturnValue(false); + const value = { - license: expiredLicense, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, @@ -88,7 +66,6 @@ describe('ML Flyout component', () => { it('able to create job if valid license is available', () => { const value = { - license: validLicense, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx index e37ec4cc4715d4..2461875d502b77 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/license_info.tsx @@ -3,13 +3,48 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; import { UptimeSettingsContext } from '../../../contexts'; import * as labels from './translations'; +import { getMLCapabilitiesAction } from '../../../state/actions'; +import { hasMLFeatureSelector } from '../../../state/selectors'; export const ShowLicenseInfo = () => { const { basePath } = useContext(UptimeSettingsContext); + const [loading, setLoading] = useState(false); + const hasMlFeature = useSelector(hasMLFeatureSelector); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getMLCapabilitiesAction.get()); + }, [dispatch]); + + useEffect(() => { + let retryInterval: any; + if (loading) { + retryInterval = setInterval(() => { + dispatch(getMLCapabilitiesAction.get()); + }, 5000); + } else { + clearInterval(retryInterval); + } + + return () => { + clearInterval(retryInterval); + }; + }, [dispatch, loading]); + + useEffect(() => { + setLoading(false); + }, [hasMlFeature]); + + const startLicenseTrial = () => { + setLoading(true); + }; + return ( <> { iconType="help" >

{labels.START_TRAIL_DESC}

- - {labels.START_TRAIL} - + {}}> + + {labels.START_TRAIL} + +
diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx index 8c3f814e841f7a..3e60f09452587a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.tsx @@ -20,9 +20,11 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; import * as labels from './translations'; import { UptimeSettingsContext } from '../../../contexts'; import { ShowLicenseInfo } from './license_info'; +import { hasMLFeatureSelector } from '../../../state/selectors'; interface Props { isCreatingJob: boolean; @@ -32,11 +34,11 @@ interface Props { } export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateMLJob }: Props) { - const { basePath, license } = useContext(UptimeSettingsContext); + const { basePath } = useContext(UptimeSettingsContext); - const isLoadingMLJob = false; + const hasMlFeature = useSelector(hasMLFeatureSelector); - const hasPlatinumLicense = license?.getFeature('ml')?.isAvailable; + const isLoadingMLJob = false; return ( @@ -47,7 +49,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM - {!hasPlatinumLicense && } + {!hasMlFeature && }

{labels.CREAT_ML_JOB_DESC}

@@ -80,7 +82,7 @@ export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateM onClick={() => onClickCreate()} fill isLoading={isCreatingJob} - disabled={isCreatingJob || isLoadingMLJob || !hasPlatinumLicense || !canCreateMLJob} + disabled={isCreatingJob || isLoadingMLJob || !hasMlFeature || !canCreateMLJob} > {labels.CREATE_NEW_JOB} diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx index e66808f76d24a2..1de19dda3b88f4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx @@ -8,7 +8,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { MachineLearningFlyout } from './ml_flyout_container'; import { - hasMLFeatureAvailable, + hasMLFeatureSelector, hasMLJobSelector, isMLJobDeletedSelector, isMLJobDeletingSelector, @@ -35,7 +35,7 @@ export const MLIntegrationComponent = () => { const dispatch = useDispatch(); - const isMLAvailable = useSelector(hasMLFeatureAvailable); + const isMLAvailable = useSelector(hasMLFeatureSelector); const deleteMLJob = () => dispatch(deleteMLJobAction.get({ monitorId: monitorId as string })); const isMLJobDeleting = useSelector(isMLJobDeletingSelector); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index b586c1241290bc..df8ceed76b7968 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -14,7 +14,7 @@ import { } from '../../../state/actions'; import { anomaliesSelector, - hasMLFeatureAvailable, + hasMLFeatureSelector, hasMLJobSelector, selectDurationLines, } from '../../../state/selectors'; @@ -34,7 +34,7 @@ export const MonitorDuration: React.FC = ({ monitorId }) => { const { durationLines, loading } = useSelector(selectDurationLines); - const isMLAvailable = useSelector(hasMLFeatureAvailable); + const isMLAvailable = useSelector(hasMLFeatureSelector); const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector); diff --git a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx index 4fabf3f2ed4972..142c6e17c5fd90 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -9,11 +9,9 @@ import { UptimeAppProps } from '../uptime_app'; import { CLIENT_DEFAULTS, CONTEXT_DEFAULTS } from '../../common/constants'; import { CommonlyUsedRange } from '../components/common/uptime_date_picker'; import { useGetUrlParams } from '../hooks'; -import { ILicense } from '../../../../plugins/licensing/common/types'; export interface UptimeSettingsContextValues { basePath: string; - license?: ILicense | null; dateRangeStart: string; dateRangeEnd: string; isApmAvailable: boolean; @@ -41,27 +39,12 @@ const defaultContext: UptimeSettingsContextValues = { export const UptimeSettingsContext = createContext(defaultContext); export const UptimeSettingsContextProvider: React.FC = ({ children, ...props }) => { - const { - basePath, - isApmAvailable, - isInfraAvailable, - isLogsAvailable, - commonlyUsedRanges, - plugins, - } = props; + const { basePath, isApmAvailable, isInfraAvailable, isLogsAvailable, commonlyUsedRanges } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); - let license: ILicense | null = null; - - // @ts-ignore - plugins.licensing.license$.subscribe((licenseItem: ILicense) => { - license = licenseItem; - }); - const value = useMemo(() => { return { - license, basePath, isApmAvailable, isInfraAvailable, @@ -71,7 +54,6 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ - license, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index d08db2ccf5f2d2..4c2b671203f0ad 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -36,7 +36,7 @@ export const snapshotDataSelector = ({ snapshot }: AppState) => snapshot; const mlCapabilitiesSelector = (state: AppState) => state.ml.mlCapabilities.data; -export const hasMLFeatureAvailable = createSelector( +export const hasMLFeatureSelector = createSelector( mlCapabilitiesSelector, (mlCapabilities) => mlCapabilities?.isPlatinumOrTrialLicense && mlCapabilities?.mlFeatureEnabledInSpace From 81022a320660fc9b40008e74cde91d5f3134fbb3 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Mon, 29 Jun 2020 10:01:59 -0400 Subject: [PATCH 06/49] [Ingest Manager] rollover data stream when index template mappings are not compatible (#69180) * rollover data stream when index template mappings are not compatible * update error messages Co-authored-by: Elastic Machine --- .../ingest_manager/common/types/models/epm.ts | 2 +- .../epm/elasticsearch/template/template.ts | 83 ++++++++++--------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index 599165d2bfd981..01cbdbb0ea0314 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -273,7 +273,7 @@ export interface IndexTemplate { index_patterns: string[]; template: { settings: any; - mappings: object; + mappings: any; aliases: object; }; data_stream: { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index b7760a9032aca8..9e8f327d520e3b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -330,11 +330,15 @@ const getIndices = async ( template: TemplateRef ): Promise => { const { templateName, indexTemplate } = template; - const res = await callCluster('search', getIndexQuery(templateName)); - const indices: any[] = res?.aggregations?.index.buckets; - if (indices) { - return indices.map((index) => ({ - indexName: index.key, + // Until ES provides a way to update mappings of a data stream + // get the last index of the data stream, which is the current write index + const res = await callCluster('transport.request', { + method: 'GET', + path: `/_data_stream/${templateName}-*`, + }); + if (res.length) { + return res.map((datastream: any) => ({ + indexName: datastream.indices[datastream.indices.length - 1].index_name, indexTemplate, })); } @@ -359,18 +363,40 @@ const updateExistingIndex = async ({ indexTemplate: IndexTemplate; }) => { const { settings, mappings } = indexTemplate.template; + + // for now, remove from object so as not to update stream or dataset properties of the index until type and name + // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue + // to skip updating and assume the value in the index mapping is correct + delete mappings.properties.stream; + delete mappings.properties.dataset; + + // get the dataset values from the index template to compose data stream name + const indexMappings = await getIndexMappings(indexName, callCluster); + const dataset = indexMappings[indexName].mappings.properties.dataset.properties; + if (!dataset.type.value || !dataset.name.value || !dataset.namespace.value) + throw new Error(`dataset values are missing from the index template ${indexName}`); + const dataStreamName = `${dataset.type.value}-${dataset.name.value}-${dataset.namespace.value}`; + // try to update the mappings first - // for now we assume updates are compatible try { await callCluster('indices.putMapping', { index: indexName, body: mappings, }); + // if update fails, rollover data stream } catch (err) { - throw new Error('incompatible mappings update'); + try { + const path = `/${dataStreamName}/_rollover`; + await callCluster('transport.request', { + method: 'POST', + path, + }); + } catch (error) { + throw new Error(`cannot rollover data stream ${dataStreamName}`); + } } // update settings after mappings was successful to ensure - // pointing to theme new pipeline is safe + // pointing to the new pipeline is safe // for now, only update the pipeline if (!settings.index.default_pipeline) return; try { @@ -379,36 +405,17 @@ const updateExistingIndex = async ({ body: { index: { default_pipeline: settings.index.default_pipeline } }, }); } catch (err) { - throw new Error('incompatible settings update'); + throw new Error(`could not update index template settings for ${indexName}`); } }; -const getIndexQuery = (templateName: string) => ({ - index: `${templateName}-*`, - size: 0, - body: { - query: { - bool: { - must: [ - { - exists: { - field: 'dataset.namespace', - }, - }, - { - exists: { - field: 'dataset.name', - }, - }, - ], - }, - }, - aggs: { - index: { - terms: { - field: '_index', - }, - }, - }, - }, -}); +const getIndexMappings = async (indexName: string, callCluster: CallESAsCurrentUser) => { + try { + const indexMappings = await callCluster('indices.getMapping', { + index: indexName, + }); + return indexMappings; + } catch (err) { + throw new Error(`could not get mapping from ${indexName}`); + } +}; From dbdc3cd01a6f0444ca010e59b7696944ec8ce3f7 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 29 Jun 2020 16:17:32 +0200 Subject: [PATCH 07/49] [APM] Run API tests as restricted user (#70050) --- x-pack/plugins/apm/readme.md | 25 ++++- .../basic/tests/agent_configuration.ts | 11 +- .../basic/tests/annotations.ts | 6 +- .../basic/tests/custom_link.ts | 11 +- .../basic/tests/feature_controls.ts | 2 +- .../common/authentication.ts | 102 ++++++++++++++++++ .../test/apm_api_integration/common/config.ts | 42 +++++++- .../common/ftr_provider_context.ts | 14 ++- .../trial/tests/annotations.ts | 9 +- 9 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 x-pack/test/apm_api_integration/common/authentication.ts diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index cb694712d7c97b..778b1f2ad2d91b 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -80,19 +80,38 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### API integration tests +Our tests are separated in two suites: one suite runs with a basic license, and the other +with a trial license (the equivalent of gold+). This requires separate test servers and test runs. + **Start server** +Basic: + +``` +node scripts/functional_tests_server --config x-pack/test/apm_api_integration/basic/config.ts +``` + +Trial: + ``` -node scripts/functional_tests_server --config x-pack/test/api_integration/config.ts +node scripts/functional_tests_server --config x-pack/test/apm_api_integration/trial/config.ts ``` **Run tests** +Basic: + +``` +node scripts/functional_test_runner --config x-pack/test/apm_api_integration/basic/config.ts +``` + +Trial: + ``` -node scripts/functional_test_runner --config x-pack/test/api_integration/config.ts --grep='APM specs' +node scripts/functional_test_runner --config x-pack/test/apm_api_integration/trial/config.ts ``` -APM tests are located in `x-pack/test/api_integration/apis/apm`. +APM tests are located in `x-pack/test/apm_api_integration`. For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### Linting diff --git a/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts b/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts index f6750a8eca24e7..9f39da2037f8ea 100644 --- a/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts +++ b/x-pack/test/apm_api_integration/basic/tests/agent_configuration.ts @@ -10,11 +10,12 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function agentConfigurationTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmWriteUser'); const log = getService('log'); function searchConfigurations(configuration: any) { - return supertest + return supertestRead .post(`/api/apm/settings/agent-configuration/search`) .send(configuration) .set('kbn-xsrf', 'foo'); @@ -22,7 +23,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function createConfiguration(config: AgentConfigurationIntake) { log.debug('creating configuration', config.service); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/agent-configuration`) .send(config) .set('kbn-xsrf', 'foo'); @@ -34,7 +35,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function updateConfiguration(config: AgentConfigurationIntake) { log.debug('updating configuration', config.service); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/agent-configuration?overwrite=true`) .send(config) .set('kbn-xsrf', 'foo'); @@ -46,7 +47,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte async function deleteConfiguration({ service }: AgentConfigurationIntake) { log.debug('deleting configuration', service); - const res = await supertest + const res = await supertestWrite .delete(`/api/apm/settings/agent-configuration`) .send({ service }) .set('kbn-xsrf', 'foo'); diff --git a/x-pack/test/apm_api_integration/basic/tests/annotations.ts b/x-pack/test/apm_api_integration/basic/tests/annotations.ts index d4b4892eaf91cd..c522ebcfb5c65e 100644 --- a/x-pack/test/apm_api_integration/basic/tests/annotations.ts +++ b/x-pack/test/apm_api_integration/basic/tests/annotations.ts @@ -10,15 +10,15 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function annotationApiTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestWrite = getService('supertestAsApmAnnotationsWriteUser'); function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) { switch (method.toLowerCase()) { case 'post': - return supertest.post(url).send(data).set('kbn-xsrf', 'foo'); + return supertestWrite.post(url).send(data).set('kbn-xsrf', 'foo'); default: - throw new Error(`Unsupported methoed ${method}`); + throw new Error(`Unsupported method ${method}`); } } diff --git a/x-pack/test/apm_api_integration/basic/tests/custom_link.ts b/x-pack/test/apm_api_integration/basic/tests/custom_link.ts index 910c4797f39b76..77fdc83523ca64 100644 --- a/x-pack/test/apm_api_integration/basic/tests/custom_link.ts +++ b/x-pack/test/apm_api_integration/basic/tests/custom_link.ts @@ -10,7 +10,8 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function customLinksTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmWriteUser'); const log = getService('log'); function searchCustomLinks(filters?: any) { @@ -18,12 +19,12 @@ export default function customLinksTests({ getService }: FtrProviderContext) { pathname: `/api/apm/settings/custom_links`, query: filters, }); - return supertest.get(path).set('kbn-xsrf', 'foo'); + return supertestRead.get(path).set('kbn-xsrf', 'foo'); } async function createCustomLink(customLink: CustomLink) { log.debug('creating configuration', customLink); - const res = await supertest + const res = await supertestWrite .post(`/api/apm/settings/custom_links`) .send(customLink) .set('kbn-xsrf', 'foo'); @@ -35,7 +36,7 @@ export default function customLinksTests({ getService }: FtrProviderContext) { async function updateCustomLink(id: string, customLink: CustomLink) { log.debug('updating configuration', id, customLink); - const res = await supertest + const res = await supertestWrite .put(`/api/apm/settings/custom_links/${id}`) .send(customLink) .set('kbn-xsrf', 'foo'); @@ -47,7 +48,7 @@ export default function customLinksTests({ getService }: FtrProviderContext) { async function deleteCustomLink(id: string) { log.debug('deleting configuration', id); - const res = await supertest + const res = await supertestWrite .delete(`/api/apm/settings/custom_links/${id}`) .set('kbn-xsrf', 'foo'); diff --git a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts index f3647c65106c92..42cbef69abbec9 100644 --- a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function featureControlsTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertest = getService('supertestAsApmWriteUser'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); diff --git a/x-pack/test/apm_api_integration/common/authentication.ts b/x-pack/test/apm_api_integration/common/authentication.ts new file mode 100644 index 00000000000000..9c34b4791114a4 --- /dev/null +++ b/x-pack/test/apm_api_integration/common/authentication.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PromiseReturnType } from '../../../plugins/apm/typings/common'; +import { SecurityServiceProvider } from '../../../../test/common/services/security'; + +type SecurityService = PromiseReturnType; + +export enum ApmUser { + apmReadUser = 'apm_read_user', + apmWriteUser = 'apm_write_user', + apmAnnotationsWriteUser = 'apm_annotations_write_user', +} + +const roles = { + [ApmUser.apmReadUser]: { + elasticsearch: { + cluster: [], + indices: [ + { names: ['observability-annotations'], privileges: ['read', 'view_index_metadata'] }, + ], + }, + kibana: [ + { + base: [], + feature: { + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, + [ApmUser.apmWriteUser]: { + elasticsearch: { + cluster: [], + indices: [ + { names: ['observability-annotations'], privileges: ['read', 'view_index_metadata'] }, + ], + }, + kibana: [ + { + base: [], + feature: { + apm: ['all'], + }, + spaces: ['*'], + }, + ], + }, + [ApmUser.apmAnnotationsWriteUser]: { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['observability-annotations'], + privileges: [ + 'read', + 'view_index_metadata', + 'index', + 'manage', + 'create_index', + 'create_doc', + ], + }, + ], + }, + }, +}; + +const users = { + [ApmUser.apmReadUser]: { + roles: ['apm_user', ApmUser.apmReadUser], + }, + [ApmUser.apmWriteUser]: { + roles: ['apm_user', ApmUser.apmWriteUser], + }, + [ApmUser.apmAnnotationsWriteUser]: { + roles: ['apm_user', ApmUser.apmWriteUser, ApmUser.apmAnnotationsWriteUser], + }, +}; + +export async function createApmUser(security: SecurityService, apmUser: ApmUser) { + const role = roles[apmUser]; + const user = users[apmUser]; + + if (!role || !user) { + throw new Error(`No configuration found for ${apmUser}`); + } + + await security.role.create(apmUser, role); + + await security.user.create(apmUser, { + full_name: apmUser, + password: APM_TEST_PASSWORD, + roles: user.roles, + }); +} + +export const APM_TEST_PASSWORD = 'changeme'; diff --git a/x-pack/test/apm_api_integration/common/config.ts b/x-pack/test/apm_api_integration/common/config.ts index 83dc597829a3cd..e4dc2a78ae0189 100644 --- a/x-pack/test/apm_api_integration/common/config.ts +++ b/x-pack/test/apm_api_integration/common/config.ts @@ -5,6 +5,11 @@ */ import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import supertestAsPromised from 'supertest-as-promised'; +import { format, UrlObject } from 'url'; +import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context'; +import { PromiseReturnType } from '../../../plugins/apm/typings/common'; +import { createApmUser, APM_TEST_PASSWORD, ApmUser } from './authentication'; interface Settings { license: 'basic' | 'trial'; @@ -12,6 +17,22 @@ interface Settings { name: string; } +const supertestAsApmUser = (kibanaServer: UrlObject, apmUser: ApmUser) => async ( + context: InheritedFtrProviderContext +) => { + const security = context.getService('security'); + await security.init(); + + await createApmUser(security, apmUser); + + const url = format({ + ...kibanaServer, + auth: `${apmUser}:${APM_TEST_PASSWORD}`, + }); + + return supertestAsPromised(url); +}; + export function createTestConfig(settings: Settings) { const { testFiles, license, name } = settings; @@ -20,14 +41,27 @@ export function createTestConfig(settings: Settings) { require.resolve('../../api_integration/config.ts') ); + const services = xPackAPITestsConfig.get('services') as InheritedServices; + const servers = xPackAPITestsConfig.get('servers'); + + const supertestAsApmReadUser = supertestAsApmUser(servers.kibana, ApmUser.apmReadUser); + return { testFiles, - servers: xPackAPITestsConfig.get('servers'), - services: xPackAPITestsConfig.get('services'), + servers, + services: { + ...services, + supertest: supertestAsApmReadUser, + supertestAsApmReadUser, + supertestAsApmWriteUser: supertestAsApmUser(servers.kibana, ApmUser.apmWriteUser), + supertestAsApmAnnotationsWriteUser: supertestAsApmUser( + servers.kibana, + ApmUser.apmAnnotationsWriteUser + ), + }, junit: { reportName: name, }, - esTestCluster: { ...xPackAPITestsConfig.get('esTestCluster'), license, @@ -36,3 +70,5 @@ export function createTestConfig(settings: Settings) { }; }; } + +export type ApmServices = PromiseReturnType>['services']; diff --git a/x-pack/test/apm_api_integration/common/ftr_provider_context.ts b/x-pack/test/apm_api_integration/common/ftr_provider_context.ts index 90600816d17114..aee3d556605aa6 100644 --- a/x-pack/test/apm_api_integration/common/ftr_provider_context.ts +++ b/x-pack/test/apm_api_integration/common/ftr_provider_context.ts @@ -4,4 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -export { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { FtrProviderContext as InheritedFtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { ApmServices } from './config'; + +export type InheritedServices = InheritedFtrProviderContext extends GenericFtrProviderContext< + infer TServices, + {} +> + ? TServices + : {}; + +export { InheritedFtrProviderContext }; +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/apm_api_integration/trial/tests/annotations.ts b/x-pack/test/apm_api_integration/trial/tests/annotations.ts index 0913d0c4b90bb6..d5b6b8342e5ab2 100644 --- a/x-pack/test/apm_api_integration/trial/tests/annotations.ts +++ b/x-pack/test/apm_api_integration/trial/tests/annotations.ts @@ -13,7 +13,8 @@ const DEFAULT_INDEX_NAME = 'observability-annotations'; // eslint-disable-next-line import/no-default-export export default function annotationApiTests({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestRead = getService('supertestAsApmReadUser'); + const supertestWrite = getService('supertestAsApmAnnotationsWriteUser'); const es = getService('es'); function expectContainsObj(source: JsonObject, expected: JsonObject) { @@ -30,13 +31,13 @@ export default function annotationApiTests({ getService }: FtrProviderContext) { function request({ method, url, data }: { method: string; url: string; data?: JsonObject }) { switch (method.toLowerCase()) { case 'get': - return supertest.get(url).set('kbn-xsrf', 'foo'); + return supertestRead.get(url).set('kbn-xsrf', 'foo'); case 'post': - return supertest.post(url).send(data).set('kbn-xsrf', 'foo'); + return supertestWrite.post(url).send(data).set('kbn-xsrf', 'foo'); default: - throw new Error(`Unsupported methoed ${method}`); + throw new Error(`Unsupported method ${method}`); } } From 19bda1fceeca564d5bae73b3a78276870e25f220 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Mon, 29 Jun 2020 17:21:49 +0300 Subject: [PATCH 08/49] Reactify visualize app (#67848) * Reactify visualize app * Fix typescript failures after merging master * Make sure refresh button works * Subscribe filter manager fetches * Use redirect to landing page * Update savedSearch type * Add check for TSVB is loaded * Fix comments * Fix uiState persistence on vis load * Remove extra div around TableListView * Update DTS selectors * Add error handling for embeddable * Remove extra argument from useEditorUpdates effect * Update comments, fix typos * Remove extra div wrapper * Apply design suggestions * Revert accidental config changes * Apply navigating to dashboard * Apply redirect legacy urls * Apply incoming changes * Apply incoming changes Co-authored-by: Elastic Machine --- .../public/input_control_vis_type.ts | 2 - src/plugins/kibana_utils/common/index.ts | 1 - src/plugins/kibana_utils/public/index.ts | 1 - .../public/top_nav_menu/top_nav_menu_data.tsx | 4 +- .../saved_object/saved_object_loader.ts | 11 +- src/plugins/saved_objects/public/types.ts | 1 + .../public/components/sidebar/sidebar.tsx | 16 +- .../components/sidebar/sidebar_title.tsx | 6 +- .../public/default_editor.tsx | 3 +- .../application/components/vis_editor.js | 3 +- .../components/vis_editor_visualization.js | 2 + .../public/metrics_type.ts | 2 - src/plugins/vis_type_vega/public/vega_type.ts | 2 - src/plugins/visualizations/public/index.ts | 1 + .../public/saved_visualizations/_saved_vis.ts | 1 + .../saved_visualizations.ts | 2 +- src/plugins/visualizations/public/types.ts | 1 + src/plugins/visualizations/public/vis.ts | 17 +- .../public/vis_types/base_vis_type.ts | 9 +- .../vis_types/vis_type_alias_registry.ts | 1 + src/plugins/visualize/kibana.json | 1 - .../visualize/public/application/_app.scss | 5 - .../application/{index.scss => app.scss} | 8 +- .../visualize/public/application/app.tsx | 63 ++ .../public/application/application.ts | 111 --- .../experimental_vis_info.tsx} | 43 +- .../{editor/lib => components}/index.ts | 6 +- .../visualize_editor.scss} | 0 .../components/visualize_editor.tsx | 115 +++ .../visualize_listing.scss} | 0 .../components/visualize_listing.tsx | 154 ++++ .../components/visualize_no_match.tsx | 77 ++ .../components/visualize_top_nav.tsx | 178 ++++ .../public/application/editor/_index.scss | 1 - .../public/application/editor/editor.html | 104 --- .../public/application/editor/editor.js | 763 ------------------ .../application/editor/lib/make_stateful.ts | 58 -- .../application/editor/visualization.js | 61 -- .../editor/visualization_editor.js | 71 -- .../visualize/public/application/index.tsx | 51 ++ .../public/application/legacy_app.js | 261 ------ .../public/application/listing/_index.scss | 1 - .../listing/visualize_listing.html | 13 - .../application/listing/visualize_listing.js | 174 ---- .../listing/visualize_listing_table.js | 233 ------ .../visualize/public/application/types.ts | 80 +- .../application/{ => utils}/breadcrumbs.ts | 28 +- .../create_visualize_app_state.ts} | 31 +- .../application/utils/get_table_columns.tsx | 162 ++++ .../application/utils/get_top_nav_config.tsx | 303 +++++++ .../utils/get_visualization_instance.ts | 90 +++ .../public/application/utils/index.ts} | 14 +- .../lib => utils}/migrate_app_state.ts | 4 +- .../public/application/utils/use/index.ts} | 12 +- .../use/use_chrome_visibility.ts} | 24 +- .../utils/use/use_editor_updates.ts | 182 +++++ .../utils/use/use_linked_search_updates.ts | 74 ++ .../utils/use/use_saved_vis_instance.ts | 199 +++++ .../utils/use/use_visualize_app_state.tsx | 120 +++ .../public/application/utils/utils.ts | 71 ++ .../public/application/visualize_constants.ts | 6 +- src/plugins/visualize/public/index.ts | 2 +- .../visualize/public/kibana_services.ts | 80 -- src/plugins/visualize/public/plugin.ts | 52 +- .../functional/apps/visualize/_shared_item.js | 2 +- .../page_objects/visual_builder_page.ts | 2 + .../functional/page_objects/visualize_page.ts | 10 +- .../common/layouts/preserve_layout.css | 5 +- .../export_types/common/layouts/print.css | 5 +- .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 5 - .../feature_controls/visualize_security.ts | 6 +- .../apps/visualize/hybrid_visualization.ts | 2 +- 73 files changed, 2092 insertions(+), 2123 deletions(-) delete mode 100644 src/plugins/visualize/public/application/_app.scss rename src/plugins/visualize/public/application/{index.scss => app.scss} (67%) create mode 100644 src/plugins/visualize/public/application/app.tsx delete mode 100644 src/plugins/visualize/public/application/application.ts rename src/plugins/visualize/public/application/{help_menu/help_menu_util.js => components/experimental_vis_info.tsx} (50%) rename src/plugins/visualize/public/application/{editor/lib => components}/index.ts (80%) rename src/plugins/visualize/public/application/{editor/_editor.scss => components/visualize_editor.scss} (100%) create mode 100644 src/plugins/visualize/public/application/components/visualize_editor.tsx rename src/plugins/visualize/public/application/{listing/_listing.scss => components/visualize_listing.scss} (100%) create mode 100644 src/plugins/visualize/public/application/components/visualize_listing.tsx create mode 100644 src/plugins/visualize/public/application/components/visualize_no_match.tsx create mode 100644 src/plugins/visualize/public/application/components/visualize_top_nav.tsx delete mode 100644 src/plugins/visualize/public/application/editor/_index.scss delete mode 100644 src/plugins/visualize/public/application/editor/editor.html delete mode 100644 src/plugins/visualize/public/application/editor/editor.js delete mode 100644 src/plugins/visualize/public/application/editor/lib/make_stateful.ts delete mode 100644 src/plugins/visualize/public/application/editor/visualization.js delete mode 100644 src/plugins/visualize/public/application/editor/visualization_editor.js create mode 100644 src/plugins/visualize/public/application/index.tsx delete mode 100644 src/plugins/visualize/public/application/legacy_app.js delete mode 100644 src/plugins/visualize/public/application/listing/_index.scss delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing.html delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing.js delete mode 100644 src/plugins/visualize/public/application/listing/visualize_listing_table.js rename src/plugins/visualize/public/application/{ => utils}/breadcrumbs.ts (69%) rename src/plugins/visualize/public/application/{editor/lib/visualize_app_state.ts => utils/create_visualize_app_state.ts} (86%) create mode 100644 src/plugins/visualize/public/application/utils/get_table_columns.tsx create mode 100644 src/plugins/visualize/public/application/utils/get_top_nav_config.tsx create mode 100644 src/plugins/visualize/public/application/utils/get_visualization_instance.ts rename src/plugins/{kibana_utils/common/default_feedback_message.ts => visualize/public/application/utils/index.ts} (69%) rename src/plugins/visualize/public/application/{editor/lib => utils}/migrate_app_state.ts (94%) rename src/plugins/{kibana_utils/common/default_feedback_message.test.ts => visualize/public/application/utils/use/index.ts} (68%) rename src/plugins/visualize/public/application/{visualize_app.ts => utils/use/use_chrome_visibility.ts} (65%) create mode 100644 src/plugins/visualize/public/application/utils/use/use_editor_updates.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts create mode 100644 src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx create mode 100644 src/plugins/visualize/public/application/utils/utils.ts delete mode 100644 src/plugins/visualize/public/kibana_services.ts diff --git a/src/plugins/input_control_vis/public/input_control_vis_type.ts b/src/plugins/input_control_vis/public/input_control_vis_type.ts index 8114dbf110f8b3..2af53ea4d28e83 100644 --- a/src/plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/plugins/input_control_vis/public/input_control_vis_type.ts @@ -23,7 +23,6 @@ import { createInputControlVisController } from './vis_controller'; import { getControlsTab } from './components/editor/controls_tab'; import { OptionsTab } from './components/editor/options_tab'; import { InputControlVisDependencies } from './plugin'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; export function createInputControlVisTypeDefinition(deps: InputControlVisDependencies) { const InputControlVisController = createInputControlVisController(deps); @@ -39,7 +38,6 @@ export function createInputControlVisTypeDefinition(deps: InputControlVisDepende defaultMessage: 'Create interactive controls for easy dashboard manipulation.', }), stage: 'experimental', - feedbackMessage: defaultFeedbackMessage, visualization: InputControlVisController, visConfig: { defaults: { diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index 99daed98dbe640..c94021872b4e10 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -28,4 +28,3 @@ export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_w export { url } from './url'; export { now } from './now'; export { calculateObjectHash } from './calculate_object_hash'; -export { defaultFeedbackMessage } from './default_feedback_message'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 6f61e2c228970d..2911a9ae75689d 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -31,7 +31,6 @@ export { UiComponentInstance, url, createGetterSetter, - defaultFeedbackMessage, } from '../common'; export * from './core'; export * from '../common/errors'; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index 2b7466ffd6ab37..a1653c52892554 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -19,7 +19,7 @@ import { ButtonIconSide } from '@elastic/eui'; -export type TopNavMenuAction = (anchorElement: EventTarget) => void; +export type TopNavMenuAction = (anchorElement: HTMLElement) => void; export interface TopNavMenuData { id?: string; @@ -29,7 +29,7 @@ export interface TopNavMenuData { testId?: string; className?: string; disableButton?: boolean | (() => boolean); - tooltip?: string | (() => string); + tooltip?: string | (() => string | undefined); emphasize?: boolean; iconType?: string; iconSide?: ButtonIconSide; diff --git a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts index 53ef1f3f04ad9b..9e7346f3b673cd 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts @@ -51,14 +51,17 @@ export class SavedObjectLoader { } /** - * Retrieve a saved object by id. Returns a promise that completes when the object finishes + * Retrieve a saved object by id or create new one. + * Returns a promise that completes when the object finishes * initializing. - * @param id + * @param opts * @returns {Promise} */ - async get(id?: string) { + async get(opts?: Record | string) { + // can accept object as argument in accordance to SavedVis class + // see src/plugins/saved_objects/public/saved_object/saved_object_loader.ts // @ts-ignore - const obj = new this.Class(id); + const obj = new this.Class(opts); return obj.init(); } diff --git a/src/plugins/saved_objects/public/types.ts b/src/plugins/saved_objects/public/types.ts index 973a493c0a15e9..6db72b396a86ae 100644 --- a/src/plugins/saved_objects/public/types.ts +++ b/src/plugins/saved_objects/public/types.ts @@ -63,6 +63,7 @@ export interface SavedObjectSaveOpts { confirmOverwrite?: boolean; isTitleDuplicateConfirmed?: boolean; onTitleDuplicate?: () => void; + returnToOrigin?: boolean; } export interface SavedObjectCreationOpts { diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index 837dd9bff2c6de..c41315e7bc0dca 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -23,9 +23,13 @@ import { i18n } from '@kbn/i18n'; import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { Vis, PersistedState } from 'src/plugins/visualizations/public'; -import { SavedSearch } from 'src/plugins/discover/public'; +import { + Vis, + PersistedState, + VisualizeEmbeddableContract, +} from 'src/plugins/visualizations/public'; import { TimeRange } from 'src/plugins/data/public'; +import { SavedObject } from 'src/plugins/saved_objects/public'; import { DefaultEditorNavBar, OptionTab } from './navbar'; import { DefaultEditorControls } from './controls'; import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; @@ -34,6 +38,7 @@ import { SidebarTitle } from './sidebar_title'; import { Schema } from '../../schemas'; interface DefaultEditorSideBarProps { + embeddableHandler: VisualizeEmbeddableContract; isCollapsed: boolean; onClickCollapse: () => void; optionTabs: OptionTab[]; @@ -41,11 +46,12 @@ interface DefaultEditorSideBarProps { vis: Vis; isLinkedSearch: boolean; eventEmitter: EventEmitter; - savedSearch?: SavedSearch; + savedSearch?: SavedObject; timeRange: TimeRange; } function DefaultEditorSideBar({ + embeddableHandler, isCollapsed, onClickCollapse, optionTabs, @@ -104,12 +110,12 @@ function DefaultEditorSideBar({ aggs: state.data.aggs ? (state.data.aggs.aggs.map((agg) => agg.toJSON()) as any) : [], }, }); - eventEmitter.emit('updateVis'); + embeddableHandler.reload(); eventEmitter.emit('dirtyStateChange', { isDirty: false, }); setTouched(false); - }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter]); + }, [vis, state, formState.invalid, setTouched, isDirty, eventEmitter, embeddableHandler]); const onSubmit: KeyboardEventHandler = useCallback( (event) => { diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index ebc92170c87350..6713c2ce2391be 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -36,17 +36,17 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { Vis } from 'src/plugins/visualizations/public'; -import { SavedSearch } from 'src/plugins/discover/public'; +import { SavedObject } from 'src/plugins/saved_objects/public'; import { useKibana } from '../../../../kibana_react/public'; interface LinkedSearchProps { - savedSearch: SavedSearch; + savedSearch: SavedObject; eventEmitter: EventEmitter; } interface SidebarTitleProps { isLinkedSearch: boolean; - savedSearch?: SavedSearch; + savedSearch?: SavedObject; vis: Vis; eventEmitter: EventEmitter; } diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index 731358bdcbdec2..60b6ebab5ad8eb 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -59,7 +59,7 @@ function DefaultEditor({ embeddableHandler.render(visRef.current); setTimeout(() => { - eventEmitter.emit('apply'); + eventEmitter.emit('embeddableRendered'); }); return () => embeddableHandler.destroy(); @@ -102,6 +102,7 @@ function DefaultEditor({ initialWidth={editorInitialWidth} > { this.props.vis.params = this.state.model; - this.props.eventEmitter.emit('updateVis'); + this.props.embeddableHandler.reload(); this.props.eventEmitter.emit('dirtyStateChange', { isDirty: false, }); @@ -187,6 +187,7 @@ export class VisEditor extends Component { autoApply={this.state.autoApply} model={model} embeddableHandler={this.props.embeddableHandler} + eventEmitter={this.props.eventEmitter} vis={this.props.vis} timeRange={this.props.timeRange} uiState={this.uiState} diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js index 0ae1c86ae31175..23a9555da2452c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js @@ -73,6 +73,7 @@ class VisEditorVisualizationUI extends Component { this._handler = embeddableHandler; await this._handler.render(this._visEl.current); + this.props.eventEmitter.emit('embeddableRendered'); this._subscription = this._handler.handler.data$.subscribe((data) => { this.setPanelInterval(data.value.visData); @@ -279,6 +280,7 @@ VisEditorVisualizationUI.propTypes = { uiState: PropTypes.object, onToggleAutoApply: PropTypes.func, embeddableHandler: PropTypes.object, + eventEmitter: PropTypes.object, timeRange: PropTypes.object, dirty: PropTypes.bool, autoApply: PropTypes.bool, diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index c06f94efb3c493..649ee765cc6428 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -24,7 +24,6 @@ import { metricsRequestHandler } from './request_handler'; import { EditorController } from './application'; // @ts-ignore import { PANEL_TYPES } from '../common/panel_types'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; import { VisEditor } from './application/components/vis_editor_lazy'; export const metricsVisDefinition = { @@ -34,7 +33,6 @@ export const metricsVisDefinition = { defaultMessage: 'Build time-series using a visual pipeline interface', }), icon: 'visVisualBuilder', - feedbackMessage: defaultFeedbackMessage, visConfig: { defaults: { id: '61ca57f0-469d-11e7-af02-69e470af7417', diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index c864553c118b93..55ad134c053015 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -21,7 +21,6 @@ import { i18n } from '@kbn/i18n'; import { DefaultEditorSize } from '../../vis_default_editor/public'; import { VegaVisualizationDependencies } from './plugin'; import { VegaVisEditor } from './components'; -import { defaultFeedbackMessage } from '../../kibana_utils/public'; import { createVegaRequestHandler } from './vega_request_handler'; // @ts-ignore @@ -56,6 +55,5 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen showFilterBar: true, }, stage: 'experimental', - feedbackMessage: defaultFeedbackMessage, }; }; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 0bbf862216ed5b..2ac53c2c81acc9 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -51,4 +51,5 @@ export { VisSavedObject, VisResponseValue, } from './types'; +export { VisualizationListItem } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; diff --git a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts index eb00dce8aba2fd..8edf494ddc0ece 100644 --- a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts @@ -62,6 +62,7 @@ export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { title: vis.title, description: vis.description, visState: { + title: vis.title, type: vis.type, aggs: vis.data.aggs, params: vis.params, diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts index c6a25df7615a2b..d44fc2f4a75af0 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts @@ -56,7 +56,7 @@ export function createSavedVisLoader(services: SavedObjectKibanaServicesWithVisu source.icon = source.type.icon; source.image = source.type.image; source.typeTitle = source.type.title; - source.editUrl = `#/edit/${id}`; + source.editUrl = `/edit/${id}`; return source; }; diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index 3455d88b6ce9e1..daf275297fb822 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -35,6 +35,7 @@ export type VisualizationControllerConstructor = new ( ) => VisualizationController; export interface SavedVisState { + title: string; type: string; params: VisParams; aggs: AggConfigOptions[]; diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index aaab0566af65e6..e8ae48cdce1452 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -29,6 +29,7 @@ import { isFunction, defaults, cloneDeep } from 'lodash'; import { Assign } from '@kbn/utility-types'; +import { i18n } from '@kbn/i18n'; import { PersistedState } from './persisted_state'; import { getTypes, getAggs, getSearch, getSavedSearchLoader } from './services'; import { VisType } from './vis_types'; @@ -105,7 +106,13 @@ export class Vis { private getType(visType: string) { const type = getTypes().get(visType); if (!type) { - throw new Error(`Invalid type "${visType}"`); + const errorMessage = i18n.translate('visualizations.visualizationTypeInvalidMessage', { + defaultMessage: 'Invalid visualization type "{visType}"', + values: { + visType, + }, + }); + throw new Error(errorMessage); } return type; } @@ -150,7 +157,13 @@ export class Vis { const configStates = this.initializeDefaultsFromSchemas(aggs, this.type.schemas.all || []); if (!this.data.indexPattern) { if (aggs.length) { - throw new Error('trying to initialize aggs without index pattern'); + const errorMessage = i18n.translate( + 'visualizations.initializeWithoutIndexPatternErrorMessage', + { + defaultMessage: 'Trying to initialize aggs without index pattern', + } + ); + throw new Error(errorMessage); } return; } diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 2464bb72d26957..44b76a52b34fef 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -27,7 +27,6 @@ export interface BaseVisTypeOptions { icon?: string; image?: string; stage?: 'experimental' | 'beta' | 'production'; - feedbackMessage?: string; options?: Record; visualization: VisualizationControllerConstructor; visConfig?: Record; @@ -48,7 +47,7 @@ export class BaseVisType { icon?: string; image?: string; stage: 'experimental' | 'beta' | 'production'; - feedbackMessage: string; + isExperimental: boolean; options: Record; visualization: VisualizationControllerConstructor; visConfig: Record; @@ -87,7 +86,7 @@ export class BaseVisType { this.editorConfig = _.defaultsDeep({}, opts.editorConfig, { collections: {} }); this.options = _.defaultsDeep({}, opts.options, defaultOptions); this.stage = opts.stage || 'production'; - this.feedbackMessage = opts.feedbackMessage || ''; + this.isExperimental = opts.stage === 'experimental'; this.hidden = opts.hidden || false; this.requestHandler = opts.requestHandler || 'courier'; this.responseHandler = opts.responseHandler || 'none'; @@ -97,10 +96,6 @@ export class BaseVisType { this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; } - shouldMarkAsExperimentalInUI() { - return this.stage === 'experimental'; - } - public get schemas() { if (this.editorConfig && this.editorConfig.schemas) { return this.editorConfig.schemas; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 73e3360004e5a7..bc80d549c81e6f 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -27,6 +27,7 @@ export interface VisualizationListItem { title: string; description?: string; typeTitle: string; + image?: string; } export interface VisualizationsAppExtension { diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index cda45f3acc102d..c27cfec24b332d 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -9,7 +9,6 @@ "navigation", "savedObjects", "visualizations", - "dashboard", "embeddable" ], "optionalPlugins": ["home", "share"] diff --git a/src/plugins/visualize/public/application/_app.scss b/src/plugins/visualize/public/application/_app.scss deleted file mode 100644 index 8a52ebf4cc0883..00000000000000 --- a/src/plugins/visualize/public/application/_app.scss +++ /dev/null @@ -1,5 +0,0 @@ -.visAppWrapper { - display: flex; - flex-direction: column; - flex-grow: 1; -} diff --git a/src/plugins/visualize/public/application/index.scss b/src/plugins/visualize/public/application/app.scss similarity index 67% rename from src/plugins/visualize/public/application/index.scss rename to src/plugins/visualize/public/application/app.scss index 620380a77ba46c..f7f68fbc2c3597 100644 --- a/src/plugins/visualize/public/application/index.scss +++ b/src/plugins/visualize/public/application/app.scss @@ -5,6 +5,8 @@ // visChart__legend--small // visChart__legend-isLoading -@import 'app'; -@import 'editor/index'; -@import 'listing/index'; +.visAppWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; +} diff --git a/src/plugins/visualize/public/application/app.tsx b/src/plugins/visualize/public/application/app.tsx new file mode 100644 index 00000000000000..0e71d72a3d4c72 --- /dev/null +++ b/src/plugins/visualize/public/application/app.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './app.scss'; +import React, { useEffect } from 'react'; +import { Route, Switch, useLocation } from 'react-router-dom'; + +import { syncQueryStateWithUrl } from '../../../data/public'; +import { useKibana } from '../../../kibana_react/public'; +import { VisualizeServices } from './types'; +import { VisualizeEditor, VisualizeListing, VisualizeNoMatch } from './components'; +import { VisualizeConstants } from './visualize_constants'; + +export const VisualizeApp = () => { + const { + services: { + data: { query }, + kbnUrlStateStorage, + }, + } = useKibana(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, kbnUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, kbnUrlStateStorage, pathname]); + + return ( + + + + + + + + + + ); +}; diff --git a/src/plugins/visualize/public/application/application.ts b/src/plugins/visualize/public/application/application.ts deleted file mode 100644 index 60bb73d6de2ccb..00000000000000 --- a/src/plugins/visualize/public/application/application.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import './index.scss'; - -import angular, { IModule } from 'angular'; - -// required for i18nIdDirective -import 'angular-sanitize'; -// required for ngRoute -import 'angular-route'; - -import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; - -import { AppMountContext } from 'kibana/public'; -import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; -import { - configureAppAngularModule, - createTopNavDirective, - createTopNavHelper, -} from '../../../kibana_legacy/public'; - -// @ts-ignore -import { initVisualizeApp } from './legacy_app'; -import { VisualizeKibanaServices } from '../kibana_services'; - -let angularModuleInstance: IModule | null = null; - -export const renderApp = ( - element: HTMLElement, - appBasePath: string, - deps: VisualizeKibanaServices -) => { - if (!angularModuleInstance) { - angularModuleInstance = createLocalAngularModule(deps.core, deps.navigation); - // global routing stuff - configureAppAngularModule( - angularModuleInstance, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true, - deps.scopedHistory - ); - initVisualizeApp(angularModuleInstance, deps); - } - const $injector = mountVisualizeApp(appBasePath, element); - return () => $injector.get('$rootScope').$destroy(); -}; - -const mainTemplate = (basePath: string) => `

- -
-`; - -const moduleName = 'app/visualize'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -function mountVisualizeApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'visAppWrapper'); - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - // initialize global state handler - element.appendChild(mountpoint); - return $injector; -} - -function createLocalAngularModule(core: AppMountContext['core'], navigation: NavigationStart) { - createLocalI18nModule(); - createLocalTopNavModule(navigation); - - const visualizeAngularModule: IModule = angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'app/visualize/I18n', - 'app/visualize/TopNav', - ]); - return visualizeAngularModule; -} - -function createLocalTopNavModule(navigation: NavigationStart) { - angular - .module('app/visualize/TopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); -} - -function createLocalI18nModule() { - angular - .module('app/visualize/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/src/plugins/visualize/public/application/help_menu/help_menu_util.js b/src/plugins/visualize/public/application/components/experimental_vis_info.tsx similarity index 50% rename from src/plugins/visualize/public/application/help_menu/help_menu_util.js rename to src/plugins/visualize/public/application/components/experimental_vis_info.tsx index c297326f2e264e..51abb3ca530a4c 100644 --- a/src/plugins/visualize/public/application/help_menu/help_menu_util.js +++ b/src/plugins/visualize/public/application/components/experimental_vis_info.tsx @@ -17,18 +17,33 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; -export function addHelpMenuToAppChrome(chrome, docLinks) { - chrome.setHelpExtension({ - appName: i18n.translate('visualize.helpMenu.appName', { - defaultMessage: 'Visualize', - }), - links: [ - { - linkType: 'documentation', - href: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/visualize.html`, - }, - ], - }); -} +export const InfoComponent = () => { + const title = ( + <> + {' '} + + GitHub + + {'.'} + + ); + + return ( + + ); +}; + +export const ExperimentalVisInfo = memo(InfoComponent); diff --git a/src/plugins/visualize/public/application/editor/lib/index.ts b/src/plugins/visualize/public/application/components/index.ts similarity index 80% rename from src/plugins/visualize/public/application/editor/lib/index.ts rename to src/plugins/visualize/public/application/components/index.ts index 78589383925fb9..a3a7fde1d6569f 100644 --- a/src/plugins/visualize/public/application/editor/lib/index.ts +++ b/src/plugins/visualize/public/application/components/index.ts @@ -17,6 +17,6 @@ * under the License. */ -export { useVisualizeAppState } from './visualize_app_state'; -export { makeStateful } from './make_stateful'; -export { addEmbeddableToDashboardUrl } from '../../../../../dashboard/public/'; +export { VisualizeListing } from './visualize_listing'; +export { VisualizeEditor } from './visualize_editor'; +export { VisualizeNoMatch } from './visualize_no_match'; diff --git a/src/plugins/visualize/public/application/editor/_editor.scss b/src/plugins/visualize/public/application/components/visualize_editor.scss similarity index 100% rename from src/plugins/visualize/public/application/editor/_editor.scss rename to src/plugins/visualize/public/application/components/visualize_editor.scss diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx new file mode 100644 index 00000000000000..c571a5fb078bc5 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -0,0 +1,115 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './visualize_editor.scss'; +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { EventEmitter } from 'events'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiScreenReaderOnly } from '@elastic/eui'; + +import { useKibana } from '../../../../kibana_react/public'; +import { + useChromeVisibility, + useSavedVisInstance, + useVisualizeAppState, + useEditorUpdates, + useLinkedSearchUpdates, +} from '../utils'; +import { VisualizeServices } from '../types'; +import { ExperimentalVisInfo } from './experimental_vis_info'; +import { VisualizeTopNav } from './visualize_top_nav'; + +export const VisualizeEditor = () => { + const { id: visualizationIdFromUrl } = useParams(); + const [originatingApp, setOriginatingApp] = useState(); + const { services } = useKibana(); + const [eventEmitter] = useState(new EventEmitter()); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(!visualizationIdFromUrl); + + const isChromeVisible = useChromeVisibility(services.chrome); + const { savedVisInstance, visEditorRef, visEditorController } = useSavedVisInstance( + services, + eventEmitter, + isChromeVisible, + visualizationIdFromUrl + ); + const { appState, hasUnappliedChanges } = useVisualizeAppState( + services, + eventEmitter, + savedVisInstance + ); + const { isEmbeddableRendered, currentAppState } = useEditorUpdates( + services, + eventEmitter, + setHasUnsavedChanges, + appState, + savedVisInstance, + visEditorController + ); + useLinkedSearchUpdates(services, eventEmitter, appState, savedVisInstance); + + useEffect(() => { + const { originatingApp: value } = + services.embeddable.getStateTransfer(services.scopedHistory).getIncomingEditorState() || {}; + setOriginatingApp(value); + }, [services]); + + useEffect(() => { + // clean up all registered listeners if any is left + return () => { + eventEmitter.removeAllListeners(); + }; + }, [eventEmitter]); + + return ( +
+ {savedVisInstance && appState && currentAppState && ( + + )} + {savedVisInstance?.vis?.type?.isExperimental && } + {savedVisInstance && ( + +

+ +

+
+ )} +
+
+ ); +}; diff --git a/src/plugins/visualize/public/application/listing/_listing.scss b/src/plugins/visualize/public/application/components/visualize_listing.scss similarity index 100% rename from src/plugins/visualize/public/application/listing/_listing.scss rename to src/plugins/visualize/public/application/components/visualize_listing.scss diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx new file mode 100644 index 00000000000000..cbfbd6e0e3ab6b --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -0,0 +1,154 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './visualize_listing.scss'; + +import React, { useCallback, useRef, useMemo, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useUnmount, useMount } from 'react-use'; +import { useLocation } from 'react-router-dom'; + +import { useKibana, TableListView } from '../../../../kibana_react/public'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; +import { VisualizeServices } from '../types'; +import { VisualizeConstants } from '../visualize_constants'; +import { getTableColumns, getNoItemsMessage } from '../utils'; + +export const VisualizeListing = () => { + const { + services: { + application, + chrome, + history, + savedVisualizations, + toastNotifications, + visualizations, + savedObjects, + savedObjectsPublic, + uiSettings, + visualizeCapabilities, + }, + } = useKibana(); + const { pathname } = useLocation(); + const closeNewVisModal = useRef(() => {}); + const listingLimit = savedObjectsPublic.settings.getListingLimit(); + + useEffect(() => { + if (pathname === '/new') { + // In case the user navigated to the page via the /visualize/new URL we start the dialog immediately + closeNewVisModal.current = visualizations.showNewVisModal({ + onClose: () => { + // In case the user came via a URL to this page, change the URL to the regular landing page URL after closing the modal + history.push(VisualizeConstants.LANDING_PAGE_PATH); + }, + }); + } else { + // close modal window if exists + closeNewVisModal.current(); + } + }, [history, pathname, visualizations]); + + useMount(() => { + chrome.setBreadcrumbs([ + { + text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { + defaultMessage: 'Visualize', + }), + }, + ]); + chrome.docTitle.change( + i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize' }) + ); + }); + useUnmount(() => closeNewVisModal.current()); + + const createNewVis = useCallback(() => { + closeNewVisModal.current = visualizations.showNewVisModal(); + }, [visualizations]); + + const editItem = useCallback( + ({ editUrl, editApp }) => { + if (editApp) { + application.navigateToApp(editApp, { path: editUrl }); + return; + } + // for visualizations the edit and view URLs are the same + history.push(editUrl); + }, + [application, history] + ); + + const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); + const tableColumns = useMemo(() => getTableColumns(application, history), [application, history]); + + const fetchItems = useCallback( + (filter) => { + const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); + return savedVisualizations + .findListItems(filter, listingLimit) + .then(({ total, hits }: { total: number; hits: object[] }) => ({ + total, + hits: hits.filter((result: any) => isLabsEnabled || result.type.stage !== 'experimental'), + })); + }, + [listingLimit, savedVisualizations, uiSettings] + ); + + const deleteItems = useCallback( + async (selectedItems: object[]) => { + await Promise.all( + selectedItems.map((item: any) => savedObjects.client.delete(item.savedObjectType, item.id)) + ).catch((error) => { + toastNotifications.addError(error, { + title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), + }); + }); + }, + [savedObjects.client, toastNotifications] + ); + + return ( + + ); +}; diff --git a/src/plugins/visualize/public/application/components/visualize_no_match.tsx b/src/plugins/visualize/public/application/components/visualize_no_match.tsx new file mode 100644 index 00000000000000..7776c5e8ce4866 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_no_match.tsx @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { useKibana, toMountPoint } from '../../../../kibana_react/public'; +import { VisualizeServices } from '../types'; +import { VisualizeConstants } from '../visualize_constants'; + +let bannerId: string; + +export const VisualizeNoMatch = () => { + const { services } = useKibana(); + + useEffect(() => { + services.restorePreviousUrl(); + + const { navigated } = services.kibanaLegacy.navigateToLegacyKibanaUrl( + services.history.location.pathname + ); + + if (!navigated) { + const bannerMessage = i18n.translate('visualize.noMatchRoute.bannerTitleText', { + defaultMessage: 'Page not found', + }); + + bannerId = services.overlays.banners.replace( + bannerId, + toMountPoint( + +

+ + {services.history.location.pathname} + + ), + }} + /> +

+
+ ) + ); + + // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around + setTimeout(() => { + services.overlays.banners.remove(bannerId); + }, 15000); + + services.history.replace(VisualizeConstants.LANDING_PAGE_PATH); + } + }, [services]); + + return null; +}; diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx new file mode 100644 index 00000000000000..2e7dba46487ad0 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; +import { isEqual } from 'lodash'; + +import { OverlayRef } from 'kibana/public'; +import { Query } from 'src/plugins/data/public'; +import { useKibana } from '../../../../kibana_react/public'; +import { + VisualizeServices, + VisualizeAppState, + VisualizeAppStateContainer, + SavedVisInstance, +} from '../types'; +import { APP_NAME } from '../visualize_constants'; +import { getTopNavConfig } from '../utils'; + +interface VisualizeTopNavProps { + currentAppState: VisualizeAppState; + isChromeVisible?: boolean; + isEmbeddableRendered: boolean; + hasUnsavedChanges: boolean; + setHasUnsavedChanges: (value: boolean) => void; + hasUnappliedChanges: boolean; + originatingApp?: string; + savedVisInstance: SavedVisInstance; + stateContainer: VisualizeAppStateContainer; + visualizationIdFromUrl?: string; +} + +const TopNav = ({ + currentAppState, + isChromeVisible, + isEmbeddableRendered, + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, +}: VisualizeTopNavProps) => { + const { services } = useKibana(); + const { TopNavMenu } = services.navigation.ui; + const { embeddableHandler, vis } = savedVisInstance; + const [inspectorSession, setInspectorSession] = useState(); + const openInspector = useCallback(() => { + const session = embeddableHandler.openInspector(); + setInspectorSession(session); + }, [embeddableHandler]); + + const updateQuery = useCallback( + ({ query }: { query?: Query }) => { + if (!isEqual(currentAppState.query, query)) { + stateContainer.transitions.set('query', query || currentAppState.query); + } else { + savedVisInstance.embeddableHandler.reload(); + } + }, + [currentAppState.query, savedVisInstance.embeddableHandler, stateContainer.transitions] + ); + + const config = useMemo(() => { + if (isEmbeddableRendered) { + return getTopNavConfig( + { + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + openInspector, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, + }, + services + ); + } + }, [ + isEmbeddableRendered, + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + openInspector, + originatingApp, + savedVisInstance, + stateContainer, + visualizationIdFromUrl, + services, + ]); + const [indexPattern, setIndexPattern] = useState(vis.data.indexPattern); + const showDatePicker = () => { + // tsvb loads without an indexPattern initially (TODO investigate). + // hide timefilter only if timeFieldName is explicitly undefined. + const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; + return vis.type.options.showTimePicker && hasTimeField; + }; + const showFilterBar = vis.type.options.showFilterBar; + const showQueryInput = vis.type.requiresSearch && vis.type.options.showQueryBar; + + useEffect(() => { + return () => { + if (inspectorSession) { + // Close the inspector if this scope is destroyed (e.g. because the user navigates away). + inspectorSession.close(); + } + }; + }, [inspectorSession]); + + useEffect(() => { + if (!vis.data.indexPattern) { + services.data.indexPatterns.getDefault().then((index) => { + if (index) { + setIndexPattern(index); + } + }); + } + }, [services.data.indexPatterns, vis.data.indexPattern]); + + return isChromeVisible ? ( + /** + * Most visualizations have all search bar components enabled. + * Some visualizations have fewer options, but all visualizations have the search bar. + * That's is why the showSearchBar prop is set. + * All visualizations also have the timepicker\autorefresh component, + * it is enabled by default in the TopNavMenu component. + */ + + ) : showFilterBar ? ( + /** + * The top nav is hidden in embed mode, but the filter bar must still be present so + * we show the filter bar on its own here if the chrome is not visible. + */ + + ) : null; +}; + +export const VisualizeTopNav = memo(TopNav); diff --git a/src/plugins/visualize/public/application/editor/_index.scss b/src/plugins/visualize/public/application/editor/_index.scss deleted file mode 100644 index 9d3ca4b5399472..00000000000000 --- a/src/plugins/visualize/public/application/editor/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'editor'; diff --git a/src/plugins/visualize/public/application/editor/editor.html b/src/plugins/visualize/public/application/editor/editor.html deleted file mode 100644 index 3c3455fb34f18c..00000000000000 --- a/src/plugins/visualize/public/application/editor/editor.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - -
-
-

-
-
- - - -

-

- - -
diff --git a/src/plugins/visualize/public/application/editor/editor.js b/src/plugins/visualize/public/application/editor/editor.js deleted file mode 100644 index d7c7828c58f23f..00000000000000 --- a/src/plugins/visualize/public/application/editor/editor.js +++ /dev/null @@ -1,763 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import angular from 'angular'; -import _ from 'lodash'; -import { Subscription } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { EventEmitter } from 'events'; - -import React from 'react'; -import { makeStateful, useVisualizeAppState } from './lib'; -import { VisualizeConstants } from '../visualize_constants'; -import { getEditBreadcrumbs } from '../breadcrumbs'; - -import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; -import { unhashUrl } from '../../../../kibana_utils/public'; -import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public'; -import { - addFatalError, - subscribeWithScope, - migrateLegacyQuery, -} from '../../../../kibana_legacy/public'; -import { showSaveModal, SavedObjectSaveModalOrigin } from '../../../../saved_objects/public'; -import { - esFilters, - connectToQueryState, - syncQueryStateWithUrl, - UI_SETTINGS, -} from '../../../../data/public'; - -import { initVisEditorDirective } from './visualization_editor'; -import { initVisualizationDirective } from './visualization'; - -import { getServices } from '../../kibana_services'; -import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../visualizations/public'; - -export function initEditorDirective(app, deps) { - app.directive('visualizeApp', function () { - return { - restrict: 'E', - controllerAs: 'visualizeApp', - controller: VisualizeAppController, - }; - }); - - initVisEditorDirective(app, deps); - initVisualizationDirective(app, deps); -} - -function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlStateStorage, history) { - const { - localStorage, - visualizeCapabilities, - share, - data: { query: queryService, indexPatterns }, - toastNotifications, - chrome, - core: { docLinks, fatalErrors, uiSettings, application }, - I18nContext, - setActiveUrl, - visualizations, - embeddable, - scopedHistory, - } = getServices(); - - const { - filterManager, - timefilter: { timefilter }, - } = queryService; - - // starts syncing `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - queryService, - kbnUrlStateStorage - ); - - // Retrieve the resolved SavedVis instance. - const { vis, savedVis, savedSearch, embeddableHandler } = $route.current.locals.resolved; - $scope.eventEmitter = new EventEmitter(); - const _applyVis = () => { - $scope.$apply(); - }; - // This will trigger a digest cycle. This is needed when vis is updated from a global angular like in visualize_embeddable.js. - $scope.eventEmitter.on('apply', _applyVis); - // vis is instance of src/legacy/ui/public/vis/vis.js. - // SearchSource is a promise-based stream of search results that can inherit from other search sources. - const searchSource = vis.data.searchSource; - - $scope.vis = vis; - $scope.savedSearch = savedSearch; - - const $appStatus = { - dirty: !savedVis.id, - }; - - const defaultQuery = { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || - uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }; - - const { originatingApp } = - embeddable.getStateTransfer(scopedHistory()).getIncomingEditorState() || {}; - $scope.getOriginatingApp = () => originatingApp; - - const visStateToEditorState = () => { - const savedVisState = visualizations.convertFromSerializedVis(vis.serialize()); - return { - uiState: vis.uiState.toJSON(), - query: vis.data.searchSource.getOwnField('query') || defaultQuery, - filters: vis.data.searchSource.getOwnField('filter') || [], - vis: { ...savedVisState.visState, title: vis.title }, - linked: !!savedVis.savedSearchId, - }; - }; - - const stateDefaults = visStateToEditorState(); - - const { stateContainer, stopStateSync } = useVisualizeAppState({ - stateDefaults, - kbnUrlStateStorage, - }); - - $scope.eventEmitter.on('dirtyStateChange', ({ isDirty }) => { - if (!isDirty) { - stateContainer.transitions.updateVisState(visStateToEditorState().vis); - } - $timeout(() => { - $scope.dirty = isDirty; - }); - }); - - $scope.eventEmitter.on('updateVis', () => { - embeddableHandler.reload(); - }); - - $scope.embeddableHandler = embeddableHandler; - - $scope.topNavMenu = [ - ...($scope.getOriginatingApp() && savedVis.id - ? [ - { - id: 'saveAndReturn', - label: i18n.translate('visualize.topNavMenu.saveAndReturnVisualizationButtonLabel', { - defaultMessage: 'Save and return', - }), - emphasize: true, - iconType: 'check', - description: i18n.translate( - 'visualize.topNavMenu.saveAndReturnVisualizationButtonAriaLabel', - { - defaultMessage: 'Finish editing visualization and return to the last app', - } - ), - testId: 'visualizesaveAndReturnButton', - disableButton() { - return Boolean($scope.dirty); - }, - tooltip() { - if ($scope.dirty) { - return i18n.translate( - 'visualize.topNavMenu.saveAndReturnVisualizationDisabledButtonTooltip', - { - defaultMessage: 'Apply or Discard your changes before finishing', - } - ); - } - }, - run: async () => { - const saveOptions = { - confirmOverwrite: false, - returnToOrigin: true, - }; - return doSave(saveOptions); - }, - }, - ] - : []), - ...(visualizeCapabilities.save - ? [ - { - id: 'save', - label: - savedVis.id && $scope.getOriginatingApp() - ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { - defaultMessage: 'save as', - }) - : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { - defaultMessage: 'save', - }), - emphasize: !savedVis.id || !$scope.getOriginatingApp(), - description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { - defaultMessage: 'Save Visualization', - }), - testId: 'visualizeSaveButton', - disableButton() { - return Boolean($scope.dirty); - }, - tooltip() { - if ($scope.dirty) { - return i18n.translate( - 'visualize.topNavMenu.saveVisualizationDisabledButtonTooltip', - { - defaultMessage: 'Apply or Discard your changes before saving', - } - ); - } - }, - run: async () => { - const onSave = ({ - newTitle, - newCopyOnSave, - isTitleDuplicateConfirmed, - onTitleDuplicate, - newDescription, - returnToOrigin, - }) => { - const currentTitle = savedVis.title; - savedVis.title = newTitle; - savedVis.copyOnSave = newCopyOnSave; - savedVis.description = newDescription; - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - returnToOrigin, - }; - return doSave(saveOptions).then((response) => { - // If the save wasn't successful, put the original values back. - if (!response.id || response.error) { - savedVis.title = currentTitle; - } - return response; - }); - }; - - const saveModal = ( - {}} - originatingApp={$scope.getOriginatingApp()} - /> - ); - showSaveModal(saveModal, I18nContext); - }, - }, - ] - : []), - { - id: 'share', - label: i18n.translate('visualize.topNavMenu.shareVisualizationButtonLabel', { - defaultMessage: 'share', - }), - description: i18n.translate('visualize.topNavMenu.shareVisualizationButtonAriaLabel', { - defaultMessage: 'Share Visualization', - }), - testId: 'shareTopNavButton', - run: (anchorElement) => { - const hasUnappliedChanges = $scope.dirty; - const hasUnsavedChanges = $appStatus.dirty; - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: true, - allowShortUrl: visualizeCapabilities.createShortUrl, - shareableUrl: unhashUrl(window.location.href), - objectId: savedVis.id, - objectType: 'visualization', - sharingData: { - title: savedVis.title, - }, - isDirty: hasUnappliedChanges || hasUnsavedChanges, - }); - }, - // disable the Share button if no action specified - disableButton: !share, - }, - { - id: 'inspector', - label: i18n.translate('visualize.topNavMenu.openInspectorButtonLabel', { - defaultMessage: 'inspect', - }), - description: i18n.translate('visualize.topNavMenu.openInspectorButtonAriaLabel', { - defaultMessage: 'Open Inspector for visualization', - }), - testId: 'openInspectorButton', - disableButton() { - return !embeddableHandler.hasInspector || !embeddableHandler.hasInspector(); - }, - run() { - const inspectorSession = embeddableHandler.openInspector(); - - if (inspectorSession) { - // Close the inspector if this scope is destroyed (e.g. because the user navigates away). - const removeWatch = $scope.$on('$destroy', () => inspectorSession.close()); - // Remove that watch in case the user closes the inspector session herself. - inspectorSession.onClose.finally(removeWatch); - } - }, - tooltip() { - if (!embeddableHandler.hasInspector || !embeddableHandler.hasInspector()) { - return i18n.translate('visualize.topNavMenu.openInspectorDisabledButtonTooltip', { - defaultMessage: `This visualization doesn't support any inspectors.`, - }); - } - }, - }, - ]; - - if (savedVis.id) { - chrome.docTitle.change(savedVis.title); - } - - // sync initial app filters from state to filterManager - filterManager.setAppFilters(_.cloneDeep(stateContainer.getState().filters)); - // setup syncing of app filters between appState and filterManager - const stopSyncingAppFilters = connectToQueryState( - queryService, - { - set: ({ filters }) => stateContainer.transitions.set('filters', filters), - get: () => ({ filters: stateContainer.getState().filters }), - state$: stateContainer.state$.pipe(map((state) => ({ filters: state.filters }))), - }, - { - filters: esFilters.FilterStateStore.APP_STATE, - } - ); - - const stopAllSyncing = () => { - stopStateSync(); - stopSyncingQueryServiceStateWithUrl(); - stopSyncingAppFilters(); - }; - - // The savedVis is pulled from elasticsearch, but the appState is pulled from the url, with the - // defaults applied. If the url was from a previous session which included modifications to the - // appState then they won't be equal. - if (!_.isEqual(stateContainer.getState().vis, stateDefaults.vis)) { - try { - const { aggs, ...visState } = stateContainer.getState().vis; - vis.setState({ ...visState, data: { aggs } }); - } catch (error) { - // stop syncing url updtes with the state to prevent extra syncing - stopAllSyncing(); - - toastNotifications.addWarning({ - title: i18n.translate('visualize.visualizationTypeInvalidNotificationMessage', { - defaultMessage: 'Invalid visualization type', - }), - text: toMountPoint({error.message}), - }); - - history.replace(`${VisualizeConstants.LANDING_PAGE_PATH}?notFound=visualization`); - - // prevent further controller execution - return; - } - } - - $scope.filters = filterManager.getFilters(); - - $scope.onFiltersUpdated = (filters) => { - // The filters will automatically be set when the filterManager emits an update event (see below) - filterManager.setFilters(filters); - }; - - $scope.showSaveQuery = visualizeCapabilities.saveQuery; - - $scope.$watch( - () => visualizeCapabilities.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability; - } - ); - - const updateSavedQueryFromUrl = (savedQueryId) => { - if (!savedQueryId) { - delete $scope.savedQuery; - - return; - } - - if ($scope.savedQuery && $scope.savedQuery.id === savedQueryId) { - return; - } - - queryService.savedQueries.getSavedQuery(savedQueryId).then((savedQuery) => { - $scope.$evalAsync(() => { - $scope.updateSavedQuery(savedQuery); - }); - }); - }; - - function init() { - if (vis.data.indexPattern) { - $scope.indexPattern = vis.data.indexPattern; - } else { - indexPatterns.getDefault().then((defaultIndexPattern) => { - $scope.indexPattern = defaultIndexPattern; - }); - } - - const initialState = stateContainer.getState(); - - const handleLinkedSearch = (linked) => { - if (linked && !savedVis.savedSearchId && savedSearch) { - savedVis.savedSearchId = savedSearch.id; - vis.data.savedSearchId = savedSearch.id; - searchSource.setParent(savedSearch.searchSource); - } else if (!linked && savedVis.savedSearchId) { - delete savedVis.savedSearchId; - delete vis.data.savedSearchId; - } - }; - - // Create a PersistedState instance for uiState. - const { persistedState, unsubscribePersisted, persistOnChange } = makeStateful( - 'uiState', - stateContainer - ); - vis.uiState = persistedState; - vis.uiState.on('reload', embeddableHandler.reload); - $scope.uiState = persistedState; - $scope.savedVis = savedVis; - $scope.query = initialState.query; - $scope.searchSource = searchSource; - $scope.refreshInterval = timefilter.getRefreshInterval(); - handleLinkedSearch(initialState.linked); - - $scope.showFilterBar = () => { - return vis.type.options.showFilterBar; - }; - - $scope.showQueryInput = () => { - return vis.type.requiresSearch && vis.type.options.showQueryBar; - }; - - $scope.showQueryBarTimePicker = () => { - // tsvb loads without an indexPattern initially (TODO investigate). - // hide timefilter only if timeFieldName is explicitly undefined. - const hasTimeField = vis.data.indexPattern ? !!vis.data.indexPattern.timeFieldName : true; - return vis.type.options.showTimePicker && hasTimeField; - }; - - $scope.timeRange = timefilter.getTime(); - - const unsubscribeStateUpdates = stateContainer.subscribe((state) => { - const newQuery = migrateLegacyQuery(state.query); - if (!_.isEqual(state.query, newQuery)) { - stateContainer.transitions.set('query', newQuery); - } - persistOnChange(state); - updateSavedQueryFromUrl(state.savedQuery); - - // if the browser history was changed manually we need to reflect changes in the editor - if ( - !_.isEqual( - { - ...visualizations.convertFromSerializedVis(vis.serialize()).visState, - title: vis.title, - }, - state.vis - ) - ) { - const { aggs, ...visState } = state.vis; - vis.setState({ - ...visState, - data: { - aggs, - }, - }); - embeddableHandler.reload(); - $scope.eventEmitter.emit('updateEditor'); - } - - $appStatus.dirty = true; - $scope.fetch(); - }); - - const updateTimeRange = () => { - $scope.timeRange = timefilter.getTime(); - $scope.$broadcast('render'); - }; - - // update the query if savedQuery is stored - updateSavedQueryFromUrl(initialState.savedQuery); - - const subscriptions = new Subscription(); - - subscriptions.add( - subscribeWithScope( - $scope, - timefilter.getRefreshIntervalUpdate$(), - { - next: () => { - $scope.refreshInterval = timefilter.getRefreshInterval(); - }, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - subscriptions.add( - subscribeWithScope( - $scope, - timefilter.getTimeUpdate$(), - { - next: updateTimeRange, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - - subscriptions.add( - chrome.getIsVisible$().subscribe((isVisible) => { - $scope.$evalAsync(() => { - $scope.isVisible = isVisible; - }); - }) - ); - - // update the searchSource when query updates - $scope.fetch = function () { - const { query, linked, filters } = stateContainer.getState(); - $scope.query = query; - handleLinkedSearch(linked); - vis.data.searchSource.setField('query', query); - vis.data.searchSource.setField('filter', filters); - $scope.$broadcast('render'); - }; - - // update the searchSource when filters update - subscriptions.add( - subscribeWithScope( - $scope, - filterManager.getUpdates$(), - { - next: () => { - $scope.filters = filterManager.getFilters(); - }, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - subscriptions.add( - subscribeWithScope( - $scope, - filterManager.getFetches$(), - { - next: $scope.fetch, - }, - (error) => addFatalError(fatalErrors, error) - ) - ); - - $scope.$on('$destroy', () => { - if ($scope._handler) { - $scope._handler.destroy(); - } - savedVis.destroy(); - subscriptions.unsubscribe(); - $scope.eventEmitter.off('apply', _applyVis); - - unsubscribePersisted(); - vis.uiState.off('reload', embeddableHandler.reload); - unsubscribeStateUpdates(); - - stopAllSyncing(); - }); - - $timeout(() => { - $scope.$broadcast('render'); - }); - } - - $scope.updateQueryAndFetch = function ({ query, dateRange }) { - const isUpdate = - (query && !_.isEqual(query, stateContainer.getState().query)) || - (dateRange && !_.isEqual(dateRange, $scope.timeRange)); - - stateContainer.transitions.set('query', query); - timefilter.setTime(dateRange); - - // If nothing has changed, trigger the fetch manually, otherwise it will happen as a result of the changes - if (!isUpdate) { - embeddableHandler.reload(); - } - }; - - $scope.onRefreshChange = function ({ isPaused, refreshInterval }) { - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : $scope.refreshInterval.value, - }); - }; - - $scope.onClearSavedQuery = () => { - delete $scope.savedQuery; - stateContainer.transitions.removeSavedQuery(defaultQuery); - filterManager.setFilters(filterManager.getGlobalFilters()); - }; - - const updateStateFromSavedQuery = (savedQuery) => { - stateContainer.transitions.updateFromSavedQuery(savedQuery); - - const savedQueryFilters = savedQuery.attributes.filters || []; - const globalFilters = filterManager.getGlobalFilters(); - filterManager.setFilters([...globalFilters, ...savedQueryFilters]); - - if (savedQuery.attributes.timefilter) { - timefilter.setTime({ - from: savedQuery.attributes.timefilter.from, - to: savedQuery.attributes.timefilter.to, - }); - if (savedQuery.attributes.timefilter.refreshInterval) { - timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); - } - } - }; - - $scope.updateSavedQuery = (savedQuery) => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }; - - /** - * Called when the user clicks "Save" button. - */ - function doSave(saveOptions) { - // vis.title was not bound and it's needed to reflect title into visState - const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; - stateContainer.transitions.setVis({ - title: savedVis.title, - type: savedVis.type || stateContainer.getState().vis.type, - }); - savedVis.searchSourceFields = searchSource.getSerializedFields(); - savedVis.visState = stateContainer.getState().vis; - savedVis.uiStateJSON = angular.toJson($scope.uiState.toJSON()); - $appStatus.dirty = false; - - return savedVis.save(saveOptions).then( - function (id) { - $scope.$evalAsync(() => { - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate( - 'visualize.topNavMenu.saveVisualization.successNotificationText', - { - defaultMessage: `Saved '{visTitle}'`, - values: { - visTitle: savedVis.title, - }, - } - ), - 'data-test-subj': 'saveVisualizationSuccess', - }); - - if ($scope.getOriginatingApp() && saveOptions.returnToOrigin) { - const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(savedVis.id)}`; - - // Manually insert a new url so the back button will open the saved visualization. - history.replace(appPath); - setActiveUrl(appPath); - if (newlyCreated && embeddable) { - embeddable - .getStateTransfer() - .navigateToWithEmbeddablePackage($scope.getOriginatingApp(), { - state: { id: savedVis.id, type: VISUALIZE_EMBEDDABLE_TYPE }, - }); - } else { - application.navigateToApp($scope.getOriginatingApp()); - } - } else if (savedVis.id === $route.current.params.id) { - chrome.docTitle.change(savedVis.lastSavedTitle); - chrome.setBreadcrumbs($injector.invoke(getEditBreadcrumbs)); - savedVis.vis.title = savedVis.title; - savedVis.vis.description = savedVis.description; - } else { - history.replace({ - ...history.location, - pathname: `${VisualizeConstants.EDIT_PATH}/${savedVis.id}`, - }); - } - } - }); - return { id }; - }, - (error) => { - // eslint-disable-next-line - console.error(error); - toastNotifications.addDanger({ - title: i18n.translate('visualize.topNavMenu.saveVisualization.failureNotificationText', { - defaultMessage: `Error on saving '{visTitle}'`, - values: { - visTitle: savedVis.title, - }, - }), - text: error.message, - 'data-test-subj': 'saveVisualizationError', - }); - return { error }; - } - ); - } - - const unlinkFromSavedSearch = () => { - const searchSourceParent = savedSearch.searchSource; - const searchSourceGrandparent = searchSourceParent.getParent(); - const currentIndex = searchSourceParent.getField('index'); - - searchSource.setField('index', currentIndex); - searchSource.setParent(searchSourceGrandparent); - - stateContainer.transitions.unlinkSavedSearch({ - query: searchSourceParent.getField('query'), - parentFilters: searchSourceParent.getOwnField('filter'), - }); - - toastNotifications.addSuccess( - i18n.translate('visualize.linkedToSearch.unlinkSuccessNotificationText', { - defaultMessage: `Unlinked from saved search '{searchTitle}'`, - values: { - searchTitle: savedSearch.title, - }, - }) - ); - }; - - $scope.getAdditionalMessage = () => { - return ( - '' + - i18n.translate('visualize.experimentalVisInfoText', { - defaultMessage: 'This visualization is marked as experimental.', - }) + - ' ' + - vis.type.feedbackMessage - ); - }; - - $scope.eventEmitter.on('unlinkFromSavedSearch', unlinkFromSavedSearch); - - addHelpMenuToAppChrome(chrome, docLinks); - - init(); -} diff --git a/src/plugins/visualize/public/application/editor/lib/make_stateful.ts b/src/plugins/visualize/public/application/editor/lib/make_stateful.ts deleted file mode 100644 index c7163f9b7705d4..00000000000000 --- a/src/plugins/visualize/public/application/editor/lib/make_stateful.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PersistedState } from '../../../../../visualizations/public'; -import { ReduxLikeStateContainer } from '../../../../../kibana_utils/public'; -import { VisualizeAppState, VisualizeAppStateTransitions } from '../../types'; - -/** - * @returns Create a PersistedState instance, initialize state changes subscriber/unsubscriber - */ -export function makeStateful( - prop: keyof VisualizeAppState, - stateContainer: ReduxLikeStateContainer -) { - // set up the persistedState state - const persistedState = new PersistedState(); - - // update the appState when the stateful instance changes - const updateOnChange = function () { - stateContainer.transitions.set(prop, persistedState.getChanges()); - }; - - const handlerOnChange = (method: 'on' | 'off') => - persistedState[method]('change', updateOnChange); - - handlerOnChange('on'); - const unsubscribePersisted = () => handlerOnChange('off'); - - // update the stateful object when the app state changes - const persistOnChange = function (state: VisualizeAppState) { - if (state[prop]) { - persistedState.set(state[prop]); - } - }; - - const appState = stateContainer.getState(); - - // if the thing we're making stateful has an appState value, write to persisted state - if (appState[prop]) persistedState.setSilent(appState[prop]); - - return { persistedState, unsubscribePersisted, persistOnChange }; -} diff --git a/src/plugins/visualize/public/application/editor/visualization.js b/src/plugins/visualize/public/application/editor/visualization.js deleted file mode 100644 index 26f61f3f0a2c2b..00000000000000 --- a/src/plugins/visualize/public/application/editor/visualization.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function initVisualizationDirective(app) { - app.directive('visualizationEmbedded', function ($timeout) { - return { - restrict: 'E', - scope: { - embeddableHandler: '=', - uiState: '=?', - timeRange: '=', - filters: '=', - query: '=', - appState: '=', - }, - link: function ($scope, element) { - $scope.renderFunction = async () => { - if (!$scope.rendered) { - $scope.embeddableHandler.render(element[0]); - $scope.rendered = true; - } - - $scope.embeddableHandler.updateInput({ - timeRange: $scope.timeRange, - filters: $scope.filters || [], - query: $scope.query, - }); - }; - - $scope.$on('render', (event) => { - event.preventDefault(); - $timeout(() => { - $scope.renderFunction(); - }); - }); - - $scope.$on('$destroy', () => { - if ($scope.embeddableHandler) { - $scope.embeddableHandler.destroy(); - } - }); - }, - }; - }); -} diff --git a/src/plugins/visualize/public/application/editor/visualization_editor.js b/src/plugins/visualize/public/application/editor/visualization_editor.js deleted file mode 100644 index 4963d9bc5ed72e..00000000000000 --- a/src/plugins/visualize/public/application/editor/visualization_editor.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { DefaultEditorController } from '../../../../vis_default_editor/public'; - -export function initVisEditorDirective(app, deps) { - app.directive('visualizationEditor', function ($timeout) { - return { - restrict: 'E', - scope: { - vis: '=', - uiState: '=?', - timeRange: '=', - filters: '=', - query: '=', - savedSearch: '=', - embeddableHandler: '=', - eventEmitter: '=', - }, - link: function ($scope, element) { - const Editor = $scope.vis.type.editor || DefaultEditorController; - const editor = new Editor( - element[0], - $scope.vis, - $scope.eventEmitter, - $scope.embeddableHandler - ); - - $scope.renderFunction = () => { - editor.render({ - core: deps.core, - data: deps.data, - uiState: $scope.uiState, - timeRange: $scope.timeRange, - filters: $scope.filters, - query: $scope.query, - linked: !!$scope.vis.data.savedSearchId, - savedSearch: $scope.savedSearch, - }); - }; - - $scope.$on('render', (event) => { - event.preventDefault(); - $timeout(() => { - $scope.renderFunction(); - }); - }); - - $scope.$on('$destroy', () => { - editor.destroy(); - }); - }, - }; - }); -} diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx new file mode 100644 index 00000000000000..4bec244e6efc94 --- /dev/null +++ b/src/plugins/visualize/public/application/index.tsx @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; + +import { AppMountParameters } from 'kibana/public'; +import { KibanaContextProvider } from '../../../kibana_react/public'; +import { VisualizeApp } from './app'; +import { VisualizeServices } from './types'; +import { addHelpMenuToAppChrome, addBadgeToAppChrome } from './utils'; + +export const renderApp = ({ element }: AppMountParameters, services: VisualizeServices) => { + // add help link to visualize docs into app chrome menu + addHelpMenuToAppChrome(services.chrome, services.docLinks); + // add readonly badge if saving restricted + if (!services.visualizeCapabilities.save) { + addBadgeToAppChrome(services.chrome); + } + + const app = ( + + + + + + + + ); + + ReactDOM.render(app, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/visualize/public/application/legacy_app.js b/src/plugins/visualize/public/application/legacy_app.js deleted file mode 100644 index 452118f8097da0..00000000000000 --- a/src/plugins/visualize/public/application/legacy_app.js +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { createHashHistory } from 'history'; - -import { createKbnUrlStateStorage, redirectWhenMissing } from '../../../kibana_utils/public'; -import { createSavedSearchesLoader } from '../../../discover/public'; - -import editorTemplate from './editor/editor.html'; -import visualizeListingTemplate from './listing/visualize_listing.html'; - -import { initVisualizeAppDirective } from './visualize_app'; -import { VisualizeConstants } from './visualize_constants'; -import { VisualizeListingController } from './listing/visualize_listing'; - -import { - getLandingBreadcrumbs, - getWizardStep1Breadcrumbs, - getCreateBreadcrumbs, - getEditBreadcrumbs, -} from './breadcrumbs'; - -const getResolvedResults = (deps) => { - const { core, data, visualizations, createVisEmbeddableFromObject } = deps; - - const results = {}; - - return (savedVis) => { - results.savedVis = savedVis; - const serializedVis = visualizations.convertToSerializedVis(savedVis); - return visualizations - .createVis(serializedVis.type, serializedVis) - .then((vis) => { - if (vis.type.setup) { - return vis.type.setup(vis).catch(() => vis); - } - return vis; - }) - .then((vis) => { - results.vis = vis; - return createVisEmbeddableFromObject(vis, { - timeRange: data.query.timefilter.timefilter.getTime(), - filters: data.query.filterManager.getFilters(), - }); - }) - .then((embeddableHandler) => { - results.embeddableHandler = embeddableHandler; - - embeddableHandler.getOutput$().subscribe((output) => { - if (output.error) { - core.notifications.toasts.addError(output.error, { - title: i18n.translate('visualize.error.title', { - defaultMessage: 'Visualization error', - }), - }); - } - }); - - if (results.vis.data.savedSearchId) { - return createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - indexPatterns: data.indexPatterns, - search: data.search, - chrome: core.chrome, - overlays: core.overlays, - }).get(results.vis.data.savedSearchId); - } - }) - .then((savedSearch) => { - if (savedSearch) { - results.savedSearch = savedSearch; - } - return results; - }); - }; -}; - -export function initVisualizeApp(app, deps) { - initVisualizeAppDirective(app, deps); - - app.factory('history', () => createHashHistory()); - app.factory('kbnUrlStateStorage', (history) => - createKbnUrlStateStorage({ - history, - useHash: deps.core.uiSettings.get('state:storeInSessionStorage'), - }) - ); - - app.config(function ($routeProvider) { - const defaults = { - reloadOnSearch: false, - requireUICapability: 'visualize.show', - badge: () => { - if (deps.visualizeCapabilities.save) { - return undefined; - } - - return { - text: i18n.translate('visualize.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('visualize.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save visualizations', - }), - iconType: 'glasses', - }; - }, - }; - - $routeProvider - .when(VisualizeConstants.LANDING_PAGE_PATH, { - ...defaults, - template: visualizeListingTemplate, - k7Breadcrumbs: getLandingBreadcrumbs, - controller: VisualizeListingController, - controllerAs: 'listingController', - resolve: { - createNewVis: () => false, - hasDefaultIndex: (history) => deps.data.indexPatterns.ensureDefaultIndexPattern(history), - }, - }) - .when(VisualizeConstants.WIZARD_STEP_1_PAGE_PATH, { - ...defaults, - template: visualizeListingTemplate, - k7Breadcrumbs: getWizardStep1Breadcrumbs, - controller: VisualizeListingController, - controllerAs: 'listingController', - resolve: { - createNewVis: () => true, - hasDefaultIndex: (history) => deps.data.indexPatterns.ensureDefaultIndexPattern(history), - }, - }) - .when(VisualizeConstants.CREATE_PATH, { - ...defaults, - template: editorTemplate, - k7Breadcrumbs: getCreateBreadcrumbs, - resolve: { - resolved: function ($route, history) { - const { data, savedVisualizations, visualizations, toastNotifications } = deps; - const visTypes = visualizations.all(); - const visType = find(visTypes, { name: $route.current.params.type }); - const shouldHaveIndex = visType.requiresSearch && visType.options.showIndexSelection; - const hasIndex = - $route.current.params.indexPattern || $route.current.params.savedSearchId; - if (shouldHaveIndex && !hasIndex) { - throw new Error( - i18n.translate( - 'visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage', - { - defaultMessage: 'You must provide either an indexPattern or a savedSearchId', - } - ) - ); - } - - // This delay is needed to prevent some navigation issues in Firefox/Safari. - // see https://github.com/elastic/kibana/issues/65161 - const delay = (res) => { - return new Promise((resolve) => { - setTimeout(() => resolve(res), 0); - }); - }; - - return data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => savedVisualizations.get($route.current.params)) - .then((savedVis) => { - savedVis.searchSourceFields = { index: $route.current.params.indexPattern }; - return savedVis; - }) - .then(getResolvedResults(deps)) - .then(delay) - .catch( - redirectWhenMissing({ - history, - mapping: VisualizeConstants.LANDING_PAGE_PATH, - toastNotifications, - }) - ); - }, - }, - }) - .when(`${VisualizeConstants.EDIT_PATH}/:id`, { - ...defaults, - template: editorTemplate, - k7Breadcrumbs: getEditBreadcrumbs, - resolve: { - resolved: function ($route, history) { - const { chrome, data, savedVisualizations, toastNotifications } = deps; - - return data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => savedVisualizations.get($route.current.params.id)) - .then((savedVis) => { - chrome.recentlyAccessed.add(savedVis.getFullPath(), savedVis.title, savedVis.id); - return savedVis; - }) - .then(getResolvedResults(deps)) - .catch( - redirectWhenMissing({ - history, - navigateToApp: deps.core.application.navigateToApp, - basePath: deps.core.http.basePath, - mapping: { - visualization: VisualizeConstants.LANDING_PAGE_PATH, - search: { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - 'index-pattern': { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - 'index-pattern-field': { - app: 'management', - path: 'kibana/objects/savedVisualizations/' + $route.current.params.id, - }, - }, - toastNotifications, - onBeforeRedirect() { - deps.setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); - }, - }) - ); - }, - }, - }) - .otherwise({ - resolveRedirectTo: function ($rootScope) { - const path = window.location.hash.substr(1); - deps.restorePreviousUrl(); - $rootScope.$applyAsync(() => { - const { navigated } = deps.kibanaLegacy.navigateToLegacyKibanaUrl(path); - if (!navigated) { - deps.kibanaLegacy.navigateToDefaultApp(); - } - }); - // prevent angular from completing the navigation - return new Promise(() => {}); - }, - }); - }); -} diff --git a/src/plugins/visualize/public/application/listing/_index.scss b/src/plugins/visualize/public/application/listing/_index.scss deleted file mode 100644 index 924c164e467d88..00000000000000 --- a/src/plugins/visualize/public/application/listing/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'listing'; diff --git a/src/plugins/visualize/public/application/listing/visualize_listing.html b/src/plugins/visualize/public/application/listing/visualize_listing.html deleted file mode 100644 index 8838348e0b6796..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing.html +++ /dev/null @@ -1,13 +0,0 @@ -
- -
diff --git a/src/plugins/visualize/public/application/listing/visualize_listing.js b/src/plugins/visualize/public/application/listing/visualize_listing.js deleted file mode 100644 index e8e8d92034113a..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing.js +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; -import { withI18nContext } from './visualize_listing_table'; - -import { VisualizeConstants } from '../visualize_constants'; -import { i18n } from '@kbn/i18n'; - -import { getServices } from '../../kibana_services'; -import { syncQueryStateWithUrl } from '../../../../data/public'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../visualizations/public'; - -import { EuiLink } from '@elastic/eui'; -import React from 'react'; - -export function initListingDirective(app, I18nContext) { - app.directive('visualizeListingTable', (reactDirective) => - reactDirective(withI18nContext(I18nContext)) - ); -} - -export function VisualizeListingController($scope, createNewVis, kbnUrlStateStorage, history) { - const { - addBasePath, - chrome, - savedObjectsClient, - savedVisualizations, - data: { query }, - toastNotifications, - visualizations, - core: { docLinks, savedObjects, uiSettings, application }, - savedObjects: savedObjectsPublic, - } = getServices(); - - chrome.docTitle.change( - i18n.translate('visualize.listingPageTitle', { defaultMessage: 'Visualize' }) - ); - - // syncs `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - query, - kbnUrlStateStorage - ); - - const { - timefilter: { timefilter }, - } = query; - - timefilter.disableAutoRefreshSelector(); - timefilter.disableTimeRangeSelector(); - - this.addBasePath = addBasePath; - this.uiSettings = uiSettings; - this.savedObjects = savedObjects; - - this.createNewVis = () => { - this.closeNewVisModal = visualizations.showNewVisModal(); - }; - - this.editItem = ({ editUrl, editApp }) => { - if (editApp) { - application.navigateToApp(editApp, { path: editUrl }); - return; - } - // for visualizations the edit and view URLs are the same - window.location.href = addBasePath(editUrl); - }; - - this.getViewElement = (field, record) => { - const dataTestSubj = `visListingTitleLink-${record.title.split(' ').join('-')}`; - if (record.editApp) { - return ( - { - application.navigateToApp(record.editApp, { path: record.editUrl }); - }} - data-test-subj={dataTestSubj} - > - {field} - - ); - } else if (record.editUrl) { - return ( - - {field} - - ); - } else { - return {field}; - } - }; - - if (createNewVis) { - // In case the user navigated to the page via the /visualize/new URL we start the dialog immediately - this.closeNewVisModal = visualizations.showNewVisModal({ - onClose: () => { - // In case the user came via a URL to this page, change the URL to the regular landing page URL after closing the modal - history.push({ - // Should preserve querystring part so the global state is preserved. - ...history.location, - pathname: VisualizeConstants.LANDING_PAGE_PATH, - }); - }, - }); - } - - this.fetchItems = (filter) => { - const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); - return savedVisualizations - .findListItems(filter, savedObjectsPublic.settings.getListingLimit()) - .then((result) => { - this.totalItems = result.total; - - return { - total: result.total, - hits: result.hits.filter( - (result) => isLabsEnabled || result.type.stage !== 'experimental' - ), - }; - }); - }; - - this.deleteSelectedItems = function deleteSelectedItems(selectedItems) { - return Promise.all( - selectedItems.map((item) => { - return savedObjectsClient.delete(item.savedObjectType, item.id); - }) - ).catch((error) => { - toastNotifications.addError(error, { - title: i18n.translate('visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); - }); - }; - - chrome.setBreadcrumbs([ - { - text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { - defaultMessage: 'Visualize', - }), - }, - ]); - - this.listingLimit = savedObjectsPublic.settings.getListingLimit(); - this.initialPageSize = savedObjectsPublic.settings.getPerPage(); - - addHelpMenuToAppChrome(chrome, docLinks); - - $scope.$on('$destroy', () => { - if (this.closeNewVisModal) { - this.closeNewVisModal(); - } - - stopSyncingQueryServiceStateWithUrl(); - }); -} diff --git a/src/plugins/visualize/public/application/listing/visualize_listing_table.js b/src/plugins/visualize/public/application/listing/visualize_listing_table.js deleted file mode 100644 index fcd62d7ddee732..00000000000000 --- a/src/plugins/visualize/public/application/listing/visualize_listing_table.js +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { TableListView } from '../../../../kibana_react/public'; - -import { EuiIcon, EuiBetaBadge, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; - -import { getServices } from '../../kibana_services'; - -class VisualizeListingTable extends Component { - constructor(props) { - super(props); - } - - render() { - const { visualizeCapabilities, core, toastNotifications } = getServices(); - return ( - item.canDelete} - initialFilter={''} - noItemsFragment={this.getNoItemsMessage()} - entityName={i18n.translate('visualize.listing.table.entityName', { - defaultMessage: 'visualization', - })} - entityNamePlural={i18n.translate('visualize.listing.table.entityNamePlural', { - defaultMessage: 'visualizations', - })} - tableListTitle={i18n.translate('visualize.listing.table.listTitle', { - defaultMessage: 'Visualizations', - })} - toastNotifications={toastNotifications} - uiSettings={core.uiSettings} - /> - ); - } - - getTableColumns() { - const tableColumns = [ - { - field: 'title', - name: i18n.translate('visualize.listing.table.titleColumnName', { - defaultMessage: 'Title', - }), - sortable: true, - render: (field, record) => this.props.getViewElement(field, record), - }, - { - field: 'typeTitle', - name: i18n.translate('visualize.listing.table.typeColumnName', { - defaultMessage: 'Type', - }), - sortable: true, - render: (field, record) => ( - - {this.renderItemTypeIcon(record)} - {record.typeTitle} - {this.getBadge(record)} - - ), - }, - { - field: 'description', - name: i18n.translate('visualize.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - sortable: true, - render: (field, record) => {record.description}, - }, - ]; - - return tableColumns; - } - - getNoItemsMessage() { - if (this.props.hideWriteControls) { - return ( -
- - - - } - /> -
- ); - } - - return ( -
- - - - } - body={ - -

- -

-
- } - actions={ - - - - } - /> -
- ); - } - - renderItemTypeIcon(item) { - let icon; - if (item.image) { - icon = ( - - ); - } else { - icon = ( -