diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 62abf281e659f..b7fb3ff04db71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -137,6 +137,7 @@ /x-pack/test/functional/es_archives/maps/ @elastic/kibana-gis /x-pack/test/visual_regression/tests/maps/index.js @elastic/kibana-gis #CC# /src/plugins/maps_legacy/ @elastic/kibana-gis +#CC# /src/plugins/maps_oss/ @elastic/kibana-gis #CC# /x-pack/plugins/file_upload @elastic/kibana-gis #CC# /x-pack/plugins/maps_legacy_licensing @elastic/kibana-gis #CC# /src/plugins/home/server/tutorials @elastic/kibana-gis diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md index e7facb4a109cd..4a9fc940c596f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md @@ -19,6 +19,7 @@ export interface UiSettingsParams | [category](./kibana-plugin-core-public.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | | [deprecation](./kibana-plugin-core-public.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-core-public.uisettingsparams.description.md) | string | description provided to a user in UI | +| [metric](./kibana-plugin-core-public.uisettingsparams.metric.md) | {
type: UiStatsMetricType;
name: string;
} | Metric to track once this property changes | | [name](./kibana-plugin-core-public.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-public.uisettingsparams.options.md) | string[] | array of permitted values for this setting | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md new file mode 100644 index 0000000000000..0855cfd77a46b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [metric](./kibana-plugin-core-public.uisettingsparams.metric.md) + +## UiSettingsParams.metric property + +> Warning: This API is now obsolete. +> +> Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place +> + +Metric to track once this property changes + +Signature: + +```typescript +metric?: { + type: UiStatsMetricType; + name: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md index f134decb5102b..7bcb996e98e16 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md @@ -19,6 +19,7 @@ export interface UiSettingsParams | [category](./kibana-plugin-core-server.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | | [deprecation](./kibana-plugin-core-server.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-core-server.uisettingsparams.description.md) | string | description provided to a user in UI | +| [metric](./kibana-plugin-core-server.uisettingsparams.metric.md) | {
type: UiStatsMetricType;
name: string;
} | Metric to track once this property changes | | [name](./kibana-plugin-core-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-server.uisettingsparams.options.md) | string[] | array of permitted values for this setting | diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md new file mode 100644 index 0000000000000..4d54bf9ae472b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [metric](./kibana-plugin-core-server.uisettingsparams.metric.md) + +## UiSettingsParams.metric property + +> Warning: This API is now obsolete. +> +> Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place +> + +Metric to track once this property changes + +Signature: + +```typescript +metric?: { + type: UiStatsMetricType; + name: string; + }; +``` diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index d52faa87cfecd..ee3311a94a202 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { EuiHeaderBreadcrumbs } from '@elastic/eui'; +import { EuiFlexGroup, EuiHeaderBreadcrumbs } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; @@ -51,15 +51,14 @@ export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$, breadcrumbsAppendEx ), })); - if (breadcrumbsAppendExtension) { + if (breadcrumbsAppendExtension && crumbs[crumbs.length - 1]) { const lastCrumb = crumbs[crumbs.length - 1]; lastCrumb.text = ( - <> - {lastCrumb.text} - - + +
{lastCrumb.text}
+ +
); } - return ; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 781a50f849e24..c8add5a8ddf58 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -38,6 +38,7 @@ import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { UnregisterCallback } from 'history'; import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types'; @@ -1362,6 +1363,11 @@ export interface UiSettingsParams { // Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts deprecation?: DeprecationSettings; description?: string; + // @deprecated + metric?: { + type: UiStatsMetricType; + name: string; + }; name?: string; optionLabels?: Record; options?: string[]; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 88d7fecbcf502..a03e5ec9acd27 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -160,6 +160,7 @@ import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { UpdateDocumentByQueryParams } from 'elasticsearch'; import { UpdateDocumentParams } from 'elasticsearch'; import { URL } from 'url'; @@ -2746,6 +2747,11 @@ export interface UiSettingsParams { category?: string[]; deprecation?: DeprecationSettings; description?: string; + // @deprecated + metric?: { + type: UiStatsMetricType; + name: string; + }; name?: string; optionLabels?: Record; options?: string[]; diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index ed1076b571960..0b7a8e1efd9df 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -17,6 +17,7 @@ * under the License. */ import { Type } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; /** * UI element type to represent the settings. @@ -80,6 +81,15 @@ export interface UiSettingsParams { * Used to validate value on write and read. */ schema: Type; + /** + * Metric to track once this property changes + * @deprecated + * Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place + */ + metric?: { + type: UiStatsMetricType; + name: string; + }; } /** diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 0e49fe17089f0..df0d31a904c59 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["management"], - "optionalPlugins": ["home"], + "optionalPlugins": ["home", "usageCollection"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index afdd90959eabd..bbc27ca025ede 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -22,6 +22,7 @@ import { Subscription } from 'rxjs'; import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; import { useParams } from 'react-router-dom'; +import { UiStatsMetricType } from '@kbn/analytics'; import { CallOuts } from './components/call_outs'; import { Search } from './components/search'; import { Form } from './components/form'; @@ -39,6 +40,7 @@ interface AdvancedSettingsProps { dockLinks: DocLinksStart['links']; toasts: ToastsStart; componentRegistry: ComponentRegistry['start']; + trackUiMetric?: (metricType: UiStatsMetricType, eventName: string | string[]) => void; } interface AdvancedSettingsComponentProps extends AdvancedSettingsProps { @@ -241,6 +243,7 @@ export class AdvancedSettingsComponent extends Component< enableSaving={this.props.enableSaving} dockLinks={this.props.dockLinks} toasts={this.props.toasts} + trackUiMetric={this.props.trackUiMetric} /> { dockLinks={props.dockLinks} toasts={props.toasts} componentRegistry={props.componentRegistry} + trackUiMetric={props.trackUiMetric} /> ); }; diff --git a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx index d243d85e12a66..c30768a262056 100644 --- a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx @@ -36,6 +36,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { UiStatsMetricType } from '@kbn/analytics'; import { toMountPoint } from '../../../../../kibana_react/public'; import { DocLinksStart, ToastsStart } from '../../../../../../core/public'; @@ -56,6 +57,7 @@ interface FormProps { enableSaving: boolean; dockLinks: DocLinksStart['links']; toasts: ToastsStart; + trackUiMetric?: (metricType: UiStatsMetricType, eventName: string | string[]) => void; } interface FormState { @@ -149,7 +151,7 @@ export class Form extends PureComponent { if (!setting) { return; } - const { defVal, type, requiresPageReload } = setting; + const { defVal, type, requiresPageReload, metric } = setting; let valueToSave = value; let equalsToDefault = false; switch (type) { @@ -163,6 +165,11 @@ export class Form extends PureComponent { const isArray = Array.isArray(JSON.parse((defVal as string) || '{}')); valueToSave = valueToSave.trim(); valueToSave = valueToSave || (isArray ? '[]' : '{}'); + case 'boolean': + if (metric && this.props.trackUiMetric) { + const metricName = valueToSave ? `${metric.name}_on` : `${metric.name}_off`; + this.props.trackUiMetric(metric.type, metricName); + } default: equalsToDefault = valueToSave === defVal; } diff --git a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts index 406bc35f826e8..e5a1ee1437a91 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts @@ -75,6 +75,7 @@ export function toEditableConfig({ options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, + metric: def.metric, }; return conf; diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index ab348451b1eef..0b3d73cb28806 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -30,6 +30,7 @@ import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; import './index.scss'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced Settings', @@ -49,12 +50,14 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, params: ManagementAppMountParams, - componentRegistry: ComponentRegistry['start'] + componentRegistry: ComponentRegistry['start'], + usageCollection?: UsageCollectionSetup ) { params.setBreadcrumbs(crumb); const [{ uiSettings, notifications, docLinks, application, chrome }] = await getStartServices(); const canSave = application.capabilities.advancedSettings.save as boolean; + const trackUiMetric = usageCollection?.reportUiStats.bind(usageCollection, 'advanced_settings'); if (!canSave) { chrome.setBadge(readOnlyBadge); @@ -71,6 +74,7 @@ export async function mountManagementSection( dockLinks={docLinks.links} uiSettings={uiSettings} componentRegistry={componentRegistry} + trackUiMetric={trackUiMetric} /> diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 6e243926f7d7d..05e695f998500 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import { UiStatsMetricType } from '@kbn/analytics'; import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public'; export interface FieldSetting { @@ -39,6 +40,10 @@ export interface FieldSetting { message: string; docLinksKey: string; }; + metric?: { + type: UiStatsMetricType; + name: string; + }; } // until eui searchbar and query are typed diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 188b11177eaec..165af48b2023c 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -30,7 +30,10 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { + public setup( + core: CoreSetup, + { management, home, usageCollection }: AdvancedSettingsPluginSetup + ) { const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ @@ -41,7 +44,12 @@ export class AdvancedSettingsPlugin const { mountManagementSection } = await import( './management_app/mount_management_section' ); - return mountManagementSection(core.getStartServices, params, component.start); + return mountManagementSection( + core.getStartServices, + params, + component.start, + usageCollection + ); }, }); diff --git a/src/plugins/advanced_settings/public/types.ts b/src/plugins/advanced_settings/public/types.ts index cc59f52b1f30f..bd5cb0e61fb04 100644 --- a/src/plugins/advanced_settings/public/types.ts +++ b/src/plugins/advanced_settings/public/types.ts @@ -21,6 +21,7 @@ import { ComponentRegistry } from './component_registry'; import { HomePublicPluginSetup } from '../../home/public'; import { ManagementSetup } from '../../management/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; export interface AdvancedSettingsSetup { component: ComponentRegistry['setup']; @@ -32,6 +33,7 @@ export interface AdvancedSettingsStart { export interface AdvancedSettingsPluginSetup { management: ManagementSetup; home?: HomePublicPluginSetup; + usageCollection?: UsageCollectionSetup; } export { ComponentRegistry }; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 2984ca336819a..bb7a8f58c926c 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -60,6 +60,7 @@ import { ShardsResponse } from 'elasticsearch'; import { ToastInputFields } from 'src/core/public/notifications'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { Unit } from '@elastic/datemath'; // Warning: (ae-forgotten-export) The symbol "AggConfigSerialized" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 5447b982eef14..f45281ee62202 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { UiSettingsParams } from 'kibana/server'; +import { METRIC_TYPE } from '@kbn/analytics'; import { DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, @@ -170,9 +171,13 @@ export const uiSettings: Record = { }), value: true, description: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchText', { - defaultMessage: 'Remove columns that not available in the new index pattern.', + defaultMessage: 'Remove columns that are not available in the new index pattern.', }), category: ['discover'], schema: schema.boolean(), + metric: { + type: METRIC_TYPE.CLICK, + name: 'discover:modifyColumnsOnSwitchTitle', + }, }, }; diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index 661fb75f81c00..01790b2a4a0c0 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -51,6 +51,10 @@ export class AlertInstance< return false; } + getLastScheduledActions() { + return this.meta.lastScheduledActions; + } + getScheduledActionOptions() { return this.scheduledExecutionOptions; } diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 2f0754d34492f..ed73fec24db26 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -113,6 +113,7 @@ test('enqueues execution per selected action', async () => { }, "kibana": Object { "alerting": Object { + "action_group_id": "default", "instance_id": "2", }, "saved_objects": Array [ diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 21e642d228b4d..f49310c42c247 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -116,6 +116,7 @@ export function createExecutionHandler({ kibana: { alerting: { instance_id: alertInstanceId, + action_group_id: actionGroup, }, saved_objects: [ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, ...namespace }, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 4d0d69010914e..859b6ec4362ce 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -184,11 +184,15 @@ describe('Task Runner', () => { expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(` Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "outcome": "success", }, "kibana": Object { + "alerting": Object { + "status": "ok", + }, "saved_objects": Array [ Object { "id": "1", @@ -249,29 +253,13 @@ describe('Task Runner', () => { const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ - event: { - action: 'execute', - outcome: 'success', - }, - kibana: { - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - }, - ], - }, - message: "alert executed: test:1: 'alert-name'", - }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { event: { action: 'new-instance', }, kibana: { alerting: { + action_group_id: 'default', instance_id: '1', }, saved_objects: [ @@ -285,7 +273,7 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' created new instance: '1'", }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { event: { action: 'active-instance', }, @@ -305,13 +293,14 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { event: { action: 'execute-action', }, kibana: { alerting: { instance_id: '1', + action_group_id: 'default', }, saved_objects: [ { @@ -330,6 +319,27 @@ describe('Task Runner', () => { message: "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { + action: 'execute', + outcome: 'success', + }, + kibana: { + alerting: { + status: 'active', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "alert executed: test:1: 'alert-name'", + }); }); test('includes the apiKey in the request used to initialize the actionsClient', async () => { @@ -402,10 +412,13 @@ describe('Task Runner', () => { Array [ Object { "event": Object { - "action": "execute", - "outcome": "success", + "action": "new-instance", }, "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "1", + }, "saved_objects": Array [ Object { "id": "1", @@ -415,17 +428,17 @@ describe('Task Runner', () => { }, ], }, - "message": "alert executed: test:1: 'alert-name'", + "message": "test:1: 'alert-name' created new instance: '1'", }, ], Array [ Object { "event": Object { - "action": "new-instance", + "action": "active-instance", }, "kibana": Object { "alerting": Object { - "action_group_id": undefined, + "action_group_id": "default", "instance_id": "1", }, "saved_objects": Array [ @@ -437,13 +450,13 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' created new instance: '1'", + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }, ], Array [ Object { "event": Object { - "action": "active-instance", + "action": "execute-action", }, "kibana": Object { "alerting": Object { @@ -457,19 +470,26 @@ describe('Task Runner', () => { "rel": "primary", "type": "alert", }, + Object { + "id": "1", + "namespace": undefined, + "type": "action", + }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }, ], Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { - "action": "execute-action", + "action": "execute", + "outcome": "success", }, "kibana": Object { "alerting": Object { - "instance_id": "1", + "status": "active", }, "saved_objects": Array [ Object { @@ -478,14 +498,9 @@ describe('Task Runner', () => { "rel": "primary", "type": "alert", }, - Object { - "id": "1", - "namespace": undefined, - "type": "action", - }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", + "message": "alert executed: test:1: 'alert-name'", }, ], ] @@ -498,6 +513,7 @@ describe('Task Runner', () => { executorServices.alertInstanceFactory('1').scheduleActions('default'); } ); + const date = new Date().toISOString(); const taskRunner = new TaskRunner( alertType, { @@ -505,8 +521,14 @@ describe('Task Runner', () => { state: { ...mockedTaskInstance.state, alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, + '1': { + meta: { lastScheduledActions: { group: 'default', date } }, + state: { bar: false }, + }, + '2': { + meta: { lastScheduledActions: { group: 'default', date } }, + state: { bar: false }, + }, }, }, }, @@ -545,10 +567,13 @@ describe('Task Runner', () => { Array [ Object { "event": Object { - "action": "execute", - "outcome": "success", + "action": "resolved-instance", }, "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "2", + }, "saved_objects": Array [ Object { "id": "1", @@ -558,18 +583,18 @@ describe('Task Runner', () => { }, ], }, - "message": "alert executed: test:1: 'alert-name'", + "message": "test:1: 'alert-name' resolved instance: '2'", }, ], Array [ Object { "event": Object { - "action": "resolved-instance", + "action": "active-instance", }, "kibana": Object { "alerting": Object { - "action_group_id": undefined, - "instance_id": "2", + "action_group_id": "default", + "instance_id": "1", }, "saved_objects": Array [ Object { @@ -580,18 +605,19 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' resolved instance: '2'", + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }, ], Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { - "action": "active-instance", + "action": "execute", + "outcome": "success", }, "kibana": Object { "alerting": Object { - "action_group_id": "default", - "instance_id": "1", + "status": "active", }, "saved_objects": Array [ Object { @@ -602,7 +628,7 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "alert executed: test:1: 'alert-name'", }, ], ] @@ -787,14 +813,19 @@ describe('Task Runner', () => { Array [ Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, "event": Object { "action": "execute", "outcome": "failure", + "reason": "execute", }, "kibana": Object { + "alerting": Object { + "status": "error", + }, "saved_objects": Array [ Object { "id": "1", @@ -834,6 +865,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "decrypt", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { @@ -867,6 +932,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "unknown", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { @@ -899,6 +998,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "read", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Runner of a legacy Alert task which has no schedule throws an exception when fetching attributes', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 86bf7006e8d09..5bccf5c497a60 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Dictionary, pickBy, mapValues, without } from 'lodash'; +import { Dictionary, pickBy, mapValues, without, cloneDeep } from 'lodash'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_manager/server'; @@ -40,6 +40,8 @@ import { partiallyUpdateAlert } from '../saved_objects'; const FALLBACK_RETRY_INTERVAL = '5m'; +type Event = Exclude; + interface AlertTaskRunResult { state: AlertTaskState; schedule: IntervalSchedule | undefined; @@ -153,7 +155,8 @@ export class TaskRunner { alert: SanitizedAlert, params: AlertExecutorOptions['params'], executionHandler: ReturnType, - spaceId: string + spaceId: string, + event: Event ): Promise { const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; const { @@ -166,24 +169,10 @@ export class TaskRunner { alertRawInstances, (rawAlertInstance) => new AlertInstance(rawAlertInstance) ); + const originalAlertInstances = cloneDeep(alertInstances); - const originalAlertInstanceIds = Object.keys(alertInstances); const eventLogger = this.context.eventLogger; const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; - const event: IEvent = { - event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: alertId, - namespace, - }, - ], - }, - }; - eventLogger.startTiming(event); let updatedAlertTypeState: void | Record; try { @@ -205,21 +194,17 @@ export class TaskRunner { updatedBy, }); } catch (err) { - eventLogger.stopTiming(event); event.message = `alert execution failure: ${alertLabel}`; event.error = event.error || {}; event.error.message = err.message; event.event = event.event || {}; event.event.outcome = 'failure'; - eventLogger.logEvent(event); throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err); } - eventLogger.stopTiming(event); event.message = `alert executed: ${alertLabel}`; event.event = event.event || {}; event.event.outcome = 'success'; - eventLogger.logEvent(event); // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object const instancesWithScheduledActions = pickBy(alertInstances, (alertInstance: AlertInstance) => @@ -227,7 +212,7 @@ export class TaskRunner { ); generateNewAndResolvedInstanceEvents({ eventLogger, - originalAlertInstanceIds, + originalAlertInstances, currentAlertInstances: instancesWithScheduledActions, alertId, alertLabel, @@ -261,7 +246,8 @@ export class TaskRunner { async validateAndExecuteAlert( services: Services, apiKey: RawAlert['apiKey'], - alert: SanitizedAlert + alert: SanitizedAlert, + event: Event ) { const { params: { alertId, spaceId }, @@ -278,10 +264,17 @@ export class TaskRunner { alert.actions, alert.params ); - return this.executeAlertInstances(services, alert, validatedParams, executionHandler, spaceId); + return this.executeAlertInstances( + services, + alert, + validatedParams, + executionHandler, + spaceId, + event + ); } - async loadAlertAttributesAndRun(): Promise> { + async loadAlertAttributesAndRun(event: Event): Promise> { const { params: { alertId, spaceId }, } = this.taskInstance; @@ -304,7 +297,7 @@ export class TaskRunner { return { state: await promiseResult( - this.validateAndExecuteAlert(services, apiKey, alert) + this.validateAndExecuteAlert(services, apiKey, alert, event) ), schedule: asOk( // fetch the alert again to ensure we return the correct schedule as it may have @@ -322,18 +315,65 @@ export class TaskRunner { schedule: taskSchedule, } = this.taskInstance; - const { state, schedule } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun()); - const namespace = spaceId === 'default' ? undefined : spaceId; + const namespace = this.context.spaceIdToNamespace(spaceId); + const eventLogger = this.context.eventLogger; + const event: IEvent = { + // explicitly set execute timestamp so it will be before other events + // generated here (new-instance, schedule-action, etc) + '@timestamp': new Date().toISOString(), + event: { action: EVENT_LOG_ACTIONS.execute }, + kibana: { + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: 'alert', + id: alertId, + namespace, + }, + ], + }, + }; + eventLogger.startTiming(event); + + const { state, schedule } = await errorAsAlertTaskRunResult( + this.loadAlertAttributesAndRun(event) + ); const executionStatus: AlertExecutionStatus = map( state, (alertTaskState: AlertTaskState) => executionStatusFromState(alertTaskState), (err: Error) => executionStatusFromError(err) ); + + // set the executionStatus date to same as event, if it's set + if (event.event?.start) { + executionStatus.lastExecutionDate = new Date(event.event.start); + } + this.logger.debug( `alertExecutionStatus for ${this.alertType.id}:${alertId}: ${JSON.stringify(executionStatus)}` ); + eventLogger.stopTiming(event); + event.kibana = event.kibana || {}; + event.kibana.alerting = event.kibana.alerting || {}; + event.kibana.alerting.status = executionStatus.status; + + // if executionStatus indicates an error, fill in fields in + // event from it + if (executionStatus.error) { + event.event = event.event || {}; + event.event.reason = executionStatus.error?.reason || 'unknown'; + event.event.outcome = 'failure'; + event.error = event.error || {}; + event.error.message = event.error.message || executionStatus.error.message; + if (!event.message) { + event.message = `${this.alertType.id}:${alertId}: execution failed`; + } + } + + eventLogger.logEvent(event); + const client = this.context.internalSavedObjectsRepository; const attributes = { executionStatus: alertExecutionStatusToRaw(executionStatus), @@ -381,7 +421,7 @@ export class TaskRunner { interface GenerateNewAndResolvedInstanceEventsParams { eventLogger: IEventLogger; - originalAlertInstanceIds: string[]; + originalAlertInstances: Dictionary; currentAlertInstances: Dictionary; alertId: string; alertLabel: string; @@ -389,26 +429,23 @@ interface GenerateNewAndResolvedInstanceEventsParams { } function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) { - const { - eventLogger, - alertId, - namespace, - currentAlertInstances, - originalAlertInstanceIds, - } = params; + const { eventLogger, alertId, namespace, currentAlertInstances, originalAlertInstances } = params; + const originalAlertInstanceIds = Object.keys(originalAlertInstances); const currentAlertInstanceIds = Object.keys(currentAlertInstances); const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds); const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds); for (const id of resolvedIds) { + const actionGroup = originalAlertInstances[id].getLastScheduledActions()?.group; const message = `${params.alertLabel} resolved instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message, actionGroup); } for (const id of newIds) { + const actionGroup = currentAlertInstances[id].getScheduledActionOptions()?.actionGroup; const message = `${params.alertLabel} created new instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message); + logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message, actionGroup); } for (const id of currentAlertInstanceIds) { @@ -425,7 +462,7 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst kibana: { alerting: { instance_id: instanceId, - action_group_id: group, + ...(group ? { action_group_id: group } : {}), }, saved_objects: [ { diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index be00364cab92e..30d4bb34ea345 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -19,6 +19,6 @@ test('MLLink produces the correct URL', async () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/jobs?mlManagement=(groupIds:!(apm),jobId:!(something))&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` + `"/app/ml/jobs?_a=(queryText:'id:(something)%20groups:(apm)')&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` ); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 9f2b4121351bc..6d22002222a66 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -7,6 +7,8 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; import { + registerAccountSourcesRoute, + registerAccountSourcesStatusRoute, registerAccountSourceRoute, registerAccountCreateSourceRoute, registerAccountSourceDocumentsRoute, @@ -15,6 +17,9 @@ import { registerAccountSourceSettingsRoute, registerAccountPreSourceRoute, registerAccountPrepareSourcesRoute, + registerAccountSourceSearchableRoute, + registerOrgSourcesRoute, + registerOrgSourcesStatusRoute, registerOrgSourceRoute, registerOrgCreateSourceRoute, registerOrgSourceDocumentsRoute, @@ -23,6 +28,7 @@ import { registerOrgSourceSettingsRoute, registerOrgPreSourceRoute, registerOrgPrepareSourcesRoute, + registerOrgSourceSearchableRoute, registerOrgSourceOauthConfigurationsRoute, registerOrgSourceOauthConfigurationRoute, } from './sources'; @@ -38,6 +44,60 @@ const mockConfig = { }; describe('sources routes', () => { + describe('GET /api/workplace_search/account/sources', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources', + payload: 'params', + }); + + registerAccountSourcesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources', + }); + }); + }); + + describe('GET /api/workplace_search/account/sources/status', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources/status', + payload: 'params', + }); + + registerAccountSourcesStatusRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/status', + }); + }); + }); + describe('GET /api/workplace_search/account/sources/{id}', () => { let mockRouter: MockRouter; @@ -351,6 +411,97 @@ describe('sources routes', () => { }); }); + describe('PUT /api/workplace_search/sources/{id}/searchable', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/sources/{id}/searchable', + payload: 'body', + }); + + registerAccountSourceSearchableRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + body: { + searchable: true, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/searchable', + body: mockRequest.body, + }); + }); + }); + + describe('GET /api/workplace_search/org/sources', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources', + payload: 'params', + }); + + registerOrgSourcesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources', + }); + }); + }); + + describe('GET /api/workplace_search/org/sources/status', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources/status', + payload: 'params', + }); + + registerOrgSourcesStatusRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/status', + }); + }); + }); + describe('GET /api/workplace_search/org/sources/{id}', () => { let mockRouter: MockRouter; @@ -664,6 +815,43 @@ describe('sources routes', () => { }); }); + describe('PUT /api/workplace_search/org/sources/{id}/searchable', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/org/sources/{id}/searchable', + payload: 'body', + }); + + registerOrgSourceSearchableRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + body: { + searchable: true, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/searchable', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/settings/connectors', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index f496628d02379..efef53440117e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -25,6 +25,40 @@ const oAuthConfigSchema = schema.object({ consumer_key: schema.string(), }); +export function registerAccountSourcesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources', + })(context, request, response); + } + ); +} + +export function registerAccountSourcesStatusRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources/status', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources/status', + })(context, request, response); + } + ); +} + export function registerAccountSourceRoute({ router, enterpriseSearchRequestHandler, @@ -228,6 +262,65 @@ export function registerAccountPrepareSourcesRoute({ ); } +export function registerAccountSourceSearchableRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/sources/{id}/searchable', + validate: { + body: schema.object({ + searchable: schema.boolean(), + }), + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/searchable`, + body: request.body, + })(context, request, response); + } + ); +} + +export function registerOrgSourcesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/sources', + })(context, request, response); + } + ); +} + +export function registerOrgSourcesStatusRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources/status', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/sources/status', + })(context, request, response); + } + ); +} + export function registerOrgSourceRoute({ router, enterpriseSearchRequestHandler, @@ -431,6 +524,31 @@ export function registerOrgPrepareSourcesRoute({ ); } +export function registerOrgSourceSearchableRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/org/sources/{id}/searchable', + validate: { + body: schema.object({ + searchable: schema.boolean(), + }), + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/searchable`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourceOauthConfigurationsRoute({ router, enterpriseSearchRequestHandler, @@ -522,6 +640,8 @@ export function registerOrgSourceOauthConfigurationRoute({ } export const registerSourcesRoutes = (dependencies: RouteDependencies) => { + registerAccountSourcesRoute(dependencies); + registerAccountSourcesStatusRoute(dependencies); registerAccountSourceRoute(dependencies); registerAccountCreateSourceRoute(dependencies); registerAccountSourceDocumentsRoute(dependencies); @@ -530,6 +650,9 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerAccountSourceSettingsRoute(dependencies); registerAccountPreSourceRoute(dependencies); registerAccountPrepareSourcesRoute(dependencies); + registerAccountSourceSearchableRoute(dependencies); + registerOrgSourcesRoute(dependencies); + registerOrgSourcesStatusRoute(dependencies); registerOrgSourceRoute(dependencies); registerOrgCreateSourceRoute(dependencies); registerOrgSourceDocumentsRoute(dependencies); @@ -538,6 +661,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerOrgSourceSettingsRoute(dependencies); registerOrgPreSourceRoute(dependencies); registerOrgPrepareSourcesRoute(dependencies); + registerOrgSourceSearchableRoute(dependencies); registerOrgSourceOauthConfigurationsRoute(dependencies); registerOrgSourceOauthConfigurationRoute(dependencies); }; diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 5c7eb50117d9b..3131235ebcfbe 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -45,6 +45,10 @@ "outcome": { "ignore_above": 1024, "type": "keyword" + }, + "reason": { + "ignore_above": 1024, + "type": "keyword" } } }, @@ -85,6 +89,10 @@ "action_group_id": { "type": "keyword", "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 } } }, diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 3dbb43b15350f..d2024ea8ed14a 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -18,7 +18,7 @@ type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> : DeepPartial; }; -export const ECS_VERSION = '1.5.0'; +export const ECS_VERSION = '1.6.0'; // types and config-schema describing the es structures export type IValidatedEvent = TypeOf; @@ -42,6 +42,7 @@ export const EventSchema = schema.maybe( duration: ecsNumber(), end: ecsDate(), outcome: ecsString(), + reason: ecsString(), }) ), error: schema.maybe( @@ -61,6 +62,7 @@ export const EventSchema = schema.maybe( schema.object({ instance_id: ecsString(), action_group_id: ecsString(), + status: ecsString(), }) ), saved_objects: schema.maybe( diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index c9af2b0aa57fb..bd05f84d4e2b8 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -22,6 +22,10 @@ exports.EcsKibanaExtensionsMappings = { type: 'keyword', ignore_above: 1024, }, + status: { + type: 'keyword', + ignore_above: 1024, + }, }, }, // array of saved object references, for "linking" via search @@ -63,11 +67,13 @@ exports.EcsEventLogProperties = [ 'event.duration', 'event.end', 'event.outcome', // optional, but one of failure, success, unknown + 'event.reason', 'error.message', 'user.name', 'kibana.server_uuid', 'kibana.alerting.instance_id', 'kibana.alerting.action_group_id', + 'kibana.alerting.status', 'kibana.saved_objects.rel', 'kibana.saved_objects.namespace', 'kibana.saved_objects.id', diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index 0ab3071f70efa..ea699af45ccd2 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -102,16 +102,16 @@ describe('EventLogger', () => { event: { provider: 'test-provider', action: 'a' }, }); - const ignoredTimestamp = '1999-01-01T00:00:00Z'; + const respectedTimestamp = '2999-01-01T00:00:00.000Z'; eventLogger.logEvent({ - '@timestamp': ignoredTimestamp, + '@timestamp': respectedTimestamp, event: { action: 'b', }, }); const event = await waitForLogEvent(systemLogger); - expect(event!['@timestamp']).not.toEqual(ignoredTimestamp); + expect(event!['@timestamp']).toEqual(respectedTimestamp); expect(event?.event?.action).toEqual('b'); }); diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 8730870f9620b..658d90d809652 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -72,7 +72,6 @@ export class EventLogger implements IEventLogger { const event: IEvent = {}; const fixedProperties = { - '@timestamp': new Date().toISOString(), ecs: { version: ECS_VERSION, }, @@ -81,8 +80,12 @@ export class EventLogger implements IEventLogger { }, }; + const defaultProperties = { + '@timestamp': new Date().toISOString(), + }; + // merge the initial properties and event properties - merge(event, this.initialProperties, eventProperties, fixedProperties); + merge(event, defaultProperties, this.initialProperties, eventProperties, fixedProperties); let validatedEvent: IValidatedEvent; try { diff --git a/x-pack/plugins/fleet/server/services/epm/archive/index.ts b/x-pack/plugins/fleet/server/services/epm/archive/index.ts index 28f635e9412ae..810740d697fcb 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/index.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ArchivePackage } from '../../../../common/types'; +import { ArchivePackage, AssetParts } from '../../../../common/types'; import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; import { + cacheGet, cacheSet, cacheDelete, getArchiveFilelist, @@ -100,3 +101,40 @@ export const deletePackageCache = (name: string, version: string) => { // this has been populated in unpackArchiveToCache() paths?.forEach((path) => cacheDelete(path)); }; + +export function getPathParts(path: string): AssetParts { + let dataset; + + let [pkgkey, service, type, file] = path.split('/'); + + // if it's a data stream + if (service === 'data_stream') { + // save the dataset name + dataset = type; + // drop the `data_stream/dataset-name` portion & re-parse + [pkgkey, service, type, file] = path.replace(`data_stream/${dataset}/`, '').split('/'); + } + + // This is to cover for the fields.yml files inside the "fields" directory + if (file === undefined) { + file = type; + type = 'fields'; + service = ''; + } + + return { + pkgkey, + service, + type, + file, + dataset, + path, + } as AssetParts; +} + +export function getAsset(key: string) { + const buffer = cacheGet(key); + if (buffer === undefined) throw new Error(`Cannot find asset ${key}`); + + return buffer; +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts index c5253e4902cab..46c0729a650d0 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts @@ -5,15 +5,15 @@ */ import { CallESAsCurrentUser, ElasticsearchAssetType } from '../../../../types'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; export async function installILMPolicy(paths: string[], callCluster: CallESAsCurrentUser) { const ilmPaths = paths.filter((path) => isILMPolicy(path)); if (!ilmPaths.length) return; await Promise.all( ilmPaths.map(async (path) => { - const body = Registry.getAsset(path).toString('utf-8'); - const { file } = Registry.pathParts(path); + const body = getAsset(path).toString('utf-8'); + const { file } = getPathParts(path); const name = file.substr(0, file.lastIndexOf('.')); try { await callCluster('transport.request', { @@ -28,7 +28,7 @@ export async function installILMPolicy(paths: string[], callCluster: CallESAsCur ); } const isILMPolicy = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.ilmPolicy; }; export async function policyExists( diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 58abdeb0d443d..c5c9e8ac2c01b 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -11,7 +11,8 @@ import { ElasticsearchAssetType, InstallablePackage, } from '../../../../types'; -import * as Registry from '../../registry'; +import { ArchiveEntry } from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { CallESAsCurrentUser } from '../../../../types'; import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; @@ -127,7 +128,7 @@ export async function installPipelinesForDataStream({ dataStream, packageVersion: pkgVersion, }); - const content = Registry.getAsset(path).toString('utf-8'); + const content = getAsset(path).toString('utf-8'); pipelines.push({ name, nameForInstallation, @@ -192,10 +193,10 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } -const isDirectory = ({ path }: Registry.ArchiveEntry) => path.endsWith('/'); +const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return ( !isDirectory({ path }) && pathParts.type === ElasticsearchAssetType.ingestPipeline && @@ -204,7 +205,7 @@ const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { ); }; const isPipeline = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.ingestPipeline; }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 25d412b685904..199026da30c11 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -17,7 +17,7 @@ import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { generateMappings, generateTemplateName, getTemplate } from './template'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; export const installTemplates = async ( @@ -76,9 +76,9 @@ export const installTemplates = async ( const installPreBuiltTemplates = async (paths: string[], callCluster: CallESAsCurrentUser) => { const templatePaths = paths.filter((path) => isTemplate(path)); const templateInstallPromises = templatePaths.map(async (path) => { - const { file } = Registry.pathParts(path); + const { file } = getPathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); - const content = JSON.parse(Registry.getAsset(path).toString('utf8')); + const content = JSON.parse(getAsset(path).toString('utf8')); let templateAPIPath = '_template'; // v2 index templates need to be installed through the new API endpoint. @@ -121,9 +121,9 @@ const installPreBuiltComponentTemplates = async ( ) => { const templatePaths = paths.filter((path) => isComponentTemplate(path)); const templateInstallPromises = templatePaths.map(async (path) => { - const { file } = Registry.pathParts(path); + const { file } = getPathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); - const content = JSON.parse(Registry.getAsset(path).toString('utf8')); + const content = JSON.parse(getAsset(path).toString('utf8')); const callClusterParams: { method: string; @@ -151,12 +151,12 @@ const installPreBuiltComponentTemplates = async ( }; const isTemplate = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.indexTemplate; }; const isComponentTemplate = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.componentTemplate; }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts index 46f36dba96747..764e1b51f1bca 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Registry from '../../registry'; - -export const getAsset = (path: string): Buffer => { - return Registry.getAsset(path); -}; +export { getAsset } from '../../archive'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index 1002eedc48740..9da5e8cd0a937 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { saveInstalledEsRefs } from '../../packages/install'; -import * as Registry from '../../registry'; +import { getPathParts } from '../../archive'; import { ElasticsearchAssetType, EsAssetReference, @@ -104,7 +104,7 @@ export const installTransform = async ( }; const isTransform = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return !path.endsWith('/') && pathParts.type === ElasticsearchAssetType.transform; }; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e7b251ef133c5..fe93ed84b32f2 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -10,7 +10,7 @@ import { SavedObjectsClientContract, } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { AssetType, KibanaAssetType, @@ -57,7 +57,7 @@ const AssetInstallers: Record< }; export async function getKibanaAsset(key: string): Promise { - const buffer = Registry.getAsset(key); + const buffer = getAsset(key); // cache values are buffers. convert to string / JSON return JSON.parse(buffer.toString('utf8')); @@ -117,14 +117,14 @@ export async function getKibanaAssets( ): Promise> { const kibanaAssetTypes = Object.values(KibanaAssetType); const isKibanaAssetType = (path: string) => { - const parts = Registry.pathParts(path); + const parts = getPathParts(path); return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); }; const filteredPaths = paths .filter(isKibanaAssetType) - .map<[string, AssetParts]>((path) => [path, Registry.pathParts(path)]); + .map<[string, AssetParts]>((path) => [path, getPathParts(path)]); const assetArrays: Array> = []; for (const assetType of kibanaAssetTypes) { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts index 2e2090312c9ae..50d8f2f4d2fb2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts @@ -6,7 +6,7 @@ import { InstallablePackage } from '../../../types'; import * as Registry from '../registry'; -import { getArchiveFilelist } from '../archive/cache'; +import { getArchiveFilelist, getAsset } from '../archive'; // paths from RegistryPackage are routes to the assets on EPR // e.g. `/package/nginx/1.2.0/data_stream/access/fields/fields.yml` @@ -59,7 +59,7 @@ export async function getAssetsData( // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); const entries: Registry.ArchiveEntry[] = assets.map((path) => { - const buffer = Registry.getAsset(path); + const buffer = getAsset(path); return { path, buffer }; }); diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts index a2d5c8147002d..1208ffdaefe4a 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts @@ -5,7 +5,8 @@ */ import { AssetParts } from '../../../types'; -import { getBufferExtractor, pathParts, splitPkgKey } from './index'; +import { getPathParts } from '../archive'; +import { getBufferExtractor, splitPkgKey } from './index'; import { untarBuffer, unzipBuffer } from './extract'; const testPaths = [ @@ -46,7 +47,7 @@ const testPaths = [ test('testPathParts', () => { for (const value of testPaths) { - expect(pathParts(value.path)).toStrictEqual(value.assetParts as AssetParts); + expect(getPathParts(value.path)).toStrictEqual(value.assetParts as AssetParts); } }); diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 52a1894570b2a..c35e91bdf580b 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -8,7 +8,6 @@ import semver from 'semver'; import { Response } from 'node-fetch'; import { URL } from 'url'; import { - AssetParts, AssetsGroupedByServiceByType, CategoryId, CategorySummaryList, @@ -18,8 +17,12 @@ import { RegistrySearchResults, RegistrySearchResult, } from '../../../types'; -import { unpackArchiveToCache } from '../archive'; -import { cacheGet, getArchiveFilelist, setArchiveFilelist } from '../archive'; +import { + getArchiveFilelist, + getPathParts, + setArchiveFilelist, + unpackArchiveToCache, +} from '../archive'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; @@ -146,36 +149,6 @@ export async function getRegistryPackage( return { paths, registryPackageInfo }; } -export function pathParts(path: string): AssetParts { - let dataset; - - let [pkgkey, service, type, file] = path.split('/'); - - // if it's a data stream - if (service === 'data_stream') { - // save the dataset name - dataset = type; - // drop the `data_stream/dataset-name` portion & re-parse - [pkgkey, service, type, file] = path.replace(`data_stream/${dataset}/`, '').split('/'); - } - - // This is to cover for the fields.yml files inside the "fields" directory - if (file === undefined) { - file = type; - type = 'fields'; - service = ''; - } - - return { - pkgkey, - service, - type, - file, - dataset, - path, - } as AssetParts; -} - export async function ensureCachedArchiveInfo( name: string, version: string, @@ -204,19 +177,12 @@ async function fetchArchiveBuffer( return { archiveBuffer, archivePath }; } -export function getAsset(key: string) { - const buffer = cacheGet(key); - if (buffer === undefined) throw new Error(`Cannot find asset ${key}`); - - return buffer; -} - export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType { const kibanaAssetTypes = Object.values(KibanaAssetType); // ASK: best way, if any, to avoid `any`? const assets = paths.reduce((map: any, path) => { - const parts = pathParts(path.replace(/^\/package\//, '')); + const parts = getPathParts(path.replace(/^\/package\//, '')); if (parts.service === 'kibana' && kibanaAssetTypes.includes(parts.type)) { if (!map[parts.service]) map[parts.service] = {}; if (!map[parts.service][parts.type]) map[parts.service][parts.type] = []; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index b5386dec34205..313ebefb85301 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -51,4 +51,5 @@ export type TestSubjects = | 'templateList' | 'templatesTab' | 'templateTable' + | 'title' | 'viewButton'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 6bf6c11a37bb4..ab796767487b5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -30,6 +30,7 @@ export interface DataStreamsTabTestBed extends TestBed { clickDeleteActionAt: (index: number) => void; clickConfirmDelete: () => void; clickDeleteDataStreamButton: () => void; + clickDetailPanelIndexTemplateLink: () => void; }; findDeleteActionAt: (index: number) => ReactWrapper; findDeleteConfirmationModal: () => ReactWrapper; @@ -38,6 +39,7 @@ export interface DataStreamsTabTestBed extends TestBed { findEmptyPromptIndexTemplateLink: () => ReactWrapper; findDetailPanelIlmPolicyLink: () => ReactWrapper; findDetailPanelIlmPolicyName: () => ReactWrapper; + findDetailPanelIndexTemplateLink: () => ReactWrapper; } export const setup = async (overridingDependencies: any = {}): Promise => { @@ -143,6 +145,17 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { component, router, find } = testBed; + const indexTemplateLink = find('indexTemplateLink'); + + await act(async () => { + router.navigateTo(indexTemplateLink.props().href!); + }); + + component.update(); + }; + const findDetailPanel = () => { const { find } = testBed; return find('dataStreamDetailPanel'); @@ -158,6 +171,11 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { find } = testBed; + return find('indexTemplateLink'); + }; + const findDetailPanelIlmPolicyName = () => { const descriptionList = testBed.component.find(EuiDescriptionListDescription); // ilm policy is the last in the details list @@ -176,6 +194,7 @@ export const setup = async (overridingDependencies: any = {}): Promise { setLoadIndicesResponse, setLoadDataStreamsResponse, setLoadDataStreamResponse, + setLoadTemplateResponse, + setLoadTemplatesResponse, } = httpRequestsMockHelpers; setLoadIndicesResponse([ @@ -103,6 +106,10 @@ describe('Data Streams tab', () => { ]); setLoadDataStreamResponse(dataStreamForDetailPanel); + const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' }); + setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); + setLoadTemplateResponse(indexTemplate); + testBed = await setup({ history: createMemoryHistory() }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -244,6 +251,26 @@ describe('Data Streams tab', () => { dataStreams: ['dataStream1'], }); }); + + test('clicking index template name navigates to the index template details', async () => { + const { + actions: { clickNameAt, clickDetailPanelIndexTemplateLink }, + findDetailPanelIndexTemplateLink, + component, + find, + } = testBed; + + await clickNameAt(0); + + const indexTemplateLink = findDetailPanelIndexTemplateLink(); + expect(indexTemplateLink.text()).toBe('indexTemplate'); + + await clickDetailPanelIndexTemplateLink(); + + component.update(); + expect(find('summaryTab').exists()).toBeTruthy(); + expect(find('title').text().trim()).toBe('indexTemplate'); + }); }); }); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 9ec6993717435..05d7e97745b9e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -29,9 +29,9 @@ import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; import { useUrlGenerator } from '../../../../services/use_url_generator'; +import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; -import { getIndexListUri } from '../../../../..'; interface DetailsListProps { details: Array<{ @@ -207,7 +207,14 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ defaultMessage: 'The index template that configured the data stream and configures its backing indices', }), - content: indexTemplateName, + content: ( + + {indexTemplateName} + + ), }, { name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.ilmPolicyTitle', { @@ -218,10 +225,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ }), content: ilmPolicyName && ilmPolicyLink ? ( - + {ilmPolicyName} ) : ( diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index d38b4690fed71..48790c3faca52 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -99,11 +99,17 @@ export type LogEntryContext = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ - data: rt.type({ - entries: rt.array(logEntryRT), - topCursor: rt.union([logEntriesCursorRT, rt.null]), - bottomCursor: rt.union([logEntriesCursorRT, rt.null]), - }), + data: rt.intersection([ + rt.type({ + entries: rt.array(logEntryRT), + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), + }), + rt.partial({ + hasMoreBefore: rt.boolean, + hasMoreAfter: rt.boolean, + }), + ]), }); export type LogEntriesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/index.tsx index 6698018e8cc19..62a4d7ffc3d81 100644 --- a/x-pack/plugins/infra/public/components/log_stream/index.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { noop } from 'lodash'; import useMount from 'react-use/lib/useMount'; import { euiStyled } from '../../../../observability/public'; @@ -17,6 +17,8 @@ import { useLogStream } from '../../containers/logs/log_stream'; import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; +const PAGE_THRESHOLD = 2; + export interface LogStreamProps { sourceId?: string; startTimestamp: number; @@ -58,7 +60,16 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re }); // Internal state - const { loadingState, entries, fetchEntries } = useLogStream({ + const { + loadingState, + pageLoadingState, + entries, + hasMoreBefore, + hasMoreAfter, + fetchEntries, + fetchPreviousEntries, + fetchNextEntries, + } = useLogStream({ sourceId, startTimestamp, endTimestamp, @@ -70,6 +81,8 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re const isReloading = isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading'; + const isLoadingMore = pageLoadingState === 'loading'; + const columnConfigurations = useMemo(() => { return sourceConfiguration ? sourceConfiguration.configuration.logColumns : []; }, [sourceConfiguration]); @@ -84,13 +97,33 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re [entries] ); + const parsedHeight = typeof height === 'number' ? `${height}px` : height; + // Component lifetime useMount(() => { loadSourceConfiguration(); fetchEntries(); }); - const parsedHeight = typeof height === 'number' ? `${height}px` : height; + // Pagination handler + const handlePagination = useCallback( + ({ fromScroll, pagesBeforeStart, pagesAfterEnd }) => { + if (!fromScroll) { + return; + } + + if (isLoadingMore) { + return; + } + + if (pagesBeforeStart < PAGE_THRESHOLD) { + fetchPreviousEntries(); + } else if (pagesAfterEnd < PAGE_THRESHOLD) { + fetchNextEntries(); + } + }, + [isLoadingMore, fetchPreviousEntries, fetchNextEntries] + ); return ( @@ -101,13 +134,13 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re scale="medium" wrap={false} isReloading={isReloading} - isLoadingMore={false} - hasMoreBeforeStart={false} - hasMoreAfterEnd={false} + isLoadingMore={isLoadingMore} + hasMoreBeforeStart={hasMoreBefore} + hasMoreAfterEnd={hasMoreAfter} isStreaming={false} lastLoadedTime={null} jumpToTarget={noop} - reportVisibleInterval={noop} + reportVisibleInterval={handlePagination} loadNewerItems={noop} reloadItems={fetchEntries} highlightedItem={highlight ?? null} diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx index 67f2c8d58ec0d..39c21fdc228df 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx @@ -6,10 +6,11 @@ import { EuiCard, EuiIcon, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { SetupStatus } from '../../../../../common/log_analysis'; import { CreateJobButton, RecreateJobButton } from '../../log_analysis_setup/create_job_button'; -import { useLinkProps } from '../../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; +import { mountReactNode } from '../../../../../../../../src/core/public/utils'; export const LogAnalysisModuleListCard: React.FC<{ jobId: string; @@ -26,6 +27,39 @@ export const LogAnalysisModuleListCard: React.FC<{ moduleStatus, onViewSetup, }) => { + const { + services: { + ml, + application: { navigateToUrl }, + notifications: { toasts }, + }, + } = useKibanaContextForPlugin(); + + const [viewInMlLink, setViewInMlLink] = useState(''); + + const getMlUrl = async () => { + if (!ml.urlGenerator) { + toasts.addWarning({ + title: mountReactNode( + + ), + }); + return; + } + setViewInMlLink(await ml.urlGenerator.createUrl({ page: 'jobs', pageState: { jobId } })); + }; + + useEffect(() => { + getMlUrl(); + }); + + const navigateToMlApp = async () => { + await navigateToUrl(viewInMlLink); + }; + const moduleIcon = moduleStatus.type === 'required' ? ( @@ -33,12 +67,6 @@ export const LogAnalysisModuleListCard: React.FC<{ ); - const viewInMlLinkProps = useLinkProps({ - app: 'ml', - pathname: '/jobs', - search: { mlManagement: `(jobId:${jobId})` }, - }); - const moduleSetupButton = moduleStatus.type === 'required' ? ( @@ -50,13 +78,17 @@ export const LogAnalysisModuleListCard: React.FC<{ ) : ( <> - - - - + {viewInMlLink ? ( + <> + + + + + + ) : null} ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 146746af980c9..bf4c5fbe0b13b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -367,16 +367,16 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action case Action.ReceiveNewEntries: return { ...prevState, - ...action.payload, + entries: action.payload.entries, + topCursor: action.payload.topCursor, + bottomCursor: action.payload.bottomCursor, centerCursor: getCenterCursor(action.payload.entries), lastLoadedTime: new Date(), isReloading: false, - - // Be optimistic. If any of the before/after requests comes empty, set - // the corresponding flag to `false` - hasMoreBeforeStart: true, - hasMoreAfterEnd: true, + hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, + hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, }; + case Action.ReceiveEntriesBefore: { const newEntries = action.payload.entries; const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); @@ -385,7 +385,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action const update = { entries, isLoadingMore: false, - hasMoreBeforeStart: newEntries.length > 0, + hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, // Keep the previous cursor if request comes empty, to easily extend the range. topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, centerCursor: getCenterCursor(entries), @@ -402,7 +402,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action const update = { entries, isLoadingMore: false, - hasMoreAfterEnd: newEntries.length > 0, + hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, // Keep the previous cursor if request comes empty, to easily extend the range. bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, centerCursor: getCenterCursor(entries), @@ -419,6 +419,8 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action topCursor: null, bottomCursor: null, centerCursor: null, + // Assume there are more pages on both ends unless proven wrong by the + // API with an explicit `false` response. hasMoreBeforeStart: true, hasMoreAfterEnd: true, }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 4a6da6063e960..566edcce91318 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useMemo } from 'react'; +import { useMemo, useEffect } from 'react'; +import useSetState from 'react-use/lib/useSetState'; +import usePrevious from 'react-use/lib/usePrevious'; import { esKuery } from '../../../../../../../src/plugins/data/public'; import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; @@ -21,19 +23,62 @@ interface LogStreamProps { interface LogStreamState { entries: LogEntry[]; + topCursor: LogEntriesCursor | null; + bottomCursor: LogEntriesCursor | null; + hasMoreBefore: boolean; + hasMoreAfter: boolean; +} + +type LoadingState = 'uninitialized' | 'loading' | 'success' | 'error'; + +interface LogStreamReturn extends LogStreamState { fetchEntries: () => void; - loadingState: 'uninitialized' | 'loading' | 'success' | 'error'; + fetchPreviousEntries: () => void; + fetchNextEntries: () => void; + loadingState: LoadingState; + pageLoadingState: LoadingState; } +const INITIAL_STATE: LogStreamState = { + entries: [], + topCursor: null, + bottomCursor: null, + // Assume there are pages available until the API proves us wrong + hasMoreBefore: true, + hasMoreAfter: true, +}; + +const EMPTY_DATA = { + entries: [], + topCursor: null, + bottomCursor: null, +}; + export function useLogStream({ sourceId, startTimestamp, endTimestamp, query, center, -}: LogStreamProps): LogStreamState { +}: LogStreamProps): LogStreamReturn { const { services } = useKibanaContextForPlugin(); - const [entries, setEntries] = useState([]); + const [state, setState] = useSetState(INITIAL_STATE); + + // Ensure the pagination keeps working when the timerange gets extended + const prevStartTimestamp = usePrevious(startTimestamp); + const prevEndTimestamp = usePrevious(endTimestamp); + + useEffect(() => { + if (prevStartTimestamp && prevStartTimestamp > startTimestamp) { + setState({ hasMoreBefore: true }); + } + }, [prevStartTimestamp, startTimestamp, setState]); + + useEffect(() => { + if (prevEndTimestamp && prevEndTimestamp < endTimestamp) { + setState({ hasMoreAfter: true }); + } + }, [prevEndTimestamp, endTimestamp, setState]); const parsedQuery = useMemo(() => { return query @@ -46,7 +91,7 @@ export function useLogStream({ { cancelPreviousOn: 'creation', createPromise: () => { - setEntries([]); + setState(INITIAL_STATE); const fetchPosition = center ? { center } : { before: 'last' }; return fetchLogEntries( @@ -61,26 +106,130 @@ export function useLogStream({ ); }, onResolve: ({ data }) => { - setEntries(data.entries); + setState((prevState) => ({ + ...data, + hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, + hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + })); }, }, [sourceId, startTimestamp, endTimestamp, query] ); - const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [ - entriesPromise.state, - ]); + const [previousEntriesPromise, fetchPreviousEntries] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + if (state.topCursor === null) { + throw new Error( + 'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } + + if (!state.hasMoreBefore) { + return Promise.resolve({ data: EMPTY_DATA }); + } + + return fetchLogEntries( + { + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + before: state.topCursor, + }, + services.http.fetch + ); + }, + onResolve: ({ data }) => { + if (!data.entries.length) { + return; + } + setState((prevState) => ({ + entries: [...data.entries, ...prevState.entries], + hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, + topCursor: data.topCursor ?? prevState.topCursor, + })); + }, + }, + [sourceId, startTimestamp, endTimestamp, query, state.topCursor] + ); + + const [nextEntriesPromise, fetchNextEntries] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + if (state.bottomCursor === null) { + throw new Error( + 'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } + + if (!state.hasMoreAfter) { + return Promise.resolve({ data: EMPTY_DATA }); + } + + return fetchLogEntries( + { + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + after: state.bottomCursor, + }, + services.http.fetch + ); + }, + onResolve: ({ data }) => { + if (!data.entries.length) { + return; + } + setState((prevState) => ({ + entries: [...prevState.entries, ...data.entries], + hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + bottomCursor: data.bottomCursor ?? prevState.bottomCursor, + })); + }, + }, + [sourceId, startTimestamp, endTimestamp, query, state.bottomCursor] + ); + + const loadingState = useMemo( + () => convertPromiseStateToLoadingState(entriesPromise.state), + [entriesPromise.state] + ); + + const pageLoadingState = useMemo(() => { + const states = [previousEntriesPromise.state, nextEntriesPromise.state]; + + if (states.includes('pending')) { + return 'loading'; + } + + if (states.includes('rejected')) { + return 'error'; + } + + if (states.includes('resolved')) { + return 'success'; + } + + return 'uninitialized'; + }, [previousEntriesPromise.state, nextEntriesPromise.state]); return { - entries, + ...state, fetchEntries, + fetchPreviousEntries, + fetchNextEntries, loadingState, + pageLoadingState, }; } function convertPromiseStateToLoadingState( state: 'uninitialized' | 'pending' | 'resolved' | 'rejected' -): LogStreamState['loadingState'] { +): LoadingState { switch (state) { case 'uninitialized': return 'uninitialized'; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 6ff699066eb15..116345b35fdce 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -18,6 +18,7 @@ import type { ObservabilityPluginStart, } from '../../observability/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import { MlPluginStart } from '../../ml/public'; // Our own setup and start contract values export type InfraClientSetupExports = void; @@ -38,6 +39,7 @@ export interface InfraClientStartDeps { spaces: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionStart; + ml: MlPluginStart; } export type InfraClientCoreSetup = CoreSetup; diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index 9309ad85a3570..6ffa1ad4b0b82 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -35,8 +35,9 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams - ): Promise { - const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; + ): Promise<{ documents: LogEntryDocument[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { + const { startTimestamp, endTimestamp, query, cursor, highlightTerm } = params; + const size = params.size ?? LOG_ENTRIES_PAGE_SIZE; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -72,7 +73,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { index: sourceConfiguration.logAlias, ignoreUnavailable: true, body: { - size: typeof size !== 'undefined' ? size : LOG_ENTRIES_PAGE_SIZE, + size: size + 1, // Extra one to test if it has more before or after track_total_hits: false, _source: false, fields, @@ -104,8 +105,22 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { esQuery ); - const hits = sortDirection === 'asc' ? esResult.hits.hits : esResult.hits.hits.reverse(); - return mapHitsToLogEntryDocuments(hits, fields); + const hits = esResult.hits.hits; + const hasMore = hits.length > size; + + if (hasMore) { + hits.pop(); + } + + if (sortDirection === 'desc') { + hits.reverse(); + } + + return { + documents: mapHitsToLogEntryDocuments(hits, fields), + hasMoreBefore: sortDirection === 'desc' ? hasMore : undefined, + hasMoreAfter: sortDirection === 'asc' ? hasMore : undefined, + }; } public async getContainedLogSummaryBuckets( diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index cc9d4c749c77d..1cf0afd50b80c 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -74,7 +74,7 @@ export class InfraLogEntriesDomain { requestContext: RequestHandlerContext, sourceId: string, params: LogEntriesAroundParams - ) { + ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* @@ -87,14 +87,18 @@ export class InfraLogEntriesDomain { */ const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; - const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query, - cursor: { before: center }, - size: Math.floor(halfSize), - highlightTerm, - }); + const { entries: entriesBefore, hasMoreBefore } = await this.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query, + cursor: { before: center }, + size: Math.floor(halfSize), + highlightTerm, + } + ); /* * Elasticsearch's `search_after` returns documents after the specified cursor. @@ -108,23 +112,27 @@ export class InfraLogEntriesDomain { ? entriesBefore[entriesBefore.length - 1].cursor : { time: center.time - 1, tiebreaker: 0 }; - const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query, - cursor: { after: cursorAfter }, - size: Math.ceil(halfSize), - highlightTerm, - }); + const { entries: entriesAfter, hasMoreAfter } = await this.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query, + cursor: { after: cursorAfter }, + size: Math.ceil(halfSize), + highlightTerm, + } + ); - return [...entriesBefore, ...entriesAfter]; + return { entries: [...entriesBefore, ...entriesAfter], hasMoreBefore, hasMoreAfter }; } public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, params: LogEntriesParams - ): Promise { + ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { configuration } = await this.libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId @@ -136,7 +144,7 @@ export class InfraLogEntriesDomain { const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getLogEntries( + const { documents, hasMoreBefore, hasMoreAfter } = await this.adapter.getLogEntries( requestContext, configuration, requiredFields, @@ -173,7 +181,7 @@ export class InfraLogEntriesDomain { }; }); - return entries; + return { entries, hasMoreBefore, hasMoreAfter }; } public async getLogSummaryBucketsBetween( @@ -323,7 +331,7 @@ export interface LogEntriesAdapter { sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams - ): Promise; + ): Promise<{ documents: LogEntryDocument[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }>; getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index c1f63d9c29577..2baf3fd7aa990 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -34,14 +34,21 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } = payload; let entries; + let hasMoreBefore; + let hasMoreAfter; + if ('center' in payload) { - entries = await logEntries.getLogEntriesAround(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - center: payload.center, - size, - }); + ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntriesAround( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query: parseFilterQuery(query), + center: payload.center, + size, + } + )); } else { let cursor: LogEntriesParams['cursor']; if ('before' in payload) { @@ -50,13 +57,17 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) cursor = { after: payload.after }; } - entries = await logEntries.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - cursor, - size, - }); + ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query: parseFilterQuery(query), + cursor, + size, + } + )); } const hasEntries = entries.length > 0; @@ -67,6 +78,8 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) entries, topCursor: hasEntries ? entries[0].cursor : null, bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, + hasMoreBefore, + hasMoreAfter, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index cc8483fb5c658..b315d22c47165 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -79,7 +79,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa return response.ok({ body: logEntriesHighlightsResponseRT.encode({ - data: entriesPerHighlightTerm.map((entries) => { + data: entriesPerHighlightTerm.map(({ entries }) => { if (entries.length > 0) { return { entries, diff --git a/x-pack/plugins/ml/common/util/string_utils.test.ts b/x-pack/plugins/ml/common/util/string_utils.test.ts index 8afc7e52c9fa5..3503e2be35e86 100644 --- a/x-pack/plugins/ml/common/util/string_utils.test.ts +++ b/x-pack/plugins/ml/common/util/string_utils.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderTemplate, getMedianStringLength, stringHash } from './string_utils'; +import { + renderTemplate, + getMedianStringLength, + stringHash, + getGroupQueryText, +} from './string_utils'; const strings: string[] = [ 'foo', @@ -54,4 +59,19 @@ describe('ML - string utils', () => { expect(hash1).not.toBe(hash2); }); }); + + describe('getGroupQueryText', () => { + const groupIdOne = 'test_group_id_1'; + const groupIdTwo = 'test_group_id_2'; + + it('should get query string for selected group ids', () => { + const actual = getGroupQueryText([groupIdOne, groupIdTwo]); + expect(actual).toBe(`groups:(${groupIdOne} or ${groupIdTwo})`); + }); + + it('should get query string for selected group id', () => { + const actual = getGroupQueryText([groupIdOne]); + expect(actual).toBe(`groups:(${groupIdOne})`); + }); + }); }); diff --git a/x-pack/plugins/ml/common/util/string_utils.ts b/x-pack/plugins/ml/common/util/string_utils.ts index b4591fd2943e6..4691bac0a065a 100644 --- a/x-pack/plugins/ml/common/util/string_utils.ts +++ b/x-pack/plugins/ml/common/util/string_utils.ts @@ -39,3 +39,11 @@ export function stringHash(str: string): number { } return hash < 0 ? hash * -2 : hash; } + +export function getGroupQueryText(groupIds: string[]): string { + return `groups:(${groupIds.join(' or ')})`; +} + +export function getJobQueryText(jobIds: string | string[]): string { + return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 8ed2436843e0e..17ef84179ce63 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -30,15 +30,13 @@ import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; -import { - getSelectedIdFromUrl, - getGroupQueryText, -} from '../../../../../jobs/jobs_list/components/utils'; +import { getSelectedIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; import { filterAnalytics } from '../../../../common/search_bar_filters'; import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; +import { getGroupQueryText } from '../../../../../../../common/util/string_utils'; const filters: EuiSearchBarProps['filters'] = [ { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.js rename to x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.ts diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js deleted file mode 100644 index 08373542c1234..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js +++ /dev/null @@ -1,210 +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 PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; - -import { ml } from '../../../../services/ml_api_service'; -import { JobGroup } from '../job_group'; -import { - getGroupQueryText, - getSelectedIdFromUrl, - clearSelectedJobIdFromUrl, - getJobQueryText, -} from '../utils'; - -import { EuiSearchBar, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -function loadGroups() { - return ml.jobs - .groups() - .then((groups) => { - return groups.map((g) => ({ - value: g.id, - view: ( -
- -   - - - -
- ), - })); - }) - .catch((error) => { - console.log(error); - return []; - }); -} - -export class JobFilterBar extends Component { - constructor(props) { - super(props); - - this.state = { error: null }; - this.setFilters = props.setFilters; - } - - urlFilterIdCleared = false; - - componentDidMount() { - // If job id is selected in url, filter table to that id - let defaultQueryText; - const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href); - - if (groupIds !== undefined) { - defaultQueryText = getGroupQueryText(groupIds); - } else if (jobId !== undefined) { - defaultQueryText = getJobQueryText(jobId); - } - - if (defaultQueryText !== undefined) { - this.setState( - { - defaultQueryText, - }, - () => { - // trigger onChange with query for job id to trigger table filter - const query = EuiSearchBar.Query.parse(defaultQueryText); - this.onChange({ query }); - } - ); - } - } - - onChange = ({ query, error }) => { - if (error) { - this.setState({ error }); - } else { - if (query.text === '' && this.urlFilterIdCleared === false) { - this.urlFilterIdCleared = true; - clearSelectedJobIdFromUrl(window.location.href); - } - let clauses = []; - if (query && query.ast !== undefined && query.ast.clauses !== undefined) { - clauses = query.ast.clauses; - } - this.setFilters(clauses); - this.setState({ error: null }); - } - }; - - render() { - const { error, defaultQueryText } = this.state; - const filters = [ - { - type: 'field_value_toggle_group', - field: 'job_state', - items: [ - { - value: 'opened', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.openedLabel', { - defaultMessage: 'Opened', - }), - }, - { - value: 'closed', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.closedLabel', { - defaultMessage: 'Closed', - }), - }, - { - value: 'failed', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.failedLabel', { - defaultMessage: 'Failed', - }), - }, - ], - }, - { - type: 'field_value_toggle_group', - field: 'datafeed_state', - items: [ - { - value: 'started', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.startedLabel', { - defaultMessage: 'Started', - }), - }, - { - value: 'stopped', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.stoppedLabel', { - defaultMessage: 'Stopped', - }), - }, - ], - }, - { - type: 'field_value_selection', - field: 'groups', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.groupLabel', { - defaultMessage: 'Group', - }), - multiSelect: 'or', - cache: 10000, - options: () => loadGroups(), - }, - ]; - // if prop flag for default filter set to true - // set defaultQuery to job id and force trigger filter with onChange - pass it the query object for the job id - return ( - - - {defaultQueryText === undefined && ( - - )} - {defaultQueryText !== undefined && ( - - )} - - - - - - ); - } -} -JobFilterBar.propTypes = { - setFilters: PropTypes.func.isRequired, -}; - -function getError(error) { - if (error) { - return i18n.translate('xpack.ml.jobsList.jobFilterBar.invalidSearchErrorMessage', { - defaultMessage: 'Invalid search: {errorMessage}', - values: { errorMessage: error.message }, - }); - } - - return ''; -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx new file mode 100644 index 0000000000000..f0fa62b7a3d8a --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useCallback, useEffect, useMemo, useState } from 'react'; + +import { + EuiSearchBar, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + SearchFilterConfig, + EuiSearchBarProps, + Query, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { JobGroup } from '../job_group'; +import { useMlKibana } from '../../../../contexts/kibana'; + +interface JobFilterBarProps { + jobId: string; + groupIds: string[]; + setFilters: (query: Query | null) => void; + queryText?: string; +} + +export const JobFilterBar: FC = ({ queryText, setFilters }) => { + const [error, setError] = useState(null); + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const loadGroups = useCallback(async () => { + try { + const response = await mlApiServices.jobs.groups(); + return response.map((g: any) => ({ + value: g.id, + view: ( +
+ +   + + + +
+ ), + })); + } catch (e) { + return []; + } + }, []); + + const queryInstance: Query = useMemo(() => { + return EuiSearchBar.Query.parse(queryText ?? ''); + }, [queryText]); + + const onChange: EuiSearchBarProps['onChange'] = ({ query, error: queryError }) => { + if (error) { + setError(queryError); + } else { + setFilters(query); + setError(null); + } + }; + + useEffect(() => { + setFilters(queryInstance); + }, []); + + const filters: SearchFilterConfig[] = useMemo( + () => [ + { + type: 'field_value_toggle_group', + field: 'job_state', + items: [ + { + value: 'opened', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.openedLabel', { + defaultMessage: 'Opened', + }), + }, + { + value: 'closed', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.closedLabel', { + defaultMessage: 'Closed', + }), + }, + { + value: 'failed', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.failedLabel', { + defaultMessage: 'Failed', + }), + }, + ], + }, + { + type: 'field_value_toggle_group', + field: 'datafeed_state', + items: [ + { + value: 'started', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.startedLabel', { + defaultMessage: 'Started', + }), + }, + { + value: 'stopped', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.stoppedLabel', { + defaultMessage: 'Stopped', + }), + }, + ], + }, + { + type: 'field_value_selection', + field: 'groups', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.groupLabel', { + defaultMessage: 'Group', + }), + multiSelect: 'or', + cache: 10000, + options: () => loadGroups(), + }, + ], + [] + ); + + const errorText = useMemo(() => { + if (error === null) return ''; + + return i18n.translate('xpack.ml.jobsList.jobFilterBar.invalidSearchErrorMessage', { + defaultMessage: 'Invalid search: {errorMessage}', + values: { errorMessage: error.message }, + }); + }, [error]); + + return ( + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index c0abab6b52cf1..8a05cd51e4d65 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -22,7 +22,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyDetectionJobIdLink } from './job_id_link'; -const PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [10, 25, 50]; // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page @@ -32,11 +31,7 @@ export class JobsList extends Component { this.state = { jobsSummaryList: props.jobsSummaryList, - pageIndex: 0, - pageSize: PAGE_SIZE, itemIdToExpandedRowMap: {}, - sortField: 'id', - sortDirection: 'asc', }; } @@ -54,7 +49,7 @@ export class JobsList extends Component { const { field: sortField, direction: sortDirection } = sort; - this.setState({ + this.props.onJobsViewStateUpdate({ pageIndex, pageSize, sortField, @@ -88,7 +83,7 @@ export class JobsList extends Component { pageStart = Math.floor((listLength - 1) / size) * size; // set the state out of the render cycle setTimeout(() => { - this.setState({ + this.props.onJobsViewStateUpdate({ pageIndex: pageStart / size, }); }, 0); @@ -298,7 +293,7 @@ export class JobsList extends Component { }); } - const { pageIndex, pageSize, sortField, sortDirection } = this.state; + const { pageIndex, pageSize, sortField, sortDirection } = this.props.jobsViewState; const { pageOfItems, totalItemCount } = this.getPageOfJobs( pageIndex, @@ -368,6 +363,8 @@ JobsList.propTypes = { refreshJobs: PropTypes.func, selectedJobsCount: PropTypes.number.isRequired, loading: PropTypes.bool, + jobsViewState: PropTypes.object, + onJobsViewStateUpdate: PropTypes.func, }; JobsList.defaultProps = { isManagementTable: false, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 9eb7a03f0f5d7..570172abb28c1 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -222,8 +222,14 @@ export class JobsListView extends Component { this.setState({ selectedJobs }); } - setFilters = (filterClauses) => { + setFilters = (query) => { + const filterClauses = (query && query.ast && query.ast.clauses) || []; const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses); + + this.props.onJobsViewStateUpdate({ + queryText: query?.text, + }); + this.setState({ filteredJobsSummaryList, filterClauses }, () => { this.refreshSelectedJobs(); }); @@ -358,7 +364,10 @@ export class JobsListView extends Component {
- +
@@ -434,7 +445,10 @@ export class JobsListView extends Component { showDeleteJobModal={this.showDeleteJobModal} refreshJobs={() => this.refreshJobSummaryList(true)} /> - + this.refreshJobSummaryList(true)} + jobsViewState={this.props.jobsViewState} + onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} selectedJobsCount={this.state.selectedJobs.length} loading={loading} /> diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts index 75d6b149fda08..b781199c85237 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts @@ -5,6 +5,4 @@ */ export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string }; -export function getGroupQueryText(arr: string[]): string; -export function getJobQueryText(arr: string | string[]): string; export function clearSelectedJobIdFromUrl(str: string): void; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index bc85153928a4b..397062248689d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -395,22 +395,3 @@ export function getSelectedIdFromUrl(url) { } return result; } - -export function getGroupQueryText(groupIds) { - return `groups:(${groupIds.join(' or ')})`; -} - -export function getJobQueryText(jobIds) { - return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; -} - -export function clearSelectedJobIdFromUrl(url) { - if (typeof url === 'string') { - url = decodeURIComponent(url); - if (url.includes('mlManagement') && (url.includes('jobId') || url.includes('groupIds'))) { - const urlParams = getUrlVars(url); - const clearedParams = `jobs?_g=${urlParams._g}`; - window.history.replaceState({}, document.title, clearedParams); - } - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts index e4c3c21c5a54a..4414be0b4fdcb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getGroupQueryText, getSelectedIdFromUrl } from './utils'; +import { getSelectedIdFromUrl } from './utils'; describe('ML - Jobs List utils', () => { const jobId = 'test_job_id_1'; @@ -32,16 +32,4 @@ describe('ML - Jobs List utils', () => { expect(actual).toStrictEqual(expected); }); }); - - describe('getGroupQueryText', () => { - it('should get query string for selected group ids', () => { - const actual = getGroupQueryText([groupIdOne, groupIdTwo]); - expect(actual).toBe(`groups:(${groupIdOne} or ${groupIdTwo})`); - }); - - it('should get query string for selected group id', () => { - const actual = getGroupQueryText([groupIdOne]); - expect(actual).toBe(`groups:(${groupIdOne})`); - }); - }); }); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx index 1e45f28594572..4c6469f6800a7 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; - +import React, { FC, useCallback, useMemo } from 'react'; import { NavigationMenu } from '../../components/navigation_menu'; - // @ts-ignore import { JobsListView } from './components/jobs_list_view/index'; +import { useUrlState } from '../../util/url_state'; interface JobsPageProps { blockRefresh?: boolean; @@ -18,11 +17,49 @@ interface JobsPageProps { lastRefresh?: number; } +export interface AnomalyDetectionJobsListState { + pageSize: number; + pageIndex: number; + sortField: string; + sortDirection: string; + queryText?: string; +} + +export const getDefaultAnomalyDetectionJobsListState = (): AnomalyDetectionJobsListState => ({ + pageIndex: 0, + pageSize: 10, + sortField: 'id', + sortDirection: 'asc', +}); + export const JobsPage: FC = (props) => { + const [appState, setAppState] = useUrlState('_a'); + + const jobListState: AnomalyDetectionJobsListState = useMemo(() => { + return { + ...getDefaultAnomalyDetectionJobsListState(), + ...(appState ?? {}), + }; + }, [appState]); + + const onJobsViewStateUpdate = useCallback( + (update: Partial) => { + setAppState({ + ...jobListState, + ...update, + }); + }, + [appState, setAppState] + ); + return (
- +
); }; diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 61dfea8897e82..ad4b9ad78902b 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, Fragment, FC } from 'react'; +import React, { useEffect, useState, Fragment, FC, useMemo, useCallback } from 'react'; import { Router } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; @@ -35,6 +35,11 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; +import { + AnomalyDetectionJobsListState, + getDefaultAnomalyDetectionJobsListState, +} from '../../../../jobs/jobs_list/jobs'; +import { getMlGlobalServices } from '../../../../app'; interface Tab { 'data-test-subj': string; @@ -43,38 +48,60 @@ interface Tab { content: any; } -function getTabs(isMlEnabledInSpace: boolean): Tab[] { - return [ - { - 'data-test-subj': 'mlStackManagementJobsListAnomalyDetectionTab', - id: 'anomaly_detection_jobs', - name: i18n.translate('xpack.ml.management.jobsList.anomalyDetectionTab', { - defaultMessage: 'Anomaly detection', - }), - content: ( - - - - - ), - }, - { - 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', - id: 'analytics_jobs', - name: i18n.translate('xpack.ml.management.jobsList.analyticsTab', { - defaultMessage: 'Analytics', - }), - content: ( - - - - - ), +function useTabs(isMlEnabledInSpace: boolean): Tab[] { + const [jobsViewState, setJobsViewState] = useState( + getDefaultAnomalyDetectionJobsListState() + ); + + const updateState = useCallback( + (update: Partial) => { + setJobsViewState({ + ...jobsViewState, + ...update, + }); }, - ]; + [jobsViewState] + ); + + return useMemo( + () => [ + { + 'data-test-subj': 'mlStackManagementJobsListAnomalyDetectionTab', + id: 'anomaly_detection_jobs', + name: i18n.translate('xpack.ml.management.jobsList.anomalyDetectionTab', { + defaultMessage: 'Anomaly detection', + }), + content: ( + + + + + ), + }, + { + 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', + id: 'analytics_jobs', + name: i18n.translate('xpack.ml.management.jobsList.analyticsTab', { + defaultMessage: 'Analytics', + }), + content: ( + + + + + ), + }, + ], + [isMlEnabledInSpace, jobsViewState, updateState] + ); } export const JobsListPage: FC<{ @@ -85,7 +112,7 @@ export const JobsListPage: FC<{ const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = getTabs(isMlEnabledInSpace); + const tabs = useTabs(isMlEnabledInSpace); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; @@ -129,7 +156,7 @@ export const JobsListPage: FC<{ setCurrentTabId(id); }} size="s" - tabs={getTabs(isMlEnabledInSpace)} + tabs={tabs} initialSelectedTab={tabs[0]} /> ); @@ -142,7 +169,9 @@ export const JobsListPage: FC<{ return ( - + = { + ...(queryTextArr.length > 0 ? { queryText: queryTextArr.join(' ') } : {}), }; - url = setStateToKbnUrl( - 'mlManagement', + url = setStateToKbnUrl>( + '_a', queryState, { useHash: false, storeInHashQuery: false }, url diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 754f5bec57a07..e7f12ead3ffe9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -30,7 +30,7 @@ describe('MlUrlGenerator', () => { jobId: 'fq_single_1', }, }); - expect(url).toBe('/app/ml/jobs?mlManagement=(jobId:fq_single_1)'); + expect(url).toBe('/app/ml/jobs?_a=(queryText:fq_single_1)'); }); it('should generate valid URL for the Anomaly Detection job management page for groupIds', async () => { @@ -40,7 +40,7 @@ describe('MlUrlGenerator', () => { groupIds: ['farequote', 'categorization'], }, }); - expect(url).toBe('/app/ml/jobs?mlManagement=(groupIds:!(farequote,categorization))'); + expect(url).toBe("/app/ml/jobs?_a=(queryText:'groups:(farequote%20or%20categorization)')"); }); it('should generate valid URL for the page for selecting the type of anomaly detection job to create', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx index 156475f63aa65..b0965f8708558 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx @@ -55,7 +55,7 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual('/app/ml/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)') + expect(href).toEqual('/app/ml/jobs?_a=(queryText:linux_anomalous_network_activity_ecs)') ); }); @@ -72,7 +72,7 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual("/app/ml/jobs?mlManagement=(jobId:'job%20id%20with%20spaces')") + expect(href).toEqual("/app/ml/jobs?_a=(queryText:'job%20id%20with%20spaces')") ); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts new file mode 100644 index 0000000000000..385d8bfca4a9a --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, getTestAlertData, ObjectRemover, getEventLog } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { validateEvent } from '../../../spaces_only/tests/alerting/event_log'; + +// eslint-disable-next-line import/no-default-export +export default function eventLogTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('eventLog', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + it('should generate events for alert decrypt errors', async () => { + const spaceId = Spaces[0].id; + const response = await supertest + .post(`${getUrlPrefix(spaceId)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + schedule: { interval: '1s' }, + throttle: null, + }) + ); + + expect(response.status).to.eql(200); + const alertId = response.body.id; + objectRemover.add(spaceId, alertId, 'alert', 'alerts'); + + // break AAD + await supertest + .put(`${getUrlPrefix(spaceId)}/api/alerts_fixture/saved_object/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + name: 'bar', + }, + }) + .expect(200); + + const events = await retry.try(async () => { + // there can be a successful execute before the error one + const someEvents = await getEventLog({ + getService, + spaceId, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: ['execute'], + }); + const errorEvents = someEvents.filter( + (event) => event?.kibana?.alerting?.status === 'error' + ); + if (errorEvents.length === 0) { + throw new Error('no execute/error events yet'); + } + return errorEvents; + }); + + const event = events[0]; + expect(event).to.be.ok(); + + validateEvent(event, { + spaceId, + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + outcome: 'failure', + message: `test.noop:${alertId}: execution failed`, + errorMessage: 'Unable to decrypt attribute "apiKey"', + status: 'error', + reason: 'decrypt', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 1fbee9e18fdaa..4f8525cfcf683 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -26,6 +26,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_api_key')); loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./event_log')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./rbac_legacy')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index dbf8eb162fca7..937045b6218c6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -107,6 +107,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { expect(resolvedInstanceTimes[0] > newInstanceTimes[0]).to.be(true); // validate each event + let executeCount = 0; + const executeStatuses = ['ok', 'active', 'active']; for (const event of events) { switch (event?.event?.action) { case 'execute': @@ -115,6 +117,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], outcome: 'success', message: `alert executed: test.patternFiring:${alertId}: 'abc'`, + status: executeStatuses[executeCount++], }); break; case 'execute-action': @@ -125,6 +128,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { { type: 'action', id: createdAction.id }, ], message: `alert: test.patternFiring:${alertId}: 'abc' instanceId: 'instance' scheduled actionGroup: 'default' action: test.noop:${createdAction.id}`, + instanceId: 'instance', + actionGroupId: 'default', }); break; case 'new-instance': @@ -147,6 +152,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { spaceId: Spaces.space1.id, savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, + instanceId: 'instance', + actionGroupId: 'default', }); } }); @@ -187,60 +194,83 @@ export default function eventLogTests({ getService }: FtrProviderContext) { outcome: 'failure', message: `alert execution failure: test.throw:${alertId}: 'abc'`, errorMessage: 'this alert is intended to fail', + status: 'error', + reason: 'execute', }); }); }); +} + +interface SavedObject { + type: string; + id: string; + rel?: string; +} + +interface ValidateEventLogParams { + spaceId: string; + savedObjects: SavedObject[]; + outcome?: string; + message: string; + errorMessage?: string; + status?: string; + actionGroupId?: string; + instanceId?: string; + reason?: string; +} + +export function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { + const { spaceId, savedObjects, outcome, message, errorMessage } = params; + const { status, actionGroupId, instanceId, reason } = params; - interface SavedObject { - type: string; - id: string; - rel?: string; + if (status) { + expect(event?.kibana?.alerting?.status).to.be(status); } - interface ValidateEventLogParams { - spaceId: string; - savedObjects: SavedObject[]; - outcome?: string; - message: string; - errorMessage?: string; + if (actionGroupId) { + expect(event?.kibana?.alerting?.action_group_id).to.be(actionGroupId); } - function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { - const { spaceId, savedObjects, outcome, message, errorMessage } = params; + if (instanceId) { + expect(event?.kibana?.alerting?.instance_id).to.be(instanceId); + } - const duration = event?.event?.duration; - const eventStart = Date.parse(event?.event?.start || 'undefined'); - const eventEnd = Date.parse(event?.event?.end || 'undefined'); - const dateNow = Date.now(); + if (reason) { + expect(event?.event?.reason).to.be(reason); + } - if (duration !== undefined) { - expect(typeof duration).to.be('number'); - expect(eventStart).to.be.ok(); - expect(eventEnd).to.be.ok(); + const duration = event?.event?.duration; + const eventStart = Date.parse(event?.event?.start || 'undefined'); + const eventEnd = Date.parse(event?.event?.end || 'undefined'); + const dateNow = Date.now(); - const durationDiff = Math.abs( - Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) - ); + if (duration !== undefined) { + expect(typeof duration).to.be('number'); + expect(eventStart).to.be.ok(); + expect(eventEnd).to.be.ok(); - // account for rounding errors - expect(durationDiff < 1).to.equal(true); - expect(eventStart <= eventEnd).to.equal(true); - expect(eventEnd <= dateNow).to.equal(true); - } + const durationDiff = Math.abs( + Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) + ); - expect(event?.event?.outcome).to.equal(outcome); + // account for rounding errors + expect(durationDiff < 1).to.equal(true); + expect(eventStart <= eventEnd).to.equal(true); + expect(eventEnd <= dateNow).to.equal(true); + } - for (const savedObject of savedObjects) { - expect( - isSavedObjectInEvent(event, spaceId, savedObject.type, savedObject.id, savedObject.rel) - ).to.be(true); - } + expect(event?.event?.outcome).to.equal(outcome); - expect(event?.message).to.eql(message); + for (const savedObject of savedObjects) { + expect( + isSavedObjectInEvent(event, spaceId, savedObject.type, savedObject.id, savedObject.rel) + ).to.be(true); + } - if (errorMessage) { - expect(event?.error?.message).to.eql(errorMessage); - } + expect(event?.message).to.eql(message); + + if (errorMessage) { + expect(event?.error?.message).to.eql(errorMessage); } }