From 328febabd3693defc11ef37ddf4d88fd3e4fb286 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 27 Apr 2021 10:25:50 +0200 Subject: [PATCH 01/68] migrate logstash plugin to new ES client (#98064) * migrate logstash plugin to new ES client * handle 404s * use default ES client --- .../server/models/cluster/cluster.test.ts | 3 +- .../logstash/server/models/cluster/cluster.ts | 11 +++---- x-pack/plugins/logstash/server/plugin.ts | 31 ++----------------- .../logstash/server/routes/cluster/load.ts | 4 +-- .../logstash/server/routes/pipeline/delete.ts | 18 ++++++----- .../logstash/server/routes/pipeline/load.ts | 12 +++---- .../logstash/server/routes/pipeline/save.ts | 7 ++--- .../server/routes/pipelines/delete.ts | 18 +++++------ .../logstash/server/routes/pipelines/list.ts | 23 +++++++------- x-pack/plugins/logstash/server/types.ts | 5 +-- .../apis/logstash/cluster/load.ts | 4 +-- 11 files changed, 56 insertions(+), 80 deletions(-) diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts index 898cf16423f15..1e1afc33394f3 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { Cluster } from './cluster'; describe('cluster', () => { @@ -12,7 +13,7 @@ describe('cluster', () => { describe('fromUpstreamJSON factory method', () => { const upstreamJSON = { cluster_uuid: 'S-S4NNZDRV-g9c-JrIhx6A', - }; + } as estypes.RootNodeInfoResponse; it('returns correct Cluster instance', () => { const cluster = Cluster.fromUpstreamJSON(upstreamJSON); diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.ts index e089eef623069..88789a2d29c89 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.ts @@ -5,28 +5,27 @@ * 2.0. */ -import { get } from 'lodash'; +import { estypes } from '@elastic/elasticsearch'; /** * This model deals with a cluster object from ES and converts it to Kibana downstream */ export class Cluster { public readonly uuid: string; + constructor({ uuid }: { uuid: string }) { this.uuid = uuid; } public get downstreamJSON() { - const json = { + return { uuid: this.uuid, }; - - return json; } // generate Pipeline object from elasticsearch response - static fromUpstreamJSON(upstreamCluster: Record) { - const uuid = get(upstreamCluster, 'cluster_uuid') as string; + static fromUpstreamJSON(upstreamCluster: estypes.RootNodeInfoResponse) { + const uuid = upstreamCluster.cluster_uuid; return new Cluster({ uuid }); } } diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index 1a94a25647342..f40e500671fc3 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -5,20 +5,11 @@ * 2.0. */ -import { - CoreSetup, - CoreStart, - ILegacyCustomClusterClient, - Logger, - Plugin, - PluginInitializerContext, -} from 'src/core/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; - import { registerRoutes } from './routes'; -import type { LogstashRequestHandlerContext } from './types'; interface SetupDeps { licensing: LicensingPluginSetup; @@ -28,8 +19,7 @@ interface SetupDeps { export class LogstashPlugin implements Plugin { private readonly logger: Logger; - private esClient?: ILegacyCustomClusterClient; - private coreSetup?: CoreSetup; + constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); } @@ -37,7 +27,6 @@ export class LogstashPlugin implements Plugin { setup(core: CoreSetup, deps: SetupDeps) { this.logger.debug('Setting up Logstash plugin'); - this.coreSetup = core; registerRoutes(core.http.createRouter(), deps.security); deps.features.registerElasticsearchFeature({ @@ -55,19 +44,5 @@ export class LogstashPlugin implements Plugin { }); } - start(core: CoreStart) { - const esClient = core.elasticsearch.legacy.createClient('logstash'); - - this.coreSetup!.http.registerRouteHandlerContext( - 'logstash', - async (context, request) => { - return { esClient: esClient.asScoped(request) }; - } - ); - } - stop() { - if (this.esClient) { - this.esClient.close(); - } - } + start(core: CoreStart) {} } diff --git a/x-pack/plugins/logstash/server/routes/cluster/load.ts b/x-pack/plugins/logstash/server/routes/cluster/load.ts index ac7bc245e51eb..1b8dc7880e8dc 100644 --- a/x-pack/plugins/logstash/server/routes/cluster/load.ts +++ b/x-pack/plugins/logstash/server/routes/cluster/load.ts @@ -18,8 +18,8 @@ export function registerClusterLoadRoute(router: LogstashPluginRouter) { }, wrapRouteWithLicenseCheck(checkLicense, async (context, request, response) => { try { - const client = context.logstash!.esClient; - const info = await client.callAsCurrentUser('info'); + const { client } = context.core.elasticsearch; + const { body: info } = await client.asCurrentUser.info(); return response.ok({ body: { cluster: Cluster.fromUpstreamJSON(info).downstreamJSON, diff --git a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts index 77706051d1cd1..59aaaef63786e 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/delete.ts @@ -23,14 +23,18 @@ export function registerPipelineDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'DELETE', - }); - - return response.noContent(); + try { + await client.asCurrentUser.logstash.deletePipeline({ id }); + return response.noContent(); + } catch (e) { + if (e.statusCode === 404) { + return response.notFound(); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/load.ts b/x-pack/plugins/logstash/server/routes/pipeline/load.ts index f729a40f1abad..33f24a4ad6e26 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/load.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/load.ts @@ -25,13 +25,13 @@ export function registerPipelineLoadRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash!.esClient; + const { id } = request.params; + const { client } = context.core.elasticsearch; - const result = await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(request.params.id), - method: 'GET', - ignore: [404], - }); + const { body: result } = await client.asCurrentUser.logstash.getPipeline( + { id }, + { ignore: [404] } + ); if (result[request.params.id] === undefined) { return response.notFound(); diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index b533f210f1cd7..48a62f83c91ca 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -42,12 +42,11 @@ export function registerPipelineSaveRoute( username = user?.username; } - const client = context.logstash!.esClient; + const { client } = context.core.elasticsearch; const pipeline = Pipeline.fromDownstreamJSON(request.body, request.params.id, username); - await client.callAsCurrentUser('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipeline.id), - method: 'PUT', + await client.asCurrentUser.logstash.putPipeline({ + id: pipeline.id, body: pipeline.upstreamJSON, }); diff --git a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts index 84dcfef4f67fd..3609ac1520683 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/delete.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/delete.ts @@ -6,19 +6,19 @@ */ import { schema } from '@kbn/config-schema'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { checkLicense } from '../../lib/check_license'; import type { LogstashPluginRouter } from '../../types'; -async function deletePipelines(callWithRequest: LegacyAPICaller, pipelineIds: string[]) { +async function deletePipelines(client: ElasticsearchClient, pipelineIds: string[]) { const deletePromises = pipelineIds.map((pipelineId) => { - return callWithRequest('transport.request', { - path: '/_logstash/pipeline/' + encodeURIComponent(pipelineId), - method: 'DELETE', - }) - .then((success) => ({ success })) + return client.logstash + .deletePipeline({ + id: pipelineId, + }) + .then((response) => ({ success: response.body })) .catch((error) => ({ error })); }); @@ -45,8 +45,8 @@ export function registerPipelinesDeleteRoute(router: LogstashPluginRouter) { wrapRouteWithLicenseCheck( checkLicense, router.handleLegacyErrors(async (context, request, response) => { - const client = context.logstash.esClient; - const results = await deletePipelines(client.callAsCurrentUser, request.body.pipelineIds); + const client = context.core.elasticsearch.client.asCurrentUser; + const results = await deletePipelines(client, request.body.pipelineIds); return response.ok({ body: { results } }); }) diff --git a/x-pack/plugins/logstash/server/routes/pipelines/list.ts b/x-pack/plugins/logstash/server/routes/pipelines/list.ts index 42ff528364777..2ce57d18d3118 100644 --- a/x-pack/plugins/logstash/server/routes/pipelines/list.ts +++ b/x-pack/plugins/logstash/server/routes/pipelines/list.ts @@ -6,21 +6,22 @@ */ import { i18n } from '@kbn/i18n'; -import { LegacyAPICaller } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import type { LogstashPluginRouter } from '../../types'; import { wrapRouteWithLicenseCheck } from '../../../../licensing/server'; import { PipelineListItem } from '../../models/pipeline_list_item'; import { checkLicense } from '../../lib/check_license'; -async function fetchPipelines(callWithRequest: LegacyAPICaller) { - const params = { - path: '/_logstash/pipeline', - method: 'GET', - ignore: [404], - }; - - return await callWithRequest('transport.request', params); +async function fetchPipelines(client: ElasticsearchClient) { + const { body } = await client.transport.request( + { + method: 'GET', + path: '/_logstash/pipeline', + }, + { ignore: [404] } + ); + return body; } export function registerPipelinesListRoute(router: LogstashPluginRouter) { @@ -33,8 +34,8 @@ export function registerPipelinesListRoute(router: LogstashPluginRouter) { checkLicense, router.handleLegacyErrors(async (context, request, response) => { try { - const client = context.logstash!.esClient; - const pipelinesRecord = (await fetchPipelines(client.callAsCurrentUser)) as Record< + const { client } = context.core.elasticsearch; + const pipelinesRecord = (await fetchPipelines(client.asCurrentUser)) as Record< string, any >; diff --git a/x-pack/plugins/logstash/server/types.ts b/x-pack/plugins/logstash/server/types.ts index aef14b98c9f06..2177ae9f17f39 100644 --- a/x-pack/plugins/logstash/server/types.ts +++ b/x-pack/plugins/logstash/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ILegacyScopedClusterClient, IRouter, RequestHandlerContext } from 'src/core/server'; +import type { IRouter, RequestHandlerContext } from 'src/core/server'; import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; export interface PipelineListItemOptions { @@ -19,9 +19,6 @@ export interface PipelineListItemOptions { * @internal */ export interface LogstashRequestHandlerContext extends RequestHandlerContext { - logstash: { - esClient: ILegacyScopedClusterClient; - }; licensing: LicensingApiRequestHandlerContext; } diff --git a/x-pack/test/api_integration/apis/logstash/cluster/load.ts b/x-pack/test/api_integration/apis/logstash/cluster/load.ts index fbfdc4d51dde9..1997b65c5a871 100644 --- a/x-pack/test/api_integration/apis/logstash/cluster/load.ts +++ b/x-pack/test/api_integration/apis/logstash/cluster/load.ts @@ -10,13 +10,13 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const es = getService('legacyEs'); + const es = getService('es'); describe('load', () => { it('should return the ES cluster info', async () => { const { body } = await supertest.get('/api/logstash/cluster').expect(200); - const responseFromES = await es.info(); + const { body: responseFromES } = await es.info(); expect(body.cluster.uuid).to.eql(responseFromES.cluster_uuid); }); }); From 6dd637630d477cb38eae48670234dca816b4f701 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 27 Apr 2021 10:27:04 +0200 Subject: [PATCH 02/68] [Lens] Prevent React error on first field drop (#98269) --- x-pack/plugins/lens/public/drag_drop/drag_drop.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 51021a3e50b3f..5f116d29648c9 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -485,14 +485,18 @@ const DropsInner = memo(function DropsInner(props: DropsInnerProps) { }, [order, registerDropTarget, dropTypes, keyboardMode]); useEffect(() => { + let isMounted = true; if (activeDropTarget && activeDropTarget.id !== value.id) { setIsInZone(false); } setTimeout(() => { - if (!activeDropTarget) { + if (!activeDropTarget && isMounted) { setIsInZone(false); } }, 1000); + return () => { + isMounted = false; + }; }, [activeDropTarget, setIsInZone, value.id]); const dragEnter = () => { From c9832832df3c37c16bbb37dd26621ca23ede57e6 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 27 Apr 2021 11:16:34 +0100 Subject: [PATCH 03/68] [File Data Visualizer] Fixing missing css imports for file stats table (#98312) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/components/_index.scss | 13 +++++++++---- .../filebeat_config_flyout.tsx | 2 +- .../components/results_links/results_links.tsx | 1 + .../apps/ml/data_visualizer/file_data_visualizer.ts | 3 +++ .../services/ml/data_visualizer_file_based.ts | 6 ++++++ 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss index a7c3926407ea0..a47f3712cbb64 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss +++ b/x-pack/plugins/file_data_visualizer/public/application/components/_index.scss @@ -1,6 +1,11 @@ -@import 'file_datavisualizer_view/index'; -@import 'results_view/index'; -@import 'analysis_summary/index'; @import 'about_panel/index'; -@import 'import_summary/index'; +@import 'analysis_summary/index'; +@import 'edit_flyout/index'; +@import 'embedded_map/index'; @import 'experimental_badge/index'; +@import 'file_contents/index'; +@import 'file_datavisualizer_view/index'; +@import 'import_summary/index'; +@import 'results_view/index'; +@import 'stats_table/index'; +@import 'top_values/top_values'; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx index a5d05bb06f78e..c2b7e18059769 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/filebeat_config_flyout/filebeat_config_flyout.tsx @@ -104,7 +104,7 @@ const Contents: FC<{ username: string | null; }> = ({ value, index, username }) => { return ( - +
= ({ } + data-test-subj="fileDataVisFilebeatConfigLink" title={ Date: Tue, 27 Apr 2021 13:41:24 +0200 Subject: [PATCH 04/68] [Lens] Build endzone markers (#97849) --- .../__snapshots__/expression.test.tsx.snap | 28 ++++ .../__snapshots__/to_expression.test.ts.snap | 3 + .../axis_settings_popover.test.tsx | 12 ++ .../axis_settings_popover.tsx | 24 ++++ .../xy_visualization/expression.test.tsx | 130 ++++++++++++++++++ .../public/xy_visualization/expression.tsx | 50 +++++-- .../xy_visualization/to_expression.test.ts | 1 + .../public/xy_visualization/to_expression.ts | 1 + .../lens/public/xy_visualization/types.ts | 2 + .../lens/public/xy_visualization/x_domain.tsx | 103 ++++++++++++++ .../xy_visualization/xy_config_panel.test.tsx | 23 ++++ .../xy_visualization/xy_config_panel.tsx | 23 +++- 12 files changed, 388 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization/x_domain.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index cb82cc5b52a01..aa22bbb0c15c6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -74,6 +74,10 @@ exports[`xy_expression XYChart component it renders area 1`] = ` tickFormat={[Function]} title="a" /> + + + + + + + { false ); }); + + it('hides the endzone visibility flag if no setter is passed in', () => { + const component = shallow(); + expect(component.find('[data-test-subj="lnsshowEndzones"]').length).toBe(0); + }); + + it('shows the switch if setter is present', () => { + const component = shallow( + {}} /> + ); + expect(component.find('[data-test-subj="lnsshowEndzones"]').prop('checked')).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx index 2a40f6204c44d..d9c60ae666484 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx @@ -71,6 +71,14 @@ export interface AxisSettingsPopoverProps { * Toggles the axis title visibility */ toggleAxisTitleVisibility: (axis: AxesSettingsConfigKeys, checked: boolean) => void; + /** + * Set endzone visibility + */ + setEndzoneVisibility?: (checked: boolean) => void; + /** + * Flag whether endzones are visible + */ + endzonesVisible?: boolean; } const popoverConfig = ( axis: AxesSettingsConfigKeys, @@ -138,6 +146,8 @@ export const AxisSettingsPopover: React.FunctionComponent { const [title, setTitle] = useState(axisTitle); @@ -212,6 +222,20 @@ export const AxisSettingsPopover: React.FunctionComponent toggleGridlinesVisibility(axis)} checked={areGridlinesVisible} /> + {setEndzoneVisibility && ( + <> + + setEndzoneVisibility(!Boolean(endzonesVisible))} + checked={Boolean(endzonesVisible)} + /> + + )} ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index e1dbd4da4b902..fe0513caa08a8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -44,6 +44,7 @@ import { createMockExecutionContext } from '../../../../../src/plugins/expressio import { mountWithIntl } from '@kbn/test/jest'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import { XyEndzones } from './x_domain'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -549,6 +550,135 @@ describe('xy_expression', () => { } `); }); + + describe('endzones', () => { + const { args } = sampleArgs(); + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: new Date('2021-04-22').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-23').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-24').valueOf(), d: 'Foo' }, + ]), + }, + dateRange: { + // first and last bucket are partial + fromDate: new Date('2021-04-22T12:00:00.000Z'), + toDate: new Date('2021-04-24T12:00:00.000Z'), + }, + }; + const timeArgs: XYArgs = { + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'line', + xScaleType: 'time', + isHistogram: true, + splitAccessor: undefined, + }, + ], + }; + + test('it extends interval if data is exceeding it', () => { + const component = shallow( + + ); + + expect(component.find(Settings).prop('xDomain')).toEqual({ + // shortened to 24th midnight (elastic-charts automatically adds one min interval) + max: new Date('2021-04-24').valueOf(), + // extended to 22nd midnight because of first bucket + min: new Date('2021-04-22').valueOf(), + minInterval: 24 * 60 * 60 * 1000, + }); + }); + + test('it renders endzone component bridging gap between domain and extended domain', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + domainStart: new Date('2021-04-22T12:00:00.000Z').valueOf(), + domainEnd: new Date('2021-04-24T12:00:00.000Z').valueOf(), + domainMin: new Date('2021-04-22').valueOf(), + domainMax: new Date('2021-04-24').valueOf(), + }) + ); + }); + + test('should pass enabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: false, + }) + ); + }); + + test('should pass disabled histogram mode and min interval to endzones component', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: true, + }) + ); + }); + + test('it does not render endzones if disabled via settings', () => { + const component = shallow( + + ); + + expect(component.find(XyEndzones).length).toEqual(0); + }); + }); }); test('it has xDomain undefined if the x is not a time scale or a histogram', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 47b8dbfc15f53..5416c8eda0aa9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -57,6 +57,7 @@ import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; +import { getXDomain, XyEndzones } from './x_domain'; declare global { interface Window { @@ -183,6 +184,13 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how curve type is rendered for a line chart', }), }, + hideEndzones: { + types: ['boolean'], + default: false, + help: i18n.translate('xpack.lens.xyChart.hideEndzones.help', { + defaultMessage: 'Hide endzone markers for partial data', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -330,9 +338,17 @@ export function XYChart({ renderMode, syncColors, }: XYChartRenderProps) { - const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; + const { + legend, + layers, + fittingFunction, + gridlinesVisibilitySettings, + valueLabels, + hideEndzones, + } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const darkMode = chartsThemeService.useDarkMode(); const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) { @@ -387,15 +403,13 @@ export function XYChart({ const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); const isHistogramViz = filteredLayers.every((l) => l.isHistogram); - const xDomain = isTimeViz - ? { - min: data.dateRange?.fromDate.getTime(), - max: data.dateRange?.toDate.getTime(), - minInterval, - } - : isHistogramViz - ? { minInterval } - : undefined; + const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( + layers, + data, + minInterval, + Boolean(isTimeViz), + Boolean(isHistogramViz) + ); const getYAxesTitles = ( axisSeries: Array<{ layer: string; accessor: string }>, @@ -602,6 +616,22 @@ export function XYChart({ /> ))} + {!hideEndzones && ( + + layer.isHistogram && + (layer.seriesType.includes('stacked') || !layer.splitAccessor) && + (layer.seriesType.includes('stacked') || + !layer.seriesType.includes('bar') || + !chartHasMoreThanOneBarSeries) + )} + /> + )} + {filteredLayers.flatMap((layer, layerIndex) => layer.accessors.map((accessor, accessorIndex) => { const { diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index b726869743312..89dca6e8a3944 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -51,6 +51,7 @@ describe('#toExpression', () => { fittingFunction: 'Carry', tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true }, gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + hideEndzones: true, layers: [ { layerId: 'first', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 6a1882edde949..02c5f3773d813 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -198,6 +198,7 @@ export const buildExpression = ( }, ], valueLabels: [state?.valueLabels || 'hide'], + hideEndzones: [state?.hideEndzones || false], layers: validLayers.map((layer) => { const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 6f1a01acd6e76..0622f1c43f1c3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -414,6 +414,7 @@ export interface XYArgs { tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; curveType?: XYCurveType; + hideEndzones?: boolean; } export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; @@ -432,6 +433,7 @@ export interface XYState { tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; curveType?: XYCurveType; + hideEndzones?: boolean; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx new file mode 100644 index 0000000000000..369063644a754 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; +import React from 'react'; +import { Endzones } from '../../../../../src/plugins/charts/public'; +import { LensMultiTable } from '../types'; +import { LayerArgs } from './types'; + +export interface XDomain { + min?: number; + max?: number; + minInterval?: number; +} + +export const getXDomain = ( + layers: LayerArgs[], + data: LensMultiTable, + minInterval: number | undefined, + isTimeViz: boolean, + isHistogram: boolean +) => { + const baseDomain = isTimeViz + ? { + min: data.dateRange?.fromDate.getTime(), + max: data.dateRange?.toDate.getTime(), + minInterval, + } + : isHistogram + ? { minInterval } + : undefined; + + if (isHistogram && isFullyQualified(baseDomain)) { + const xValues = uniq( + layers + .flatMap((layer) => + data.tables[layer.layerId].rows.map((row) => row[layer.xAccessor!].valueOf() as number) + ) + .sort() + ); + + const [firstXValue] = xValues; + const lastXValue = xValues[xValues.length - 1]; + + const domainMin = Math.min(firstXValue, baseDomain.min); + const domainMaxValue = baseDomain.max - baseDomain.minInterval; + const domainMax = Math.max(domainMaxValue, lastXValue); + + return { + extendedDomain: { + min: domainMin, + max: domainMax, + minInterval: baseDomain.minInterval, + }, + baseDomain, + }; + } + + return { + baseDomain, + extendedDomain: baseDomain, + }; +}; + +function isFullyQualified( + xDomain: XDomain | undefined +): xDomain is { min: number; max: number; minInterval: number } { + return Boolean( + xDomain && + typeof xDomain.min === 'number' && + typeof xDomain.max === 'number' && + xDomain.minInterval + ); +} + +export const XyEndzones = function ({ + baseDomain, + extendedDomain, + histogramMode, + darkMode, +}: { + baseDomain?: XDomain; + extendedDomain?: XDomain; + histogramMode: boolean; + darkMode: boolean; +}) { + return isFullyQualified(baseDomain) && isFullyQualified(extendedDomain) ? ( + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index f965140a48ca0..e3e8c6e93e3aa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -138,6 +138,29 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); + + it('should pass in endzone visibility setter and current sate for time chart', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + dataType: 'date', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(AxisSettingsPopover).at(0).prop('setEndzoneVisibility')).toBeFalsy(); + expect(component.find(AxisSettingsPopover).at(1).prop('setEndzoneVisibility')).toBeTruthy(); + expect(component.find(AxisSettingsPopover).at(1).prop('endzonesVisible')).toBe(false); + expect(component.find(AxisSettingsPopover).at(2).prop('setEndzoneVisibility')).toBeFalsy(); + }); }); describe('Dimension Editor', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index c79a7e37f84d1..eccf4d9b64345 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -8,7 +8,7 @@ import './xy_config_panel.scss'; import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; +import { Position, ScaleType } from '@elastic/charts'; import { debounce } from 'lodash'; import { EuiButtonGroup, @@ -37,7 +37,7 @@ import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getSortedAccessors } from './to_expression'; +import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; @@ -187,6 +187,23 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp }); }; + // only allow changing endzone visibility if it could show up theoretically (if it's a time viz) + const onChangeEndzoneVisiblity = state?.layers.every( + (layer) => + layer.xAccessor && + getScaleType( + props.frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor), + ScaleType.Linear + ) === 'time' + ) + ? (checked: boolean): void => { + setState({ + ...state, + hideEndzones: !checked, + }); + } + : undefined; + const legendMode = state?.legend.isVisible && !state?.legend.showSingleSeries ? 'auto' @@ -278,6 +295,8 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp toggleGridlinesVisibility={onGridlinesVisibilitySettingsChange} isAxisTitleVisible={axisTitlesVisibilitySettings.x} toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} + endzonesVisible={!state?.hideEndzones} + setEndzoneVisibility={onChangeEndzoneVisiblity} /> Date: Tue, 27 Apr 2021 07:46:35 -0400 Subject: [PATCH 05/68] [Alerting] Return `400 Bad Request` errors when creating/enabling/updating rules using API key authentication (#98088) * Catching API key creation errors and throwing bad request errors instead * Catching API key creation errors and throwing bad request errors instead * Adding warning to docs * Updating error messages * Updating tests * Updating warning wording in docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/api/alerting/create_rule.asciidoc | 2 + docs/api/alerting/enable_rule.asciidoc | 2 + docs/api/alerting/update_rule.asciidoc | 2 + .../server/alerts_client/alerts_client.ts | 56 ++++++++++++++----- .../server/alerts_client/tests/create.test.ts | 12 ++++ .../server/alerts_client/tests/enable.test.ts | 11 ++++ .../server/alerts_client/tests/update.test.ts | 47 ++++++++++++++++ .../tests/update_api_key.test.ts | 21 ++++++- 8 files changed, 136 insertions(+), 17 deletions(-) diff --git a/docs/api/alerting/create_rule.asciidoc b/docs/api/alerting/create_rule.asciidoc index 01b6dfc40fcf6..59b17c5c3b5e1 100644 --- a/docs/api/alerting/create_rule.asciidoc +++ b/docs/api/alerting/create_rule.asciidoc @@ -6,6 +6,8 @@ Create {kib} rules. +WARNING: This API supports <> only. + [[create-rule-api-request]] ==== Request diff --git a/docs/api/alerting/enable_rule.asciidoc b/docs/api/alerting/enable_rule.asciidoc index 60f18b3510904..112d4bbf61faa 100644 --- a/docs/api/alerting/enable_rule.asciidoc +++ b/docs/api/alerting/enable_rule.asciidoc @@ -6,6 +6,8 @@ Enable a rule. +WARNING: This API supports <> only. + [[enable-rule-api-request]] ==== Request diff --git a/docs/api/alerting/update_rule.asciidoc b/docs/api/alerting/update_rule.asciidoc index 76c88a009be01..ec82e60a8e879 100644 --- a/docs/api/alerting/update_rule.asciidoc +++ b/docs/api/alerting/update_rule.asciidoc @@ -6,6 +6,8 @@ Update the attributes for an existing rule. +WARNING: This API supports <> only. + [[update-rule-api-request]] ==== Request diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index 210bdf954ada4..1db990edef2a9 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -260,9 +260,14 @@ export class AlertsClient { ); const username = await this.getUserName(); - const createdAPIKey = data.enabled - ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) - : null; + let createdAPIKey = null; + try { + createdAPIKey = data.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; + } catch (error) { + throw Boom.badRequest(`Error creating rule: could not create API key - ${error.message}`); + } this.validateActions(alertType, data.actions); @@ -727,9 +732,16 @@ export class AlertsClient { const { actions, references } = await this.denormalizeActions(data.actions); const username = await this.getUserName(); - const createdAPIKey = attributes.enabled - ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) - : null; + + let createdAPIKey = null; + try { + createdAPIKey = attributes.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; + } catch (error) { + throw Boom.badRequest(`Error updating rule: could not create API key - ${error.message}`); + } + const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); @@ -837,12 +849,21 @@ export class AlertsClient { } const username = await this.getUserName(); + + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest( + `Error updating API key for rule: could not create API key - ${error.message}` + ); + } + const updateAttributes = this.updateMeta({ ...attributes, - ...this.apiKeyAsAlertAttributes( - await this.createAPIKey(this.generateAPIKeyName(attributes.alertTypeId, attributes.name)), - username - ), + ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedAt: new Date().toISOString(), updatedBy: username, }); @@ -944,13 +965,20 @@ export class AlertsClient { if (attributes.enabled === false) { const username = await this.getUserName(); + + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`); + } + const updateAttributes = this.updateMeta({ ...attributes, enabled: true, - ...this.apiKeyAsAlertAttributes( - await this.createAPIKey(this.generateAPIKeyName(attributes.alertTypeId, attributes.name)), - username - ), + ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedBy: username, updatedAt: new Date().toISOString(), }); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts index 158c9478e6be1..6f493ced47371 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts @@ -1701,6 +1701,18 @@ describe('create()', () => { ); }); + test('throws an error if API key creation throws', async () => { + const data = getMockData(); + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.create({ data }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error creating rule: could not create API key - no"` + ); + }); + test('throws error when ensureActionTypeEnabled throws', async () => { const data = getMockData(); alertTypeRegistry.ensureAlertTypeEnabled.mockImplementation(() => { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts index db24d192c7755..7b0d6d7b1f10b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/enable.test.ts @@ -359,6 +359,17 @@ describe('enable()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.enable({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error enabling rule: could not create API key - no"` + ); + }); + test('falls back when failing to getDecryptedAsInternalUser', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index 24cef4677a9a2..cdbfbbac9f9a1 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -692,6 +692,53 @@ describe('update()', () => { `); }); + it('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating rule: could not create API key - no"` + ); + }); + it('should validate params', async () => { alertTypeRegistry.get.mockReturnValueOnce({ id: '123', diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts index e0be54054e593..18bae8d34a8da 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update_api_key.test.ts @@ -99,13 +99,13 @@ describe('updateApiKey()', () => { references: [], }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); + }); + + test('updates the API key for the alert', async () => { alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '234', name: '123', api_key: 'abc' }, }); - }); - - test('updates the API key for the alert', async () => { await alertsClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -145,7 +145,22 @@ describe('updateApiKey()', () => { ); }); + test('throws an error if API key creation throws', async () => { + alertsClientParams.createAPIKey.mockImplementation(() => { + throw new Error('no'); + }); + expect( + async () => await alertsClient.updateApiKey({ id: '1' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error updating API key for rule: could not create API key - no"` + ); + }); + test('falls back to SOC when getDecryptedAsInternalUser throws an error', async () => { + alertsClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '234', name: '123', api_key: 'abc' }, + }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', From 61d57370fad96b14bd660fa5d0f5e0fead34392f Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 27 Apr 2021 14:14:16 +0200 Subject: [PATCH 06/68] [Lens] Fix column order issues (#98261) --- .../droppable/droppable.test.ts | 77 +++++++++++++++++++ .../droppable/on_drop_handler.ts | 8 +- .../indexpattern.test.ts | 38 +++++++++ .../indexpattern_suggestions.test.tsx | 16 ++-- .../operations/layer_helpers.ts | 32 ++++---- .../indexpattern_datasource/to_expression.ts | 21 +++-- 6 files changed, 162 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 051feb331aec4..023e6ce979b94 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -1150,6 +1150,83 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + it('respects groups on moving operations if some columns are not listed in groups', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // col5, col6 not in visualization groups + // dragging col3 onto col1 in group a + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'], + columns: { + ...testState.layers.first.columns, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col4', 'col5', 'col6'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col4: testState.layers.first.columns.col4, + col5: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + col6: { + dataType: 'number', + operationType: 'count', + label: '', + isBucketed: false, + sourceField: 'Records', + }, + }, + }, + }, + }); + }); + it('respects groups on duplicating operations between compatible groups with overwrite', () => { // config: // a: col1, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index f0ad797a81b9f..08632171ee4f7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -147,9 +147,9 @@ function onMoveCompatible( columns: newColumns, }; - const updatedColumnOrder = getColumnOrder(newLayer); + let updatedColumnOrder = getColumnOrder(newLayer); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( @@ -342,8 +342,8 @@ function onSwapCompatible({ newColumns[targetId] = sourceColumn; newColumns[sourceId] = targetColumn; - const updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + let updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); + updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); // Time to replace setState( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 0ea533e22e4d9..c291c7ab3eac0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -860,6 +860,44 @@ describe('IndexPattern Data Source', () => { expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); expect(ast.chain[2]).toEqual('mock'); }); + + it('should keep correct column mapping keys with reference columns present', async () => { + const queryBaseState: IndexPatternBaseState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col2', 'col1'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'unique_count', + }, + col2: { + label: 'Reference', + dataType: 'number', + isBucketed: false, + // @ts-expect-error not a valid type + operationType: 'testReference', + references: ['col1'], + }, + }, + }, + }, + }; + + const state = enrichBaseState(queryBaseState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + 'col-0-col1': expect.objectContaining({ + id: 'col1', + }), + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index ccae659934ba7..864a3a6f089db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1106,11 +1106,11 @@ describe('IndexPattern Data Source suggestions', () => { operation: expect.objectContaining({ dataType: 'date', isBucketed: true }), }, { - columnId: 'newid', + columnId: 'ref', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, { - columnId: 'ref', + columnId: 'newid', operation: expect.objectContaining({ dataType: 'number', isBucketed: false }), }, ], @@ -1159,21 +1159,21 @@ describe('IndexPattern Data Source suggestions', () => { changeType: 'extended', columns: [ { - columnId: 'newid', + columnId: 'ref', operation: { dataType: 'number', isBucketed: false, - label: 'Count of records', - scale: 'ratio', + label: '', + scale: undefined, }, }, { - columnId: 'ref', + columnId: 'newid', operation: { dataType: 'number', isBucketed: false, - label: '', - scale: undefined, + label: 'Count of records', + scale: 'ratio', }, }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 35f334d5bd743..297fa4af2bc3f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -712,7 +712,12 @@ function addBucket( // they already had, with an extra level of detail. updatedColumnOrder = [...buckets, addedColumnId, ...metrics, ...references]; } - reorderByGroups(visualizationGroups, targetGroup, updatedColumnOrder, addedColumnId); + updatedColumnOrder = reorderByGroups( + visualizationGroups, + targetGroup, + updatedColumnOrder, + addedColumnId + ); const tempLayer = { ...resetIncomplete(layer, addedColumnId), columns: { ...layer.columns, [addedColumnId]: column }, @@ -749,16 +754,24 @@ export function reorderByGroups( }); const columnGroupIndex: Record = {}; updatedColumnOrder.forEach((columnId) => { - columnGroupIndex[columnId] = orderedVisualizationGroups.findIndex( + const groupIndex = orderedVisualizationGroups.findIndex( (group) => (columnId === addedColumnId && group.groupId === targetGroup) || group.accessors.some((acc) => acc.columnId === columnId) ); + if (groupIndex !== -1) { + columnGroupIndex[columnId] = groupIndex; + } else { + // referenced columns won't show up in visualization groups - put them in the back of the list. This will work as they are always metrics + columnGroupIndex[columnId] = updatedColumnOrder.length; + } }); - updatedColumnOrder.sort((a, b) => { + return [...updatedColumnOrder].sort((a, b) => { return columnGroupIndex[a] - columnGroupIndex[b]; }); + } else { + return updatedColumnOrder; } } @@ -899,12 +912,8 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } }); - const [direct, referenceBased] = _.partition( - entries, - ([, col]) => operationDefinitionMap[col.operationType].input !== 'fullReference' - ); // If a reference has another reference as input, put it last in sort order - referenceBased.sort(([idA, a], [idB, b]) => { + entries.sort(([idA, a], [idB, b]) => { if ('references' in a && a.references.includes(idB)) { return 1; } @@ -913,12 +922,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { } return 0; }); - const [aggregations, metrics] = _.partition(direct, ([, col]) => col.isBucketed); + const [aggregations, metrics] = _.partition(entries, ([, col]) => col.isBucketed); - return aggregations - .map(([id]) => id) - .concat(metrics.map(([id]) => id)) - .concat(referenceBased.map(([id]) => id)); + return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id)); } // Splits existing columnOrder into the three categories diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index b272e5476aa63..4f596aa282510 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -6,6 +6,7 @@ */ import type { IUiSettingsClient } from 'kibana/public'; +import { partition } from 'lodash'; import { AggFunctionsMapping, EsaggsExpressionFunctionDefinition, @@ -57,14 +58,24 @@ function getExpressionForLayer( const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); - if (columnEntries.length) { + const [referenceEntries, esAggEntries] = partition( + columnEntries, + ([, col]) => operationDefinitionMap[col.operationType]?.input === 'fullReference' + ); + + if (referenceEntries.length || esAggEntries.length) { const aggs: ExpressionAstExpressionBuilder[] = []; const expressions: ExpressionAstFunction[] = []; - columnEntries.forEach(([colId, col]) => { + referenceEntries.forEach(([colId, col]) => { const def = operationDefinitionMap[col.operationType]; if (def.input === 'fullReference') { expressions.push(...def.toExpression(layer, colId, indexPattern)); - } else { + } + }); + + esAggEntries.forEach(([colId, col]) => { + const def = operationDefinitionMap[col.operationType]; + if (def.input !== 'fullReference') { const wrapInFilter = Boolean(def.filterable && col.filter); let aggAst = def.toEsAggsFn( col, @@ -101,8 +112,8 @@ function getExpressionForLayer( } }); - const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { - const esAggsId = `col-${columnEntries.length === 1 ? 0 : index}-${colId}`; + const idMap = esAggEntries.reduce((currentIdMap, [colId, column], index) => { + const esAggsId = `col-${index}-${colId}`; return { ...currentIdMap, [esAggsId]: { From 088212b8dbed7eecdee38202f34a8d53c2a2bb3a Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 27 Apr 2021 14:31:04 +0200 Subject: [PATCH 07/68] [Lens] Prevent editor crash on histograms datatype mix (#98453) --- .../xy_visualization/visualization.test.ts | 54 ++++++++++++++++++ .../public/xy_visualization/visualization.tsx | 57 ++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 27ef827c138ca..aa4b91b840db3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -818,6 +818,60 @@ describe('xy_visualization', () => { }, ]); }); + + it('should return an error if two incompatible xAccessors (multiple layers) are used', () => { + // current incompatibility is only for date and numeric histograms as xAccessors + const datasourceLayers = { + first: mockDatasource.publicAPIMock, + second: createMockDatasource('testDatasource').publicAPIMock, + }; + datasourceLayers.first.getOperationForColumnId = jest.fn((id: string) => + id === 'a' + ? (({ + dataType: 'date', + scale: 'interval', + } as unknown) as Operation) + : null + ); + datasourceLayers.second.getOperationForColumnId = jest.fn((id: string) => + id === 'e' + ? (({ + dataType: 'number', + scale: 'interval', + } as unknown) as Operation) + : null + ); + expect( + xyVisualization.getErrorMessages( + { + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'second', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'e', + accessors: ['b'], + }, + ], + }, + datasourceLayers + ) + ).toEqual([ + { + shortMessage: 'Wrong data type for Horizontal axis.', + longMessage: + 'Data type mismatch for the Horizontal axis. Cannot mix date and number interval types.', + }, + ]); + }); }); describe('#getWarningMessages', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a6df995513fdf..dda1a444f4544 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -15,8 +15,14 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; -import { State, SeriesType, visualizationTypes, XYLayerConfig } from './types'; +import { + Visualization, + OperationMetadata, + VisualizationType, + AccessorConfig, + DatasourcePublicAPI, +} from '../types'; +import { State, SeriesType, visualizationTypes, XYLayerConfig, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; @@ -374,6 +380,9 @@ export const getXyVisualization = ({ } if (datasourceLayers && state) { + // temporary fix for #87068 + errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); + for (const layer of state.layers) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { @@ -517,3 +526,47 @@ function newLayerState(seriesType: SeriesType, layerId: string): XYLayerConfig { accessors: [], }; } + +// min requirement for the bug: +// * 2 or more layers +// * at least one with date histogram +// * at least one with interval function +function checkXAccessorCompatibility( + state: XYState, + datasourceLayers: Record +) { + const errors = []; + const hasDateHistogramSet = state.layers.some(checkIntervalOperation('date', datasourceLayers)); + const hasNumberHistogram = state.layers.some(checkIntervalOperation('number', datasourceLayers)); + if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { + defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + return errors; +} + +function checkIntervalOperation( + dataType: 'date' | 'number', + datasourceLayers: Record +) { + return (layer: XYLayerConfig) => { + const datasourceAPI = datasourceLayers[layer.layerId]; + if (!layer.xAccessor) { + return false; + } + const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); + return Boolean(operation?.dataType === dataType && operation.scale === 'interval'); + }; +} From 88288c8c518b50436479ccda6dfffe1a09236281 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 27 Apr 2021 16:09:38 +0300 Subject: [PATCH 08/68] [visTypeTimeseries] Reduce page load bundle to under 100kB #95873 (#97972) * [visTypeTimeseries] Reduce page load bundle to under 100kB #95873 Closes: 95873 * ts-ignore -> ts-expect-error Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../public/application/index.ts | 10 ---------- .../vis_type_timeseries/public/metrics_type.ts | 2 +- src/plugins/vis_type_timeseries/public/plugin.ts | 4 +--- .../public/request_handler.ts | 3 ++- .../public/timeseries_vis_renderer.tsx | 16 +++++++++++----- src/plugins/vis_type_timeseries/public/to_ast.ts | 6 +++--- 7 files changed, 19 insertions(+), 24 deletions(-) delete mode 100644 src/plugins/vis_type_timeseries/public/application/index.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 2a7a02b8e7f2f..95bf3f8f251b7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -92,7 +92,7 @@ pageLoadAssetSize: visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 - visTypeTimeseries: 155203 + visTypeTimeseries: 55203 visTypeVega: 153573 visTypeVislib: 242838 visTypeXy: 113478 diff --git a/src/plugins/vis_type_timeseries/public/application/index.ts b/src/plugins/vis_type_timeseries/public/application/index.ts deleted file mode 100644 index fcc0c592b1ef5..0000000000000 --- a/src/plugins/vis_type_timeseries/public/application/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { EditorController, TSVB_EDITOR_NAME } from './editor_controller'; -export * from './lib'; diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 4e45ddf434771..797e40df22710 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; -import { TSVB_EDITOR_NAME } from './application'; +import { TSVB_EDITOR_NAME } from './application/editor_controller'; import { PANEL_TYPES } from '../common/panel_types'; import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; import { toExpressionAst } from './to_ast'; diff --git a/src/plugins/vis_type_timeseries/public/plugin.ts b/src/plugins/vis_type_timeseries/public/plugin.ts index 6900630ffa971..1c1212add3d8c 100644 --- a/src/plugins/vis_type_timeseries/public/plugin.ts +++ b/src/plugins/vis_type_timeseries/public/plugin.ts @@ -6,13 +6,11 @@ * Side Public License, v 1. */ -import './application/index.scss'; - import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { VisualizationsSetup } from '../../visualizations/public'; import { VisualizePluginSetup } from '../../visualize/public'; -import { EditorController, TSVB_EDITOR_NAME } from './application'; +import { EditorController, TSVB_EDITOR_NAME } from './application/editor_controller'; import { createMetricsFn } from './metrics_fn'; import { metricsVisDefinition } from './metrics_type'; diff --git a/src/plugins/vis_type_timeseries/public/request_handler.ts b/src/plugins/vis_type_timeseries/public/request_handler.ts index bf58287870c82..9c350305820cd 100644 --- a/src/plugins/vis_type_timeseries/public/request_handler.ts +++ b/src/plugins/vis_type_timeseries/public/request_handler.ts @@ -8,7 +8,8 @@ import { KibanaContext } from '../../data/public'; -import { getTimezone, validateInterval } from './application'; +import { getTimezone } from './application/lib/get_timezone'; +import { validateInterval } from './application/lib/validate_interval'; import { getUISettings, getDataStart, getCoreStart } from './services'; import { MAX_BUCKETS_SETTING, ROUTES } from '../common/constants'; import { TimeseriesVisParams } from './types'; diff --git a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx index 7faf314cd4046..52a357bd0cc90 100644 --- a/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx +++ b/src/plugins/vis_type_timeseries/public/timeseries_vis_renderer.tsx @@ -12,14 +12,16 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { IUiSettingsClient } from 'kibana/public'; -import type { PersistedState } from '../../visualizations/public'; -import { VisualizationContainer } from '../../visualizations/public'; -import { ExpressionRenderDefinition } from '../../expressions/common/expression_renderers'; -import { TimeseriesRenderValue } from './metrics_fn'; + +import { VisualizationContainer, PersistedState } from '../../visualizations/public'; + import { isVisTableData, TimeseriesVisData } from '../common/types'; -import { TimeseriesVisParams } from './types'; import { getChartsSetup } from './services'; +import type { TimeseriesVisParams } from './types'; +import type { ExpressionRenderDefinition } from '../../expressions/common'; +import type { TimeseriesRenderValue } from './metrics_fn'; + const TimeseriesVisualization = lazy( () => import('./application/components/timeseries_visualization') ); @@ -39,6 +41,10 @@ export const getTimeseriesVisRenderer: (deps: { name: 'timeseries_vis', reuseDomNode: true, render: async (domNode, config, handlers) => { + // Build optimization. Move app styles from main bundle + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./application/index.scss'); + handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); diff --git a/src/plugins/vis_type_timeseries/public/to_ast.ts b/src/plugins/vis_type_timeseries/public/to_ast.ts index 90d57218da28c..c0c0a5b1546a9 100644 --- a/src/plugins/vis_type_timeseries/public/to_ast.ts +++ b/src/plugins/vis_type_timeseries/public/to_ast.ts @@ -7,9 +7,9 @@ */ import { buildExpression, buildExpressionFunction } from '../../expressions/public'; -import { Vis } from '../../visualizations/public'; -import { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; -import { TimeseriesVisParams } from './types'; +import type { Vis } from '../../visualizations/public'; +import type { TimeseriesExpressionFunctionDefinition } from './metrics_fn'; +import type { TimeseriesVisParams } from './types'; export const toExpressionAst = (vis: Vis) => { const timeseries = buildExpressionFunction('tsvb', { From d0b836b172a962ef2791d5eac4afa6a5be52a091 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 27 Apr 2021 15:50:52 +0200 Subject: [PATCH 09/68] do not debounce chart (#98451) --- .../editor_frame/workspace_panel/workspace_panel.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index c3bd6fde27ba3..a31146e500434 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -43,7 +43,6 @@ import { import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; -import { debouncedComponent } from '../../../debounced_component'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { UiActionsStart, @@ -368,7 +367,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ ); }); -export const InnerVisualizationWrapper = ({ +export const VisualizationWrapper = ({ expression, framePublicAPI, timefilter, @@ -619,5 +618,3 @@ export const InnerVisualizationWrapper = ({ ); }; - -export const VisualizationWrapper = debouncedComponent(InnerVisualizationWrapper); From 23727004835556c836ee2cdc03ae4172f4495a32 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 27 Apr 2021 17:31:16 +0300 Subject: [PATCH 10/68] [Docs] TSVB supports url drilldowns on 7.13+ (#98460) --- docs/user/dashboard/drilldowns.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index cbe47f23fcbaf..d74f88babb5ce 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -138,7 +138,7 @@ The following panels support dashboard and URL drilldowns. | TSVB ^| X -^| +^| X | Tag Cloud ^| X From 6c46e4107cf72a9b11bfd5b9dd106b016e361805 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 27 Apr 2021 09:32:02 -0500 Subject: [PATCH 11/68] [DOCS] Drilldown docs changes for 7.13 (#98390) --- docs/settings/url-drilldown-settings.asciidoc | 31 +++++ docs/setup/settings.asciidoc | 1 + docs/user/dashboard/drilldowns.asciidoc | 125 +++++++----------- 3 files changed, 83 insertions(+), 74 deletions(-) create mode 100644 docs/settings/url-drilldown-settings.asciidoc diff --git a/docs/settings/url-drilldown-settings.asciidoc b/docs/settings/url-drilldown-settings.asciidoc new file mode 100644 index 0000000000000..8be3a21bfbffc --- /dev/null +++ b/docs/settings/url-drilldown-settings.asciidoc @@ -0,0 +1,31 @@ +[[url-drilldown-settings-kb]] +=== URL drilldown settings in {kib} +++++ +URL drilldown settings +++++ + +Configure the URL drilldown settings in your `kibana.yml` configuration file. + +[cols="2*<"] +|=== +| [[url-drilldown-enabled]] `url_drilldown.enabled` + | When `true`, enables URL drilldowns on your {kib} instance. + +| [[external-URL-policy]] `externalUrl.policy` + | Configures the external URL policies. URL drilldowns respect the global *External URL* service, which you can use to deny or allow external URLs. +By default all external URLs are allowed. +|=== + +For example, to allow external URLs only to the `example.com` domain with the `https` scheme, except for the `danger.example.com` sub-domain, +which is denied even when `https` scheme is used: + +["source","yml"] +----------- +externalUrl.policy: + - allow: false + host: danger.example.com + - allow: true + host: example.com + protocol: https +----------- + diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 1b027739169ad..0aab86fb5a9e2 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -756,3 +756,4 @@ include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] include::{kib-repo-dir}/settings/telemetry-settings.asciidoc[] +include::{kib-repo-dir}/settings/url-drilldown-settings.asciidoc[] diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index d74f88babb5ce..fc25f84030ee2 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -2,8 +2,8 @@ [[drilldowns]] == Create custom dashboard actions -Custom dashboard actions, also known as drilldowns, allow you to create -workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns. +Custom dashboard actions, or _drilldowns_, allow you to create workflows for analyzing and troubleshooting your data. +Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all panels. Each panel can have multiple drilldowns. Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin]. @@ -11,27 +11,23 @@ Third-party developers can create drilldowns. To learn how to code drilldowns, r [[supported-drilldowns]] === Supported drilldowns -{kib} supports two types of drilldowns. - -[NOTE] -===================================== -Some drilldowns are paid subscription features, while others are free. -For a comparison of the Elastic subscription levels, -refer https://www.elastic.co/subscriptions[the subscription page]. -===================================== +{kib} supports dashboard and URL drilldowns. [float] [[dashboard-drilldowns]] ==== Dashboard drilldowns Dashboard drilldowns enable you to open a dashboard from another dashboard, -taking the time range, filters, and other parameters with you, +taking the time range, filters, and other parameters with you so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective. For example, if you have a dashboard that shows the overall status of multiple data center, you can create a drilldown that navigates from the overall status dashboard to a dashboard that shows a single data center or server. +[role="screenshot"] +image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] + [float] [[url-drilldowns]] ==== URL drilldowns @@ -39,45 +35,25 @@ that shows a single data center or server. URL drilldowns enable you to navigate from a dashboard to internal or external URLs. Destination URLs can be dynamic, depending on the dashboard context or user interaction with a panel. For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown -that opens Github from the dashboard. +that opens Github from the dashboard panel. + +[role="screenshot"] +image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] Some panels support multiple interactions, also known as triggers. The <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: -* *Single click* — A single data point in the visualization. +* *Single click* — A single data point in the panel. -* *Range selection* — A range of values in a visualization. +* *Range selection* — A range of values in a panel. For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. -To disable URL drilldowns on your {kib} instance, add the following line to `kibana.yml` config file: - -["source","yml"] ------------ -url_drilldown.enabled: false ------------ - -URL drilldown also respects the global *External URL* service, which can be used to deny/allow external URLs. -By default all external URLs are allowed. To configure external URL policies you need to use `externalUrl.policy` setting in `kibana.yml`, for example: - -["source","yml"] ------------ -externalUrl.policy: - - allow: false - host: danger.example.com - - allow: true - host: example.com - protocol: https ------------ - -The above rules allow external URLs only to `example.com` domain with `https` scheme, except for `danger.example.com` sub-domain, -which is denied even when `https` scheme is used. - [float] [[dashboard-drilldown-supported-panels]] -=== Supported panels +=== Supported panel types -The following panels support dashboard and URL drilldowns. +The following panel types support drilldowns. [options="header"] |=== @@ -160,25 +136,23 @@ The following panels support dashboard and URL drilldowns. [float] [[drilldowns-example]] -=== Try it: Create a dashboard drilldown +=== Create a dashboard drilldown To create dashboard drilldowns, you create or locate the dashboards you want to connect, then configure the drilldown that allows you to easily open one dashboard from the other dashboard. -image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] - [float] ==== Create the dashboard . Add the *Sample web logs* data. -. Create a new dashboard, then add the following panels: +. Create a new dashboard, then add the following panels from the *Visualize Library*: * *[Logs] Heatmap* * *[Logs] Host, Visits, and Bytes Table* * *[Logs] Total Requests and Bytes* * *[Logs] Visitors by OS* + -If you don’t see data for a panel, try changing the <>. +If you don’t see the data on a panel, try changing the <>. . Save the dashboard. In the *Title* field, enter `Host Overview`. @@ -197,79 +171,82 @@ Filter: `geo.src: CN` . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -. Give the drilldown a name, then select *Go to dashboard*. +. Click *Go to dashboard*. -. From the *Choose a destination dashboard* dropdown, select *Host Overview*. +.. Give the drilldown a name. For example, `My Drilldown`. -. To carry over the filter, query, and date range, make sure that *Use filters and query from origin dashboard* and *Use date range from origin dashboard* are selected. -+ -[role="screenshot"] -image::images/drilldown_create.png[Create drilldown with entries for drilldown name and destination] +.. From the *Choose a destination dashboard* dropdown, select *Host Overview*. -. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. +.. To use the geo.src filter, KQL query, and time filter, select *Use filters and query from origin dashboard* and *Use date range from origin dashboard*. + +.. Click *Create drilldown*. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. -. In the *[Logs] Visitors by OS* panel, click *win 8*, then select the drilldown. +. In the *[Logs] Visitors by OS* panel, click *win 8*, then select `My Drilldown`. + [role="screenshot"] image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to another dashboard] -. On the *Host Overview* dashboard, verify that the search query, filters, -and date range are carried over. +. On the *Host Overview* dashboard, verify that the geo.src filter, KQL query, and time filter are applied. [float] [[create-a-url-drilldown]] -=== Try it: Create a URL drilldown +=== Create a URL drilldown To create URL drilldowns, you add <> to a URL template, which configures the behavior of the drilldown. -image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] - . Add the *Sample web logs* data. -. Open the *[Logs] Web traffic* dashboard. This isn’t data from Github, but works for demonstration purposes. +. Open the *[Logs] Web traffic* dashboard. . In the toolbar, click *Edit*. . Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. -.. In the *Name* field, enter `Show on Github`. +. Click *Go to URL*. -.. Select *Go to URL*. +.. Give the drilldown a name. For example, `Show on Github`. -.. Enter the URL template: +.. For the *Trigger*, select *Single click*. + +.. To navigate to the {kib} repository Github issues, enter the following in the *Enter URL* field: + [source, bash] ---- https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ---- + -The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. -+ -[role="screenshot"] -image:images/url_drilldown_url_template.png[URL template input] +`{{event.value}}` is substituted with a value associated with a selected pie slice. .. Click *Create drilldown*. -+ -The drilldown is stored as dashboard metadata. . Save the dashboard. -+ -If you fail to save the dashboard, the drilldown is lost when you navigate away from the dashboard. . On the *[Logs] Visitors by OS* panel, click any chart slice, then select *Show on Github*. + [role="screenshot"] image:images/url_drilldown_popup.png[URL drilldown popup] -. On the page that lists the issues in the {kib} repository, verify the slice value appears in Github. +. In the list of {kib} repository issues, verify that the slice value appears. + [role="screenshot"] image:images/url_drilldown_github.png[Github] +[float] +[[manage-drilldowns]] +=== Manage drilldowns + +Make changes to your drilldowns, make a copy of your drilldowns for another panel, and delete drilldowns. + +. Open the panel menu that includes the drilldown, then click *Manage drilldowns*. + +. On the *Manage* tab, use the following options: + +* To change drilldowns, click *Edit* next to the drilldown you want to change, make your changes, then click *Save*. + +* To make a copy, click *Copy* next to the drilldown you want to change, enter the drilldown name, then click *Create drilldown*. + +* To delete a drilldown, select the drilldown you want to delete, then click *Delete*. + include::url-drilldown.asciidoc[] From 18d9d435afe76b3a04b1977ea070655a365a2474 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 27 Apr 2021 16:49:20 +0200 Subject: [PATCH 12/68] [ML] Transforms: Adds a link to discover from the transform list to the actions menu. (#97805) Adds a link to discover from the transform list to the actions menu. Conditions for the link to be enabled: - Kibana index pattern must be available - Transform must have been started once and done some progress so there's the destination index available --- x-pack/plugins/transform/kibana.json | 3 +- .../public/app/__mocks__/app_dependencies.tsx | 19 +++- .../transform/public/app/app_dependencies.tsx | 13 ++- .../transform/public/app/common/index.ts | 1 - .../public/app/common/navigation.test.tsx | 16 --- .../public/app/common/navigation.tsx | 19 ---- .../public/app/mount_management_section.ts | 6 +- .../step_create/step_create_form.tsx | 43 +++++++- .../discover_action_name.test.tsx | 88 +++++++++++++++++ .../action_discover/discover_action_name.tsx | 97 ++++++++++++++++++ .../components/action_discover/index.ts | 9 ++ .../action_discover/use_action_discover.tsx | 99 +++++++++++++++++++ .../transform_list/expanded_row.test.tsx | 30 +++--- .../transform_list/use_actions.test.tsx | 14 ++- .../components/transform_list/use_actions.tsx | 3 + .../transform_list/use_columns.test.tsx | 7 +- x-pack/plugins/transform/public/plugin.ts | 12 ++- .../apps/transform/creation_index_pattern.ts | 26 +++++ .../test/functional/apps/transform/index.ts | 1 + .../functional/services/transform/discover.ts | 65 ++++++++++++ .../functional/services/transform/index.ts | 3 + .../services/transform/transform_table.ts | 9 +- 22 files changed, 508 insertions(+), 75 deletions(-) delete mode 100644 x-pack/plugins/transform/public/app/common/navigation.test.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts create mode 100644 x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx create mode 100644 x-pack/test/functional/services/transform/discover.ts diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index d5da9377ed870..4216ac9761e86 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -9,7 +9,8 @@ "licensing", "management", "features", - "savedObjects" + "savedObjects", + "share" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 41b7482c4c0f8..5a6f8cf72e36d 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -7,17 +7,28 @@ import { useContext } from 'react'; +import type { ScopedHistory } from 'kibana/public'; + import { coreMock } from '../../../../../../src/core/public/mocks'; import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { savedObjectsPluginMock } from '../../../../../../src/plugins/saved_objects/public/mocks'; +import { SharePluginStart } from '../../../../../../src/plugins/share/public'; + import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import type { AppDependencies } from '../app_dependencies'; import { MlSharedContext } from './shared_context'; +import type { GetMlSharedImportsReturnType } from '../../shared_imports'; const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); const dataStart = dataPluginMock.createStartContract(); -const appDependencies = { +// Replace mock to support syntax using `.then()` as used in transform code. +coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: [] }); + +const appDependencies: AppDependencies = { + application: coreStart.application, chrome: coreStart.chrome, data: dataStart, docLinks: coreStart.docLinks, @@ -28,11 +39,15 @@ const appDependencies = { storage: ({ get: jest.fn() } as unknown) as Storage, overlays: coreStart.overlays, http: coreSetup.http, + history: {} as ScopedHistory, + savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), + share: ({ urlGenerators: { getUrlGenerator: jest.fn() } } as unknown) as SharePluginStart, + ml: {} as GetMlSharedImportsReturnType, }; export const useAppDependencies = () => { const ml = useContext(MlSharedContext); - return { ...appDependencies, ml, savedObjects: jest.fn() }; + return { ...appDependencies, ml }; }; export const useToastNotifications = () => { diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index c49ab8183521f..c39aa5a49e5e9 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import { CoreSetup, CoreStart } from 'src/core/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { SavedObjectsStart } from 'src/plugins/saved_objects/public'; -import { ScopedHistory } from 'kibana/public'; +import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; +import type { ScopedHistory } from 'kibana/public'; +import type { SharePluginStart } from 'src/plugins/share/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; export interface AppDependencies { + application: CoreStart['application']; chrome: CoreStart['chrome']; data: DataPublicPluginStart; docLinks: CoreStart['docLinks']; @@ -28,6 +30,7 @@ export interface AppDependencies { overlays: CoreStart['overlays']; history: ScopedHistory; savedObjectsPlugin: SavedObjectsStart; + share: SharePluginStart; ml: GetMlSharedImportsReturnType; } diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index 8fa97139ab967..ccd90f8759358 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -28,7 +28,6 @@ export { } from './transform'; export { TRANSFORM_LIST_COLUMN, TransformListAction, TransformListRow } from './transform_list'; export { getTransformProgress, isCompletedBatchTransform } from './transform_stats'; -export { getDiscoverUrl } from './navigation'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, diff --git a/x-pack/plugins/transform/public/app/common/navigation.test.tsx b/x-pack/plugins/transform/public/app/common/navigation.test.tsx deleted file mode 100644 index af2f586873961..0000000000000 --- a/x-pack/plugins/transform/public/app/common/navigation.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getDiscoverUrl } from './navigation'; - -describe('navigation', () => { - test('getDiscoverUrl should provide encoded url to Discover page', () => { - expect(getDiscoverUrl('farequote-airline', 'http://example.com')).toBe( - 'http://example.com/app/discover#?_g=()&_a=(index:farequote-airline)' - ); - }); -}); diff --git a/x-pack/plugins/transform/public/app/common/navigation.tsx b/x-pack/plugins/transform/public/app/common/navigation.tsx index 9daaf8c840755..b847ac66de58e 100644 --- a/x-pack/plugins/transform/public/app/common/navigation.tsx +++ b/x-pack/plugins/transform/public/app/common/navigation.tsx @@ -7,28 +7,9 @@ import React, { FC } from 'react'; import { Redirect } from 'react-router-dom'; -import rison from 'rison-node'; import { SECTION_SLUG } from '../constants'; -/** - * Gets a url for navigating to Discover page. - * @param indexPatternId Index pattern ID. - * @param baseUrl Base url. - */ -export function getDiscoverUrl(indexPatternId: string, baseUrl: string): string { - const _g = rison.encode({}); - - // Add the index pattern ID to the appState part of the URL. - const _a = rison.encode({ - index: indexPatternId, - }); - - const hash = `/discover#?_g=${_g}&_a=${_a}`; - - return `${baseUrl}/app${hash}`; -} - export const RedirectToTransformManagement: FC = () => ; export const RedirectToCreateTransform: FC<{ savedObjectId: string }> = ({ savedObjectId }) => ( diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 019e1f56cee06..1d39d233f8284 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -28,8 +28,8 @@ export async function mountManagementSection( const { http, notifications, getStartServices } = coreSetup; const startServices = await getStartServices(); const [core, plugins] = startServices; - const { chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data } = plugins; + const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; + const { data, share } = plugins; const { docTitle } = chrome; // Initialize services @@ -39,6 +39,7 @@ export async function mountManagementSection( // AppCore/AppPlugins to be passed on as React context const appDependencies: AppDependencies = { + application, chrome, data, docLinks, @@ -51,6 +52,7 @@ export async function mountManagementSection( uiSettings, history, savedObjectsPlugin: plugins.savedObjects, + share, ml: await getMlSharedImports(), }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 36bdca7921622..526f59e7dad41 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -26,6 +26,11 @@ import { import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGeneratorState, +} from '../../../../../../../../../src/plugins/discover/public'; + import type { PutTransformsResponseSchema } from '../../../../../../common/api_schemas/transforms'; import { isGetTransformsStatsResponseSchema, @@ -36,7 +41,7 @@ import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants import { getErrorMessage } from '../../../../../../common/utils/errors'; -import { getTransformProgress, getDiscoverUrl } from '../../../../common'; +import { getTransformProgress } from '../../../../common'; import { useApi } from '../../../../hooks/use_api'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { RedirectToTransformManagement } from '../../../../common/navigation'; @@ -86,13 +91,45 @@ export const StepCreateForm: FC = React.memo( const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); + const [discoverLink, setDiscoverLink] = useState(); const deps = useAppDependencies(); const indexPatterns = deps.data.indexPatterns; const toastNotifications = useToastNotifications(); + const { getUrlGenerator } = deps.share.urlGenerators; + const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { + let unmounted = false; + onChange({ created, started, indexPatternId }); + + const getDiscoverUrl = async (): Promise => { + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + + let discoverUrlGenerator; + try { + discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + } catch (error) { + // ignore error thrown when url generator is not available + return; + } + + const discoverUrl = await discoverUrlGenerator.createUrl(state); + if (!unmounted) { + setDiscoverLink(discoverUrl); + } + }; + + if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + getDiscoverUrl(); + } + + return () => { + unmounted = true; + }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [created, started, indexPatternId]); @@ -477,7 +514,7 @@ export const StepCreateForm: FC = React.memo( )} - {started === true && indexPatternId !== undefined && ( + {isDiscoverAvailable && discoverLink !== undefined && ( } @@ -490,7 +527,7 @@ export const StepCreateForm: FC = React.memo( defaultMessage: 'Use Discover to explore the transform.', } )} - href={getDiscoverUrl(indexPatternId, deps.http.basePath.get())} + href={discoverLink} data-test-subj="transformWizardCardDiscover" /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx new file mode 100644 index 0000000000000..8dba93399792c --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep } from 'lodash'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; + +import { render, waitFor, screen } from '@testing-library/react'; + +import { TransformListRow } from '../../../../common'; +import { isDiscoverActionDisabled, DiscoverActionName } from './discover_action_name'; + +import transformListRow from '../../../../common/__mocks__/transform_list_row.json'; + +jest.mock('../../../../../shared_imports'); +jest.mock('../../../../../app/app_dependencies'); + +// @ts-expect-error mock data is too loosely typed +const item: TransformListRow = transformListRow; + +describe('Transform: Transform List Actions isDiscoverActionDisabled()', () => { + it('should be disabled when more than one item is passed in', () => { + expect(isDiscoverActionDisabled([item, item], false, true)).toBe(true); + }); + it('should be disabled when forceDisable is true', () => { + expect(isDiscoverActionDisabled([item], true, true)).toBe(true); + }); + it('should be disabled when the index pattern is not available', () => { + expect(isDiscoverActionDisabled([item], false, false)).toBe(true); + }); + it('should be disabled when the transform started but has no index pattern', () => { + const itemCopy = cloneDeep(item); + itemCopy.stats.state = 'started'; + expect(isDiscoverActionDisabled([itemCopy], false, false)).toBe(true); + }); + it('should be enabled when the transform started and has an index pattern', () => { + const itemCopy = cloneDeep(item); + itemCopy.stats.state = 'started'; + expect(isDiscoverActionDisabled([itemCopy], false, true)).toBe(false); + }); + it('should be enabled when the index pattern is available', () => { + expect(isDiscoverActionDisabled([item], false, true)).toBe(false); + }); +}); + +describe('Transform: Transform List Actions ', () => { + it('renders an enabled button', async () => { + // prepare + render( + + + + ); + + // assert + await waitFor(() => { + expect( + screen.queryByTestId('transformDiscoverActionNameText disabled') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('transformDiscoverActionNameText enabled')).toBeInTheDocument(); + expect(screen.queryByText('View in Discover')).toBeInTheDocument(); + }); + }); + + it('renders a disabled button', async () => { + // prepare + const itemCopy = cloneDeep(item); + itemCopy.stats.checkpointing.last.checkpoint = 0; + render( + + + + ); + + // assert + await waitFor(() => { + expect(screen.queryByTestId('transformDiscoverActionNameText disabled')).toBeInTheDocument(); + expect( + screen.queryByTestId('transformDiscoverActionNameText enabled') + ).not.toBeInTheDocument(); + expect(screen.queryByText('View in Discover')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx new file mode 100644 index 0000000000000..259bf82371dba --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; + +import { TRANSFORM_STATE } from '../../../../../../common/constants'; + +import { getTransformProgress, TransformListRow } from '../../../../common'; + +export const discoverActionNameText = i18n.translate( + 'xpack.transform.transformList.discoverActionNameText', + { + defaultMessage: 'View in Discover', + } +); + +export const isDiscoverActionDisabled = ( + items: TransformListRow[], + forceDisable: boolean, + indexPatternExists: boolean +) => { + if (items.length !== 1) { + return true; + } + + const item = items[0]; + + // Disable discover action if it's a batch transform and was never started + const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const transformProgress = getTransformProgress(item); + const isBatchTransform = typeof item.config.sync === 'undefined'; + const transformNeverStarted = + stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; + + return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; +}; + +export interface DiscoverActionNameProps { + indexPatternExists: boolean; + items: TransformListRow[]; +} +export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { + const isBulkAction = items.length > 1; + + const item = items[0]; + + // Disable discover action if it's a batch transform and was never started + const stoppedTransform = item.stats.state === TRANSFORM_STATE.STOPPED; + const transformProgress = getTransformProgress(item); + const isBatchTransform = typeof item.config.sync === 'undefined'; + const transformNeverStarted = + stoppedTransform && transformProgress === undefined && isBatchTransform === true; + + let disabledTransformMessage; + if (isBulkAction === true) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformBulkToolTip', + { + defaultMessage: 'Links to Discover are not supported as a bulk action.', + } + ); + } else if (!indexPatternExists) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformNoIndexPatternToolTip', + { + defaultMessage: `A Kibana index pattern is required for the destination index to be viewable in Discover`, + } + ); + } else if (transformNeverStarted) { + disabledTransformMessage = i18n.translate( + 'xpack.transform.transformList.discoverTransformToolTip', + { + defaultMessage: `The transform needs to be started before it's available in Discover.`, + } + ); + } + + if (typeof disabledTransformMessage !== 'undefined') { + return ( + + + {discoverActionNameText} + + + ); + } + + return ( + {discoverActionNameText} + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts new file mode 100644 index 0000000000000..b8ba624faf02c --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDiscoverAction } from './use_action_discover'; +export { DiscoverActionName } from './discover_action_name'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx new file mode 100644 index 0000000000000..468ed0e6b892d --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + DiscoverUrlGeneratorState, + DISCOVER_APP_URL_GENERATOR, +} from '../../../../../../../../../src/plugins/discover/public'; + +import { TransformListAction, TransformListRow } from '../../../../common'; + +import { useSearchItems } from '../../../../hooks/use_search_items'; +import { useAppDependencies } from '../../../../app_dependencies'; + +import { + isDiscoverActionDisabled, + discoverActionNameText, + DiscoverActionName, +} from './discover_action_name'; + +const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => + Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; + +export type DiscoverAction = ReturnType; +export const useDiscoverAction = (forceDisable: boolean) => { + const appDeps = useAppDependencies(); + const savedObjectsClient = appDeps.savedObjects.client; + const indexPatterns = appDeps.data.indexPatterns; + const { getUrlGenerator } = appDeps.share.urlGenerators; + const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; + + const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + + const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + + useEffect(() => { + async function checkIndexPatternAvailability() { + await loadIndexPatterns(savedObjectsClient, indexPatterns); + setIndexPatternsLoaded(true); + } + + checkIndexPatternAvailability(); + }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + + const clickHandler = useCallback( + async (item: TransformListRow) => { + let discoverUrlGenerator; + try { + discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + } catch (error) { + // ignore error thrown when url generator is not available + return; + } + + const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); + const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + const path = await discoverUrlGenerator.createUrl(state); + appDeps.application.navigateToApp('discover', { path }); + }, + [appDeps.application, getIndexPatternIdByTitle, getUrlGenerator] + ); + + const indexPatternExists = useCallback( + (item: TransformListRow) => { + const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); + const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + return indexPatternId !== undefined; + }, + [getIndexPatternIdByTitle] + ); + + const action: TransformListAction = useMemo( + () => ({ + name: (item: TransformListRow) => { + return ; + }, + available: () => isDiscoverAvailable, + enabled: (item: TransformListRow) => + indexPatternsLoaded && + !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + description: discoverActionNameText, + icon: 'visTable', + type: 'icon', + onClick: clickHandler, + 'data-test-subj': 'transformActionDiscover', + }), + [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index d25f8c62a4e94..77d20dc4d9078 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import moment from 'moment-timezone'; import { TransformListRow } from '../../../../common'; @@ -41,20 +41,26 @@ describe('Transform: Transform List ', () => { ); - expect(getByText('Details')).toBeInTheDocument(); - expect(getByText('Stats')).toBeInTheDocument(); - expect(getByText('JSON')).toBeInTheDocument(); - expect(getByText('Messages')).toBeInTheDocument(); - expect(getByText('Preview')).toBeInTheDocument(); + await waitFor(() => { + expect(getByText('Details')).toBeInTheDocument(); + expect(getByText('Stats')).toBeInTheDocument(); + expect(getByText('JSON')).toBeInTheDocument(); + expect(getByText('Messages')).toBeInTheDocument(); + expect(getByText('Preview')).toBeInTheDocument(); - const tabContent = getByTestId('transformDetailsTabContent'); - expect(tabContent).toBeInTheDocument(); + const tabContent = getByTestId('transformDetailsTabContent'); + expect(tabContent).toBeInTheDocument(); - expect(getByTestId('transformDetailsTab')).toHaveAttribute('aria-selected', 'true'); - expect(within(tabContent).getByText('General')).toBeInTheDocument(); + expect(getByTestId('transformDetailsTab')).toHaveAttribute('aria-selected', 'true'); + expect(within(tabContent).getByText('General')).toBeInTheDocument(); + }); fireEvent.click(getByTestId('transformStatsTab')); - expect(getByTestId('transformStatsTab')).toHaveAttribute('aria-selected', 'true'); - expect(within(tabContent).getByText('Stats')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByTestId('transformStatsTab')).toHaveAttribute('aria-selected', 'true'); + const tabContent = getByTestId('transformDetailsTabContent'); + expect(within(tabContent).getByText('Stats')).toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx index 90487d21610ea..b7d5a2b7104ae 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx @@ -7,20 +7,26 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useActions } from './use_actions'; - jest.mock('../../../../../shared_imports'); jest.mock('../../../../../app/app_dependencies'); +import { useActions } from './use_actions'; + describe('Transform: Transform List Actions', () => { - test('useActions()', () => { - const { result } = renderHook(() => useActions({ forceDisable: false, transformNodes: 1 })); + test('useActions()', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useActions({ forceDisable: false, transformNodes: 1 }) + ); + + await waitForNextUpdate(); + const actions = result.current.actions; // Using `any` for the callback. Somehow the EUI types don't pass // on the `data-test-subj` attribute correctly. We're interested // in the runtime result here anyway. expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([ + 'transformActionDiscover', 'transformActionStart', 'transformActionStop', 'transformActionEdit', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index d9b9008490666..ddf41d356529a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -13,6 +13,7 @@ import { TransformListRow } from '../../../../common'; import { useCloneAction } from '../action_clone'; import { useDeleteAction, DeleteActionModal } from '../action_delete'; +import { useDiscoverAction } from '../action_discover'; import { EditTransformFlyout } from '../edit_transform_flyout'; import { useEditAction } from '../action_edit'; import { useStartAction, StartActionModal } from '../action_start'; @@ -30,6 +31,7 @@ export const useActions = ({ } => { const cloneAction = useCloneAction(forceDisable, transformNodes); const deleteAction = useDeleteAction(forceDisable); + const discoverAction = useDiscoverAction(forceDisable); const editAction = useEditAction(forceDisable, transformNodes); const startAction = useStartAction(forceDisable, transformNodes); const stopAction = useStopAction(forceDisable); @@ -45,6 +47,7 @@ export const useActions = ({ ), actions: [ + discoverAction.action, startAction.action, stopAction.action, editAction.action, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index 53eed01f1226d..f3974430b662c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -13,8 +13,11 @@ jest.mock('../../../../../shared_imports'); jest.mock('../../../../../app/app_dependencies'); describe('Transform: Job List Columns', () => { - test('useColumns()', () => { - const { result } = renderHook(() => useColumns([], () => {}, 1, [])); + test('useColumns()', async () => { + const { result, waitForNextUpdate } = renderHook(() => useColumns([], () => {}, 1, [])); + + await waitForNextUpdate(); + const columns: ReturnType['columns'] = result.current.columns; expect(columns).toHaveLength(7); diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 67abd8a7f1a78..b058be46d677b 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -7,11 +7,12 @@ import { i18n as kbnI18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { HomePublicPluginSetup } from 'src/plugins/home/public'; -import { SavedObjectsStart } from 'src/plugins/saved_objects/public'; -import { ManagementSetup } from '../../../../src/plugins/management/public'; +import type { CoreSetup } from 'src/core/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { HomePublicPluginSetup } from 'src/plugins/home/public'; +import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; +import type { ManagementSetup } from 'src/plugins/management/public'; +import type { SharePluginStart } from 'src/plugins/share/public'; import { registerFeature } from './register_feature'; export interface PluginsDependencies { @@ -19,6 +20,7 @@ export interface PluginsDependencies { management: ManagementSetup; home: HomePublicPluginSetup; savedObjects: SavedObjectsStart; + share: SharePluginStart; } export class TransformUiPlugin { diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index a720aec6bb478..61579ac68ae53 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -89,6 +89,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: true, expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "category.keyword": {'], pivotAdvancedEditorValue: { @@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) { ], }, ], + discoverQueryHits: '7,270', }, } as PivotTransformTestData, { @@ -247,6 +249,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: false, expected: { pivotAdvancedEditorValueArr: ['{', ' "group_by": {', ' "geoip.country_iso_code": {'], pivotAdvancedEditorValue: { @@ -294,6 +297,7 @@ export default function ({ getService }: FtrProviderContext) { rows: 5, }, histogramCharts: [], + discoverQueryHits: '10', }, } as PivotTransformTestData, { @@ -317,6 +321,7 @@ export default function ({ getService }: FtrProviderContext) { get destinationIndex(): string { return `user-${this.transformId}`; }, + discoverAdjustSuperDatePicker: true, expected: { latestPreview: { column: 0, @@ -342,6 +347,7 @@ export default function ({ getService }: FtrProviderContext) { 'July 12th 2019, 23:31:12', ], }, + discoverQueryHits: '10', }, } as LatestTransformTestData, ]; @@ -533,6 +539,26 @@ export default function ({ getService }: FtrProviderContext) { progress: testData.expected.row.progress, }); }); + + it('navigates to discover and displays results of the destination index', async () => { + await transform.testExecution.logTestStep('should show the actions popover'); + await transform.table.assertTransformRowActions(testData.transformId, false); + + await transform.testExecution.logTestStep('should navigate to discover'); + await transform.table.clickTransformRowAction('Discover'); + + if (testData.discoverAdjustSuperDatePicker) { + await transform.discover.assertNoResults(testData.destinationIndex); + await transform.testExecution.logTestStep( + 'should switch quick select lookback to years' + ); + await transform.discover.assertSuperDatePickerToggleQuickMenuButtonExists(); + await transform.discover.openSuperDatePicker(); + await transform.discover.quickSelectYears(); + } + + await transform.discover.assertDiscoverQueryHits(testData.expected.discoverQueryHits); + }); }); } }); diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 89ac903b16d01..ca82459c47f2f 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -66,6 +66,7 @@ export interface BaseTransformTestData { transformDescription: string; expected: any; destinationIndex: string; + discoverAdjustSuperDatePicker: boolean; } export interface PivotTransformTestData extends BaseTransformTestData { diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts new file mode 100644 index 0000000000000..a98f7e5ae9890 --- /dev/null +++ b/x-pack/test/functional/services/transform/discover.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function TransformDiscoverProvider({ getService }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + return { + async assertDiscoverQueryHits(expectedDiscoverQueryHits: string) { + await testSubjects.existOrFail('discoverQueryHits'); + + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); + + expect(actualDiscoverQueryHits).to.eql( + expectedDiscoverQueryHits, + `Discover query hits should be ${expectedDiscoverQueryHits}, got ${actualDiscoverQueryHits}` + ); + }, + + async assertNoResults(expectedDestinationIndex: string) { + // Discover should use the destination index pattern + const actualIndexPatternSwitchLinkText = await ( + await testSubjects.find('indexPattern-switch-link') + ).getVisibleText(); + expect(actualIndexPatternSwitchLinkText).to.eql( + expectedDestinationIndex, + `Destination index should be ${expectedDestinationIndex}, got ${actualIndexPatternSwitchLinkText}` + ); + + await testSubjects.existOrFail('discoverNoResults'); + }, + + async assertSuperDatePickerToggleQuickMenuButtonExists() { + await testSubjects.existOrFail('superDatePickerToggleQuickMenuButton'); + }, + + async openSuperDatePicker() { + await testSubjects.click('superDatePickerToggleQuickMenuButton'); + await testSubjects.existOrFail('superDatePickerQuickMenu'); + }, + + async quickSelectYears() { + const quickMenuElement = await testSubjects.find('superDatePickerQuickMenu'); + + // No test subject, select "Years" to look back 15 years instead of 15 minutes. + await find.selectValue(`[aria-label*="Time unit"]`, 'y'); + + // Apply + const applyButton = await quickMenuElement.findByClassName('euiQuickSelect__applyButton'); + const actualApplyButtonText = await applyButton.getVisibleText(); + expect(actualApplyButtonText).to.be('Apply'); + + await applyButton.click(); + await testSubjects.existOrFail('discoverQueryHits'); + }, + }; +} diff --git a/x-pack/test/functional/services/transform/index.ts b/x-pack/test/functional/services/transform/index.ts index 36265fb9369d3..c9179cc307aaf 100644 --- a/x-pack/test/functional/services/transform/index.ts +++ b/x-pack/test/functional/services/transform/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { TransformAPIProvider } from './api'; import { TransformEditFlyoutProvider } from './edit_flyout'; +import { TransformDiscoverProvider } from './discover'; import { TransformManagementProvider } from './management'; import { TransformNavigationProvider } from './navigation'; import { TransformSecurityCommonProvider } from './security_common'; @@ -22,6 +23,7 @@ import { MachineLearningTestResourcesProvider } from '../ml/test_resources'; export function TransformProvider(context: FtrProviderContext) { const api = TransformAPIProvider(context); + const discover = TransformDiscoverProvider(context); const editFlyout = TransformEditFlyoutProvider(context); const management = TransformManagementProvider(context); const navigation = TransformNavigationProvider(context); @@ -35,6 +37,7 @@ export function TransformProvider(context: FtrProviderContext) { return { api, + discover, editFlyout, management, navigation, diff --git a/x-pack/test/functional/services/transform/transform_table.ts b/x-pack/test/functional/services/transform/transform_table.ts index 17d4e56e0cdf9..cafaa2606f255 100644 --- a/x-pack/test/functional/services/transform/transform_table.ts +++ b/x-pack/test/functional/services/transform/transform_table.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +type TransformRowActionName = 'Clone' | 'Delete' | 'Edit' | 'Start' | 'Stop' | 'Discover'; + export function TransformTableProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); @@ -238,6 +240,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { await testSubjects.existOrFail('transformActionClone'); await testSubjects.existOrFail('transformActionDelete'); + await testSubjects.existOrFail('transformActionDiscover'); await testSubjects.existOrFail('transformActionEdit'); if (isTransformRunning) { @@ -251,7 +254,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { public async assertTransformRowActionEnabled( transformId: string, - action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit', + action: TransformRowActionName, expectedValue: boolean ) { const selector = `transformAction${action}`; @@ -274,7 +277,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { public async clickTransformRowActionWithRetry( transformId: string, - action: 'Delete' | 'Start' | 'Stop' | 'Clone' | 'Edit' + action: TransformRowActionName ) { await retry.tryForTime(30 * 1000, async () => { await browser.pressKeys(browser.keys.ESCAPE); @@ -285,7 +288,7 @@ export function TransformTableProvider({ getService }: FtrProviderContext) { }); } - public async clickTransformRowAction(action: string) { + public async clickTransformRowAction(action: TransformRowActionName) { await testSubjects.click(`transformAction${action}`); } From 8de766904d28da27ad707a920ade9510e86a2941 Mon Sep 17 00:00:00 2001 From: Andrea Del Rio Date: Tue, 27 Apr 2021 09:12:17 -0700 Subject: [PATCH 13/68] [K8] Small fixes (#98099) --- .../sidebar/discover_field_search.tsx | 4 +- .../public/lib/panel/_embeddable_panel.scss | 5 +- .../public/top_nav_menu/_index.scss | 10 +++- .../solution_toolbar/items/button.scss | 5 ++ .../solution_toolbar/items/quick_group.scss | 6 +++ .../home/components/filter_list_button.tsx | 46 ++++++++++--------- 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx index 67dda6dd0e9a8..e11c1716efe6b 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx @@ -233,7 +233,7 @@ export function DiscoverFieldSearch({ onChange, value, types, useNewFieldsApi }: const footer = () => { return ( - + - + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { defaultMessage: 'Filter by type', })} diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index f7ee1f3c741c4..9072c26576097 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -120,9 +120,10 @@ // EDITING MODE .embPanel--editing { - border-style: dashed; - border-color: $euiColorMediumShade; + border-style: dashed !important; + border-color: $euiColorMediumShade !important; transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + border-width: $euiBorderWidthThin; &:hover, &:focus { diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index bc27cf061eb68..9af1bb5434bb1 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,5 +1,13 @@ .kbnTopNavMenu { - margin-right: $euiSizeXS; + @include kbnThemeStyle('v7') { + margin-right: $euiSizeXS; + } + + @include kbnThemeStyle('v8') { + button:last-child { + margin-right: 0; + } + } } .kbnTopNavMenu__badgeWrapper { diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index b8022201acf59..4fc3651ee9f73 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -4,4 +4,9 @@ // Lighten the border color for all states border-color: $euiBorderColor !important; // sass-lint:disable-line no-important + + @include kbnThemeStyle('v8') { + border-width: $euiBorderWidthThin; + border-style: solid; + } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 870a9a945ed5d..876ee659b71d7 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -1,6 +1,12 @@ .quickButtonGroup { .quickButtonGroup__button { background-color: $euiColorEmptyShade; + @include kbnThemeStyle('v8') { + // sass-lint:disable-block no-important + border-width: $euiBorderWidthThin !important; + border-style: solid !important; + border-color: $euiBorderColor !important; + } } // Temporary fix for two tone icons to make them monochrome diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx index 44f565f98cdb0..4bd9a01380c0e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFilterButton, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; interface Filter { name: string; @@ -65,26 +65,28 @@ export function FilterListButton({ onChange, filters }: Props< ); return ( - -
- {Object.entries(filters).map(([filter, item], index) => ( - toggleFilter(filter as T)} - data-test-subj="filterItem" - > - {(item as Filter).name} - - ))} -
-
+ + +
+ {Object.entries(filters).map(([filter, item], index) => ( + toggleFilter(filter as T)} + data-test-subj="filterItem" + > + {(item as Filter).name} + + ))} +
+
+
); } From aa281ffad7c0b1808154e00b937251f36e2ff76a Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 27 Apr 2021 09:36:27 -0700 Subject: [PATCH 14/68] [Metrics UI] Unskip Home Page Functional Test (#98085) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../test/functional/apps/infra/home_page.ts | 48 +------------------ x-pack/test/functional/apps/infra/index.ts | 20 ++++---- x-pack/test/functional/apps/infra/link_to.ts | 2 +- 3 files changed, 14 insertions(+), 56 deletions(-) diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index a5b8e69833fb6..1cc7c87f3a1a8 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -5,24 +5,17 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; import { DATES } from './constants'; const DATE_WITH_DATA = DATES.metricsAndLogs.hosts.withData; const DATE_WITHOUT_DATA = DATES.metricsAndLogs.hosts.withoutData; -const COMMON_REQUEST_HEADERS = { - 'kbn-xsrf': 'some-xsrf-token', -}; - export default ({ getPageObjects, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const pageObjects = getPageObjects(['common', 'infraHome']); - const supertest = getService('supertest'); - // FLAKY: https://github.com/elastic/kibana/issues/75724 - describe.skip('Home page', function () { + describe('Home page', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('empty_kibana'); @@ -54,45 +47,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHome.goToTime(DATE_WITHOUT_DATA); await pageObjects.infraHome.getNoMetricsDataPrompt(); }); - - it('records telemetry for hosts', async () => { - await pageObjects.infraHome.goToTime(DATE_WITH_DATA); - await pageObjects.infraHome.getWaffleMap(); - - const resp = await supertest - .post(`/api/telemetry/v2/clusters/_stats`) - .set(COMMON_REQUEST_HEADERS) - .set('Accept', 'application/json') - .send({ - unencrypted: true, - }) - .expect(200) - .then((res: any) => res.body); - - expect( - resp[0].stack_stats.kibana.plugins.infraops.last_24_hours.hits.infraops_hosts - ).to.be.greaterThan(0); - }); - - it('records telemetry for docker', async () => { - await pageObjects.infraHome.goToTime(DATE_WITH_DATA); - await pageObjects.infraHome.getWaffleMap(); - await pageObjects.infraHome.goToDocker(); - - const resp = await supertest - .post(`/api/telemetry/v2/clusters/_stats`) - .set(COMMON_REQUEST_HEADERS) - .set('Accept', 'application/json') - .send({ - unencrypted: true, - }) - .expect(200) - .then((res: any) => res.body); - - expect( - resp[0].stack_stats.kibana.plugins.infraops.last_24_hours.hits.infraops_docker - ).to.be.greaterThan(0); - }); }); }); }; diff --git a/x-pack/test/functional/apps/infra/index.ts b/x-pack/test/functional/apps/infra/index.ts index 9c828253245d0..8cdcf6b619b26 100644 --- a/x-pack/test/functional/apps/infra/index.ts +++ b/x-pack/test/functional/apps/infra/index.ts @@ -8,15 +8,19 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext) => { - describe('InfraOps app', function () { + describe('InfraOps App', function () { this.tags('ciGroup7'); - loadTestFile(require.resolve('./metrics_anomalies')); - loadTestFile(require.resolve('./home_page')); loadTestFile(require.resolve('./feature_controls')); - loadTestFile(require.resolve('./log_entry_categories_tab')); - loadTestFile(require.resolve('./log_entry_rate_tab')); - loadTestFile(require.resolve('./logs_source_configuration')); - loadTestFile(require.resolve('./metrics_source_configuration')); - loadTestFile(require.resolve('./link_to')); + describe('Metrics UI', function () { + loadTestFile(require.resolve('./home_page')); + loadTestFile(require.resolve('./metrics_source_configuration')); + loadTestFile(require.resolve('./metrics_anomalies')); + }); + describe('Logs UI', function () { + loadTestFile(require.resolve('./log_entry_categories_tab')); + loadTestFile(require.resolve('./log_entry_rate_tab')); + loadTestFile(require.resolve('./logs_source_configuration')); + loadTestFile(require.resolve('./link_to')); + }); }); }; diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index b7a554cea311f..3e070fb9849b1 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -22,7 +22,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const traceId = '433b4651687e18be2c6c8e3b11f53d09'; - describe('Infra link-to', function () { + describe('link-to Logs', function () { it('redirects to the logs app and parses URL search params correctly', async () => { const location = { hash: '', From 52a90e3dc9a19e7c211d13fd5c672634b3f51526 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 27 Apr 2021 13:04:08 -0400 Subject: [PATCH 15/68] [Fleet] Use default port for fleet server cloud url (#98492) --- .../fleet/server/services/settings.test.ts | 18 +++++++++++++++++- .../plugins/fleet/server/services/settings.ts | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/settings.test.ts b/x-pack/plugins/fleet/server/services/settings.test.ts index a9f9600addc39..87b3e163c1bb3 100644 --- a/x-pack/plugins/fleet/server/services/settings.test.ts +++ b/x-pack/plugins/fleet/server/services/settings.test.ts @@ -17,7 +17,7 @@ describe('getCloudFleetServersHosts', () => { expect(getCloudFleetServersHosts()).toBeUndefined(); }); - it('should return fleet server hosts if cloud is correctly setup', () => { + it('should return fleet server hosts if cloud is correctly setup with default port == 443', () => { mockedAppContextService.getCloud.mockReturnValue({ cloudId: 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', @@ -32,4 +32,20 @@ describe('getCloudFleetServersHosts', () => { ] `); }); + + it('should return fleet server hosts if cloud is correctly setup with a default port', () => { + mockedAppContextService.getCloud.mockReturnValue({ + cloudId: + 'test:dGVzdC5mcjo5MjQzJGRhM2I2YjNkYWY5ZDRjODE4ZjI4ZmEzNDdjMzgzODViJDgxMmY4NWMxZjNjZTQ2YTliYjgxZjFjMWIxMzRjNmRl', + isCloudEnabled: true, + deploymentId: 'deployment-id-1', + apm: {}, + }); + + expect(getCloudFleetServersHosts()).toMatchInlineSnapshot(` + Array [ + "https://deployment-id-1.fleet.test.fr:9243", + ] + `); + }); }); diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 4ef9a3a95cbd0..2046e2571c926 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -84,6 +84,10 @@ export function getCloudFleetServersHosts() { } // Fleet Server url are formed like this `https://.fleet. - return [`https://${cloudSetup.deploymentId}.fleet.${res.host}`]; + return [ + `https://${cloudSetup.deploymentId}.fleet.${res.host}${ + res.defaultPort !== '443' ? `:${res.defaultPort}` : '' + }`, + ]; } } From 808959e316082718c1c1081c362fbc6c91f2fdbc Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 27 Apr 2021 19:09:54 +0200 Subject: [PATCH 16/68] Handle `401 Unauthorized` errors in a more user-friendly way (#94927) --- .eslintrc.js | 4 + test/common/services/security/user.ts | 30 +- test/scripts/jenkins_xpack_build_plugins.sh | 1 + .../plugins/reporting/server/routes/jobs.ts | 2 + x-pack/plugins/security/common/constants.ts | 15 + .../capture_url/capture_url_app.test.ts | 63 ++- .../capture_url/capture_url_app.ts | 45 +- .../authentication/login/components/index.ts | 2 +- .../login/components/login_form/index.ts | 2 +- .../components/login_form/login_form.test.tsx | 6 +- .../components/login_form/login_form.tsx | 14 +- .../authentication/login/login_page.test.tsx | 9 +- .../authentication/login/login_page.tsx | 34 +- .../__snapshots__/prompt_page.test.tsx.snap | 5 + .../unauthenticated_page.test.tsx.snap | 3 + .../authentication_service.test.mocks.ts | 9 + .../authentication_service.test.ts | 458 ++++++++++++++++-- .../authentication/authentication_service.ts | 72 ++- .../authentication/authenticator.test.ts | 72 ++- .../server/authentication/authenticator.ts | 41 +- .../can_redirect_request.test.ts | 30 ++ .../authentication/can_redirect_request.ts | 7 +- .../authentication/providers/base.mock.ts | 1 + .../server/authentication/providers/base.ts | 4 + .../authentication/providers/oidc.test.ts | 76 ++- .../server/authentication/providers/oidc.ts | 54 ++- .../authentication/providers/saml.test.ts | 63 ++- .../server/authentication/providers/saml.ts | 55 ++- .../security/server/authentication/tokens.ts | 10 +- .../unauthenticated_page.test.tsx | 35 ++ .../authentication/unauthenticated_page.tsx | 55 +++ .../reset_session_page.test.tsx.snap | 2 +- .../authorization/authorization_service.tsx | 24 +- .../authorization/reset_session_page.test.tsx | 10 +- .../authorization/reset_session_page.tsx | 120 ++--- x-pack/plugins/security/server/index.ts | 1 + x-pack/plugins/security/server/plugin.ts | 7 +- .../security/server/prompt_page.test.tsx | 57 +++ .../plugins/security/server/prompt_page.tsx | 96 ++++ .../routes/authentication/common.test.ts | 6 +- .../server/routes/authentication/common.ts | 3 +- .../server/routes/authentication/oidc.ts | 17 +- .../server/routes/authentication/saml.test.ts | 7 +- .../server/routes/authentication/saml.ts | 16 +- x-pack/plugins/security/server/routes/tags.ts | 27 ++ .../server/routes/views/capture_url.test.ts | 42 +- .../server/routes/views/capture_url.ts | 6 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apis/security/basic_login.js | 4 +- .../test/functional/apps/security/security.ts | 2 +- .../tests/anonymous/login.ts | 20 +- .../tests/kerberos/kerberos_login.ts | 14 +- .../login_selector/basic_functionality.ts | 61 ++- .../oidc/authorization_code_flow/oidc_auth.ts | 157 +++--- .../tests/oidc/implicit_flow/oidc_auth.ts | 18 +- .../tests/pki/pki_auth.ts | 16 +- .../tests/saml/saml_login.ts | 161 +++--- .../common/test_endpoints/kibana.json | 7 + .../common/test_endpoints/public/index.ts | 9 + .../common/test_endpoints/public/plugin.tsx | 29 ++ .../common/test_endpoints/server/index.ts | 15 + .../test_endpoints/server/init_routes.ts | 38 ++ .../login_selector.config.ts | 3 + .../test/security_functional/oidc.config.ts | 3 + .../test/security_functional/saml.config.ts | 3 + .../login_selector/basic_functionality.ts | 60 +++ .../tests/oidc/url_capture.ts | 21 + .../tests/saml/url_capture.ts | 21 + 69 files changed, 1837 insertions(+), 545 deletions(-) create mode 100644 x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap create mode 100644 x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap create mode 100644 x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts create mode 100644 x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx create mode 100644 x-pack/plugins/security/server/authentication/unauthenticated_page.tsx create mode 100644 x-pack/plugins/security/server/prompt_page.test.tsx create mode 100644 x-pack/plugins/security/server/prompt_page.tsx create mode 100644 x-pack/plugins/security/server/routes/tags.ts create mode 100644 x-pack/test/security_functional/fixtures/common/test_endpoints/kibana.json create mode 100644 x-pack/test/security_functional/fixtures/common/test_endpoints/public/index.ts create mode 100644 x-pack/test/security_functional/fixtures/common/test_endpoints/public/plugin.tsx create mode 100644 x-pack/test/security_functional/fixtures/common/test_endpoints/server/index.ts create mode 100644 x-pack/test/security_functional/fixtures/common/test_endpoints/server/init_routes.ts diff --git a/.eslintrc.js b/.eslintrc.js index 19ba7cacc3c44..0f7af42fafbfd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1377,6 +1377,10 @@ module.exports = { ['parent', 'sibling', 'index'], ], pathGroups: [ + { + pattern: '{**,.}/*.test.mocks', + group: 'unknown', + }, { pattern: '{@kbn/**,src/**,kibana{,/**}}', group: 'internal', diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index 3bd31bb5ed186..d6813105ecbf6 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -33,7 +33,7 @@ export class User { public async delete(username: string) { this.log.debug(`deleting user ${username}`); - const { data, status, statusText } = await await this.kbnClient.request({ + const { data, status, statusText } = await this.kbnClient.request({ path: `/internal/security/users/${username}`, method: 'DELETE', }); @@ -44,4 +44,32 @@ export class User { } this.log.debug(`deleted user ${username}`); } + + public async disable(username: string) { + this.log.debug(`disabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_disable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`disabled user ${username}`); + } + + public async enable(username: string) { + this.log.debug(`enabling user ${username}`); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${encodeURIComponent(username)}/_enable`, + method: 'POST', + }); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`enabled user ${username}`); + } } diff --git a/test/scripts/jenkins_xpack_build_plugins.sh b/test/scripts/jenkins_xpack_build_plugins.sh index 496964983cc6c..cb0b5ec1d56da 100755 --- a/test/scripts/jenkins_xpack_build_plugins.sh +++ b/test/scripts/jenkins_xpack_build_plugins.sh @@ -13,6 +13,7 @@ node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \ --scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \ --scan-dir "$XPACK_DIR/test/usage_collection/plugins" \ + --scan-dir "$XPACK_DIR/test/security_functional/fixtures/common" \ --scan-dir "$KIBANA_DIR/examples" \ --scan-dir "$XPACK_DIR/examples" \ --workers 12 \ diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index 7141b1a141185..3f2a95a34224c 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; +import { ROUTE_TAG_CAN_REDIRECT } from '../../../security/server'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; @@ -198,6 +199,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { docId: schema.string({ minLength: 3 }), }), }, + options: { tags: [ROUTE_TAG_CAN_REDIRECT] }, }, userHandler(async (user, context, req, res) => { // ensure the async dependencies are loaded diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a205109f537e7..ef83230fc2aba 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -19,7 +19,22 @@ export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; +/** + * This is the key of a query parameter that contains the name of the authentication provider that should be used to + * authenticate request. It's also used while the user is being redirected during single-sign-on authentication flows. + * That query parameter is discarded after the authentication flow succeeds. See the `Authenticator`, + * `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for more information. + */ export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint'; + +/** + * This is the key of a query parameter that contains metadata about the (client-side) URL hash while the user is being + * redirected during single-sign-on authentication flows. That query parameter is discarded after the authentication + * flow succeeds. See the `Authenticator`, `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for + * more information. + */ +export const AUTH_URL_HASH_QUERY_STRING_PARAMETER = 'auth_url_hash'; + export const LOGOUT_PROVIDER_QUERY_STRING_PARAMETER = 'provider'; export const LOGOUT_REASON_QUERY_STRING_PARAMETER = 'msg'; export const NEXT_URL_QUERY_STRING_PARAMETER = 'next'; diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts index bf68d9f7a6e5e..44fd5ab195341 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -5,19 +5,29 @@ * 2.0. */ -import type { AppMount, ScopedHistory } from 'src/core/public'; -import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; +import { coreMock } from 'src/core/public/mocks'; import { captureURLApp } from './capture_url_app'; describe('captureURLApp', () => { + let mockLocationReplace: jest.Mock; beforeAll(() => { + mockLocationReplace = jest.fn(); Object.defineProperty(window, 'location', { - value: { href: 'https://some-host' }, + value: { + href: 'https://some-host', + hash: '#/?_g=()', + origin: 'https://some-host', + replace: mockLocationReplace, + }, writable: true, }); }); + beforeEach(() => { + mockLocationReplace.mockClear(); + }); + it('properly registers application', () => { const coreSetupMock = coreMock.createSetup(); @@ -42,34 +52,37 @@ describe('captureURLApp', () => { it('properly handles captured URL', async () => { window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`; + '/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; const coreSetupMock = coreMock.createSetup(); - coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' }); - captureURLApp.create(coreSetupMock); const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - await (mount as AppMount)({ - element: document.createElement('div'), - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, - }); + await mount(coreMock.createAppMountParamters()); - expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1); - expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ - providerType: 'saml', - providerName: 'saml1', - currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( - '/mock-base-path/app/home' - )}&providerType=saml&providerName=saml1#/?_g=()`, - }), - }); + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/mock-base-path/app/home?auth_provider_hint=saml1&auth_url_hash=%23%2F%3F_g%3D%28%29#/?_g=()' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); + }); - expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()'); + it('properly handles open redirects', async () => { + window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + 'https://evil.com/mock-base-path/app/home?auth_provider_hint=saml1' + )}#/?_g=()`; + + const coreSetupMock = coreMock.createSetup(); + captureURLApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await mount(coreMock.createAppMountParamters()); + + expect(mockLocationReplace).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith( + 'https://some-host/?auth_url_hash=%23%2F%3F_g%3D%28%29' + ); + expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts index 7797ce4e62102..af45314c5bacb 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { parse } from 'url'; - import type { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; +import { AUTH_URL_HASH_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { parseNext } from '../../../common/parse_next'; + interface CreateDeps { application: ApplicationSetup; http: HttpSetup; @@ -22,20 +23,17 @@ interface CreateDeps { * path segment into the `next` query string parameter (so that it's not lost during redirect). And * since browsers preserve hash fragments during redirects (assuming redirect location doesn't * specify its own hash fragment, which is true in our case) this app can capture both path and - * hash URL segments and send them back to the authentication provider via login endpoint. + * hash URL segments and re-try request sending hash fragment in a dedicated query string parameter. * * The flow can look like this: - * 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication. - * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`. - * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`. - * 4. The app captures full URL and sends it back as is via login endpoint: - * { - * providerType: 'saml', - * providerName: 'saml1', - * currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch' - * } - * 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment - * and finally passes it to the provider that initiated capturing. + * 1. User visits `https://kibana.com/app/kibana#/management/elasticsearch` that initiates authentication. + * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1`. + * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1#/management/elasticsearch`. + * 4. The app reconstructs original URL, adds `auth_url_hash` query string parameter with the captured hash fragment and redirects user to: + * https://kibana.com/app/kibana?auth_provider_hint=saml1&auth_url_hash=%23%2Fmanagement%2Felasticsearch#/management/elasticsearch + * 5. Once Kibana receives this request, it immediately picks exactly the same provider to handle authentication (based on `auth_provider_hint=saml1`), + * and, since it has full URL now (original request path, query string and hash extracted from `auth_url_hash=%23%2Fmanagement%2Felasticsearch`), + * it can proceed to a proper authentication handshake. */ export const captureURLApp = Object.freeze({ id: 'security_capture_url', @@ -48,19 +46,14 @@ export const captureURLApp = Object.freeze({ appRoute: '/internal/security/capture-url', async mount() { try { - const { providerName, providerType } = parse(window.location.href, true).query ?? {}; - if (!providerName || !providerType) { - fatalErrors.add(new Error('Provider to capture URL for is not specified.')); - return () => {}; - } - - const { location } = await http.post<{ location: string }>('/internal/security/login', { - body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }), - }); - - window.location.href = location; + const url = new URL( + parseNext(window.location.href, http.basePath.serverBasePath), + window.location.origin + ); + url.searchParams.append(AUTH_URL_HASH_QUERY_STRING_PARAMETER, window.location.hash); + window.location.replace(url.toString()); } catch (err) { - fatalErrors.add(new Error('Cannot login with captured URL.')); + fatalErrors.add(new Error(`Cannot parse current URL: ${err && err.message}.`)); } return () => {}; diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 66e91a390784a..63928e4e82e37 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, LoginFormMessageType } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 6215f4e1e5b7a..d12ea30c784cb 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { LoginForm } from './login_form'; +export { LoginForm, MessageType as LoginFormMessageType } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index f58150d4580b8..e816fa032a0e5 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -14,7 +14,7 @@ import ReactMarkdown from 'react-markdown'; import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest'; import { coreMock } from 'src/core/public/mocks'; -import { LoginForm, PageMode } from './login_form'; +import { LoginForm, MessageType, PageMode } from './login_form'; function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { const assertions: Array<[string, boolean]> = @@ -90,7 +90,7 @@ describe('LoginForm', () => { { }); expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` + `Username or password is incorrect. Please try again.` ); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index ca573ada36d22..df131e2eac133 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -40,7 +40,7 @@ interface Props { http: HttpStart; notifications: NotificationsStart; selector: LoginSelector; - infoMessage?: string; + message?: { type: MessageType.Danger | MessageType.Info; content: string }; loginAssistanceMessage: string; loginHelp?: string; authProviderHint?: string; @@ -66,7 +66,7 @@ enum LoadingStateType { AutoLogin, } -enum MessageType { +export enum MessageType { None, Info, Danger, @@ -106,9 +106,7 @@ export class LoginForm extends Component { loadingState: { type: LoadingStateType.None }, username: '', password: '', - message: this.props.infoMessage - ? { type: MessageType.Info, content: this.props.infoMessage } - : { type: MessageType.None }, + message: this.props.message || { type: MessageType.None }, mode, previousMode: mode, }; @@ -206,7 +204,7 @@ export class LoginForm extends Component { > @@ -480,8 +478,8 @@ export class LoginForm extends Component { const message = (error as IHttpFetchError).response?.status === 401 ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } + 'xpack.security.login.basicLoginForm.usernameOrPasswordIsIncorrectErrorMessage', + { defaultMessage: 'Username or password is incorrect. Please try again.' } ) : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { defaultMessage: 'Oops! Error. Try again.', diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index a9596aff3bf0e..b3e2fac3ea2cc 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -14,7 +14,7 @@ import { coreMock } from 'src/core/public/mocks'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; import { LoginPage } from './login_page'; const createLoginState = (options?: Partial) => { @@ -228,9 +228,12 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props(); + const { authProviderHint, message } = wrapper.find(LoginForm).props(); expect(authProviderHint).toBe('basic1'); - expect(infoMessage).toBe('Your session has timed out. Please log in again.'); + expect(message).toEqual({ + type: LoginFormMessageType.Info, + content: 'Your session has timed out. Please log in again.', + }); }); it('renders as expected when loginAssistanceMessage is set', async () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index 562adec7918d3..40438ac1c78f3 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -23,7 +23,7 @@ import { LOGOUT_REASON_QUERY_STRING_PARAMETER, } from '../../../common/constants'; import type { LoginState } from '../../../common/login_state'; -import { DisabledLoginForm, LoginForm } from './components'; +import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components'; interface Props { http: HttpStart; @@ -36,18 +36,34 @@ interface State { loginState: LoginState | null; } -const infoMessageMap = new Map([ +const messageMap = new Map([ [ 'SESSION_EXPIRED', - i18n.translate('xpack.security.login.sessionExpiredDescription', { - defaultMessage: 'Your session has timed out. Please log in again.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.sessionExpiredDescription', { + defaultMessage: 'Your session has timed out. Please log in again.', + }), + }, ], [ 'LOGGED_OUT', - i18n.translate('xpack.security.login.loggedOutDescription', { - defaultMessage: 'You have logged out of Elastic.', - }), + { + type: LoginFormMessageType.Info, + content: i18n.translate('xpack.security.login.loggedOutDescription', { + defaultMessage: 'You have logged out of Elastic.', + }), + }, + ], + [ + 'UNAUTHENTICATED', + { + type: LoginFormMessageType.Danger, + content: i18n.translate('xpack.security.unauthenticated.errorDescription', { + defaultMessage: + "We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.", + }), + }, ], ]); @@ -226,7 +242,7 @@ export class LoginPage extends Component { notifications={this.props.notifications} selector={selector} // @ts-expect-error Map.get is ok with getting `undefined` - infoMessage={infoMessageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} + message={messageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())} loginAssistanceMessage={this.props.loginAssistanceMessage} loginHelp={loginHelp} authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()} diff --git a/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap new file mode 100644 index 0000000000000..bcb97538b4f05 --- /dev/null +++ b/x-pack/plugins/security/server/__snapshots__/prompt_page.test.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromptPage renders as expected with additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; + +exports[`PromptPage renders as expected without additional scripts 1`] = `"ElasticMockedFonts

Some Title

Some Body
Action#1
Action#2
"`; diff --git a/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap new file mode 100644 index 0000000000000..55168401992f7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/__snapshots__/unauthenticated_page.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UnauthenticatedPage renders as expected 1`] = `"ElasticMockedFonts

We couldn't log you in

We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.

"`; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts new file mode 100644 index 0000000000000..12a63134f4ef2 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockCanRedirectRequest = jest.fn(); +jest.mock('./can_redirect_request', () => ({ canRedirectRequest: mockCanRedirectRequest })); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts index b0be9445c3fc3..d38f963a60c33 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -6,6 +6,9 @@ */ jest.mock('./authenticator'); +jest.mock('./unauthenticated_page'); + +import { mockCanRedirectRequest } from './authentication_service.test.mocks'; import Boom from '@hapi/boom'; @@ -18,6 +21,7 @@ import type { KibanaRequest, Logger, LoggerFactory, + OnPreResponseToolkit, } from 'src/core/server'; import { coreMock, @@ -37,6 +41,7 @@ import type { ConfigType } from '../config'; import { ConfigSchema, createConfig } from '../config'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { sessionMock } from '../session_management/session.mock'; import { AuthenticationResult } from './authentication_result'; @@ -47,15 +52,60 @@ describe('AuthenticationService', () => { let logger: jest.Mocked; let mockSetupAuthenticationParams: { http: jest.Mocked; + config: ConfigType; license: jest.Mocked; + buildNumber: number; + }; + let mockStartAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: ReturnType; + featureUsageService: jest.Mocked; + session: jest.Mocked>; }; beforeEach(() => { logger = loggingSystemMock.createLogger(); + const httpMock = coreMock.createSetup().http; + (httpMock.basePath.prepend as jest.Mock).mockImplementation( + (path) => `${httpMock.basePath.serverBasePath}${path}` + ); + (httpMock.basePath.get as jest.Mock).mockImplementation(() => httpMock.basePath.serverBasePath); mockSetupAuthenticationParams = { - http: coreMock.createSetup().http, + http: httpMock, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.create().get(), { + isTLSEnabled: false, + }), license: licenseMock.create(), + buildNumber: 100500, }; + mockCanRedirectRequest.mockReturnValue(false); + + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + loggers: loggingSystemMock.create(), + featureUsageService: securityFeatureUsageServiceMock.createStartContract(), + session: sessionMock.create(), + }; + (mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation( + () => mockStartAuthenticationParams.http.basePath.serverBasePath + ); service = new AuthenticationService(logger); }); @@ -71,40 +121,19 @@ describe('AuthenticationService', () => { expect.any(Function) ); }); + + it('properly registers onPreResponse handler', () => { + service.setup(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledWith( + expect.any(Function) + ); + }); }); describe('#start()', () => { - let mockStartAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; - http: jest.Mocked; - clusterClient: ReturnType; - featureUsageService: jest.Mocked; - session: jest.Mocked>; - }; beforeEach(() => { - const coreStart = coreMock.createStart(); - mockStartAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - http: coreStart.http, - clusterClient: elasticsearchServiceMock.createClusterClient(), - loggers: loggingSystemMock.create(), - featureUsageService: securityFeatureUsageServiceMock.createStartContract(), - session: sessionMock.create(), - }; - service.setup(mockSetupAuthenticationParams); }); @@ -318,4 +347,371 @@ describe('AuthenticationService', () => { }); }); }); + + describe('onPreResponse handler', () => { + function getService({ runStart = true }: { runStart?: boolean } = {}) { + service.setup(mockSetupAuthenticationParams); + + if (runStart) { + service.start(mockStartAuthenticationParams); + } + + const onPreResponseHandler = + mockSetupAuthenticationParams.http.registerOnPreResponse.mock.calls[0][0]; + const [authenticator] = jest.requireMock('./authenticator').Authenticator.mock.instances; + + return { authenticator, onPreResponseHandler }; + } + + it('ignores responses with non-401 status code', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + + const { onPreResponseHandler } = getService(); + for (const statusCode of [200, 400, 403, 404]) { + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + } + }); + + it('ignores responses to requests that cannot handle redirects', async () => { + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(false); + + const { onPreResponseHandler } = getService(); + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + it('ignores responses if authenticator is not initialized', async () => { + // Run `setup`, but not `start` to simulate non-initialized `Authenticator`. + const { onPreResponseHandler } = getService({ runStart: false }); + + const mockReturnedValue = { type: 'next' as any }; + const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest(), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + }); + + describe('when login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + }); + + it('redirects to the login page when user does not have an active session', async () => { + mockCanRedirectRequest.mockReturnValue(true); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when login selector is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { + providers: { + saml: { saml1: { order: 0, realm: 'saml1' } }, + basic: { basic1: { order: 1 } }, + }, + }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('redirects to the login page when user does not have an active session', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('performs logout if user has an active session', async () => { + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome', + }, + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: '
', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + Refresh: + '0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F', + }, + }); + }); + }); + + describe('when neither login selector nor login form is available', () => { + let mockReturnedValue: { type: any; body: string }; + let mockOnPreResponseToolkit: jest.Mocked; + beforeEach(() => { + mockReturnedValue = { type: 'render' as any, body: 'body' }; + mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit(); + mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue); + + mockSetupAuthenticationParams.config = createConfig( + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0, realm: 'saml1' } } } }, + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ); + }); + + it('renders unauthenticated page if user does not have an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('renders unauthenticated page if user has an active session', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/app/some', + }); + }); + + it('does not preserve path for the authentication flow paths', async () => { + const mockRenderUnauthorizedPage = jest + .requireMock('./unauthenticated_page') + .renderUnauthenticatedPage.mockReturnValue('rendered-view'); + + const { authenticator, onPreResponseHandler } = getService(); + authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some'); + mockCanRedirectRequest.mockReturnValue(true); + + await expect( + onPreResponseHandler( + httpServerMock.createKibanaRequest({ + path: '/api/security/saml/callback', + query: { param: 'one two' }, + routeTags: [ROUTE_TAG_AUTH_FLOW], + }), + { statusCode: 401 }, + mockOnPreResponseToolkit + ) + ).resolves.toBe(mockReturnedValue); + + expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({ + body: 'rendered-view', + headers: { + 'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`, + }, + }); + + expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({ + basePath: mockSetupAuthenticationParams.http.basePath, + buildNumber: 100500, + originalURL: '/mock-server-basepath/', + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index 7feeff7a5d8ed..e5895422e7a74 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -15,22 +15,29 @@ import type { LoggerFactory, } from 'src/core/server'; +import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../common/constants'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticatedUser } from '../../common/model'; +import { shouldProviderUseLoginForm } from '../../common/model'; import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import type { ConfigType } from '../config'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags'; import type { Session } from '../session_management'; import { APIKeys } from './api_keys'; import type { AuthenticationResult } from './authentication_result'; import type { ProviderLoginAttempt } from './authenticator'; import { Authenticator } from './authenticator'; +import { canRedirectRequest } from './can_redirect_request'; import type { DeauthenticationResult } from './deauthentication_result'; +import { renderUnauthenticatedPage } from './unauthenticated_page'; interface AuthenticationServiceSetupParams { - http: Pick; + http: Pick; + config: ConfigType; license: SecurityLicense; + buildNumber: number; } interface AuthenticationServiceStartParams { @@ -62,12 +69,23 @@ export interface AuthenticationServiceStart { export class AuthenticationService { private license!: SecurityLicense; private authenticator?: Authenticator; + private session?: PublicMethodsOf; constructor(private readonly logger: Logger) {} - setup({ http, license }: AuthenticationServiceSetupParams) { + setup({ config, http, license, buildNumber }: AuthenticationServiceSetupParams) { this.license = license; + // If we cannot automatically authenticate users we should redirect them straight to the login + // page if possible, so that they can try other methods to log in. If not possible, we should + // render a dedicated `Unauthenticated` page from which users can explicitly trigger a new + // login attempt. There are two cases when we can redirect to the login page: + // 1. Login selector is enabled + // 2. Login selector is disabled, but the provider with the lowest `order` uses login form + const isLoginPageAvailable = + config.authc.selector.enabled || + shouldProviderUseLoginForm(config.authc.sortedProviders[0].type); + http.registerAuth(async (request, response, t) => { if (!license.isLicenseAvailable()) { this.logger.error('License is not available, authentication is not possible.'); @@ -118,8 +136,9 @@ export class AuthenticationService { } if (authenticationResult.failed()) { - this.logger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); const error = authenticationResult.error!; + this.logger.info(`Authentication attempt failed: ${getDetailedErrorMessage(error)}`); + // proxy Elasticsearch "native" errors const statusCode = getErrorStatusCode(error); if (typeof statusCode === 'number') { @@ -139,7 +158,49 @@ export class AuthenticationService { return t.notHandled(); }); - this.logger.debug('Successfully registered core authentication handler.'); + http.registerOnPreResponse(async (request, preResponse, toolkit) => { + if (preResponse.statusCode !== 401 || !canRedirectRequest(request)) { + return toolkit.next(); + } + + if (!this.authenticator) { + // Core doesn't allow returning error here. + this.logger.error('Authentication sub-system is not fully initialized yet.'); + return toolkit.next(); + } + + // If users can eventually re-login we want to redirect them directly to the page they tried + // to access initially, but we only want to do that for routes that aren't part of the various + // authentication flows that wouldn't make any sense after successful authentication. + const originalURL = !request.route.options.tags.includes(ROUTE_TAG_AUTH_FLOW) + ? this.authenticator.getRequestOriginalURL(request) + : `${http.basePath.get(request)}/`; + if (!isLoginPageAvailable) { + return toolkit.render({ + body: renderUnauthenticatedPage({ buildNumber, basePath: http.basePath, originalURL }), + headers: { 'Content-Security-Policy': http.csp.header }, + }); + } + + const needsToLogout = (await this.session?.getSID(request)) !== undefined; + if (needsToLogout) { + this.logger.warn('Could not authenticate user with the existing session. Forcing logout.'); + } + + return toolkit.render({ + body: '
', + headers: { + 'Content-Security-Policy': http.csp.header, + Refresh: `0;url=${http.basePath.prepend( + `${ + needsToLogout ? '/logout' : '/login' + }?msg=UNAUTHENTICATED&${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + originalURL + )}` + )}`, + }, + }); + }); } start({ @@ -161,6 +222,7 @@ export class AuthenticationService { const getCurrentUser = (request: KibanaRequest) => http.auth.get(request).state ?? null; + this.session = session; this.authenticator = new Authenticator({ legacyAuditLogger, audit, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 1bd430d0c5c98..ca33be92e9e99 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -20,6 +20,10 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../common/constants'; import type { SecurityLicenseFeatures } from '../../common/licensing'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; @@ -1780,13 +1784,13 @@ describe('Authenticator', () => { ); }); - it('returns `notHandled` if session does not exist.', async () => { + it('redirects to login form if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); @@ -1843,12 +1847,12 @@ describe('Authenticator', () => { expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); }); - it('returns `notHandled` if session does not exist and provider name is invalid', async () => { + it('redirects to login form if session does not exist and provider name is invalid', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); @@ -1937,4 +1941,64 @@ describe('Authenticator', () => { ); }); }); + + describe('`getRequestOriginalURL` method', () => { + let authenticator: Authenticator; + let mockOptions: ReturnType; + beforeEach(() => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + authenticator = new Authenticator(mockOptions); + }); + + it('filters out auth specific query parameters', () => { + expect(authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest())).toBe( + '/mock-server-basepath/path' + ); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }) + ) + ).toBe('/mock-server-basepath/path'); + }); + + it('allows to include additional query parameters', () => { + expect( + authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest(), [ + ['some-param', 'some-value'], + ['some-param2', 'some-value2'], + ]) + ).toBe('/mock-server-basepath/path?some-param=some-value&some-param2=some-value2'); + + expect( + authenticator.getRequestOriginalURL( + httpServerMock.createKibanaRequest({ + query: { + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1', + [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash', + }, + }), + [ + ['some-param', 'some-value'], + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc1'], + ] + ) + ).toBe('/mock-server-basepath/path?some-param=some-value&auth_provider_hint=oidc1'); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f86ff54963da9..4eeadf23c50b2 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -11,6 +11,7 @@ import type { IBasePath, IClusterClient, LoggerFactory } from 'src/core/server'; import { KibanaRequest } from '../../../../../src/core/server'; import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, LOGOUT_PROVIDER_QUERY_STRING_PARAMETER, LOGOUT_REASON_QUERY_STRING_PARAMETER, NEXT_URL_QUERY_STRING_PARAMETER, @@ -45,6 +46,15 @@ import { } from './providers'; import { Tokens } from './tokens'; +/** + * List of query string parameters used to pass various authentication related metadata that should + * be stripped away from URL as soon as they are no longer needed. + */ +const AUTH_METADATA_QUERY_STRING_PARAMETERS = [ + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +]; + /** * The shape of the login attempt. */ @@ -201,6 +211,7 @@ export class Authenticator { const providerCommonOptions = { client: this.options.clusterClient, basePath: this.options.basePath, + getRequestOriginalURL: this.getRequestOriginalURL.bind(this), tokens: new Tokens({ client: this.options.clusterClient.asInternalUser, logger: this.options.loggers.get('tokens'), @@ -419,7 +430,9 @@ export class Authenticator { } } - return DeauthenticationResult.notHandled(); + // If none of the configured providers could perform a logout, we should redirect user to the + // default logout location. + return DeauthenticationResult.redirectTo(this.getLoggedOutURL(request)); } /** @@ -452,6 +465,24 @@ export class Authenticator { this.options.featureUsageService.recordPreAccessAgreementUsage(); } + getRequestOriginalURL( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) { + const originalURLSearchParams = [ + ...[...request.url.searchParams.entries()].filter( + ([key]) => !AUTH_METADATA_QUERY_STRING_PARAMETERS.includes(key) + ), + ...(additionalQueryStringParameters ?? []), + ]; + + return `${this.options.basePath.get(request)}${request.url.pathname}${ + originalURLSearchParams.length > 0 + ? `?${new URLSearchParams(originalURLSearchParams).toString()}` + : '' + }`; + } + /** * Initializes HTTP Authentication provider and appends it to the end of the list of enabled * authentication providers. @@ -762,9 +793,13 @@ export class Authenticator { /** * Creates a logged out URL for the specified request and provider. * @param request Request that initiated logout. - * @param providerType Type of the provider that handles logout. + * @param providerType Type of the provider that handles logout. If not specified, then the first + * provider in the chain (default) is assumed. */ - private getLoggedOutURL(request: KibanaRequest, providerType: string) { + private getLoggedOutURL( + request: KibanaRequest, + providerType: string = this.options.config.authc.sortedProviders[0].type + ) { // The app that handles logout needs to know the reason of the logout and the URL we may need to // redirect user to once they log in again (e.g. when session expires). const searchParams = new URLSearchParams(); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index 1507cd2d3a50a..805d647757ca5 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -7,6 +7,7 @@ import { httpServerMock } from 'src/core/server/mocks'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; import { canRedirectRequest } from './can_redirect_request'; describe('can_redirect_request', () => { @@ -24,4 +25,33 @@ describe('can_redirect_request', () => { expect(canRedirectRequest(request)).toBe(false); }); + + it('returns false for api routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' })) + ).toBe(false); + }); + + it('returns false for internal routes', () => { + expect( + canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/internal/security/some' })) + ).toBe(false); + }); + + it('returns true for the routes with the `security:canRedirect` tag', () => { + for (const request of [ + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT] }), + httpServerMock.createKibanaRequest({ + path: '/api/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + httpServerMock.createKibanaRequest({ + path: '/internal/security/some', + routeTags: [ROUTE_TAG_CAN_REDIRECT], + }), + ]) { + expect(canRedirectRequest(request)).toBe(true); + } + }); }); diff --git a/x-pack/plugins/security/server/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts index 71c6365d9aea4..5a3a09f17eb86 100644 --- a/x-pack/plugins/security/server/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -7,7 +7,8 @@ import type { KibanaRequest } from 'src/core/server'; -const ROUTE_TAG_API = 'api'; +import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags'; + const KIBANA_XSRF_HEADER = 'kbn-xsrf'; const KIBANA_VERSION_HEADER = 'kbn-version'; @@ -24,9 +25,9 @@ export function canRedirectRequest(request: KibanaRequest) { const isApiRoute = route.options.tags.includes(ROUTE_TAG_API) || - (route.path.startsWith('/api/') && route.path !== '/api/security/logout') || + route.path.startsWith('/api/') || route.path.startsWith('/internal/'); const isAjaxRequest = hasVersionHeader || hasXsrfHeader; - return !isApiRoute && !isAjaxRequest; + return !isAjaxRequest && (!isApiRoute || route.options.tags.includes(ROUTE_TAG_CAN_REDIRECT)); } diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index bb78b6e963763..5d3417ae9db11 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -20,6 +20,7 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) { client: elasticsearchServiceMock.createClusterClient(), logger: loggingSystemMock.create().get(), basePath: httpServiceMock.createBasePath(), + getRequestOriginalURL: jest.fn(), tokens: { refresh: jest.fn(), invalidate: jest.fn() }, name: options?.name ?? 'basic1', urls: { diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 18d567a143fee..c7c0edcf1e9e1 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -27,6 +27,10 @@ import type { Tokens } from '../tokens'; export interface AuthenticationProviderOptions { name: string; basePath: HttpServiceSetup['basePath']; + getRequestOriginalURL: ( + request: KibanaRequest, + additionalQueryStringParameters?: Array<[string, string]> + ) => string; client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index ebeca42682eb9..444a7f3e50a25 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -11,6 +11,10 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -376,18 +380,78 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates OIDC handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/oidc/prepare', + body: { realm: 'oidc1' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { @@ -520,6 +584,9 @@ describe('OIDCAuthenticationProvider', () => { }); it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; @@ -534,11 +601,16 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 2afa49fe6e082..83f0ec50abb0d 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,8 +10,13 @@ import type from 'type-detect'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -201,7 +206,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.captureRedirectURL(request) + ? await this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -264,7 +269,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via OpenID Connect: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -313,7 +320,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { - this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.logger.debug( + `Failed to initiate OpenID Connect authentication: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -341,7 +350,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -379,7 +390,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -440,7 +451,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -457,22 +468,29 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * Tries to initiate OIDC authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index bd51a0f815329..dfcdb66e61c35 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -10,6 +10,10 @@ import Boom from '@hapi/boom'; import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { AuthenticationResult } from '../authentication_result'; @@ -848,18 +852,63 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); + it('initiates SAML handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path'); + mockOptions.client.asInternalUser.transport.request.mockResolvedValue( + securityMock.createApiResponse({ + body: { + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path', + query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' }, + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); + + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request); + + expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/saml/prepare', + body: { realm: 'test-realm' }, + }); + }); + it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { @@ -1024,6 +1073,9 @@ describe('SAMLAuthenticationProvider', () => { }); it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { + mockOptions.getRequestOriginalURL.mockReturnValue( + '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' + ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { accessToken: 'expired-token', @@ -1040,11 +1092,16 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } ) ); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1); + expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'], + ]); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 7c27e2ebeff10..ea818e5df6e12 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -9,9 +9,14 @@ import Boom from '@hapi/boom'; import type { KibanaRequest } from 'src/core/server'; -import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; +import { + AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, + AUTH_URL_HASH_QUERY_STRING_PARAMETER, + NEXT_URL_QUERY_STRING_PARAMETER, +} from '../../../common/constants'; import { isInternalURL } from '../../../common/is_internal_url'; import type { AuthenticationInfo } from '../../elasticsearch'; +import { getDetailedErrorMessage } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -185,7 +190,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } else { this.logger.debug( `Failed to perform a login: ${ - authenticationResult.error && authenticationResult.error.message + authenticationResult.error && getDetailedErrorMessage(authenticationResult.error) }` ); } @@ -230,7 +235,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? this.captureRedirectURL(request) + ? this.initiateAuthenticationHandshake(request) : authenticationResult; } @@ -283,7 +288,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(redirect); } } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`); return DeauthenticationResult.failed(err); } } @@ -362,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }) ).body as any; } catch (err) { - this.logger.debug(`Failed to log in with SAML response: ${err.message}`); + this.logger.debug(`Failed to log in with SAML response: ${getDetailedErrorMessage(err)}`); // Since we don't know upfront what realm is targeted by the Identity Provider initiated login // there is a chance that it failed because of realm mismatch and hence we should return @@ -452,7 +457,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { refreshToken: existingState.refreshToken!, }); } catch (err) { - this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.logger.debug( + `Failed to perform IdP initiated local logout: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } @@ -483,7 +490,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.logger.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug( + `Failed to authenticate request via state: ${getDetailedErrorMessage(err)}` + ); return AuthenticationResult.failed(err); } } @@ -520,7 +529,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); - return this.captureRedirectURL(request); + return this.initiateAuthenticationHandshake(request); } return AuthenticationResult.failed( @@ -569,7 +578,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { state: { requestId, redirectURL, realm: this.realm }, }); } catch (err) { - this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); } } @@ -629,22 +638,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. + * Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will + * initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment + * first and only then initiate SAML handshake. * @param request Request instance. */ - private captureRedirectURL(request: KibanaRequest) { - const searchParams = new URLSearchParams([ - [ - NEXT_URL_QUERY_STRING_PARAMETER, - `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`, - ], - ['providerType', this.type], - ['providerName', this.options.name], - ]); + private initiateAuthenticationHandshake(request: KibanaRequest) { + const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); + if (originalURLHash != null) { + return this.authenticateViaHandshake( + request, + `${this.options.getRequestOriginalURL(request)}${originalURLHash}` + ); + } + return AuthenticationResult.redirectTo( `${ this.options.basePath.serverBasePath - }/internal/security/capture-url?${searchParams.toString()}`, + }/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent( + this.options.getRequestOriginalURL(request, [ + [AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name], + ]) + )}`, // Here we indicate that current session, if any, should be invalidated. It is a no-op for the // initial handshake, but is essential when both access and refresh tokens are expired. { state: null } diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index 8f6dd9275e59c..1adbb2dc66533 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -8,7 +8,7 @@ import type { ElasticsearchClient, Logger } from 'src/core/server'; import type { AuthenticationInfo } from '../elasticsearch'; -import { getErrorStatusCode } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; /** * Represents a pair of access and refresh tokens. @@ -73,11 +73,11 @@ export class Tokens { return { accessToken, refreshToken, - // @ts-expect-error @elastic/elasticsearch decalred GetUserAccessTokenResponse.authentication: string + // @ts-expect-error @elastic/elasticsearch declared GetUserAccessTokenResponse.authentication: string authenticationInfo: authenticationInfo as AuthenticationInfo, }; } catch (err) { - this.logger.debug(`Failed to refresh access token: ${err.message}`); + this.logger.debug(`Failed to refresh access token: ${getDetailedErrorMessage(err)}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -123,7 +123,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); + this.logger.debug(`Failed to invalidate refresh token: ${getDetailedErrorMessage(err)}`); // When using already deleted refresh token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. @@ -155,7 +155,7 @@ export class Tokens { }) ).body.invalidated_tokens; } catch (err) { - this.logger.debug(`Failed to invalidate access token: ${err.message}`); + this.logger.debug(`Failed to invalidate access token: ${getDetailedErrorMessage(err)}`); // When using already deleted access token, Elasticsearch responds with 404 and a body that // shows that no tokens were invalidated. diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx new file mode 100644 index 0000000000000..5cb6c899d7560 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../../src/core/server/mocks'; +import { UnauthenticatedPage } from './unauthenticated_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('UnauthenticatedPage', () => { + it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx new file mode 100644 index 0000000000000..48d61a72e085d --- /dev/null +++ b/x-pack/plugins/security/server/authentication/unauthenticated_page.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-expect-error no definitions in component folder +import { EuiButton } from '@elastic/eui/lib/components/button'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; + +import { PromptPage } from '../prompt_page'; + +interface Props { + originalURL: string; + buildNumber: number; + basePath: IBasePath; +} + +export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Props) { + return ( + + +

+ } + actions={[ + + + , + ]} + /> + ); +} + +export function renderUnauthenticatedPage(props: Props) { + return renderToStaticMarkup(); +} diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap index 785c57490e8ef..1011d82eb1f73 100644 --- a/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap +++ b/x-pack/plugins/security/server/authorization/__snapshots__/reset_session_page.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ResetSessionPage renders as expected 1`] = `"MockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; +exports[`ResetSessionPage renders as expected 1`] = `"ElasticMockedFonts

You do not have permission to access the requested page

Either go back to the previous page or log in as a different user.

"`; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index db3c84477ffb1..144a8bc5fd0c4 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import type { Observable, Subscription } from 'rxjs'; -import * as UiSharedDeps from '@kbn/ui-shared-deps'; import type { CapabilitiesSetup, HttpServiceSetup, @@ -163,25 +162,14 @@ export class AuthorizationService { http.registerOnPreResponse((request, preResponse, toolkit) => { if (preResponse.statusCode === 403 && canRedirectRequest(request)) { - const basePath = http.basePath.get(request); - const next = `${basePath}${request.url.pathname}${request.url.search}`; - const regularBundlePath = `${basePath}/${buildNumber}/bundles`; - - const logoutUrl = http.basePath.prepend( - `/api/security/logout?${querystring.stringify({ next })}` - ); - const styleSheetPaths = [ - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, - `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, - `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/ui/legacy_light_theme.css`, - ]; - + const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`; const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx index e76c8ff138fcb..d5e27c9d39ffd 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { coreMock } from '../../../../../src/core/server/mocks'; import { ResetSessionPage } from './reset_session_page'; jest.mock('src/core/server/rendering/views/fonts', () => ({ @@ -16,11 +17,16 @@ jest.mock('src/core/server/rendering/views/fonts', () => ({ describe('ResetSessionPage', () => { it('renders as expected', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + const body = renderToStaticMarkup( ); diff --git a/x-pack/plugins/security/server/authorization/reset_session_page.tsx b/x-pack/plugins/security/server/authorization/reset_session_page.tsx index c2d43cd3dd030..4e2e6f4631287 100644 --- a/x-pack/plugins/security/server/authorization/reset_session_page.tsx +++ b/x-pack/plugins/security/server/authorization/reset_session_page.tsx @@ -7,101 +7,53 @@ // @ts-expect-error no definitions in component folder import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button'; -// @ts-expect-error no definitions in component folder -import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; -// @ts-expect-error no definitions in component folder -import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; -// @ts-expect-error no definitions in component folder -import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; -// @ts-expect-error no definitions in component folder -import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Fonts } from '../../../../../src/core/server/rendering/views/fonts'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IBasePath } from 'src/core/server'; -// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded -// in advance the first time this page is rendered server-side. If not, the -// icon svg wouldn't contain any paths the first time the page was rendered. -appendIconComponentCache({ - alert: EuiIconAlert, -}); +import { PromptPage } from '../prompt_page'; export function ResetSessionPage({ logoutUrl, - styleSheetPaths, + buildNumber, basePath, }: { logoutUrl: string; - styleSheetPaths: string[]; - basePath: string; + buildNumber: number; + basePath: IBasePath; }) { - const uiPublicUrl = `${basePath}/ui`; return ( - - - {styleSheetPaths.map((path) => ( - - ))} - - {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} - - - - - - - - - - - - -
- } - body={ -

- -

- } - actions={[ - - - , - - - , - ]} - /> - - - - - - + + +

+ } + actions={[ + + + , + + + , + ]} + /> ); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index b66ed6e9eb7ca..087cf8f4f8ee8 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -30,6 +30,7 @@ export type { CheckPrivilegesPayload } from './authorization'; export { LegacyAuditLogger, AuditLogger, AuditEvent } from './audit'; export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; +export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 586707dd8c9aa..57be308525fdd 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -246,7 +246,12 @@ export class SecurityPlugin this.elasticsearchService.setup({ license, status: core.status }); this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); this.sessionManagementService.setup({ config, http: core.http, taskManager }); - this.authenticationService.setup({ http: core.http, license }); + this.authenticationService.setup({ + http: core.http, + config, + license, + buildNumber: this.initializerContext.env.packageInfo.buildNum, + }); registerSecurityUsageCollector({ usageCollection, config, license }); diff --git a/x-pack/plugins/security/server/prompt_page.test.tsx b/x-pack/plugins/security/server/prompt_page.test.tsx new file mode 100644 index 0000000000000..01c4488576f57 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { coreMock } from '../../../../src/core/server/mocks'; +import { PromptPage } from './prompt_page'; + +jest.mock('src/core/server/rendering/views/fonts', () => ({ + Fonts: () => <>MockedFonts, +})); + +describe('PromptPage', () => { + it('renders as expected without additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); + + it('renders as expected with additional scripts', async () => { + const mockCoreSetup = coreMock.createSetup(); + (mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation( + (path) => `/mock-basepath${path}` + ); + + const body = renderToStaticMarkup( + Some Body} + actions={[Action#1, Action#2]} + /> + ); + + expect(body).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/server/prompt_page.tsx b/x-pack/plugins/security/server/prompt_page.tsx new file mode 100644 index 0000000000000..338d39b29e534 --- /dev/null +++ b/x-pack/plugins/security/server/prompt_page.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-expect-error no definitions in component folder +import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt'; +// @ts-expect-error no definitions in component folder +import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert'; +// @ts-expect-error no definitions in component folder +import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon'; +// @ts-expect-error no definitions in component folder +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; +import type { IBasePath } from 'src/core/server'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Fonts } from '../../../../src/core/server/rendering/views/fonts'; + +// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded +// in advance the first time this page is rendered server-side. If not, the +// icon svg wouldn't contain any paths the first time the page was rendered. +appendIconComponentCache({ + alert: EuiIconAlert, +}); + +interface Props { + buildNumber: number; + basePath: IBasePath; + scriptPaths?: string[]; + title: ReactNode; + body: ReactNode; + actions: ReactNode; +} + +export function PromptPage({ + basePath, + buildNumber, + scriptPaths = [], + title, + body, + actions, +}: Props) { + const uiPublicURL = `${basePath.serverBasePath}/ui`; + const regularBundlePath = `${basePath.serverBasePath}/${buildNumber}/bundles`; + const styleSheetPaths = [ + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`, + `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + `${basePath.serverBasePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, + `${basePath.serverBasePath}/ui/legacy_light_theme.css`, + ]; + + return ( + + + Elastic + {styleSheetPaths.map((path) => ( + + ))} + + {/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */} + + + {scriptPaths.map((path) => ( + +++++ \ No newline at end of file From 78ae33164ef449e5696e65299cb193a055a869db Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Wed, 28 Apr 2021 12:26:52 -0600 Subject: [PATCH 54/68] [RAC][Alert Triage][TGrid] Update the Alerts Table (TGrid) API to implement a subset of the `EuiDataGridColumn` API (#98241) ## [RAC][Alert Triage][TGrid] Update the Alerts Table (TGrid) API to implement a subset of the `EuiDataGridColumn` API This PR implements the following subset of the `EuiDataGridColumn` API from [EuiDataGrid](https://elastic.github.io/eui/#/tabular-content/data-grid) in the `TGrid` (Timeline grid): ```ts Pick ``` The above properties are [documented in EuiDataGrid's data_grid_types.ts](https://github.com/elastic/eui/blob/master/src/components/datagrid/data_grid_types.ts), and summarized in the table below: | Property | Description | |----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `display?: ReactNode` | A `ReactNode` used when rendering the column header | | `displayAsText?: string` | Displays the column name as text (in lieu of using `display`). If not used, `id` will be shown as the column name. | | `id: string` | The unique identifier for this column, e.g. `user.name` | | `initialWidth?: number` | Initial width (in pixels) of the column | The following screenshot shows the `TGrid` rendering (from left-to-right): - An (example) RAC-flavored Observability alerts table - An (example) RAC-flavored Security Solution alerts table - The production alerts table in the Security Solutions `Detections` page, which remains the default ![three_table_configurations](https://user-images.githubusercontent.com/4459398/115944491-5a69a780-a473-11eb-85b6-36120c3092d6.png) _Above, three table configurations, rendered via the updated API_ The `public/detections/configurations` directory contains the configurations for the three tables shown in the screenshot above This change works in concert with another recent change to the `TGrid` that [added support for the `renderCellValue` API](https://github.com/elastic/kibana/pull/96098). ### Example configurations #### (example) RAC-flavored Observability alerts table ![observability_alerts_example](https://user-images.githubusercontent.com/4459398/115944556-b3d1d680-a473-11eb-8338-6097731f2d48.png) The column specification for the (example) RAC-flavored Observability alerts table, shown in the screenshot above is defined in `x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts`: ```ts export const columns: Array< Pick & ColumnHeaderOptions > = [ { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.STATUS, id: 'kibana.rac.alert.status', initialWidth: 74, }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.TRIGGERED, id: '@timestamp', initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERT_DURATION, id: 'kibana.rac.alert.duration.us', initialWidth: 116, }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, id: 'signal.rule.severity', initialWidth: 102, }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_REASON, id: 'signal.reason', initialWidth: 644, }, ]; ``` The example implementation of `EuiDataGrid`'s [`renderCellValue` API](https://github.com/elastic/kibana/pull/96098) used to render the RAC-flavored Observability alerts table shown in the screenshot above is located in `x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx`: ```ts /** * This implementation of `EuiDataGrid`'s `renderCellValue` * accepts `EuiDataGridCellValueElementProps`, plus `data` * from the TGrid */ export const renderCellValue: React.FC< EuiDataGridCellValueElementProps & CellValueElementProps > = ({ columnId, data, eventId, header, isDetails, isExpandable, isExpanded, linkValues, rowIndex, setCellProps, timelineId, }) => { const value = getMappedNonEcsValue({ data, fieldName: columnId, })?.reduce((x) => x[0]) ?? ''; switch (columnId) { case 'kibana.rac.alert.status': return ; case 'kibana.rac.alert.duration.us': return {moment(value).fromNow(true)}; case 'signal.rule.severity': return ; case 'signal.reason': return ( {reason} ); default: // NOTE: we're using `DefaultCellRenderer` in this example configuration as a fallback, but // using `DefaultCellRenderer` here is entirely optional return ( ); } }; ``` #### (example) RAC-flavored Security Solution alerts table ![secuirty_solution_rac_example](https://user-images.githubusercontent.com/4459398/115944592-e8459280-a473-11eb-9e0f-cef8519102d4.png) The column specification for the (example) RAC-flavored Security Solution alerts table, shown in the screenshot above is defined in `x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts`: ```ts /** * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, * plus additional TGrid column properties */ export const columns: Array< Pick & ColumnHeaderOptions > = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, }, { columnHeaderType: defaultColumnHeaderType, id: 'signal.rule.name', displayAsText: i18n.ALERTS_HEADERS_RULE_NAME, linkField: 'signal.rule.id', initialWidth: 212, }, { columnHeaderType: defaultColumnHeaderType, id: 'signal.rule.severity', displayAsText: i18n.ALERTS_HEADERS_SEVERITY, initialWidth: 104, }, { columnHeaderType: defaultColumnHeaderType, id: 'signal.reason', displayAsText: i18n.ALERTS_HEADERS_REASON, initialWidth: 644, }, ]; ``` ### Testing the example configurations locally For now, the alerts table in the Security Solution's `Detections` page is configured to use the existing (`7.13`) column configuration. To test the Alerts table in the Security Solution `Detections` page with the example configurations provided in this PR: 1. Edit `x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx` and change the following line: ```ts import { columns, RenderCellValue } from '../../configurations/security_solution_detections'; ``` from the above to ```ts import { columns, RenderCellValue } from '../../configurations/examples/observablity_alerts'; ``` for the (example) RAC-flavored Observability alerts table, or change it to ```ts import { columns, RenderCellValue } from '../../configurations/examples/security_solution_rac'; ``` for the (example) RAC-flavored Security solution alerts table. 2. Navigate to your local instance of the Security Solution [Detections page](http://localhost:5601/xyx/app/security/detections) (Note: you may need to enable detection rules to populate the alerts table.) 3. Click the `customize_columns` button shown in the screenshot below: ![customize_columns](https://user-images.githubusercontent.com/4459398/115796322-e3f37980-a38e-11eb-930b-5b21dfcb5e65.png) 4. In the `Customize Columns` popover, click the `Reset Fields` button, shown in the screenshot below: ![reset-fields](https://user-images.githubusercontent.com/4459398/115797081-49943580-a390-11eb-9485-7e6cae2f2a6f.png) After clicking `Reset Fields`, the new default columns will be displayed. ### Backwards compatibility The `width` property of Timeline's model was changed to `initialWidth` as part of this PR. - This change has no effect on Timelines persisted as saved objects - This change has no effect on Timeline's [Export and Import Timelines](https://www.elastic.co/guide/en/security/current/timelines-ui.html#import-export-timelines) feature - When a TGrid's column configuration containing the legacy `width` and `label` `ColumnHeaderOptions` is read from `localstorage`, these properties are migrated to `initialWidth` and `displayAsText` respectively. - Backwards compatibility was desk tested by persisting a custom column configuration while running off `master`, and then re-visiting the page after running this PR branch. As expected, the previously persisted column configuration was rendered correctly after running the PR branch. - Unit tests were added to `x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts` to test the migration of the `width` and `label` properties ### Other changes - The minium width of a resized column is now `70px`. The new minium is no longer data-type specific. --- .../alerts_viewer/default_headers.ts | 20 +- .../components/drag_and_drop/helpers.ts | 6 +- .../components/event_details/columns.tsx | 2 +- .../components/event_details/helpers.tsx | 2 +- .../events_viewer/default_headers.tsx | 18 +- .../public/common/mock/header.ts | 22 +- .../public/common/mock/timeline_results.ts | 30 +- .../components/alerts_table/actions.test.tsx | 14 +- .../alerts_table/default_config.tsx | 90 +----- .../components/alerts_table/index.tsx | 9 +- .../components/alerts_table/translations.ts | 35 +++ .../components/severity/index.test.tsx | 80 +++++ .../detections/components/severity/index.tsx | 56 ++++ .../components/status/index.test.tsx | 53 ++++ .../detections/components/status/index.tsx | 53 ++++ .../examples/observablity_alerts/columns.ts | 53 ++++ .../examples/observablity_alerts/index.ts | 11 + .../render_cell_value.test.tsx | 114 +++++++ .../observablity_alerts/render_cell_value.tsx | 83 ++++++ .../examples/security_solution_rac/columns.ts | 47 +++ .../examples/security_solution_rac/index.ts | 11 + .../render_cell_value.test.tsx | 90 ++++++ .../render_cell_value.tsx | 79 +++++ .../security_solution_detections/columns.ts | 101 +++++++ .../security_solution_detections/index.ts | 11 + .../render_cell_value.test.tsx | 62 ++++ .../render_cell_value.tsx | 47 +++ .../fields_browser/field_items.test.tsx | 6 +- .../components/fields_browser/field_items.tsx | 2 +- .../components/fields_browser/helpers.tsx | 2 +- .../components/open_timeline/helpers.test.ts | 124 ++++---- .../components/open_timeline/helpers.ts | 3 +- .../__snapshots__/index.test.tsx.snap | 16 +- .../body/column_headers/column_header.tsx | 9 +- .../body/column_headers/default_headers.ts | 16 +- .../body/column_headers/filter/index.tsx | 3 +- .../header/__snapshots__/index.test.tsx.snap | 6 +- .../column_headers/header/header_content.tsx | 12 +- .../body/column_headers/header/index.test.tsx | 53 +++- .../body/column_headers/helpers.test.ts | 6 +- .../timeline/body/column_headers/index.tsx | 8 +- .../components/timeline/body/constants.ts | 4 + .../__snapshots__/index.test.tsx.snap | 14 +- .../body/data_driven_columns/index.tsx | 2 +- .../components/timeline/body/index.tsx | 6 +- .../components/timeline/body/translations.ts | 14 + .../__snapshots__/index.test.tsx.snap | 22 +- .../__snapshots__/index.test.tsx.snap | 22 +- .../__snapshots__/index.test.tsx.snap | 22 +- .../containers/local_storage/index.test.ts | 279 ++++++++++++++++++ .../containers/local_storage/index.tsx | 36 ++- .../timelines/store/timeline/epic.test.ts | 16 +- .../public/timelines/store/timeline/epic.ts | 4 +- .../timelines/store/timeline/helpers.ts | 14 +- .../public/timelines/store/timeline/model.ts | 12 +- .../timelines/store/timeline/reducer.test.ts | 51 ++-- 56 files changed, 1635 insertions(+), 348 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/severity/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/status/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts index b0a497123f218..74ba4ec4a3be3 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts @@ -18,53 +18,53 @@ export const alertsHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'rule.reference', }, { columnHeaderType: defaultColumnHeaderType, id: 'event.dataset', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.severity', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'observer.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'agent.id', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'agent.type', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index 3466298d5ede3..e2e506e6e1a3f 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -11,7 +11,7 @@ import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; -import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; +import { alertsHeaders } from '../alerts_viewer/default_headers'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; @@ -218,7 +218,7 @@ export const addFieldToTimelineColumns = ({ linkField: linkFields[fieldId] ?? undefined, type: column.type, aggregatable: column.aggregatable, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, ...initColumnHeader, }, id: timelineId, @@ -232,7 +232,7 @@ export const addFieldToTimelineColumns = ({ column: { columnHeaderType: 'not-filtered', id: fieldId, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, id: timelineId, index: result.destination != null ? result.destination.index : 0, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 836a67441ef8a..22c2b40ed62ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -98,7 +98,7 @@ export const getColumns = ({ toggleColumn({ columnHeaderType: defaultColumnHeaderType, id: field, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }) } disabled={data.isObjectArray && data.type !== 'geo_point'} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index dfbaadbeed7b1..1f12c2de5e24f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -98,7 +98,7 @@ export const getColumnHeaderFromBrowserField = ({ id: browserField.name || '', type: browserField.type, aggregatable: browserField.aggregatable, - width, + initialWidth: width, }); /** diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx index 59d475b0b8d81..7c84a325cb667 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx @@ -16,46 +16,46 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.dataset', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/mock/header.ts b/x-pack/plugins/security_solution/public/common/mock/header.ts index 45339bd0d3df6..ae7d3c9e576a8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/header.ts +++ b/x-pack/plugins/security_solution/public/common/mock/header.ts @@ -22,7 +22,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: '@timestamp', type: 'date', aggregatable: true, - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -33,7 +33,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.severity', type: 'long', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -44,7 +44,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.category', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -55,7 +55,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.action', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'host', @@ -66,7 +66,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'host.name', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'source', @@ -76,7 +76,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'source.ip', type: 'ip', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'destination', @@ -86,7 +86,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'destination.ip', type: 'ip', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { aggregatable: true, @@ -97,7 +97,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ format: 'bytes', id: 'destination.bytes', type: 'number', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'user', @@ -107,7 +107,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'user.name', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'base', @@ -117,7 +117,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: '_id', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'base', @@ -128,6 +128,6 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'message', type: 'text', aggregatable: false, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index c02c47d45f732..806031b07e0c9 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -1937,37 +1937,37 @@ export const mockTimelineModel: TimelineModel = { { columnHeaderType: 'not-filtered', id: '@timestamp', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -2082,14 +2082,14 @@ export const defaultTimelineProps: CreateTimelineProps = { activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, columns: [ - { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', width: 190 }, - { columnHeaderType: 'not-filtered', id: 'message', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'event.category', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'event.action', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'host.name', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'source.ip', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'destination.ip', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'user.name', width: 180 }, + { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', initialWidth: 190 }, + { columnHeaderType: 'not-filtered', id: 'message', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.category', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.action', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'host.name', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'source.ip', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'destination.ip', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'user.name', initialWidth: 180 }, ], dataProviders: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index d5b64a8fe27fc..08e88567b0fd0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -106,37 +106,37 @@ describe('alert actions', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 6a83039bf1ec8..478c8930b8dd3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -9,15 +9,9 @@ import { RowRendererId } from '../../../../common/types/timeline'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; -import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_DATE_COLUMN_MIN_WIDTH, -} from '../../../timelines/components/timeline/body/constants'; -import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; +import { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - -import * as i18n from './translations'; +import { columns } from '../../configurations/security_solution_detections/columns'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -98,87 +92,9 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; -export const alertsHeaders: ColumnHeaderOptions[] = [ - { - columnHeaderType: defaultColumnHeaderType, - id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.name', - label: i18n.ALERTS_HEADERS_RULE, - linkField: 'signal.rule.id', - width: DEFAULT_COLUMN_MIN_WIDTH, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.version', - label: i18n.ALERTS_HEADERS_VERSION, - width: 95, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.type', - label: i18n.ALERTS_HEADERS_METHOD, - width: 100, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.severity', - label: i18n.ALERTS_HEADERS_SEVERITY, - width: 105, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.risk_score', - label: i18n.ALERTS_HEADERS_RISK_SCORE, - width: 115, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.module', - linkField: 'rule.reference', - width: DEFAULT_COLUMN_MIN_WIDTH, - }, - { - category: 'event', - columnHeaderType: defaultColumnHeaderType, - id: 'event.action', - type: 'string', - aggregatable: true, - width: 140, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.category', - width: 150, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'host.name', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'user.name', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'source.ip', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'destination.ip', - width: 140, - }, -]; - export const alertsDefaultModel: SubsetTimelineModel = { ...timelineDefaults, - columns: alertsHeaders, + columns, showCheckboxes: true, excludedRowRendererIds: Object.values(RowRendererId), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 2890eb912b84c..9dc83d7898963 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -48,8 +48,8 @@ import { import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { buildTimeRangeFilter } from './helpers'; -import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +import { columns, RenderCellValue } from '../../configurations/security_solution_detections'; interface OwnProps { defaultFilters?: Filter[]; @@ -311,7 +311,10 @@ export const AlertsTableComponent: React.FC = ({ useEffect(() => { initializeTimeline({ - defaultModel: alertsDefaultModel, + defaultModel: { + ...alertsDefaultModel, + columns, + }, documentType: i18n.ALERTS_DOCUMENT_TYPE, filterManager, footerText: i18n.TOTAL_COUNT_OF_ALERTS, @@ -346,7 +349,7 @@ export const AlertsTableComponent: React.FC = ({ headerFilterGroup={headerFilterGroup} id={timelineId} onRuleChange={onRuleChange} - renderCellValue={DefaultCellRenderer} + renderCellValue={RenderCellValue} rowRenderers={defaultRowRenderers} scopeId={SourcererScopeName.detections} start={from} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 56f6337d5a55c..2d9f947dcea67 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -60,6 +60,13 @@ export const ALERTS_HEADERS_RULE = i18n.translate( } ); +export const ALERTS_HEADERS_RULE_NAME = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.ruleNameTitle', + { + defaultMessage: 'Rule name', + } +); + export const ALERTS_HEADERS_VERSION = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.versionTitle', { @@ -81,6 +88,13 @@ export const ALERTS_HEADERS_SEVERITY = i18n.translate( } ); +export const ALERTS_HEADERS_REASON = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.reasonTitle', + { + defaultMessage: 'Reason', + } +); + export const ALERTS_HEADERS_RISK_SCORE = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.riskScoreTitle', { @@ -172,6 +186,13 @@ export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => 'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', }); +export const ALERT_DURATION = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.alertDurationTitle', + { + defaultMessage: 'Alert duration', + } +); + export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => i18n.translate('xpack.securitySolution.detectionEngine.alerts.openedAlertSuccessToastMessage', { values: { totalAlerts }, @@ -216,3 +237,17 @@ export const MORE_ACTIONS = i18n.translate( defaultMessage: 'More actions', } ); + +export const STATUS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.statusTitle', + { + defaultMessage: 'Status', + } +); + +export const TRIGGERED = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.triggeredTitle', + { + defaultMessage: 'Triggered', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx new file mode 100644 index 0000000000000..946a59b5bdf0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { Severity } from '.'; + +interface Expected { + color: string; + severity: 'low' | 'medium' | 'high' | 'critical' | 'any-other-severity'; + textColor: string; +} + +describe('Severity', () => { + const expected: Expected[] = [ + { + color: '#C5CFD8', + severity: 'low', + textColor: 'default', + }, + { + color: '#EFC44C', + severity: 'medium', + textColor: 'default', + }, + { + color: '#FF7E62', + severity: 'high', + textColor: 'ghost', + }, + { + color: '#C3505E', + severity: 'critical', + textColor: 'ghost', + }, + { + color: 'hollow', + severity: 'any-other-severity', + textColor: 'default', + }, + ]; + + test('it capitalizes the provided `severity`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-badge"]').first()).toHaveStyleRule( + 'text-transform', + 'capitalize' + ); + }); + + test('it renders the provided `severity`', () => { + const wrapper = mount(); + + expect(wrapper.text()).toBe('critical'); + }); + + expected.forEach(({ severity, color, textColor }) => { + test(`it renders the expected badge color when severity is ${severity}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-badge"]').first().props().color).toEqual( + color + ); + }); + + test(`it renders the expected text color when severity is ${severity}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-text"]').first().props().color).toEqual( + textColor + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx b/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx new file mode 100644 index 0000000000000..23361e3ceed59 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +const SeverityBadge = styled(EuiBadge)` + align-items: center; + display: inline-flex; + height: 40px; + text-transform: capitalize; +`; + +const getBadgeColorFromSeverity = (severity: string) => { + switch (`${severity}`.toLowerCase()) { + case 'low': + return '#C5CFD8'; + case 'medium': + return '#EFC44C'; + case 'high': + return '#FF7E62'; + case 'critical': + return '#C3505E'; + default: + return 'hollow'; + } +}; + +const getTextColorFromSeverity = (severity: string) => { + switch (`${severity}`.toLowerCase()) { + case 'critical': // fall through + case 'high': + return 'ghost'; + default: + return 'default'; + } +}; + +interface Props { + severity: string; +} + +const SeverityComponent: React.FC = ({ severity }) => ( + + + {severity} + + +); + +export const Severity = React.memo(SeverityComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx new file mode 100644 index 0000000000000..1df652a9b09af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { Status } from '.'; + +interface Expected { + badgeColor: string; + iconType: 'check' | 'alert'; + status: 'active' | 'recovered' | 'any-other-status'; +} + +describe('Status', () => { + const expected: Expected[] = [ + { + badgeColor: 'danger', + iconType: 'alert', + status: 'active', + }, + { + badgeColor: 'hollow', + iconType: 'check', + status: 'recovered', + }, + { + badgeColor: 'danger', + iconType: 'alert', + status: 'any-other-status', + }, + ]; + + expected.forEach(({ status, badgeColor, iconType }) => { + test(`it renders the expected badge color when status is ${status}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="status-icon"]').first().props().color).toEqual( + badgeColor + ); + }); + + test(`it renders the expected icon type when status is ${status}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="status-icon"]').first().props().type).toEqual(iconType); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/status/index.tsx b/x-pack/plugins/security_solution/public/detections/components/status/index.tsx new file mode 100644 index 0000000000000..c9ece19d1b1e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/status/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +export const STATUS_CLASS_NAME = 'alert-status-icon'; + +const StatusContainer = styled.span` + display: inline-flex; + justify-content: center; + width: 100%; +`; + +export const getBadgeColorFromStatus = (status: string) => { + switch (`${status}`.toLowerCase()) { + case 'recovered': + return 'hollow'; + default: + return 'danger'; + } +}; + +export const getIconTypeFromStatus = (status: string) => { + switch (`${status}`.toLowerCase()) { + case 'recovered': + return 'check'; + default: + return 'alert'; + } +}; + +interface Props { + status: string; +} + +const StatusComponent: React.FC = ({ status }) => ( + + + +); + +export const Status = React.memo(StatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts new file mode 100644 index 0000000000000..8cbb532501a2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; +import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import * as i18n from '../../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.STATUS, + id: 'kibana.rac.alert.status', + initialWidth: 74, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.TRIGGERED, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERT_DURATION, + id: 'kibana.rac.alert.duration.us', + initialWidth: 116, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + id: 'signal.rule.severity', + initialWidth: 102, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_REASON, + id: 'signal.reason', + initialWidth: 644, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx new file mode 100644 index 0000000000000..9c2114a4ef085 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it renders a custom alert status', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="alert-status"]').exists()).toBe(true); + }); + + test('it renders a custom alert duration', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="alert-duration"]').exists()).toBe(true); + }); + + test('it renders a custom rule severity', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="rule-severity"]').exists()).toBe(true); + }); + + test('it renders a custom reason', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="reason"]').exists()).toBe(true); + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer` for any other field', () => { + const aRandomFieldName = 'a.random.field.name'; + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual({ + ...props, + columnId: aRandomFieldName, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx new file mode 100644 index 0000000000000..bc8c4bd6bfe69 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui'; +import { random } from 'lodash/fp'; +import moment from 'moment'; +import React from 'react'; + +import { TruncatableText } from '../../../../common/components/truncatable_text'; +import { Severity } from '../../../components/severity'; +import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { Status } from '../../../components/status'; + +const reason = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => { + const value = + getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]) ?? ''; + + switch (columnId) { + case 'kibana.rac.alert.status': + return ( + + ); + case 'kibana.rac.alert.duration.us': + return {moment().fromNow(true)}; + case 'signal.rule.severity': + return ; + case 'signal.reason': + return ( + + {reason} + + ); + default: + // NOTE: we're using `DefaultCellRenderer` in this example configuration as a fallback, but + // using `DefaultCellRenderer` here is entirely optional + return ( + + ); + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts new file mode 100644 index 0000000000000..96d2d870b1270 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; +import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import * as i18n from '../../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.name', + displayAsText: i18n.ALERTS_HEADERS_RULE_NAME, + linkField: 'signal.rule.id', + initialWidth: 212, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.severity', + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + initialWidth: 104, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.reason', + displayAsText: i18n.ALERTS_HEADERS_REASON, + initialWidth: 644, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx new file mode 100644 index 0000000000000..aa4eb543a3d9b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it renders a custom severity', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="custom-severity"]').exists()).toBe(true); + }); + + test('it renders a custom reason', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="custom-reason"]').exists()).toBe(true); + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer` for any other field', () => { + const aRandomFieldName = 'a.random.field.name'; + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual({ + ...props, + columnId: aRandomFieldName, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx new file mode 100644 index 0000000000000..097cb54a7b0ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import React from 'react'; + +import { DefaultDraggable } from '../../../../common/components/draggables'; +import { TruncatableText } from '../../../../common/components/truncatable_text'; +import { Severity } from '../../../components/severity'; +import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; + +const reason = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => { + const value = + getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]) ?? ''; + const draggableId = `${timelineId}-${eventId}-${columnId}-${value}`; + + switch (columnId) { + case 'signal.rule.severity': + return ( + + + + ); + case 'signal.reason': + return {reason}; + default: + return ( + + ); + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts new file mode 100644 index 0000000000000..23a0740294e84 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_DATE_COLUMN_MIN_WIDTH, +} from '../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; + +import * as i18n from '../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_RULE, + id: 'signal.rule.name', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + linkField: 'signal.rule.id', + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_VERSION, + id: 'signal.rule.version', + initialWidth: 95, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_METHOD, + id: 'signal.rule.type', + initialWidth: 100, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + id: 'signal.rule.severity', + initialWidth: 105, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_RISK_SCORE, + id: 'signal.rule.risk_score', + initialWidth: 115, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.module', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + linkField: 'rule.reference', + }, + { + aggregatable: true, + category: 'event', + columnHeaderType: defaultColumnHeaderType, + id: 'event.action', + initialWidth: 140, + type: 'string', + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.category', + initialWidth: 150, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'host.name', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'user.name', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'source.ip', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'destination.ip', + initialWidth: 140, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx new file mode 100644 index 0000000000000..18350c102c049 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../common/mock'; +import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer`', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual(props); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx new file mode 100644 index 0000000000000..e9bfdefa433c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import React from 'react'; + +import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => ( + +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx index f55af79a5dbee..07911541bb2fe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx @@ -34,7 +34,7 @@ const columnHeaders: ColumnHeaderOptions[] = [ id: '@timestamp', type: 'date', aggregatable: true, - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, ]; @@ -207,7 +207,7 @@ describe('field_items', () => { expect(toggleColumn).toBeCalledWith({ columnHeaderType: 'not-filtered', id: '@timestamp', - width: 180, + initialWidth: 180, }); }); @@ -266,7 +266,7 @@ describe('field_items', () => { expect(toggleColumn).toBeCalledWith({ columnHeaderType: 'not-filtered', id: 'signal.rule.name', - width: 180, + initialWidth: 180, }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index e50f5a6e39ee3..a2db284e51790 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -228,7 +228,7 @@ export const getFieldItems = ({ toggleColumn({ columnHeaderType: defaultColumnHeaderType, id: field.name ?? '', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, ...getAlertColumnHeader(timelineId, field.name ?? ''), }) } diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx index 576ce6239645f..4d06632d6441d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx @@ -16,7 +16,7 @@ import { } from '../../../common/components/accessibility/helpers'; import { TimelineId } from '../../../../common/types/timeline'; import { BrowserField, BrowserFields } from '../../../common/containers/source'; -import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; +import { alertsHeaders } from '../../../common/components/alerts_viewer/default_headers'; import { DEFAULT_CATEGORY_NAME, defaultHeaders, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 1222f168b2ae9..c06c3f076e097 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -252,42 +252,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -363,42 +363,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -474,42 +474,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -583,42 +583,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -698,7 +698,7 @@ describe('helpers', () => { id: '@timestamp', placeholder: undefined, type: 'number', - width: 190, + initialWidth: 190, }, { aggregatable: undefined, @@ -709,7 +709,7 @@ describe('helpers', () => { id: 'message', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -720,7 +720,7 @@ describe('helpers', () => { id: 'event.category', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -731,7 +731,7 @@ describe('helpers', () => { id: 'host.name', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -742,7 +742,7 @@ describe('helpers', () => { id: 'source.ip', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -753,7 +753,7 @@ describe('helpers', () => { id: 'destination.ip', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -764,7 +764,7 @@ describe('helpers', () => { id: 'user.name', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, ], version: '1', @@ -870,37 +870,37 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], version: '1', @@ -1018,42 +1018,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -1129,42 +1129,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 8c4eb2112640f..e45a1a117769b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -113,7 +113,8 @@ const setTimelineColumn = (col: ColumnHeaderResult) => { columnHeaderType: defaultColumnHeaderType, id: col.id != null ? col.id : 'unknown', - width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: + col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 9fe8b954657a3..6a56d1b16238a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -465,43 +465,43 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, }, Object { "columnHeaderType": "not-filtered", "id": "message", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "event.category", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "event.action", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "host.name", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "source.ip", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "destination.ip", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "user.name", - "width": 180, + "initialWidth": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 7d203fab9e88f..3ab4d564391f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -14,6 +14,7 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { useDraggableKeyboardWrapper } from '../../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../constants'; import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableFieldId, @@ -76,10 +77,10 @@ const ColumnHeaderComponent: React.FC = ({ const dispatch = useDispatch(); const resizableSize = useMemo( () => ({ - width: header.width, + width: header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH, height: 'auto', }), - [header.width] + [header.initialWidth] ); const resizableStyle: { position: 'absolute' | 'relative'; @@ -220,7 +221,7 @@ const ColumnHeaderComponent: React.FC = ({ ref={dragProvided.innerRef} > - + = ({ ), - [handleClosePopOverTrigger, headerButton, header.width, hoverActionsOwnFocus, panels] + [handleClosePopOverTrigger, headerButton, header.initialWidth, hoverActionsOwnFocus, panels] ); const onFocus = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts index c4f49b240b6e6..fea65d0499a13 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts @@ -15,42 +15,42 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ columnHeaderType: defaultColumnHeaderType, id: '@timestamp', type: 'number', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx index 64b3598fa5d89..bdf4cc42fa794 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx @@ -8,6 +8,7 @@ import { noop } from 'lodash/fp'; import React from 'react'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../constants'; import { OnFilterChange } from '../../../events'; import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; import { TextFilter } from '../text_filter'; @@ -24,7 +25,7 @@ export const Filter = React.memo(({ header, onFilterChange = noop }) => { return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 4cd2193f148a3..50da19c3d48f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -7,8 +7,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } isLoading={false} @@ -30,8 +30,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } isLoading={false} @@ -52,8 +52,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } onFilterChange={[Function]} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx index 393594c69bb81..484cb78417c2f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx @@ -46,7 +46,11 @@ const HeaderContentComponent: React.FC = ({ data-test-subj="header-tooltip" content={} > - <>{header.label ?? header.id} + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + @@ -63,7 +67,11 @@ const HeaderContentComponent: React.FC = ({ data-test-subj="header-tooltip" content={} > - <>{header.label ?? header.id} + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx index b0198e60f3b9a..f2496484c25ea 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx @@ -65,9 +65,9 @@ describe('Header', () => { ).toEqual(columnHeader.id); }); - test('it renders the header text alias when label is provided', () => { - const label = 'Timestamp'; - const headerWithLabel = { ...columnHeader, label }; + test('it renders the header text alias when displayAsText is provided', () => { + const displayAsText = 'Timestamp'; + const headerWithLabel = { ...columnHeader, displayAsText }; const wrapper = mount( @@ -76,7 +76,52 @@ describe('Header', () => { expect( wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() - ).toEqual(label); + ).toEqual(displayAsText); + }); + + test('it renders the header as a `ReactNode` when `display` is provided', () => { + const display: React.ReactNode = ( +
+ {'The display property renders the column heading as a ReactNode'} +
+ ); + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="rendered-via-display"]`).exists()).toBe(true); + }); + + test('it prefers to render `display` instead of `displayAsText` when both are provided', () => { + const displayAsText = 'this text should NOT be rendered'; + const display: React.ReactNode = ( +
{'this text is rendered via display'}
+ ); + const headerWithLabel = { ...columnHeader, display, displayAsText }; + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe('this text is rendered via display'); + }); + + test('it falls back to rendering header.id when `display` is not a valid React node', () => { + const display = {}; // a plain object is NOT a `ReactNode` + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() + ).toEqual(columnHeader.id); }); test('it renders a sort indicator', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 1260f59be3621..2fcfed6489eb2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -67,7 +67,7 @@ describe('helpers', () => { name: '@timestamp', searchable: true, type: 'date', - width: 190, + initialWidth: 190, }, { aggregatable: true, @@ -81,7 +81,7 @@ describe('helpers', () => { name: 'source.ip', searchable: true, type: 'ip', - width: 180, + initialWidth: 180, }, { aggregatable: true, @@ -96,7 +96,7 @@ describe('helpers', () => { name: 'destination.ip', searchable: true, type: 'ip', - width: 180, + initialWidth: 180, }, ]; const mockHeader = defaultHeaders.filter((h) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 99fb6c3dd8907..efb076337864b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -170,7 +170,7 @@ export const ColumnHeadersComponent = ({ > - + @@ -218,10 +218,10 @@ export const ColumnHeadersComponent = ({ const myColumns = useMemo( () => - columnHeaders.map(({ aggregatable, label, id, type }) => ({ + columnHeaders.map(({ aggregatable, displayAsText, id, type }) => ({ id, isSortable: aggregatable, - displayAsText: label, + displayAsText, schema: type, })), [columnHeaders] @@ -254,7 +254,7 @@ export const ColumnHeadersComponent = ({ [onSortColumns, sort] ); const displayValues = useMemo( - () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.label ?? ch.id }), {}), + () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.displayAsText ?? ch.id }), {}), [columnHeaders] ); const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index e33efe75e6895..445211229574b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -21,5 +21,9 @@ export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH /** The default minimum width of a column (when a width for the column type is not specified) */ export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px + +/** The minimum width of a resized column */ +export const RESIZED_COLUMN_MIN_WITH = 70; // px + /** The default minimum width of a column of type `date` */ export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap index 91d039a19495c..9cba2f98428a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap @@ -87,7 +87,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "message", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -179,7 +179,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "event.category", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -271,7 +271,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "event.action", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -363,7 +363,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "host.name", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -455,7 +455,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "source.ip", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -547,7 +547,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "destination.ip", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -639,7 +639,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "user.name", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx index aeb9af46ea2ec..e5012ec3522b0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx @@ -98,7 +98,7 @@ export const DataDrivenColumns = React.memo( onKeyDown={onKeyDown} role="button" tabIndex={0} - width={header.width} + width={header.initialWidth} > <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 59c0610c544e9..4d5f773b73e1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -12,6 +12,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { CellValueElementProps } from '../cell_rendering'; +import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; import { RowRendererId, TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { FIRST_ARIA_INDEX, @@ -162,7 +163,10 @@ export const BodyComponent = React.memo( const columnWidths = useMemo( () => - columnHeaders.reduce((totalWidth, header) => totalWidth + header.width, actionsColumnWidth), + columnHeaders.reduce( + (totalWidth, header) => totalWidth + (header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH), + actionsColumnWidth + ), [actionsColumnWidth, columnHeaders] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index bc9c774b40413..d104dc3a85f72 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -28,6 +28,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); +export const INVESTIGATE = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateLabel', + { + defaultMessage: 'Investigate', + } +); + export const UNPINNED = i18n.translate( 'xpack.securitySolution.timeline.body.pinning.unpinnedTooltip', { @@ -74,6 +81,13 @@ export const VIEW_DETAILS = i18n.translate( } ); +export const VIEW_SUMMARY = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.viewSummaryLabel', + { + defaultMessage: 'View summary', + } +); + export const VIEW_DETAILS_FOR_ROW = ({ ariaRowindex, columnValues, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap index 9ec1fa7071277..78f19e390ae28 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap @@ -14,8 +14,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -24,8 +24,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -35,8 +35,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -46,8 +46,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -57,8 +57,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -68,8 +68,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -79,8 +79,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -90,8 +90,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -100,8 +100,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -110,8 +110,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -121,8 +121,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap index ce59d191a472d..2ccf562c9ca6f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -13,8 +13,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -23,8 +23,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -34,8 +34,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -45,8 +45,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -56,8 +56,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -67,8 +67,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -78,8 +78,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -89,8 +89,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -99,8 +99,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -109,8 +109,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -120,8 +120,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index f6ff6b50221b7..5f529ba827c45 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -14,8 +14,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -24,8 +24,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -35,8 +35,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -46,8 +46,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -57,8 +57,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -68,8 +68,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -79,8 +79,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -90,8 +90,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -100,8 +100,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -110,8 +110,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -121,8 +121,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts index 2846d95051b17..f1b5f6a944678 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { cloneDeep } from 'lodash/fp'; import { LOCAL_STORAGE_TIMELINE_KEY, + migrateColumnWidthToInitialWidth, + migrateColumnLabelToDisplayAsText, useTimelinesStorage, getTimelinesInStorageByIds, getAllTimelinesInStorage, @@ -16,11 +19,15 @@ import { import { TimelineId } from '../../../../common/types/timeline'; import { mockTimelineModel, createSecuritySolutionStorageMock } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; +import { TimelineModel } from '../../store/timeline/model'; jest.mock('../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; +const getExpectedColumns = (model: TimelineModel) => + model.columns.map(migrateColumnWidthToInitialWidth).map(migrateColumnLabelToDisplayAsText); + describe('SiemLocalStorage', () => { const { localStorage, storage } = createSecuritySolutionStorageMock(); @@ -122,6 +129,179 @@ describe('SiemLocalStorage', () => { [TimelineId.hostsPageEvents]: mockTimelineModel, }); }); + + it('migrates columns saved to localstorage with a `width` to `initialWidth`', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `width` instead of `initialWidth` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + width: 98765, // create a legacy `width` column + initialWidth: undefined, // `initialWidth` must be undefined, otherwise the migration will not occur + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + // all legacy `width` values are migrated to `initialWidth`: + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + displayAsText: undefined, + initialWidth: 98765, + width: 98765, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate columns saved to localstorage with a `width` to `initialWidth` when `initialWidth` is valid', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `width` instead of `initialWidth` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + width: 98765, // create a legacy `width` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + displayAsText: undefined, + initialWidth: c.initialWidth, // initialWidth is unchanged + width: 98765, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('migrates columns saved to localstorage with a `label` to `displayAsText`', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `label` instead of `displayAsText` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + label: `A legacy label ${i}`, // create a legacy `label` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + // all legacy `label` values are migrated to `displayAsText`: + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: `A legacy label ${i}`, + label: `A legacy label ${i}`, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate columns saved to localstorage with a `label` to `displayAsText` when `displayAsText` is valid', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `label` instead of `displayAsText` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: + 'Label will NOT be migrated to displayAsText, because displayAsText already has a value', + label: `A legacy label ${i}`, // create a legacy `label` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: + 'Label will NOT be migrated to displayAsText, because displayAsText already has a value', + label: `A legacy label ${i}`, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate `columns` when `columns` is not an array', () => { + const timelineStorage = useTimelinesStorage(); + + const invalidColumnsMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: 'this is NOT an array', + }; + timelineStorage.addTimeline( + TimelineId.hostsPageEvents, + (invalidColumnsMockTimelineModel as unknown) as TimelineModel + ); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: 'this is NOT an array', + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); }); describe('getAllTimelinesInStorage', () => { @@ -159,4 +339,103 @@ describe('SiemLocalStorage', () => { }); }); }); + + describe('migrateColumnWidthToInitialWidth', () => { + it('migrates the `width` property to `initialWidth` for older columns saved to localstorage', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + width: 1234, // the column `width` was saved to localstorage before the `initialWidth` property existed + initialWidth: undefined, // `initialWidth` did not exist when this column was saved to localstorage + }; + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 1234, // migrated from `width` + width: 1234, + }); + }); + + it("leaves `initialWidth` unchanged when the column read from localstorage doesn't have a `width`", () => { + const column = cloneDeep(mockTimelineModel.columns[0]); // `column.width` does not exist + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, // unchanged, because there is no `width` to migrate + }); + }); + + it('does NOT migrate the `width` property to `initialWidth` when the column read from localstorage already has a valid `initialWidth`', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), // `column.initialWidth` already exists + width: 1234, + }; + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, // unchanged, because the column read from localstorge already has a valid `initialWidth` + width: 1234, + }); + }); + }); + + describe('migrateColumnLabelToDisplayAsText', () => { + it('migrates the `label` property to `displayAsText` for older columns saved to localstorage', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + label: 'A legacy label', // the column `label` was saved to localstorage before the `displayAsText` property existed + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'A legacy label', // migrated from `label` + id: '@timestamp', + initialWidth: 190, + label: 'A legacy label', + }); + }); + + it("leaves `displayAsText` undefined when the column read from localstorage doesn't have a `label`", () => { + const column = cloneDeep(mockTimelineModel.columns[0]); // `column.label` does not exist + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: undefined, // undefined, because there is no `label` to migrate + id: '@timestamp', + initialWidth: 190, + }); + }); + + it("leaves `displayAsText` unchanged when the column read from localstorage doesn't have a `label`", () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + displayAsText: 'Do NOT update this', + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'Do NOT update this', // unchanged, because there is no `label` to migrate + id: '@timestamp', + initialWidth: 190, + }); + }); + + it('does NOT migrate the `label` property to `displayAsText` when the column read from localstorage already has a valid `displayAsText`', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + displayAsText: 'Already valid', + label: 'A legacy label', + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'Already valid', // unchanged, because the column read from localstorge already has a valid `displayAsText` + label: 'A legacy label', + id: '@timestamp', + initialWidth: 190, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index 19ccc0bc6ef85..38eb6d3d222f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -5,10 +5,11 @@ * 2.0. */ +import { isEmpty } from 'lodash/fp'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { TimelinesStorage } from './types'; import { useKibana } from '../../../common/lib/kibana'; -import { TimelineModel } from '../../store/timeline/model'; +import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; export const LOCAL_STORAGE_TIMELINE_KEY = 'timelines'; @@ -16,6 +17,32 @@ const EMPTY_TIMELINE = {} as { [K in TimelineIdLiteral]: TimelineModel; }; +/** + * Migrates the value of the column's `width` property to `initialWidth` + * when `width` is valid, and `initialWidth` is invalid + */ +export const migrateColumnWidthToInitialWidth = ( + column: ColumnHeaderOptions & { width?: number } +) => ({ + ...column, + initialWidth: + Number.isInteger(column.width) && !Number.isInteger(column.initialWidth) + ? column.width + : column.initialWidth, +}); + +/** + * Migrates the value of the column's `label` property to `displayAsText` + * when `label` is valid, and `displayAsText` is `undefined` + */ +export const migrateColumnLabelToDisplayAsText = ( + column: ColumnHeaderOptions & { label?: string } +) => ({ + ...column, + displayAsText: + !isEmpty(column.label) && column.displayAsText == null ? column.label : column.displayAsText, +}); + export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: TimelineIdLiteral[]) => { const allTimelines = storage.get(LOCAL_STORAGE_TIMELINE_KEY); @@ -38,6 +65,13 @@ export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: Timeli ...(timelineModel.sort != null && !Array.isArray(timelineModel.sort) ? { sort: [timelineModel.sort] } : {}), + ...(Array.isArray(timelineModel.columns) + ? { + columns: timelineModel.columns + .map(migrateColumnWidthToInitialWidth) + .map(migrateColumnLabelToDisplayAsText), + } + : {}), }, }; }, {} as { [K in TimelineIdLiteral]: TimelineModel }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index c8e8e00caf530..eabcdd53fb994 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -21,42 +21,42 @@ describe('Epic Timeline', () => { { columnHeaderType: 'not-filtered', id: '@timestamp', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 30d09da2f736d..5f5d76990b5ff 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -360,7 +360,9 @@ export const convertTimelineAsInput = ( } else if (key === 'columns' && get(key, timeline) != null) { return set( key, - get(key, timeline).map((col: ColumnHeaderOptions) => omit(['width', '__typename'], col)), + get(key, timeline).map((col: ColumnHeaderOptions) => + omit(['initialWidth', 'width', '__typename'], col) + ), acc ); } else if (key === 'filters' && get(key, timeline) != null) { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 135cbb3f73281..2172cf8562c97 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -11,7 +11,6 @@ import uuid from 'uuid'; import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; import { DataProvider, @@ -42,6 +41,10 @@ import { DEFAULT_FROM_MOMENT, DEFAULT_TO_MOMENT, } from '../../../common/utils/default_date_settings'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + RESIZED_COLUMN_MIN_WITH, +} from '../../components/timeline/body/constants'; import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -495,13 +498,14 @@ export const applyDeltaToTimelineColumnWidth = ({ }, }; } - const minWidthPixels = getColumnWidthFromType(timeline.columns[columnIndex].type!); - const requestedWidth = timeline.columns[columnIndex].width + delta; // raw change in width - const width = Math.max(minWidthPixels, requestedWidth); // if the requested width is smaller than the min, use the min + + const requestedWidth = + (timeline.columns[columnIndex].initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH) + delta; // raw change in width + const initialWidth = Math.max(RESIZED_COLUMN_MIN_WITH, requestedWidth); // if the requested width is smaller than the min, use the min const columnWithNewWidth = { ...timeline.columns[columnIndex], - width, + initialWidth, }; const columns = [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index faece61cf9b7e..559cec57dd55c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiDataGridColumn } from '@elastic/eui'; + import { Filter, IFieldSubType } from '../../../../../../../src/plugins/data/public'; import { DataProvider } from '../../components/timeline/data_providers/data_provider'; @@ -32,21 +34,21 @@ export type ColumnHeaderType = 'not-filtered' | 'text-filter'; export type ColumnId = string; /** The specification of a column header */ -export interface ColumnHeaderOptions { +export type ColumnHeaderOptions = Pick< + EuiDataGridColumn, + 'display' | 'displayAsText' | 'id' | 'initialWidth' +> & { aggregatable?: boolean; category?: string; columnHeaderType: ColumnHeaderType; description?: string; example?: string; format?: string; - id: ColumnId; - label?: string; linkField?: string; placeholder?: string; subType?: IFieldSubType; type?: string; - width: number; -} +}; export interface TimelineModel { /** The selected tab to displayed in the timeline */ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index d467747346b8b..1c65c01a0bdfc 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -20,8 +20,10 @@ import { DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + RESIZED_COLUMN_MIN_WITH, +} from '../../../timelines/components/timeline/body/constants'; import { defaultHeaders } from '../../../common/mock'; import { @@ -278,7 +280,7 @@ describe('Timeline', () => { id: 'event.action', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }; mockWithExistingColumns = { ...timelineById, @@ -600,12 +602,12 @@ describe('Timeline', () => { expect(update).not.toBe(timelineByIdMock); }); - test('should update (just) the specified column of type `date` when the id matches, and the result of applying the delta is greater than the min width for a date column', () => { + test('should update initialWidth with the specified delta when the delta is positive', () => { const aDateColumn = columnsMock[0]; const delta = 50; const expectedToHaveNewWidth = { ...aDateColumn, - width: getColumnWidthFromType(aDateColumn.type!) + delta, + initialWidth: Number(aDateColumn.initialWidth) + 50, }; const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; @@ -619,12 +621,12 @@ describe('Timeline', () => { expect(update.foo.columns).toEqual(expectedColumns); }); - test('should NOT update (just) the specified column of type `date` when the id matches, because the result of applying the delta is less than the min width for a date column', () => { + test('should update initialWidth with the specified delta when the delta is negative, and the resulting width is greater than the min column width', () => { const aDateColumn = columnsMock[0]; - const delta = -50; // this will be less than the min + const delta = 50 * -1; // the result will still be above the min column size const expectedToHaveNewWidth = { ...aDateColumn, - width: getColumnWidthFromType(aDateColumn.type!), // we expect the minimum + initialWidth: Number(aDateColumn.initialWidth) - 50, }; const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; @@ -638,37 +640,18 @@ describe('Timeline', () => { expect(update.foo.columns).toEqual(expectedColumns); }); - test('should update (just) the specified non-date column when the id matches, and the result of applying the delta is greater than the min width for the column', () => { - const aNonDateColumn = columnsMock[1]; - const delta = 50; - const expectedToHaveNewWidth = { - ...aNonDateColumn, - width: getColumnWidthFromType(aNonDateColumn.type!) + delta, - }; - const expectedColumns = [columnsMock[0], expectedToHaveNewWidth, columnsMock[2]]; - - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: aNonDateColumn.id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update.foo.columns).toEqual(expectedColumns); - }); - - test('should NOT update the specified non-date column when the id matches, because the result of applying the delta is less than the min width for the column', () => { - const aNonDateColumn = columnsMock[1]; - const delta = -50; + test('should set initialWidth to `RESIZED_COLUMN_MIN_WITH` when the requested delta results in a column that is too small ', () => { + const aDateColumn = columnsMock[0]; + const delta = (Number(aDateColumn.initialWidth) - 5) * -1; // the requested delta would result in a width of just 5 pixels, which is too small const expectedToHaveNewWidth = { - ...aNonDateColumn, - width: getColumnWidthFromType(aNonDateColumn.type!), + ...aDateColumn, + initialWidth: RESIZED_COLUMN_MIN_WITH, // we expect the minimum }; - const expectedColumns = [columnsMock[0], expectedToHaveNewWidth, columnsMock[2]]; + const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; const update = applyDeltaToTimelineColumnWidth({ id: 'foo', - columnId: aNonDateColumn.id, + columnId: aDateColumn.id, delta, timelineById: mockWithExistingColumns, }); From 9703b74efcc341d5c55b5d2dfb4babe91c509952 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 28 Apr 2021 14:36:50 -0400 Subject: [PATCH 55/68] [Fleet] Fix opening the settings flyout from the add agent flyout (#98536) --- .../components/settings_flyout/index.tsx | 2 +- .../applications/fleet/layouts/default.tsx | 21 +++++++++++++------ .../sections/agents/agent_list_page/index.tsx | 10 +++++---- .../agent_enrollment_flyout/index.tsx | 21 ++++++++++++------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index f3c353fd75dba..56f28ada004e2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -340,7 +340,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { onClose={onConfirmModalClose} /> )} - +

diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 543819aca87a5..4ff5243483a3a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -7,7 +7,14 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiTabs, + EuiTab, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiPortal, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Section } from '../sections'; @@ -58,11 +65,13 @@ export const DefaultLayout: React.FunctionComponent = ({ return ( <> {modal === 'settings' && ( - { - setModal(null); - }} - /> + + { + setModal(null); + }} + /> + )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 88249f7f5d5ce..70cb6cddad5fa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -481,10 +481,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( <> {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} {agentToReassign && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx index 0ad1706e5273f..1aa88dcef4adc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -37,9 +37,7 @@ interface Props { agentPolicies?: AgentPolicy[]; } -const MissingFleetServerHostCallout: React.FunctionComponent<{ onClose: () => void }> = ({ - onClose, -}) => { +const MissingFleetServerHostCallout: React.FunctionComponent = () => { const { setModal } = useUrlModal(); return ( vo fill iconType="gear" onClick={() => { - onClose(); setModal('settings'); }} > @@ -89,11 +86,21 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); + const { modal } = useUrlModal(); + const [lastModal, setLastModal] = useState(modal); const settings = useGetSettings(); const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + // Refresh settings when there is a modal/flyout change + useEffect(() => { + if (modal !== lastModal) { + settings.resendRequest(); + setLastModal(modal); + } + }, [modal, lastModal, settings]); + return ( - +

@@ -130,7 +137,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + ) : undefined } > From 3f46d6f8a76e7a2a88489de6947760194ee55f0c Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 28 Apr 2021 20:37:58 +0200 Subject: [PATCH 56/68] [ML] Refresh Anomaly Detection jobs list on alerting rules updates (#98603) --- x-pack/plugins/ml/public/alerting/job_selector.tsx | 5 +---- .../plugins/ml/public/alerting/ml_alerting_flyout.tsx | 11 +++++++++-- .../components/job_details/extract_job_details.js | 4 ++-- .../jobs_list/components/job_details/job_details.js | 5 +++-- .../components/jobs_list_view/jobs_list_view.js | 1 + .../components/multi_job_actions/actions_menu.js | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index da353b52ef1c0..d00d4efc25b8d 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -99,10 +99,7 @@ export const JobSelectorControl: FC = ({ + } isInvalid={!!errors?.length} error={errors} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx index dac1fad72255c..b87a447bd4b15 100644 --- a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -82,6 +82,7 @@ export const MlAnomalyAlertFlyout: FC = ({ interface JobListMlAnomalyAlertFlyoutProps { setShowFunction: (callback: Function) => void; unsetShowFunction: () => void; + onSave: () => void; } /** @@ -93,6 +94,7 @@ interface JobListMlAnomalyAlertFlyoutProps { export const JobListMlAnomalyAlertFlyout: FC = ({ setShowFunction, unsetShowFunction, + onSave, }) => { const [isVisible, setIsVisible] = useState(false); const [jobIds, setJobIds] = useState(); @@ -115,6 +117,7 @@ export const JobListMlAnomalyAlertFlyout: FC = onCloseFlyout={() => setIsVisible(false)} onSave={() => { setIsVisible(false); + onSave(); }} /> ) : null; @@ -122,9 +125,10 @@ export const JobListMlAnomalyAlertFlyout: FC = interface EditRuleFlyoutProps { initialAlert: MlAnomalyDetectionAlertRule; + onSave: () => void; } -export const EditAlertRule: FC = ({ initialAlert }) => { +export const EditAlertRule: FC = ({ initialAlert, onSave }) => { const [isVisible, setIsVisible] = useState(false); return ( <> @@ -136,7 +140,10 @@ export const EditAlertRule: FC = ({ initialAlert }) => { { + setIsVisible(false); + onSave(); + }} /> ) : null} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index 5b7a41e572dab..673484f08e196 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiLink } from '@elastic/eui'; import { EditAlertRule } from '../../../../../alerting/ml_alerting_flyout'; -export function extractJobDetails(job, basePath) { +export function extractJobDetails(job, basePath, refreshJobList) { if (Object.keys(job).length === 0) { return {}; } @@ -82,7 +82,7 @@ export function extractJobDetails(job, basePath) { }), position: 'right', items: (job.alerting_rules ?? []).map((v) => { - return ['', ]; + return ['', ]; }), }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index c8412a2a83d8a..812d156421c16 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -55,6 +55,8 @@ export class JobDetailsUI extends Component { ); } else { + const { showFullDetails, refreshJobList } = this.props; + const { general, customUrl, @@ -71,9 +73,8 @@ export class JobDetailsUI extends Component { jobTimingStats, datafeedTimingStats, alertRules, - } = extractJobDetails(job, basePath); + } = extractJobDetails(job, basePath, refreshJobList); - const { showFullDetails, refreshJobList } = this.props; const tabs = [ { id: 'job-settings', 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 ac7224b3f3164..214b7616cf927 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 @@ -521,6 +521,7 @@ export class JobsListView extends Component { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 6b3d6bc8971f5..2c73a73b77abe 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -145,7 +145,7 @@ class MultiJobActionsMenuUI extends Component { ); } - if (this.canCreateMlAlerts) { + if (this.canCreateMlAlerts && this.props.jobs.length === 1) { items.push( Date: Wed, 28 Apr 2021 21:49:47 +0300 Subject: [PATCH 57/68] [Search] return full IKibanaSearchResponse from fetch$ (#98268) * return full object from fetch$ * jest * fun-ctional tests * jest * functional * test * test * tesssst * Update src/plugins/data/common/search/search_source/fetch/request_error.ts Co-authored-by: Michael Dokolin * Update src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts Co-authored-by: Michael Dokolin * ts * ts * ts * Revert "Update src/plugins/data/common/search/search_source/fetch/request_error.ts" This reverts commit ab182e84edea2cb693b59faefeb9bae626703a3e. Co-authored-by: Michael Dokolin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- dev_docs/tutorials/data/search.mdx | 2 +- ...plugins-data-public.searchsource.fetch_.md | 4 +-- .../search_examples/public/search/app.tsx | 2 +- .../data/common/search/aggs/buckets/terms.ts | 2 +- .../esaggs/request_handler.test.ts | 10 +++++- .../expressions/esaggs/request_handler.ts | 2 +- .../data/common/search/poll_search.test.ts | 2 ++ .../search_source/fetch/request_error.ts | 8 ++--- .../search/search_source/fetch/types.ts | 7 ++-- .../search_source/search_source.test.ts | 32 +++++++++++-------- .../search/search_source/search_source.ts | 18 +++++++---- src/plugins/data/common/search/utils.ts | 2 +- src/plugins/data/public/public.api.md | 2 +- .../search/fetch/handle_response.test.ts | 26 +++++++++------ .../public/search/fetch/handle_response.tsx | 15 +++++---- .../search_interceptor.test.ts | 26 +++++++++++++-- .../data/server/search/search_service.ts | 2 +- .../public/application/angular/discover.js | 2 +- .../embeddable/search_embeddable.ts | 2 +- .../classes/sources/es_source/es_source.ts | 6 ++-- .../apps/maps/blended_vector_layer.js | 6 ++-- .../maps/documents_source/docvalue_fields.js | 6 ++-- .../apps/maps/embeddable/dashboard.js | 6 ++-- .../apps/maps/es_geo_grid_source.js | 10 +++--- .../functional/apps/maps/es_pew_pew_source.js | 2 +- x-pack/test/functional/apps/maps/joins.js | 4 +-- 26 files changed, 124 insertions(+), 82 deletions(-) diff --git a/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx index 69b4d5dab58b5..9cf46bb96c72a 100644 --- a/dev_docs/tutorials/data/search.mdx +++ b/dev_docs/tutorials/data/search.mdx @@ -355,7 +355,7 @@ export class SearchEmbeddable this.updateOutput({ loading: true, error: undefined }); // Make the request, wait for the final result - const resp = await searchSource.fetch$({ + const {rawResponse: resp} = await searchSource.fetch$({ sessionId: searchSessionId, }).toPromise(); diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index 4369cf7c087da..8bc4b7606ab51 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): Observable>; +fetch$(options?: ISearchOptions): Observable>>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): Observable>; Returns: -`Observable>` +`Observable>>` diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 65d939088515a..c9ede2ff2b45f 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -233,7 +233,7 @@ export const SearchExamplesApp = ({ } setRequest(searchSource.getSearchRequestBody()); - const res = await searchSource.fetch$().toPromise(); + const { rawResponse: res } = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 03cf14a577a50..1b876051d009b 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,7 +101,7 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const response = await nestedSearchSource + const { rawResponse: response } = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index b30e5740fa3fb..32775464d055f 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -11,7 +11,7 @@ import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; -import { searchSourceCommonMock } from '../../search_source/mocks'; +import { searchSourceCommonMock, searchSourceInstanceMock } from '../../search_source/mocks'; import { handleRequest, RequestHandlerParams } from './request_handler'; @@ -20,12 +20,20 @@ jest.mock('../../tabify', () => ({ })); import { tabifyAggResponse } from '../../tabify'; +import { of } from 'rxjs'; describe('esaggs expression function - public', () => { let mockParams: MockedKeys; beforeEach(() => { jest.clearAllMocks(); + + searchSourceInstanceMock.fetch$ = jest.fn().mockReturnValue( + of({ + rawResponse: {}, + }) + ); + mockParams = { abortSignal: (jest.fn() as unknown) as jest.Mocked, aggs: ({ diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 173b2067cad6b..d152ebf159a8e 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -111,7 +111,7 @@ export const handleRequest = async ({ inspectorAdapters.requests?.reset(); - const response = await requestSearchSource + const { rawResponse: response } = await requestSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/poll_search.test.ts b/src/plugins/data/common/search/poll_search.test.ts index 037fd0fc059d1..38c52f5d5bec4 100644 --- a/src/plugins/data/common/search/poll_search.test.ts +++ b/src/plugins/data/common/search/poll_search.test.ts @@ -20,11 +20,13 @@ describe('pollSearch', () => { resolve({ isRunning: false, isPartial: finishWithError, + rawResponse: {}, }); } else { resolve({ isRunning: true, isPartial: true, + rawResponse: {}, }); } }); diff --git a/src/plugins/data/common/search/search_source/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts index d8c750d011b03..48e216fa05541 100644 --- a/src/plugins/data/common/search/search_source/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { KbnError } from '../../../../../kibana_utils/common'; +import { IKibanaSearchResponse } from '../../types'; import { SearchError } from './types'; /** @@ -16,9 +16,9 @@ import { SearchError } from './types'; * @param {Object} resp - optional HTTP response */ export class RequestFailure extends KbnError { - public resp?: estypes.SearchResponse; - constructor(err: SearchError | null = null, resp?: estypes.SearchResponse) { - super(`Request to Elasticsearch failed: ${JSON.stringify(resp || err?.message)}`); + public resp?: IKibanaSearchResponse; + constructor(err: SearchError | null = null, resp?: IKibanaSearchResponse) { + super(`Request to Elasticsearch failed: ${JSON.stringify(resp?.rawResponse || err?.message)}`); this.resp = resp; } diff --git a/src/plugins/data/common/search/search_source/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts index 79aa45163b913..069b2a3117a0a 100644 --- a/src/plugins/data/common/search/search_source/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { GetConfigFn } from '../../../types'; +import { IKibanaSearchResponse } from '../../types'; /** * @internal @@ -24,10 +24,7 @@ export interface FetchHandlers { * Callback which can be used to hook into responses, modify them, or perform * side effects like displaying UI errors on the client. */ - onResponse: ( - request: SearchRequest, - response: estypes.SearchResponse - ) => estypes.SearchResponse; + onResponse: (request: SearchRequest, response: IKibanaSearchResponse) => IKibanaSearchResponse; } export interface SearchError { diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 68e386acfd48c..a3f043a5e2657 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -903,18 +903,26 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { "test": 1, }, - ] + }, + ] `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { "test": 2, }, - ] + }, + ] `); }); @@ -958,13 +966,9 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(1); expect(error).toBeCalledTimes(1); expect(complete).toBeCalledTimes(0); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "test": 1, - }, - ] - `); + expect(next.mock.calls[0][0].rawResponse).toStrictEqual({ + test: 1, + }); expect(error.mock.calls[0][0]).toBe(undefined); }); }); @@ -1174,7 +1178,7 @@ describe('SearchSource', () => { expect(fetchSub.next).toHaveBeenCalledTimes(3); expect(fetchSub.complete).toHaveBeenCalledTimes(1); expect(fetchSub.error).toHaveBeenCalledTimes(0); - expect(resp).toStrictEqual({ other: 5 }); + expect(resp.rawResponse).toStrictEqual({ other: 5 }); expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 585126e1184d2..5130224329ba2 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -271,7 +271,9 @@ export class SearchSource { * Fetch this source from Elasticsearch, returning an observable over the response(s) * @param options */ - fetch$(options: ISearchOptions = {}) { + fetch$( + options: ISearchOptions = {} + ): Observable>> { const { getConfig } = this.dependencies; const syncSearchByDefault = getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES); @@ -308,7 +310,11 @@ export class SearchSource { * @deprecated Use fetch$ instead */ fetch(options: ISearchOptions = {}) { - return this.fetch$(options).toPromise(); + return this.fetch$(options) + .toPromise() + .then((r) => { + return r.rawResponse as estypes.SearchResponse; + }); } /** @@ -341,7 +347,7 @@ export class SearchSource { * PRIVATE APIS ******/ - private inspectSearch(s$: Observable>, options: ISearchOptions) { + private inspectSearch(s$: Observable>, options: ISearchOptions) { const { id, title, description, adapter } = options.inspector || { title: '' }; const requestResponder = adapter?.start(title, { @@ -384,7 +390,7 @@ export class SearchSource { last(undefined, null), tap((finalResponse) => { if (finalResponse) { - requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.stats(getResponseInspectorStats(finalResponse.rawResponse, this)); requestResponder?.ok({ json: finalResponse }); } }), @@ -424,8 +430,8 @@ export class SearchSource { ); } } - return response; } + return response; } /** @@ -477,7 +483,7 @@ export class SearchSource { } }); }), - map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + map((response) => onResponse(searchRequest, response)) ); } diff --git a/src/plugins/data/common/search/utils.ts b/src/plugins/data/common/search/utils.ts index e87434cd6ca83..e11957c6fa9fc 100644 --- a/src/plugins/data/common/search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -12,7 +12,7 @@ import type { IKibanaSearchResponse } from './types'; * @returns true if response had an error while executing in ES */ export const isErrorResponse = (response?: IKibanaSearchResponse) => { - return !response || (!response.isRunning && response.isPartial); + return !response || !response.rawResponse || (!response.isRunning && response.isPartial); }; /** diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index cb3dfb839a023..868330ce078c7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2446,7 +2446,7 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): Observable>; + fetch$(options?: ISearchOptions): Observable>>; // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; diff --git a/src/plugins/data/public/search/fetch/handle_response.test.ts b/src/plugins/data/public/search/fetch/handle_response.test.ts index 8854bee5c7657..1a430f860f438 100644 --- a/src/plugins/data/public/search/fetch/handle_response.test.ts +++ b/src/plugins/data/public/search/fetch/handle_response.test.ts @@ -12,7 +12,7 @@ import { handleResponse } from './handle_response'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../../../core/public/notifications/notifications_service.mock'; import { setNotifications } from '../../services'; -import { SearchResponse } from 'elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; jest.mock('@kbn/i18n', () => { return { @@ -33,8 +33,10 @@ describe('handleResponse', () => { test('should notify if timed out', () => { const request = { body: {} }; const response = { - timed_out: true, - } as SearchResponse; + rawResponse: { + timed_out: true, + }, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -46,13 +48,15 @@ describe('handleResponse', () => { test('should notify if shards failed', () => { const request = { body: {} }; const response = { - _shards: { - failed: 1, - total: 2, - successful: 1, - skipped: 1, + rawResponse: { + _shards: { + failed: 1, + total: 2, + successful: 1, + skipped: 1, + }, }, - } as SearchResponse; + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -63,7 +67,9 @@ describe('handleResponse', () => { test('returns the response', () => { const request = {}; - const response = {} as SearchResponse; + const response = { + rawResponse: {}, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); }); diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 57ee5737e50a2..58e4da6b95fae 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; -import type { estypes } from '@elastic/elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: estypes.SearchResponse) { - if (response.timed_out) { +export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { + const { rawResponse } = response; + if (rawResponse.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { defaultMessage: 'Data might be incomplete because your request timed out', @@ -24,12 +25,12 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR }); } - if (response._shards && response._shards.failed) { + if (rawResponse._shards && rawResponse._shards.failed) { const title = i18n.translate('data.search.searchSource.fetch.shardsFailedNotificationMessage', { defaultMessage: '{shardsFailed} of {shardsTotal} shards failed', values: { - shardsFailed: response._shards.failed, - shardsTotal: response._shards.total, + shardsFailed: rawResponse._shards.failed, + shardsTotal: rawResponse._shards.total, }, }); const description = i18n.translate( @@ -43,7 +44,7 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR <> {description} - + ); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index 89b696a80d57d..0e81f362a030d 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -144,7 +144,7 @@ describe('SearchInterceptor', () => { describe('search', () => { test('Observable should resolve if fetch is successful', async () => { - const mockResponse: any = { result: 200 }; + const mockResponse: any = { rawResponse: {} }; fetchMock.mockResolvedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, @@ -233,6 +233,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -255,6 +256,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -281,6 +283,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -289,6 +292,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -325,6 +329,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -349,6 +354,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -357,6 +363,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -389,6 +396,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -433,6 +441,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -441,6 +450,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -511,7 +521,10 @@ describe('SearchInterceptor', () => { sessionId, }); - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); expect(fetchMock.mock.calls[0][0]).toEqual( expect.objectContaining({ options: { sessionId, isStored: true, isRestore: true, strategy: 'ese' }, @@ -527,7 +540,10 @@ describe('SearchInterceptor', () => { const sessionId = 'sid'; setup(null); - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); expect(fetchMock.mock.calls[0][0]).toEqual( expect.not.objectContaining({ options: { sessionId }, @@ -548,6 +564,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -556,6 +573,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -792,6 +810,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -838,6 +857,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: false, + rawResponse: {}, id: 1, }, }, diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 383e09b4a6ebe..f52c622c48ed0 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -251,7 +251,7 @@ export class SearchService implements Plugin { private registerSearchStrategy = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, - SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse + SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse >( name: string, strategy: ISearchStrategy diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4099d5e8ef7e2..c66ca19c96743 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -431,7 +431,7 @@ function discoverController($route, $scope) { }, }) .toPromise() - .then(onResults) + .then(({ rawResponse }) => onResults(rawResponse)) .catch((error) => { // If the request was aborted then no need to surface this error in the UI if (error instanceof Error && error.name === 'AbortError') return; diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index dbaf07fed18c2..99ecb4c11eef2 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -325,7 +325,7 @@ export class SearchEmbeddable try { // Make the request - const resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 50043772af95b..8e31ad7855197 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,9 +167,8 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - let resp; try { - resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, @@ -182,6 +181,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }, }) .toPromise(); + return resp; } catch (error) { if (isSearchSourceAbortError(error)) { throw new DataRequestAbortError(); @@ -194,8 +194,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }) ); } - - return resp; } async makeSearchSource( diff --git a/x-pack/test/functional/apps/maps/blended_vector_layer.js b/x-pack/test/functional/apps/maps/blended_vector_layer.js index 6d4fca1b0b7c0..e207410eb2281 100644 --- a/x-pack/test/functional/apps/maps/blended_vector_layer.js +++ b/x-pack/test/functional/apps/maps/blended_vector_layer.js @@ -27,20 +27,20 @@ export default function ({ getPageObjects, getService }) { }); it('should request documents when zoomed to smaller regions showing less data', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); // Allow a range of hits to account for variances in browser window size. expect(response.hits.hits.length).to.be.within(30, 40); }); it('should request clusters when zoomed to larger regions showing lots of data', async () => { await PageObjects.maps.setView(20, -90, 2); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(17); }); it('should request documents when query narrows data', async () => { await PageObjects.maps.setAndSubmitQuery('bytes > 19000'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.hits.hits.length).to.equal(75); }); }); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index 1d6477b243cdf..fb0fdcf333cf2 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -23,7 +23,7 @@ export default function ({ getPageObjects, getService }) { it('should only fetch geo_point field and nothing else when source does not have data driven styling', async () => { await PageObjects.maps.loadSavedMap('document example'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['geo.coordinates']); @@ -31,7 +31,7 @@ export default function ({ getPageObjects, getService }) { it('should only fetch geo_point field and data driven styling fields', async () => { await PageObjects.maps.loadSavedMap('document example with data driven styles'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['bytes', 'geo.coordinates', 'hour_of_day']); @@ -39,7 +39,7 @@ export default function ({ getPageObjects, getService }) { it('should format date fields as epoch_millis when data driven styling is applied to a date field', async () => { await PageObjects.maps.loadSavedMap('document example with data driven styles on date field'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['@timestamp', 'bytes', 'geo.coordinates']); diff --git a/x-pack/test/functional/apps/maps/embeddable/dashboard.js b/x-pack/test/functional/apps/maps/embeddable/dashboard.js index 860273bc23cc1..6c962c98c6a98 100644 --- a/x-pack/test/functional/apps/maps/embeddable/dashboard.js +++ b/x-pack/test/functional/apps/maps/embeddable/dashboard.js @@ -83,7 +83,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply container state (time, query, filters) to embeddable when loaded', async () => { - const response = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: response } = await PageObjects.maps.getResponseFromDashboardPanel( 'geo grid vector grid example' ); expect(response.aggregations.gridSplit.buckets.length).to.equal(6); @@ -95,12 +95,12 @@ export default function ({ getPageObjects, getService }) { await filterBar.selectIndexPattern('meta_for_geo_shapes*'); await filterBar.addFilter('shape_name', 'is', 'alpha'); // runtime fields do not have autocomplete - const gridResponse = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: gridResponse } = await PageObjects.maps.getResponseFromDashboardPanel( 'geo grid vector grid example' ); expect(gridResponse.aggregations.gridSplit.buckets.length).to.equal(1); - const joinResponse = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponseFromDashboardPanel( 'join example', 'meta_for_geo_shapes*.runtime_shape_name' ); diff --git a/x-pack/test/functional/apps/maps/es_geo_grid_source.js b/x-pack/test/functional/apps/maps/es_geo_grid_source.js index 6dee4b87bceea..27949ca720e34 100644 --- a/x-pack/test/functional/apps/maps/es_geo_grid_source.js +++ b/x-pack/test/functional/apps/maps/es_geo_grid_source.js @@ -141,7 +141,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to geotile_grid aggregation request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(1); }); }); @@ -152,7 +152,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(4); }); @@ -204,7 +204,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to geotile_grid aggregation request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(1); }); }); @@ -215,7 +215,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(4); }); @@ -244,7 +244,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(13); }); }); diff --git a/x-pack/test/functional/apps/maps/es_pew_pew_source.js b/x-pack/test/functional/apps/maps/es_pew_pew_source.js index 66406cd6d8f91..ea94ee3bc67d8 100644 --- a/x-pack/test/functional/apps/maps/es_pew_pew_source.js +++ b/x-pack/test/functional/apps/maps/es_pew_pew_source.js @@ -24,7 +24,7 @@ export default function ({ getPageObjects, getService }) { }); it('should request source clusters for destination locations', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.destSplit.buckets.length).to.equal(2); }); diff --git a/x-pack/test/functional/apps/maps/joins.js b/x-pack/test/functional/apps/maps/joins.js index 181b6928e0ec0..a3210e61f86a9 100644 --- a/x-pack/test/functional/apps/maps/joins.js +++ b/x-pack/test/functional/apps/maps/joins.js @@ -121,7 +121,7 @@ export default function ({ getPageObjects, getService }) { }); it('should not apply query to source and apply query to join', async () => { - const joinResponse = await PageObjects.maps.getResponse( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponse( 'meta_for_geo_shapes*.runtime_shape_name' ); expect(joinResponse.aggregations.join.buckets.length).to.equal(2); @@ -138,7 +138,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to join request', async () => { - const joinResponse = await PageObjects.maps.getResponse( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponse( 'meta_for_geo_shapes*.runtime_shape_name' ); expect(joinResponse.aggregations.join.buckets.length).to.equal(1); From b31f4a1a97736043a18e2dc2741ddd0667ec0128 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 28 Apr 2021 15:26:47 -0400 Subject: [PATCH 58/68] [actions] adds config allowing per-host networking options (#96630) resolves: https://github.com/elastic/kibana/issues/80120 Adds a new Kibana configuration key xpack.actions.customHostSettings which allows per-host configuration of connection settings for https and smtp for alerting actions. Initially this is just for TLS settings, expandable to other settings in the future. The purpose of these is to allow customers to provide server certificates for servers accessed by actions, whose certificate authority is not available publicly. Alternatively, a per-server rejectUnauthorized: false configuration may be used to bypass the verification step for specific servers, but require it for other servers that do not have per-host customization. Support was also added to allow per-host customization of ignoreTLS and requireTLS flags for use with the email action. --- docs/settings/alert-action-settings.asciidoc | 91 +++- .../alerting-troubleshooting.asciidoc | 16 + .../resources/base/bin/kibana-docker | 1 + .../actions/server/actions_config.mock.ts | 1 + .../actions/server/actions_config.test.ts | 81 +++ .../plugins/actions/server/actions_config.ts | 27 +- .../server/builtin_action_types/email.test.ts | 2 + .../lib/axios_utils_connection.test.ts | 277 ++++++++++ .../lib/get_custom_agents.test.ts | 118 ++++ .../lib/get_custom_agents.ts | 45 +- .../lib/send_email.test.ts | 144 ++++- .../builtin_action_types/lib/send_email.ts | 35 +- .../server/builtin_action_types/teams.test.ts | 2 + .../builtin_action_types/webhook.test.ts | 2 + x-pack/plugins/actions/server/config.test.ts | 13 + x-pack/plugins/actions/server/config.ts | 25 + .../server/lib/custom_host_settings.test.ts | 504 ++++++++++++++++++ .../server/lib/custom_host_settings.ts | 173 ++++++ x-pack/plugins/actions/server/plugin.ts | 6 +- .../alerting_api_integration/common/config.ts | 65 ++- .../common/lib/get_tls_webhook_servers.ts | 78 +++ .../tests/actions/get_all.ts | 24 +- .../spaces_only/config.ts | 1 + .../actions/builtin_action_types/webhook.ts | 89 ++++ .../spaces_only/tests/actions/get_all.ts | 37 +- 25 files changed, 1840 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts create mode 100644 x-pack/plugins/actions/server/lib/custom_host_settings.test.ts create mode 100644 x-pack/plugins/actions/server/lib/custom_host_settings.ts create mode 100644 x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c748d63484e28..50ed0d2652c6f 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -47,6 +47,88 @@ You can configure the following settings in the `kibana.yml` file. | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. + +| `xpack.actions.customHostSettings` {ess-icon} + | A list of custom host settings to override existing global settings. + Defaults to an empty list. + + + + Each entry in the list must have a `url` property, to associate a connection + type (mail or https), hostname and port with the remaining options in the + entry. + + + In the following example, two custom host settings + are defined. The first provides a custom host setting for mail server + `mail.example.com` using port 465 that supplies server certificate authorization + data from both a file and inline, and requires TLS for the + connection. The second provides a custom host setting for https server + `webhook.example.com` which turns off server certificate authorization. + +|=== + +[source,yaml] +-- +xpack.actions.customHostSettings: + - url: smtp://mail.example.com:465 + tls: + certificateAuthoritiesFiles: [ 'one.crt' ] + certificateAuthoritiesData: | + -----BEGIN CERTIFICATE----- + ... multiple lines of certificate data here ... + -----END CERTIFICATE----- + smtp: + requireTLS: true + - url: https://webhook.example.com + tls: + rejectUnauthorized: false +-- + +[cols="2*<"] +|=== + +| `xpack.actions.customHostSettings[n]` +`.url` {ess-icon} + | A URL associated with this custom host setting. Should be in the form of + `protocol://hostname:port`, where `protocol` is `https` or `smtp`. If the + port is not provided, 443 is used for `https` and 25 is used for + `smtp`. The `smtp` URLs are used for the Email actions that use this + server, and the `https` URLs are used for actions which use `https` to + connect to services. + + + + Entries with `https` URLs can use the `tls` options, and entries with `smtp` + URLs can use both the `tls` and `smtp` options. + + + + No other URL values should be part of this URL, including paths, + query strings, and authentication information. When an http or smtp request + is made as part of executing an action, only the protocol, hostname, and + port of the URL for that request are used to look up these configuration + values. + +| `xpack.actions.customHostSettings[n]` +`.smtp.ignoreTLS` {ess-icon} + | A boolean value indicating that TLS must not be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.smtp.requireTLS` {ess-icon} + | A boolean value indicating that TLS must be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.tls.rejectUnauthorized` {ess-icon} + | A boolean value indicating whether to bypass server certificate validation. + Overrides the general `xpack.actions.rejectUnauthorized` configuration + for requests made for this hostname/port. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesFiles` + | A file name or list of file names of PEM-encoded certificate files to use + to validate the server. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesData` {ess-icon} + | The contents of a PEM-encoded certificate file, or multiple files appended + into a single string. This configuration can be used for environments where + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -79,13 +161,18 @@ a|`xpack.actions.` | `xpack.actions.rejectUnauthorized` {ess-icon} | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + - As an alternative to setting both `xpack.actions.proxyRejectUnauthorizedCertificates` and `xpack.actions.rejectUnauthorized`, you can point the OS level environment variable `NODE_EXTRA_CA_CERTS` to a file that contains the root CAs needed to trust certificates. + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. | `xpack.actions.maxResponseContentLength` {ess-icon} | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} - | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as [ms|s|m|h|d|w|M|Y], for example, '20m', '24h', '7d', '1w'. Defaults to 60s. + | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + + + `[ms,s,m,h,d,w,M,Y]` + + + + For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. |=== diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index f4673d10bc248..6d4a0e9375678 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -53,3 +53,19 @@ Alerting and action tasks are identified by their type. When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +=== Connectors have TLS errors when executing actions + +*Problem*: + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Resolution*: + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 220bd2c91057d..3b2feeecabb7c 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -162,6 +162,7 @@ kibana_vars=( timelion.enabled vega.enableExternalUrls xpack.actions.allowedHosts + xpack.actions.customHostSettings xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfiguredAlertHistoryEsIndex diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 76f6a62ce6597..fbd9a8cddbdcb 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -21,6 +21,7 @@ const createActionsConfigMock = () => { maxContentLength: 1000000, timeout: 360000, }), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 70c8b0e8185d5..925e77ca85fb2 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -13,8 +13,14 @@ import { AllowedHosts, EnabledActionTypes, } from './actions_config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; +import { Logger } from '../../../../src/core/server'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; + import moment from 'moment'; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], @@ -355,4 +361,79 @@ describe('getProxySettings', () => { const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); }); + + test('getCustomHostSettings() returns undefined when no matching config', () => { + const httpsUrl = 'https://elastic.co/foo/bar'; + const smtpUrl = 'smtp://elastic.co'; + let config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://www.elastic.co:443', + }, + ], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + }); + + test('getCustomHostSettings() returns matching config', () => { + const httpsUrl = 'https://elastic.co/ignoring/paths/here'; + const smtpUrl = 'smtp://elastic.co:123'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'smtp://elastic.co:123', + tls: { + rejectUnauthorized: false, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(config.customHostSettings![0]); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(config.customHostSettings![1]); + }); + + test('getCustomHostSettings() returns undefined when bad url is passed in', () => { + const badUrl = 'https://elastic.co/foo/bar'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + const chs = getActionsConfigurationUtilities(config).getCustomHostSettings(badUrl); + expect(chs).toEqual(undefined); + }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 4c73cab76f9e8..b8cd5878a8972 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,8 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings } from './types'; @@ -32,6 +33,7 @@ export interface ActionsConfigurationUtilities { isRejectUnauthorizedCertificatesEnabled: () => boolean; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; + getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -107,6 +109,27 @@ function getResponseSettingsFromConfig(config: ActionsConfig): ResponseSettings }; } +function getCustomHostSettings( + config: ActionsConfig, + targetUrl: string +): CustomHostSettings | undefined { + const customHostSettings = config.customHostSettings; + if (!customHostSettings) { + return; + } + + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(targetUrl); + } catch (err) { + // presumably this bad URL is reported elsewhere + return; + } + + const canonicalUrl = getCanonicalCustomHostUrl(parsedUrl); + return customHostSettings.find((settings) => settings.url === canonicalUrl); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { @@ -119,6 +142,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), + // returns the global rejectUnauthorized setting isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { @@ -135,5 +159,6 @@ export function getActionsConfigurationUtilities( throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config'); } }, + getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4596619c50940..5747b4bbb28f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -282,6 +282,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -342,6 +343,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts new file mode 100644 index 0000000000000..80bf51e19c379 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import http from 'http'; +import https from 'https'; +import axios from 'axios'; +import { duration as momentDuration } from 'moment'; +import { schema } from '@kbn/config-schema'; + +import { request } from './axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createReadySignal } from '../../../../event_log/server/lib/ready_signal'; +import { ActionsConfig } from '../../config'; +import { + ActionsConfigurationUtilities, + getActionsConfigurationUtilities, +} from '../../actions_config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeAll(() => { + // needed to prevent the dreaded Error: Cross origin http://localhost forbidden + // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 + savedAxiosDefaultsAdapter = axios.defaults.adapter; + axios.defaults.adapter = require('axios/lib/adapters/http'); + }); + + afterAll(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer.close(); + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer(); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with rejectUnauthorized false config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with rejectUnauthorized custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + rejectUnauthorized: false, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer(true); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 1', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 2', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); +}); + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(useHttps: boolean = false): Promise { + let server: http.Server | https.Server; + const readySignal = createReadySignal(); + + if (!useHttps) { + server = http.createServer((req, res) => { + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + }); + } else { + const httpsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, + }; + server = https.createServer(httpsOptions, (req, res) => { + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + } + + server.listen(() => { + const addressInfo = server.address(); + if (addressInfo == null || typeof addressInfo === 'string') { + server.close(); + throw new Error('error getting address of server, closing'); + } + + const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); + readySignal.signal({ server, url }); + }); + + // let the node process stop if for some reason this server isn't closed + server.unref(); + + return readySignal.wait(); +} + +const BaseActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + return getActionsConfigurationUtilities({ + ...BaseActionsConfig, + ...config, + }); +} + +function localUrlFromPort(useHttps: boolean, port: number, host: string): string { + return `${useHttps ? 'https' : 'http'}://${host}:${port}`; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index f6d1be9bffc6b..805c22806ce4c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const targetHost = 'elastic.co'; const targetUrl = `https://${targetHost}/foo/bar/baz`; +const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', @@ -106,4 +111,117 @@ describe('getCustomAgents', () => { expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); }); + + test('handles custom host settings', () => { + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles custom host settings with proxy', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles overriding global rejectUnauthorized false', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); + + test('handles overriding global rejectUnauthorized false with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index ff2d005f4d841..6ec926004e73e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -6,7 +6,7 @@ */ import { Agent as HttpAgent } from 'http'; -import { Agent as HttpsAgent } from 'https'; +import { Agent as HttpsAgent, AgentOptions } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; @@ -22,7 +22,8 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const proxySettings = configurationUtilities.getProxySettings(); + // the default for rejectUnauthorized is the global setting, which can + // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ @@ -30,10 +31,39 @@ export function getCustomAgents( }), }; + // Get the current proxy settings, and custom host settings for this URL. + // If there are neither of these, return the default agents + const proxySettings = configurationUtilities.getProxySettings(); + const customHostSettings = configurationUtilities.getCustomHostSettings(url); + if (!proxySettings && !customHostSettings) { + return defaultAgents; + } + + // update the defaultAgents.httpsAgent if configured + const tlsSettings = customHostSettings?.tls; + let agentOptions: AgentOptions | undefined; + if (tlsSettings) { + logger.debug(`Creating customized connection settings for: ${url}`); + agentOptions = defaultAgents.httpsAgent.options; + + if (tlsSettings.certificateAuthoritiesData) { + agentOptions.ca = tlsSettings.certificateAuthoritiesData; + } + + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + if (tlsSettings.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + } + } + + // if there weren't any proxy settings, return the currently calculated agents if (!proxySettings) { return defaultAgents; } + // there is a proxy in use, but it's possible we won't use it via custom host + // proxyOnlyHosts and proxyBypassHosts let targetUrl: URL; try { targetUrl = new URL(url); @@ -56,6 +86,7 @@ export function getCustomAgents( return defaultAgents; } } + logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { @@ -65,6 +96,9 @@ export function getCustomAgents( return defaultAgents; } + // At this point, we are going to use a proxy, so we need new agents. + // We will though, copy over the calculated tls options from above, into + // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ host: proxyUrl.hostname, @@ -76,5 +110,12 @@ export function getCustomAgents( }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it + if (agentOptions) { + httpsAgent.options = { + ...httpsAgent.options, + ...agentOptions, + }; + } + return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 4b45c6d787cd6..cceeefde71dc2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -356,16 +357,151 @@ describe('send_email module', () => { ] `); }); + + test('it handles custom host settings from config', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + }, + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // note in the object below, the rejectUnauthenticated got set to false, + // given the implementation allowing that for no auth and !secure. + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it allows custom host settings to override calculated values', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + requireTLS: false, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // in this case, rejectUnauthorized is true, as the custom host settings + // overrode the calculated value of false + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "ignoreTLS": true, + "port": 1025, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); + + test('it handles custom host settings with a proxy', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": undefined, + "host": "example.com", + "port": 1025, + "proxy": "https://proxy.com", + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); }); function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, @@ -392,12 +528,16 @@ function getSendEmailOptions( function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index c0a254967b4fe..005e73b1fc2f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -11,6 +11,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { CustomHostSettings } from '../../config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -52,7 +53,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record = {}; + // The transport options do not seem to be exposed as a type, and we reference + // some deep properties, so need to use any here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); @@ -73,6 +77,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom useProxy = false; } } + let customHostSettings: CustomHostSettings | undefined; if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; @@ -83,6 +88,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.host = host; transportConfig.port = port; transportConfig.secure = !!secure; + customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { transportConfig.tls = { @@ -99,6 +105,33 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom } else { transportConfig.tls = { rejectUnauthorized }; } + + // finally, allow customHostSettings to override some of the settings + // see: https://nodemailer.com/smtp/ + if (customHostSettings) { + const tlsConfig: Record = {}; + const tlsSettings = customHostSettings.tls; + const smtpSettings = customHostSettings.smtp; + + if (tlsSettings?.certificateAuthoritiesData) { + tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + } + if (tlsSettings?.rejectUnauthorized !== undefined) { + tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; + } + + if (!transportConfig.tls) { + transportConfig.tls = tlsConfig; + } else { + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + } + + if (smtpSettings?.ignoreTLS) { + transportConfig.ignoreTLS = true; + } else if (smtpSettings?.requireTLS) { + transportConfig.requireTLS = true; + } + } } const nodemailerTransport = nodemailer.createTransport(transportConfig); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 8a185d353de02..95088fa5f7965 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -167,6 +167,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -230,6 +231,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d3f059eede615..00e56303dbe22 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -290,6 +290,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -382,6 +383,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 092b5d2cce587..4c4fd143369e1 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -164,6 +164,19 @@ describe('config validation', () => { ] `); }); + + // Most of the customHostSettings tests are in ./lib/custom_host_settings.test.ts + // but this one seemed more relevant for this test suite, since url is the one + // required property. + test('validates customHostSettings contains a URL', () => { + const config: Record = { + customHostSettings: [{}], + }; + + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` + ); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 7225c54d57596..0dc1aed68f4d0 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -23,6 +23,30 @@ const preconfiguredActionSchema = schema.object({ secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), }); +const customHostSettingsSchema = schema.object({ + url: schema.string({ minLength: 1 }), + smtp: schema.maybe( + schema.object({ + ignoreTLS: schema.maybe(schema.boolean()), + requireTLS: schema.maybe(schema.boolean()), + }) + ), + tls: schema.maybe( + schema.object({ + rejectUnauthorized: schema.maybe(schema.boolean()), + certificateAuthoritiesFiles: schema.maybe( + schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]) + ), + certificateAuthoritiesData: schema.maybe(schema.string({ minLength: 1 })), + }) + ), +}); + +export type CustomHostSettings = TypeOf; + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( @@ -50,6 +74,7 @@ export const configSchema = schema.object({ rejectUnauthorized: schema.boolean({ defaultValue: true }), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), + customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), cleanupFailedExecutionsTask: schema.object({ enabled: schema.boolean({ defaultValue: true }), cleanupInterval: schema.duration({ defaultValue: '5m' }), diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts new file mode 100644 index 0000000000000..ad07ea21d7917 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -0,0 +1,504 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +import { ActionsConfig } from '../config'; +import { Logger } from '../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; + +import { resolveCustomHosts, getCanonicalCustomHostUrl } from './custom_host_settings'; + +const CA_DIR = '../../../../../../packages/kbn-dev-utils/certs'; +const CA_FILE1 = pathResolve(__filename, pathJoin(CA_DIR, 'ca.crt')); +const CA_CONTENTS1 = fsReadFileSync(CA_FILE1, 'utf8'); +const CA_FILE2 = pathResolve(__filename, pathJoin(CA_DIR, 'kibana.crt')); +const CA_CONTENTS2 = fsReadFileSync(CA_FILE2, 'utf8'); + +let mockLogger: Logger = loggingSystemMock.create().get(); + +function warningLogs() { + const calls = loggingSystemMock.collect(mockLogger).warn; + return calls.map((call) => `${call[0]}`); +} + +describe('custom_host_settings', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockLogger = loggingSystemMock.create().get(); + }); + + describe('getCanonicalCustomHostUrl()', () => { + test('minimal urls', () => { + expect(getCanonicalCustomHostUrl(new URL('http://elastic.com'))).toBe( + 'http://elastic.com:80' + ); + expect(getCanonicalCustomHostUrl(new URL('https://elastic.co'))).toBe( + 'https://elastic.co:443' + ); + expect(getCanonicalCustomHostUrl(new URL('smtp://mail.elastic.co'))).toBe( + 'smtp://mail.elastic.co:25' + ); + expect(warningLogs()).toEqual([]); + }); + + test('maximal urls', () => { + expect( + getCanonicalCustomHostUrl(new URL('http://user1:pass1@elastic.co:81/foo?bar#car')) + ).toBe('http://elastic.co:81'); + expect( + getCanonicalCustomHostUrl(new URL('https://user1:pass1@elastic.co:82/foo?bar#car')) + ).toBe('https://elastic.co:82'); + expect( + getCanonicalCustomHostUrl(new URL('smtp://user1:pass1@mail.elastic.co:83/foo?bar#car')) + ).toBe('smtp://mail.elastic.co:83'); + expect(warningLogs()).toEqual([]); + }); + }); + + describe('resolveCustomHosts()', () => { + const defaultActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: [], + enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration(60000), + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, + }; + + test('ensure it copies over the config parts that it does not touch', () => { + const config: ActionsConfig = { ...defaultActionsConfig }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles undefined customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: undefined }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles empty object customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: [] }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple valid settings', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co:443', + tls: { + certificateAuthoritiesData: 'xyz', + rejectUnauthorized: false, + }, + }, + { + url: 'smtp://mail.elastic.com:25', + tls: { + certificateAuthoritiesData: 'abc', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles bad url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'this! is! not! a! url!', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, invalid URL \\"this! is! not! a! url!\\", ignoring; error: Invalid URL: this! is! not! a! url!", + ] + `); + }); + + test('handles bad port', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:0', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unable to determine port for URL \\"https://almost.purrfect.com:0\\", ignoring", + ] + `); + }); + + test('handles auth info', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://kitty:cat@almost.purrfect.com', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://kitty:cat@almost.purrfect.com\\" contains authentication information which will be ignored, but should be removed from the configuration", + ] + `); + }); + + test('handles hash', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com#important', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com#important\\" contains hash information which will be ignored", + ] + `); + }); + + test('handles path', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/about', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/about\\" contains path information which will be ignored", + ] + `); + }); + + test('handles / path same as no path, since we have no choice', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles unsupported URL protocols', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'http://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unsupported protocol used in URL \\"http://almost.purrfect.com/\\", ignoring", + ] + `); + }); + + test('handles smtp options for non-smtp urls', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/\\" contains smtp properties but does not use smtp; ignoring smtp properties", + ] + `); + }); + + test('handles ca files not found', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "error reading file \\"this-file-does-not-exist\\" specified in xpack.actions.customHosts, ignoring: ENOENT: no such file or directory, open 'this-file-does-not-exist'", + ] + `); + }); + + test('handles a single ca file', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: CA_FILE1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple ca files', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles ca files and ca data', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE2], + certificateAuthoritiesData: CA_CONTENTS1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles smtp ignoreTLS and requireTLS both used', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + requireTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com:25', + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"smtp://almost.purrfect.com/\\" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false", + ] + `); + }); + + test('handles duplicate URLs', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: false, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, multiple URLs match the canonical url \\"https://almost.purrfect.com:443\\"; only the first will be used", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts new file mode 100644 index 0000000000000..bfc8dad48aab6 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from 'fs'; +import { cloneDeep } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; +import { ActionsConfig, CustomHostSettings } from '../config'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +type ActionsConfigWriteable = DeepWriteable; +type CustomHostSettingsWriteable = DeepWriteable; + +export function getCanonicalCustomHostUrl(url: URL): string { + const port = getActualPort(url.protocol, url.port); + + return `${url.protocol}//${url.hostname}:${port}`; +} + +const ErrorPrefix = 'In configuration xpack.actions.customHosts,'; +const ValidProtocols = new Set(['https:', 'smtp:']); +const ProtocolsForSmtp = new Set(['smtp:']); + +// converts the custom host data in config, for ease of use, and to perform +// validation we can't do in config-schema, since the cloud validation can't +// do these sorts of validations +export function resolveCustomHosts(logger: Logger, config: ActionsConfig): ActionsConfig { + const result: ActionsConfigWriteable = cloneDeep(config); + + if (!result.customHostSettings) { + return result as ActionsConfig; + } + + const savedSettings: CustomHostSettingsWriteable[] = []; + + for (const customHostSetting of result.customHostSettings) { + const originalUrl = customHostSetting.url; + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(originalUrl); + } catch (err) { + logger.warn(`${ErrorPrefix} invalid URL "${originalUrl}", ignoring; error: ${err.message}`); + continue; + } + + customHostSetting.url = getCanonicalCustomHostUrl(parsedUrl); + + if (!ValidProtocols.has(parsedUrl.protocol)) { + logger.warn(`${ErrorPrefix} unsupported protocol used in URL "${originalUrl}", ignoring`); + continue; + } + + const port = getActualPort(parsedUrl.protocol, parsedUrl.port); + if (!port) { + logger.warn(`${ErrorPrefix} unable to determine port for URL "${originalUrl}", ignoring`); + continue; + } + + if (parsedUrl.username || parsedUrl.password) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains authentication information which will be ignored, but should be removed from the configuration` + ); + } + + if (parsedUrl.hash) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains hash information which will be ignored` + ); + } + + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains path information which will be ignored` + ); + } + + if (!ProtocolsForSmtp.has(parsedUrl.protocol) && customHostSetting.smtp) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains smtp properties but does not use smtp; ignoring smtp properties` + ); + delete customHostSetting.smtp; + } + + // read the specified ca files, add their content to certificateAuthoritiesData + if (customHostSetting.tls) { + let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (typeof files === 'string') { + files = [files]; + } + for (const file of files) { + const contents = getFileContents(logger, file); + if (contents) { + appendToCertificateAuthoritiesData(customHostSetting, contents); + } + } + } + + const customSmtpSettings = customHostSetting.smtp; + if (customSmtpSettings) { + if (customSmtpSettings.requireTLS && customSmtpSettings.ignoreTLS) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false` + ); + customSmtpSettings.requireTLS = true; + customSmtpSettings.ignoreTLS = false; + } + } + + savedSettings.push(customHostSetting); + } + + // check to see if there are any dups on the url + const existingUrls = new Set(); + for (const customHostSetting of savedSettings) { + const url = customHostSetting.url; + if (existingUrls.has(url)) { + logger.warn( + `${ErrorPrefix} multiple URLs match the canonical url "${url}"; only the first will be used` + ); + // mark this entry to be able to delete it after processing them all + customHostSetting.url = ''; + } + existingUrls.add(url); + } + + // remove the settings we want to skip + result.customHostSettings = savedSettings.filter((setting) => setting.url !== ''); + + return result as ActionsConfig; +} + +function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { + const tls = customHost.tls; + if (tls) { + if (!tls.certificateAuthoritiesData) { + tls.certificateAuthoritiesData = cert; + } else { + tls.certificateAuthoritiesData += '\n' + cert; + } + } +} + +function getFileContents(logger: Logger, fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch (err) { + logger.warn( + `error reading file "${fileName}" specified in xpack.actions.customHosts, ignoring: ${err.message}` + ); + return; + } +} + +// 0 isn't a valid port, so result can be checked as falsy +function getActualPort(protocol: string, port: string): number { + if (port !== '') { + const portNumber = parseInt(port, 10); + if (isNaN(portNumber)) { + return 0; + } + return portNumber; + } + + // from https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_port + if (protocol === 'http:') return 80; + if (protocol === 'https:') return 443; + if (protocol === 'smtp:') return 25; + return 0; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 106e41259e692..2036ed6c7d343 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,6 +35,7 @@ import { } from './cleanup_failed_executions'; import { ActionsConfig, getValidatedConfig } from './config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction } from './create_execute_function'; @@ -157,7 +158,10 @@ export class ActionsPlugin implements Plugin()); + this.actionsConfig = getValidatedConfig( + this.logger, + resolveCustomHosts(this.logger, initContext.config.get()) + ); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 6a0ab54087844..7844eaf3920c6 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -12,6 +12,7 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { getAllExternalServiceSimulatorPaths } from './fixtures/plugins/actions_simulators/server/plugin'; +import { getTlsWebhookServerUrls } from './lib/get_tls_webhook_servers'; interface CreateTestConfigOptions { license: string; @@ -21,6 +22,7 @@ interface CreateTestConfigOptions { rejectUnauthorized?: boolean; publicBaseUrl?: boolean; preconfiguredAlertHistoryEsIndex?: boolean; + customizeLocalHostTls?: boolean; } // test.not-enabled is specifically not enabled @@ -49,6 +51,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ssl = false, rejectUnauthorized = true, preconfiguredAlertHistoryEsIndex = false, + customizeLocalHostTls = false, } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -69,7 +72,11 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ); const proxyPort = - process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6300) })); + process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6299) })); + + // Create URLs of identical simple webhook servers using TLS, but we'll + // create custom host settings for them below. + const tlsWebhookServers = await getTlsWebhookServerUrls(6300, 6399); // If testing with proxy, also test proxyOnlyHosts for this proxy; // all the actions are assumed to be acccessing localhost anyway. @@ -89,6 +96,32 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.proxyBypassHosts=${JSON.stringify(proxyHosts)}`, ]; + // set up custom host settings for webhook ports; don't set one for noCustom + const customHostSettingsValue = [ + { + url: tlsWebhookServers.rejectUnauthorizedFalse, + tls: { + rejectUnauthorized: false, + }, + }, + { + url: tlsWebhookServers.rejectUnauthorizedTrue, + tls: { + rejectUnauthorized: true, + }, + }, + { + url: tlsWebhookServers.caFile, + tls: { + rejectUnauthorized: true, + certificateAuthoritiesFiles: [CA_CERT_PATH], + }, + }, + ]; + const customHostSettings = customizeLocalHostTls + ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] + : []; + return { testFiles: [require.resolve(`../${name}/tests/`)], servers, @@ -119,7 +152,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, ...actionsProxyUrl, - + ...customHostSettings, '--xpack.eventLog.logEntries=true', `--xpack.actions.preconfiguredAlertHistoryEsIndex=${preconfiguredAlertHistoryEsIndex}`, `--xpack.actions.preconfigured=${JSON.stringify({ @@ -162,6 +195,34 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) encrypted: 'this-is-also-ignored-and-also-required', }, }, + 'custom.tls.noCustom': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.noCustom}`, + config: { + url: tlsWebhookServers.noCustom, + }, + }, + 'custom.tls.rejectUnauthorizedFalse': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedFalse}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedFalse, + }, + }, + 'custom.tls.rejectUnauthorizedTrue': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedTrue}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedTrue, + }, + }, + 'custom.tls.caFile': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.caFile}`, + config: { + url: tlsWebhookServers.caFile, + }, + }, })}`, ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), ...plugins.map( diff --git a/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts new file mode 100644 index 0000000000000..026cf21cb5920 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs'; +import https from 'https'; +import getPort from 'get-port'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; + +interface TlsWebhookURLs { + noCustom: string; + rejectUnauthorizedFalse: string; + rejectUnauthorizedTrue: string; + caFile: string; +} + +const ServerCert = fs.readFileSync(KBN_CERT_PATH, 'utf8'); +const ServerKey = fs.readFileSync(KBN_KEY_PATH, 'utf8'); + +export async function getTlsWebhookServerUrls( + portRangeStart: number, + portRangeEnd: number +): Promise { + let port: number; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const noCustom = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedFalse = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedTrue = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const caFile = `https://localhost:${port}`; + + return { + noCustom, + rejectUnauthorizedFalse, + rejectUnauthorizedTrue, + caFile, + }; +} + +export async function createTlsWebhookServer(port: string): Promise { + const httpsOptions = { + cert: ServerCert, + key: ServerKey, + }; + + const server = https.createServer(httpsOptions, async (req, res) => { + if (req.method === 'POST' || req.method === 'PUT') { + const allRead = new Promise((resolve) => { + req.on('data', (chunk) => {}); + req.on('end', () => resolve(null)); + }); + await allRead; + } + + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + const listening = new Promise((resolve) => { + server.listen(port, () => { + resolve(null); + }); + }); + await listening; + + // let node exit even if we don't close this server + server.unref(); + + return server; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 059ef59fc614a..9a3a78342c5aa 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -60,7 +60,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -168,7 +174,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -252,7 +264,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'global_read at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-es-index-action', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 49d5f52869b89..3b3a15b6d62e4 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,5 +13,6 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, rejectUnauthorized: false, + customizeLocalHostTls: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index 8ef573a3ae2c3..4af33136cd42c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -15,6 +15,7 @@ import { getWebhookServer, getHttpsWebhookServer, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { createTlsWebhookServer } from '../../../../common/lib/get_tls_webhook_servers'; // eslint-disable-next-line import/no-default-export export default function webhookTest({ getService }: FtrProviderContext) { @@ -47,6 +48,19 @@ export default function webhookTest({ getService }: FtrProviderContext) { return createdAction.id; } + async function getPortOfConnector(connectorId: string): Promise { + const response = await supertest.get(`/api/actions/connectors`).expect(200); + const connector = response.body.find((conn: { id: string }) => conn.id === connectorId); + if (connector === undefined) { + throw new Error(`unable to find connector with id ${connectorId}`); + } + + // server URL is the connector name + const url = connector.name; + const parsedUrl = new URL(url); + return parsedUrl.port; + } + describe('webhook action', () => { describe('with http endpoint', () => { let webhookSimulatorURL: string = ''; @@ -108,5 +122,80 @@ export default function webhookTest({ getService }: FtrProviderContext) { webhookServer.close(); }); }); + + describe('tls customization', () => { + it('should handle the xpack.actions.rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.noCustom'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: true', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('error'); + expect(body.service_message.indexOf('certificate')).to.be.greaterThan(0); + }); + + it('should handle the customized ca file', async () => { + const connectorId = 'custom.tls.caFile'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 28abd0b79c57c..e7f500f2771e3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -35,7 +36,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -102,7 +113,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -159,7 +180,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', From 9a15accb7ff7d117e82d0c881e8a4ad6f600726d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 28 Apr 2021 16:00:05 -0400 Subject: [PATCH 59/68] [APM] Service overview page fetches data with wrong transaction type (#98657) --- .../public/components/shared/transaction_type_select.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index dc071fe93bbbd..9353c37b90728 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -10,7 +10,6 @@ import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { useApmServiceContext } from '../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; import * as urlHelpers from './Links/url_helpers'; // The default transaction type (for non-RUM services) is "request". Set the @@ -21,11 +20,8 @@ const EuiSelectWithWidth = styled(EuiSelect)` `; export function TransactionTypeSelect() { - const { transactionTypes } = useApmServiceContext(); + const { transactionTypes, transactionType } = useApmServiceContext(); const history = useHistory(); - const { - urlParams: { transactionType }, - } = useUrlParams(); const handleChange = useCallback( (event: FormEvent) => { From 9c469feb3befcabc9b3199ca10d9682a5c432986 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 28 Apr 2021 16:07:37 -0400 Subject: [PATCH 60/68] Allow reserved privileges to coexist with other privileges (#98530) --- x-pack/plugins/security/common/constants.ts | 6 ++++ .../privilege_space_table.test.tsx | 15 +++++++++ .../routes/authorization/roles/get.test.ts | 2 +- .../authorization/roles/get_all.test.ts | 29 ++++++++++++++--- .../roles/model/elasticsearch_role.ts | 31 ++++++++++--------- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index ef83230fc2aba..0ff04e4f731d0 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,6 +17,12 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; + +/** + * Reserved application privileges are always assigned to this "wildcard" application. + * This allows them to be applied to any Kibana "tenant" (`kibana.index`). Since reserved privileges are always assigned to reserved (built-in) roles, + * it's not possible to know the tenant ahead of time. + */ export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; /** diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 4c657294c965c..6f00df3a4ee7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -742,6 +742,21 @@ describe('global base read', () => { }); }); +describe('global and reserved', () => { + it('base all, reserved_foo', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { spaces: ['*'], base: [], feature: {}, _reserved: ['foo'] }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Foo', overridden: false } }, + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + ]); + }); +}); + describe('global normal feature privilege all', () => { describe('default and marketing space', () => { it('base all', () => { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 24366a250cf11..d2385adc99162 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -285,7 +285,7 @@ describe('GET role', () => { indices: [], applications: [ { - application, + application: reservedPrivilegesApplicationWildcard, privileges: ['reserved_customApplication1', 'reserved_customApplication2'], resources: ['*'], }, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index d490153b30394..09262d7cbbadd 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -283,7 +283,7 @@ describe('GET all roles', () => { indices: [], applications: [ { - application, + application: reservedPrivilegesApplicationWildcard, privileges: ['reserved_customApplication1', 'reserved_customApplication2'], resources: ['*'], }, @@ -1030,7 +1030,7 @@ describe('GET all roles', () => { ); getRolesTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + `reserved privilege assigned with a feature privilege returns populated kibana section`, { apiResponse: async () => ({ first_role: { @@ -1039,7 +1039,12 @@ describe('GET all roles', () => { applications: [ { application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + privileges: ['feature_foo.foo-privilege-1'], + resources: ['*'], + }, + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_foo'], resources: ['*'], }, ], @@ -1068,8 +1073,22 @@ describe('GET all roles', () => { indices: [], run_as: [], }, - kibana: [], - _transform_error: ['kibana'], + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['*'], + }, + { + base: [], + feature: {}, + _reserved: ['foo'], + spaces: ['*'], + }, + ], + _transform_error: [], _unrecognized_applications: [], }, ], diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts index 74a035cdd0cb6..fa119ca704753 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts @@ -83,13 +83,13 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if space privilege assigned globally, we can't transform these + // if there is a reserved privilege assigned to an application other than the reserved privileges application wildcard, we won't transform these. if ( roleKibanaApplications.some( (entry) => - entry.resources.includes(GLOBAL_RESOURCE) && + entry.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD && entry.privileges.some((privilege) => - PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ) ) ) { @@ -98,15 +98,13 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if global base or reserved privilege assigned at a space, we can't transform these + // if space privilege assigned globally, we can't transform these if ( roleKibanaApplications.some( (entry) => - !entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some( - (privilege) => - PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some((privilege) => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) ) ) ) { @@ -115,15 +113,15 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if reserved privilege assigned with feature or base privileges, we won't transform these + // if global base or reserved privilege assigned at a space, we can't transform these if ( roleKibanaApplications.some( (entry) => - entry.privileges.some((privilege) => - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) - ) && + !entry.resources.includes(GLOBAL_RESOURCE) && entry.privileges.some( - (privilege) => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + (privilege) => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ) ) ) { @@ -163,7 +161,10 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - const allResources = roleKibanaApplications.map((entry) => entry.resources).flat(); + const allResources = roleKibanaApplications + .filter((entry) => entry.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD) + .flatMap((entry) => entry.resources); + // if we have improperly formatted resource entries, we can't transform these if ( allResources.some( From 40fddce405fcbe62ed9aed115b1f5e3d5b0bbfeb Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 28 Apr 2021 16:05:52 -0600 Subject: [PATCH 61/68] [Maps] use index_exists route instead of /api/index_management/indices (#98479) * [Maps] use index_exists route instead of /api/index_management/indices * fix functional test * add retry and correct permissions to fix functional tests * fix upload functional test --- .../plugins/file_upload/public/api/index.ts | 18 +++-- .../geojson_upload_form.tsx | 20 ++++- .../geojson_upload_form/index_name_form.tsx | 29 +++++++- .../components/json_upload_and_parse.tsx | 8 +- .../public/lazy_load_bundle/index.ts | 3 +- .../public/util/indexing_service.ts | 73 ------------------- ...ce.test.ts => validate_index_name.test.ts} | 8 +- .../file_upload/public/validate_index_name.ts | 45 ++++++++++++ x-pack/plugins/file_upload/server/routes.ts | 2 +- .../layers/file_upload_wizard/wizard.tsx | 24 ++---- .../import_geojson/add_layer_import_panel.js | 31 ++++---- .../import_geojson/file_indexing_panel.js | 5 ++ 12 files changed, 143 insertions(+), 123 deletions(-) delete mode 100644 x-pack/plugins/file_upload/public/util/indexing_service.ts rename x-pack/plugins/file_upload/public/{util/indexing_service.test.ts => validate_index_name.test.ts} (88%) create mode 100644 x-pack/plugins/file_upload/public/validate_index_name.ts diff --git a/x-pack/plugins/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 86b2d37967daa..c2520547ddad9 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -92,13 +92,17 @@ export async function checkIndexExists( ): Promise { const body = JSON.stringify({ index }); const fileUploadModules = await lazyLoadModules(); - const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ - path: `/internal/file_upload/index_exists`, - method: 'POST', - body, - query: params, - }); - return exists; + try { + const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ + path: `/internal/file_upload/index_exists`, + method: 'POST', + body, + query: params, + }); + return exists; + } catch (error) { + return false; + } } export async function getTimeFieldRange(index: string, query: unknown, timeFieldName?: string) { diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx index 65866243a3e47..ddb0e7d9b2b22 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; import { IndexNameForm } from './index_name_form'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; const GEO_FIELD_TYPE_OPTIONS = [ { @@ -32,6 +32,8 @@ interface Props { onFileSelect: (onFileSelectParameters: OnFileSelectParameters) => void; onGeoFieldTypeSelect: (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => void; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } interface State { @@ -40,11 +42,20 @@ interface State { } export class GeoJsonUploadForm extends Component { + private _isMounted = false; state: State = { hasFile: false, isPointsOnly: false, }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + _onFileSelect = async (onFileSelectParameters: OnFileSelectParameters) => { this.setState({ hasFile: true, @@ -53,7 +64,12 @@ export class GeoJsonUploadForm extends Component { this.props.onFileSelect(onFileSelectParameters); + this.props.onIndexNameValidationStart(); const indexNameError = await validateIndexName(onFileSelectParameters.indexName); + if (!this._isMounted) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(onFileSelectParameters.indexName, indexNameError); const geoFieldType = @@ -107,6 +123,8 @@ export class GeoJsonUploadForm extends Component { indexName={this.props.indexName} indexNameError={this.props.indexNameError} onIndexNameChange={this.props.onIndexNameChange} + onIndexNameValidationStart={this.props.onIndexNameValidationStart} + onIndexNameValidationEnd={this.props.onIndexNameValidationEnd} /> ) : null} diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx index a6e83cfa6f3ab..0a70111e76b23 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx @@ -5,23 +5,46 @@ * 2.0. */ +import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; import { EuiFormRow, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; export interface Props { indexName: string; indexNameError?: string; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } export class IndexNameForm extends Component { - _onIndexNameChange = async (event: ChangeEvent) => { + private _isMounted = false; + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + _onIndexNameChange = (event: ChangeEvent) => { const indexName = event.target.value; + this.props.onIndexNameChange(indexName); + this._validateIndexName(indexName); + this.props.onIndexNameValidationStart(); + }; + + _validateIndexName = _.debounce(async (indexName: string) => { const indexNameError = await validateIndexName(indexName); + if (!this._isMounted || indexName !== this.props.indexName) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(indexName, indexNameError); - }; + }, 500); render() { const errors = [...(this.props.indexNameError ? [this.props.indexNameError] : [])]; diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index 5863b18d0cea0..28e99e7ffb18b 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -274,7 +274,11 @@ export class JsonUploadAndParse extends Component ); } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index c2bc36e3cc450..b0f1b98a9ae72 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -24,7 +24,8 @@ export interface FileUploadComponentProps { isIndexingTriggered: boolean; onFileSelect: (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => void; onFileClear: () => void; - onIndexReady: (indexReady: boolean) => void; + enableImportBtn: () => void; + disableImportBtn: () => void; onUploadComplete: (results: FileUploadGeoResults) => void; onUploadError: () => void; } diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.ts b/x-pack/plugins/file_upload/public/util/indexing_service.ts deleted file mode 100644 index 4dcff3dbe7f0e..0000000000000 --- a/x-pack/plugins/file_upload/public/util/indexing_service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { getIndexPatternService, getHttp } from '../kibana_services'; - -export const getExistingIndexNames = _.debounce( - async () => { - let indexes; - try { - indexes = await getHttp().fetch({ - path: `/api/index_management/indices`, - method: 'GET', - }); - } catch (e) { - // Log to console. Further diagnostics can be made in network request - // eslint-disable-next-line no-console - console.error(e); - } - return indexes ? indexes.map(({ name }: { name: string }) => name) : []; - }, - 10000, - { leading: true } -); - -export function checkIndexPatternValid(name: string) { - const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; - const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); - const indexPatternInvalid = - byteLength > 255 || // name can't be greater than 255 bytes - name !== name.toLowerCase() || // name should be lowercase - name === '.' || - name === '..' || // name can't be . or .. - name.match(/^[-_+]/) !== null || // name can't start with these chars - name.match(reg) !== null; // name can't contain these chars - return !indexPatternInvalid; -} - -export const validateIndexName = async (indexName: string) => { - if (!checkIndexPatternValid(indexName)) { - return i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameContainsIllegalCharactersErrorMessage', - { - defaultMessage: 'Index name contains illegal characters.', - } - ); - } - - const indexNames = await getExistingIndexNames(); - const indexPatternNames = await getIndexPatternService().getTitles(); - let indexNameError; - if (indexNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameAlreadyExistsErrorMessage', - { - defaultMessage: 'Index name already exists.', - } - ); - } else if (indexPatternNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexPatternAlreadyExistsErrorMessage', - { - defaultMessage: 'Index pattern already exists.', - } - ); - } - return indexNameError; -}; diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts b/x-pack/plugins/file_upload/public/validate_index_name.test.ts similarity index 88% rename from x-pack/plugins/file_upload/public/util/indexing_service.test.ts rename to x-pack/plugins/file_upload/public/validate_index_name.test.ts index b8dfde9ccdc48..7422ced974e37 100644 --- a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts +++ b/x-pack/plugins/file_upload/public/validate_index_name.test.ts @@ -5,12 +5,10 @@ * 2.0. */ -// Not all index pattern dependencies are avab. in jest context, -// prevent unrelated import errors by mocking kibana services -jest.mock('../kibana_services', () => {}); -import { checkIndexPatternValid } from './indexing_service'; +jest.mock('./kibana_services', () => {}); +import { checkIndexPatternValid } from './validate_index_name'; -describe('indexing_service', () => { +describe('checkIndexPatternValid', () => { const validNames = [ 'lowercaseletters', // Lowercase only '123', // Cannot include \, /, *, ?, ", <, >, |, " " (space character), , (comma), # diff --git a/x-pack/plugins/file_upload/public/validate_index_name.ts b/x-pack/plugins/file_upload/public/validate_index_name.ts new file mode 100644 index 0000000000000..cd190188b6a63 --- /dev/null +++ b/x-pack/plugins/file_upload/public/validate_index_name.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService } from './kibana_services'; +import { checkIndexExists } from './api'; + +export function checkIndexPatternValid(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name === '.' || + name === '..' || // name can't be . or .. + name.match(/^[-_+]/) !== null || // name can't start with these chars + name.match(reg) !== null; // name can't contain these chars + return !indexPatternInvalid; +} + +export const validateIndexName = async (indexName: string) => { + if (!checkIndexPatternValid(indexName)) { + return i18n.translate('xpack.fileUpload.indexNameContainsIllegalCharactersErrorMessage', { + defaultMessage: 'Index name contains illegal characters.', + }); + } + + const indexPatternNames = await getIndexPatternService().getTitles(); + if (indexPatternNames.includes(indexName)) { + return i18n.translate('xpack.fileUpload.indexPatternAlreadyExistsErrorMessage', { + defaultMessage: 'Index pattern already exists.', + }); + } + + const indexExists = await checkIndexExists(indexName); + if (indexExists) { + return i18n.translate('xpack.fileUpload.indexNameAlreadyExistsErrorMessage', { + defaultMessage: 'Index name already exists.', + }); + } +}; diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index f2e796ec53ce0..8e6651ed891c6 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -195,7 +195,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge body: schema.object({ index: schema.string() }), }, options: { - tags: ['access:fileUpload:analyzeFile'], + tags: ['access:fileUpload:import'], }, }, async (context, request, response) => { diff --git a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx index 79902cf620511..7d6f6757bef18 100644 --- a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx @@ -26,14 +26,14 @@ export enum UPLOAD_STEPS { } enum INDEXING_STAGE { - READY = 'READY', + CONFIGURE = 'CONFIGURE', TRIGGERED = 'TRIGGERED', SUCCESS = 'SUCCESS', ERROR = 'ERROR', } interface State { - indexingStage: INDEXING_STAGE | null; + indexingStage: INDEXING_STAGE; fileUploadComponent: React.ComponentType | null; results?: FileUploadGeoResults; } @@ -42,7 +42,7 @@ export class ClientFileCreateSourceEditor extends Component { - if (!this._isMounted) { - return; - } - this.setState({ indexingStage: indexReady ? INDEXING_STAGE.READY : null }); - if (indexReady) { - this.props.enableNextBtn(); - } else { - this.props.disableNextBtn(); - } - }; - render() { if (!this.state.fileUploadComponent) { return null; @@ -181,7 +168,8 @@ export class ClientFileCreateSourceEditor extends Component diff --git a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js index 7bdaa3898aa47..4b973b9f66edd 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js @@ -14,10 +14,15 @@ export default function ({ getPageObjects, getService }) { const FILE_LOAD_DIR = 'test_upload_files'; const DEFAULT_LOAD_FILE_NAME = 'point.json'; const security = getService('security'); + const retry = getService('retry'); describe('GeoJSON import layer panel', () => { before(async () => { - await security.testUser.setRoles(['global_maps_all', 'global_index_pattern_management_all']); + await security.testUser.setRoles([ + 'global_maps_all', + 'geoall_data_writer', + 'global_index_pattern_management_all', + ]); await PageObjects.maps.openNewMap(); }); @@ -87,23 +92,23 @@ export default function ({ getPageObjects, getService }) { }); it('should prevent import button from activating unless valid index name provided', async () => { - // Set index to invalid name await PageObjects.maps.setIndexName('NoCapitalLetters'); - // Check button - let importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(false); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(false); + }); - // Set index to valid name await PageObjects.maps.setIndexName('validindexname'); - // Check button - importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(true); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(true); + }); - // Set index back to invalid name await PageObjects.maps.setIndexName('?noquestionmarks?'); - // Check button - importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(false); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(false); + }); }); }); } diff --git a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js index a5b376cbb33a5..1ce4ccdcec97f 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const security = getService('security'); const browser = getService('browser'); + const retry = getService('retry'); const IMPORT_FILE_PREVIEW_NAME = 'Import File'; const FILE_LOAD_DIR = 'test_upload_files'; @@ -32,6 +33,10 @@ export default function ({ getService, getPageObjects }) { const indexName = uuid(); await PageObjects.maps.setIndexName(indexName); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(true); + }); await PageObjects.maps.clickImportFileButton(); return indexName; } From 22b32c23f2eec0b24c11a054f63c6f6b93b06629 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Wed, 28 Apr 2021 15:24:07 -0700 Subject: [PATCH 62/68] Bump elastic-apm-node from v3.10.0 to v3.14.0 (#97509) Changelog: https://www.elastic.co/guide/en/apm/agent/nodejs/current/release-notes-3.x.html Notably: - Adds apm.addMetadataFilter(fn) that can be used for PII filtering - Improves communication with APM server to not be pathological if APM server is down for extended period of time and load is high. - Fixes bugs in data for the Dependencies and Service Map in the APM app. - The APM agent now collects cloud metadata. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 131 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 93 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 52e4aaae665bc..e1966459c97f2 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.10.0", + "elastic-apm-node": "^3.14.0", "elasticsearch": "^16.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", diff --git a/yarn.lock b/yarn.lock index 9219199f1e753..9998790690ad9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1389,6 +1389,20 @@ version "0.0.0" uid "" +"@elastic/ecs-helpers@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@elastic/ecs-helpers/-/ecs-helpers-1.1.0.tgz#ee7e6f870f75a2222c5d7179b36a628f1db4779e" + integrity sha512-MDLb2aFeGjg46O5mLpdCzT5yOUDnXToJSrco2ShqGIXxNJaM8uJjX+4nd+hRYV4Vex8YJyDtOFEVBldQct6ndg== + dependencies: + fast-json-stringify "^2.4.1" + +"@elastic/ecs-pino-format@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@elastic/ecs-pino-format/-/ecs-pino-format-1.1.1.tgz#f996a7a0074155cb6d63499332092bc9c74ac5e4" + integrity sha512-I7SzS0JYA8tdfsw4aTR+33HWWCaU7QY759kzt4sXm+O1waILaUWMzW3C2RL0ihQ66M99t+XMhRrA4cKStkHNXg== + dependencies: + "@elastic/ecs-helpers" "^1.1.0" + "@elastic/elasticsearch@npm:@elastic/elasticsearch-canary@^8.0.0-canary.4": version "8.0.0-canary.4" resolved "https://registry.yarnpkg.com/@elastic/elasticsearch-canary/-/elasticsearch-canary-8.0.0-canary.4.tgz#6f1a592974941baae347eb8c66a2006848349717" @@ -6545,7 +6559,7 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.5.5, ajv@ json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^6.12.5: +ajv@^6.11.0, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -7331,6 +7345,11 @@ atob@^2.1.1, atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + attr-accept@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52" @@ -7381,11 +7400,6 @@ available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2: dependencies: array-filter "^1.0.0" -await-event@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/await-event/-/await-event-2.1.0.tgz#78e9f92684bae4022f9fa0b5f314a11550f9aa76" - integrity sha1-eOn5JoS65AIvn6C18xShFVD5qnY= - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -11969,48 +11983,47 @@ ejs@^3.1.2, ejs@^3.1.5, ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@^9.4.2: - version "9.4.2" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-9.4.2.tgz#b479817b13ef38020991ccf1c9af9e335f92314a" - integrity sha512-zhOf0+cIO45tJgvQw3fWjXRWqO2MizCC9cvnQpMH2NNsQItXnZfJilhmiYJr8XYi50FxnlOvaav8koZ6tcObmw== +elastic-apm-http-client@^9.8.0: + version "9.8.0" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-9.8.0.tgz#caa738c2663b3ec8521ebede86cc841e4c77863c" + integrity sha512-JrlQbijs4dY8539zH+QNKLqLDCNyNymyy720tDaj+/i5pcwWYz5ipPARAdrKkor56AmKBxib8Fd6KsSWtIYjcA== dependencies: breadth-filter "^2.0.0" container-info "^1.0.1" end-of-stream "^1.4.4" fast-safe-stringify "^2.0.7" fast-stream-to-buffer "^1.0.0" - pump "^3.0.0" + object-filter-sequence "^1.0.0" readable-stream "^3.4.0" stream-chopper "^3.0.1" unicode-byte-truncate "^1.0.0" -elastic-apm-node@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.10.0.tgz#2b061613a2fbeb3bba4e3b87040dab55df1d8583" - integrity sha512-H1DOrpr0CwX88awQqSM4UbHGdfsk7xJ4GM6R1uYuFk1zILX/eozylcm6dYSKirpXwwMLxGSRFTOCaMa8fqiLjQ== +elastic-apm-node@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.14.0.tgz#942d6e86bd9d3710f51f0e43f04965d63c3fefd3" + integrity sha512-B7Xkz6UL44mm+2URdZy2yxpEB2C5CvZLOP3sGpf2h/hepXr4NgrVoRxGqO1F2b2wCB48smPv4a3v35b396VSwA== dependencies: + "@elastic/ecs-pino-format" "^1.1.0" after-all-results "^2.0.0" async-value-promise "^1.1.1" basic-auth "^2.0.1" - console-log-level "^1.4.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "^9.4.2" + elastic-apm-http-client "^9.8.0" end-of-stream "^1.4.4" error-stack-parser "^2.0.6" escape-string-regexp "^4.0.0" fast-safe-stringify "^2.0.7" http-headers "^3.0.2" - http-request-to-url "^1.0.0" is-native "^1.0.1" measured-reporting "^1.51.1" monitor-event-loop-delay "^1.0.0" object-filter-sequence "^1.0.0" object-identity-map "^1.0.2" original-url "^1.2.3" + pino "^6.11.2" read-pkg-up "^7.0.1" relative-microtime "^2.0.0" - require-ancestors "^1.0.0" require-in-the-middle "^5.0.3" semver "^6.3.0" set-cookie-serde "^1.0.0" @@ -13246,6 +13259,16 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@~2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-json-stringify@^2.4.1: + version "2.6.0" + resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-2.6.0.tgz#3dcb4835b63d4e17dbd17411594aa63df8c0f95b" + integrity sha512-xTZtZRopWp2Aun7sGX2EB2mFw4bMQ+xnR8BmD5Rn4K0hKXGkbcZAzTtxEX0P4KNaNx1RAwvf+FESfuM0+F4WZg== + dependencies: + ajv "^6.11.0" + deepmerge "^4.2.2" + rfdc "^1.2.0" + string-similarity "^4.0.1" + fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -13256,6 +13279,11 @@ fast-memoize@^2.5.1: resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d" integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g== +fast-redact@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.0.0.tgz#ac2f9e36c9f4976f5db9fb18c6ffbaf308cf316d" + integrity sha512-a/S/Hp6aoIjx7EmugtzLqXmcNsyFszqbt6qQ99BdG61QjBZF6shNis0BYR6TsZOQ1twYc0FN2Xdhwwbv6+KD0w== + fast-safe-stringify@2.x.x, fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" @@ -13628,6 +13656,11 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== +flatstr@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/flatstr/-/flatstr-1.0.12.tgz#c2ba6a08173edbb6c9640e3055b95e287ceb5931" + integrity sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw== + flatted@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" @@ -15462,14 +15495,6 @@ http-proxy@^1.17.0, http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -http-request-to-url@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/http-request-to-url/-/http-request-to-url-1.0.0.tgz#e56b9418f79f29d344fed05cfe2c56ccb8cc79ac" - integrity sha512-YYx0lKXG9+T1fT2q3ZgXLczMI3jW09g9BvIA6L3BG0tFqGm83Ka/+RUZGANRG7Ut/yueD7LPcZQ/+pA5ndNajw== - dependencies: - await-event "^2.1.0" - socket-location "^1.0.0" - http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -21329,6 +21354,23 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= +pino-std-serializers@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz#b56487c402d882eb96cd67c257868016b61ad671" + integrity sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg== + +pino@^6.11.2: + version "6.11.3" + resolved "https://registry.yarnpkg.com/pino/-/pino-6.11.3.tgz#0c02eec6029d25e6794fdb6bbea367247d74bc29" + integrity sha512-drPtqkkSf0ufx2gaea3TryFiBHdNIdXKf5LN0hTM82SXI4xVIve2wLwNg92e1MT6m3jASLu6VO7eGY6+mmGeyw== + dependencies: + fast-redact "^3.0.0" + fast-safe-stringify "^2.0.7" + flatstr "^1.0.12" + pino-std-serializers "^3.1.0" + quick-format-unescaped "^4.0.3" + sonic-boom "^1.0.2" + pirates@^4.0.0, pirates@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" @@ -22369,6 +22411,11 @@ queue@6.0.1: dependencies: inherits "~2.0.3" +quick-format-unescaped@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.3.tgz#6d6b66b8207aa2b35eef12be1421bb24c428f652" + integrity sha512-MaL/oqh02mhEo5m5J2rwsVL23Iw2PEaGVHgT2vFt8AAsr0lfvQA5dpXo9TPu0rz7tSBdUPgkbam0j/fj5ZM8yg== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -24048,11 +24095,6 @@ request@2.81.0, request@^2.44.0, request@^2.87.0, request@^2.88.0, request@^2.88 tunnel-agent "^0.6.0" uuid "^3.3.2" -require-ancestors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/require-ancestors/-/require-ancestors-1.0.0.tgz#807831f8f8081fb12863da81ddb15c8f2a73a004" - integrity sha512-Nqeo9Gfp0KvnxTixnxLGEbThMAi+YYgnwRoigtOs1Oo3eGBYfqCd3dagq1vBCVVuc1EnIt3Eu1eGemwOOEZozw== - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -24286,6 +24328,11 @@ reusify@^1.0.0: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + rgb-regex@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" @@ -25090,13 +25137,6 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^2.0.0" -socket-location@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/socket-location/-/socket-location-1.0.0.tgz#6f0c6f891c9a61c9a750265c14921d12196d266f" - integrity sha512-TwxpRM0pPE/3b24XQGLx8zq2J8kOwTy40FtiNC1KrWvl/Tsf7RYXruE9icecMhQwicXMo/HUJlGap8DNt2cgYw== - dependencies: - await-event "^2.1.0" - sockjs-client@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" @@ -25118,6 +25158,14 @@ sockjs@0.3.20: uuid "^3.4.0" websocket-driver "0.6.5" +sonic-boom@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-1.3.0.tgz#5c77c846ce6c395dddf2eb8e8e65f9cc576f2e76" + integrity sha512-4nX6OYvOYr6R76xfQKi6cZpTO3YSWe/vd+QdIfoH0lBy0MnPkeAbb2rRWgmgADkXUeCKPwO1FZAKlAVWAadELw== + dependencies: + atomic-sleep "^1.0.0" + flatstr "^1.0.12" + sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" @@ -25702,6 +25750,11 @@ string-replace-loader@^2.2.0: loader-utils "^1.2.3" schema-utils "^1.0.0" +string-similarity@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.3.tgz#ef52d6fc59c8a0fc93b6307fbbc08cc6e18cde21" + integrity sha512-QEwJzNFCqq+5AGImk5z4vbsEPTN/+gtyKfXBVLBcbPBRPNganZGfQnIuf9yJ+GiwSnD65sT8xrw/uwU1Q1WmfQ== + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" From f690c60517ee023fe076e4a1d253d908f5b7a5ed Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 28 Apr 2021 23:46:33 +0100 Subject: [PATCH 63/68] chore(NA): moving @kbn/dev-utils into bazel (#98496) * chore(NA): moving @kbn/dev-utils into bazel * docs(NA): updated generated plugin list --- .../monorepo-packages.asciidoc | 1 + docs/developer/plugin-list.asciidoc | 2 +- package.json | 2 +- packages/BUILD.bazel | 1 + .../elastic-eslint-config-kibana/package.json | 5 +- packages/kbn-ace/package.json | 3 - packages/kbn-analytics/package.json | 3 - packages/kbn-cli-dev-mode/package.json | 3 +- packages/kbn-config/package.json | 4 - packages/kbn-crypto/package.json | 4 - packages/kbn-dev-utils/BUILD.bazel | 128 ++++++++++++++++++ packages/kbn-dev-utils/package.json | 8 -- .../src/plugin_list/generate_plugin_list.ts | 3 +- packages/kbn-dev-utils/tsconfig.json | 3 +- packages/kbn-docs-utils/package.json | 3 +- packages/kbn-es-archiver/package.json | 1 - packages/kbn-es/package.json | 3 - packages/kbn-i18n/package.json | 3 - packages/kbn-interpreter/package.json | 3 - packages/kbn-monaco/package.json | 3 - packages/kbn-optimizer/package.json | 1 - packages/kbn-plugin-generator/package.json | 3 - packages/kbn-plugin-helpers/package.json | 1 - packages/kbn-pm/package.json | 3 - packages/kbn-storybook/package.json | 3 - packages/kbn-telemetry-tools/package.json | 4 - packages/kbn-test/package.json | 4 - packages/kbn-ui-shared-deps/package.json | 3 - x-pack/package.json | 2 - yarn.lock | 2 +- 30 files changed, 139 insertions(+), 73 deletions(-) create mode 100644 packages/kbn-dev-utils/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index dcfe317e5c826..fafbca550ae5d 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -68,6 +68,7 @@ yarn kbn watch-bazel - @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema +- @kbn/dev-utils - @kbn/expect - @kbn/logging - @kbn/std diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7d7d2c1246872..6f54e924769b8 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -6,7 +6,7 @@ NOTE: node scripts/build_plugin_list_docs - You can update the template within packages/kbn-dev-utils/target/plugin_list/generate_plugin_list.js + You can update the template within node_modules/@kbn/dev-utils/target/plugin_list/generate_plugin_list.js //// diff --git a/package.json b/package.json index e1966459c97f2..773fd3ac6ad11 100644 --- a/package.json +++ b/package.json @@ -440,7 +440,7 @@ "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:packages/kbn-dev-utils", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/es": "link:packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 2850a377aaf03..d5c9560179c61 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -10,6 +10,7 @@ filegroup( "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", + "//packages/kbn-dev-utils:build", "//packages/kbn-expect:build", "//packages/kbn-logging:build", "//packages/kbn-std:build", diff --git a/packages/elastic-eslint-config-kibana/package.json b/packages/elastic-eslint-config-kibana/package.json index 71283df00a8dd..5fb485b86fd38 100644 --- a/packages/elastic-eslint-config-kibana/package.json +++ b/packages/elastic-eslint-config-kibana/package.json @@ -16,8 +16,5 @@ "bugs": { "url": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana" }, - "homepage": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana", - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" - } + "homepage": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana" } \ No newline at end of file diff --git a/packages/kbn-ace/package.json b/packages/kbn-ace/package.json index 5b4b0312aa1ae..30a87dbd1e21b 100644 --- a/packages/kbn-ace/package.json +++ b/packages/kbn-ace/package.json @@ -8,8 +8,5 @@ "scripts": { "build": "node ./scripts/build.js", "kbn:bootstrap": "yarn build --dev" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-analytics/package.json b/packages/kbn-analytics/package.json index 5b9db79febd77..2195de578081e 100644 --- a/packages/kbn-analytics/package.json +++ b/packages/kbn-analytics/package.json @@ -12,8 +12,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --source-maps --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index 9ffc7f690fd6a..9def59623c938 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -16,7 +16,6 @@ "dependencies": { "@kbn/config": "link:../kbn-config", "@kbn/server-http-tools": "link:../kbn-server-http-tools", - "@kbn/optimizer": "link:../kbn-optimizer", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index 90f2a661b91dc..b114cb13933d1 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -8,9 +8,5 @@ "scripts": { "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json index 7e26b96218319..0787427c60b10 100644 --- a/packages/kbn-crypto/package.json +++ b/packages/kbn-crypto/package.json @@ -9,9 +9,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": {}, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel new file mode 100644 index 0000000000000..e3935040240dc --- /dev/null +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -0,0 +1,128 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-dev-utils" +PKG_REQUIRE_NAME = "@kbn/dev-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +filegroup( + name = "certs", + srcs = glob( + [ + "certs/**/*", + ], + exclude = [ + "**/README.md" + ], + ), +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", + ":certs", + "ci_stats_reporter/package.json", + "stdio/package.json", + "tooling_log/package.json" +] + +SRC_DEPS = [ + "//packages/kbn-expect", + "//packages/kbn-utils", + "@npm//@babel/core", + "@npm//axios", + "@npm//chalk", + "@npm//chance", + "@npm//cheerio", + "@npm//dedent", + "@npm//execa", + "@npm//exit-hook", + "@npm//getopts", + "@npm//globby", + "@npm//jest-styled-components", + "@npm//load-json-file", + "@npm//markdown-it", + "@npm//moment", + "@npm//normalize-path", + "@npm//rxjs", + "@npm//tree-kill", + "@npm//tslib", + "@npm//typescript", + "@npm//vinyl" +] + +TYPES_DEPS = [ + "@npm//@types/babel__core", + "@npm//@types/cheerio", + "@npm//@types/dedent", + "@npm//@types/flot", + "@npm//@types/jest", + "@npm//@types/markdown-it", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/react", + "@npm//@types/testing-library__jest-dom", + "@npm//@types/vinyl" +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index 4ce2880afbbda..90c5ef17d1859 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -5,15 +5,7 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - }, "kibana": { "devOnly": true - }, - "devDependencies": { - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts index b88382c3b0da4..127e2a9904a4f 100644 --- a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts +++ b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import Fs from 'fs'; import Path from 'path'; import normalizePath from 'normalize-path'; @@ -49,7 +48,7 @@ NOTE: node scripts/build_plugin_list_docs You can update the template within ${normalizePath( - Path.relative(REPO_ROOT, Fs.realpathSync(Path.resolve(__dirname, __filename))) + Path.relative(REPO_ROOT, Path.resolve(__dirname, __filename)) )} //// diff --git a/packages/kbn-dev-utils/tsconfig.json b/packages/kbn-dev-utils/tsconfig.json index 65536c576b679..5bb7bd0424daf 100644 --- a/packages/kbn-dev-utils/tsconfig.json +++ b/packages/kbn-dev-utils/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "stripInternal": false, "target": "ES2019", "declaration": true, "declarationMap": true, + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-dev-utils/src", "types": [ diff --git a/packages/kbn-docs-utils/package.json b/packages/kbn-docs-utils/package.json index e2db07001b543..6aca554f0f945 100644 --- a/packages/kbn-docs-utils/package.json +++ b/packages/kbn-docs-utils/package.json @@ -13,7 +13,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/config": "link:../kbn-config" } } \ No newline at end of file diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 0e4c9884d2c39..c86d94c70d739 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -13,7 +13,6 @@ "kbn:watch": "rm -rf target && ../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/test": "link:../kbn-test" } } \ No newline at end of file diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index f47f042505cad..e7356794b6113 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -11,8 +11,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-i18n/package.json b/packages/kbn-i18n/package.json index 570110589490b..1f9d21f724ea8 100644 --- a/packages/kbn-i18n/package.json +++ b/packages/kbn-i18n/package.json @@ -10,8 +10,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --watch --source-maps" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index 491a7205be210..997fbb0eb8a4f 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -11,8 +11,5 @@ }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-monaco/package.json b/packages/kbn-monaco/package.json index f4309e08f5bdb..75f1d74f1c9c9 100644 --- a/packages/kbn-monaco/package.json +++ b/packages/kbn-monaco/package.json @@ -10,9 +10,6 @@ "kbn:bootstrap": "yarn build --dev", "build:antlr4ts": "../../node_modules/antlr4ts-cli/antlr4ts ./src/painless/antlr/painless_lexer.g4 ./src/painless/antlr/painless_parser.g4 && node ./scripts/fix_generated_antlr.js" }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" - }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" } diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index 423bba0fd8c7a..f193fcf898a3d 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/ui-shared-deps": "link:../kbn-ui-shared-deps" } } \ No newline at end of file diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index ae4dfbc670f19..583085430d915 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -8,8 +8,5 @@ "scripts": { "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 6b9dd4d51baf9..2d642d9ede13b 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -17,7 +17,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index c46906112b2e2..72061c9625b09 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -11,8 +11,5 @@ "build": "../../node_modules/.bin/webpack", "kbn:watch": "../../node_modules/.bin/webpack --watch", "prettier": "../../node_modules/.bin/prettier --write './src/**/*.ts'" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-storybook/package.json b/packages/kbn-storybook/package.json index fdc7359aab58d..0e70f7c340a90 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -12,8 +12,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-telemetry-tools/package.json b/packages/kbn-telemetry-tools/package.json index 2ae1f596a1c68..31fac5c043832 100644 --- a/packages/kbn-telemetry-tools/package.json +++ b/packages/kbn-telemetry-tools/package.json @@ -12,9 +12,5 @@ "build": "../../node_modules/.bin/babel src --out-dir target --delete-dir-on-start --extensions .ts --source-maps=inline", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 9bf8a01e031cc..15d6ac90b2ebe 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -17,9 +17,5 @@ "@kbn/es": "link:../kbn-es", "@kbn/i18n": "link:../kbn-i18n", "@kbn/optimizer": "link:../kbn-optimizer" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 00c6f677cd223..8b08f64ba0f62 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -12,8 +12,5 @@ "@kbn/analytics": "link:../kbn-analytics", "@kbn/i18n": "link:../kbn-i18n", "@kbn/monaco": "link:../kbn-monaco" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/x-pack/package.json b/x-pack/package.json index c09db67483121..129c8d86adecc 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,9 +27,7 @@ "yarn": "^1.21.1" }, "devDependencies": { - "@kbn/dev-utils": "link:../packages/kbn-dev-utils", "@kbn/es": "link:../packages/kbn-es", - "@kbn/expect": "link:../packages/kbn-expect", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/storybook": "link:../packages/kbn-storybook", "@kbn/test": "link:../packages/kbn-test" diff --git a/yarn.lock b/yarn.lock index 9998790690ad9..45b7a0eaada46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2623,7 +2623,7 @@ version "0.0.0" uid "" -"@kbn/dev-utils@link:packages/kbn-dev-utils": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": version "0.0.0" uid "" From b94f712f8c60dbdaa5cea2a1aa33ecb3af0b5e48 Mon Sep 17 00:00:00 2001 From: Bryan Clement Date: Wed, 28 Apr 2021 19:54:09 -0700 Subject: [PATCH 64/68] [Asset management] Text updates (#98192) * updated scheduled query activation toggle text and interval header in query group * added id validation for schedule queries * fixed up agent resolution to ignore inactive agents, and properly pull all agents * nixed unused file * more validation for query fields * added status table to the results data tab, added more validation * updated wording * added error notifications for failed queries * pr feedback and cleanup * fix up last hook * use the pluralize macro, removed rbac tags Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../osquery/common/schemas/common/schemas.ts | 10 ++ .../create_action_request_body_schema.ts | 17 ++ .../common/schemas/routes/action/index.ts | 8 + .../action_results/use_action_results.ts | 14 +- .../public/actions/use_action_details.ts | 12 +- .../osquery/public/actions/use_all_actions.ts | 12 +- .../agent_policies/use_agent_policies.ts | 12 +- .../public/agent_policies/use_agent_policy.ts | 12 +- .../osquery/public/agents/use_agent_groups.ts | 12 +- .../public/agents/use_agent_policies.ts | 12 +- .../osquery/public/agents/use_agent_status.ts | 12 +- .../osquery/public/agents/use_all_agents.ts | 12 +- .../public/agents/use_osquery_policies.ts | 27 ++- .../common/hooks/use_osquery_integration.tsx | 12 +- .../osquery/public/common/validations.ts | 17 ++ .../live_queries/agent_results/index.tsx | 2 +- .../public/live_queries/form/index.tsx | 37 +++- .../form/live_query_query_field.tsx | 70 +------- .../osquery/public/queries/edit/tabs.tsx | 2 +- .../public/queries/form/code_editor_field.tsx | 7 +- .../osquery/public/results/results_table.tsx | 164 ++++++++++++++---- .../osquery/public/results/translations.ts | 8 + .../osquery/public/results/use_all_results.ts | 12 +- .../form/add_query_flyout.tsx | 4 + .../form/confirmation_modal.tsx | 2 +- .../form/edit_query_flyout.tsx | 4 + .../form/translations.ts | 12 ++ .../form/validations.ts | 48 +++++ .../scheduled_query_group_queries_table.tsx | 2 +- .../plugins/osquery/public/shared_imports.ts | 1 + .../osquery/server/lib/parse_agent_groups.ts | 86 ++++++--- .../routes/action/create_action_route.ts | 33 ++-- 32 files changed, 532 insertions(+), 163 deletions(-) create mode 100644 x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts create mode 100644 x-pack/plugins/osquery/common/schemas/routes/action/index.ts create mode 100644 x-pack/plugins/osquery/public/common/validations.ts create mode 100644 x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts create mode 100644 x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts diff --git a/x-pack/plugins/osquery/common/schemas/common/schemas.ts b/x-pack/plugins/osquery/common/schemas/common/schemas.ts index ffcadc7cfea8f..f5d0a357b85b8 100644 --- a/x-pack/plugins/osquery/common/schemas/common/schemas.ts +++ b/x-pack/plugins/osquery/common/schemas/common/schemas.ts @@ -12,6 +12,16 @@ export type Name = t.TypeOf; export const nameOrUndefined = t.union([name, t.undefined]); export type NameOrUndefined = t.TypeOf; +export const agentSelection = t.type({ + agents: t.array(t.string), + allAgentsSelected: t.boolean, + platformsSelected: t.array(t.string), + policiesSelected: t.array(t.string), +}); +export type AgentSelection = t.TypeOf; +export const agentSelectionOrUndefined = t.union([agentSelection, t.undefined]); +export type AgentSelectionOrUndefined = t.TypeOf; + export const description = t.string; export type Description = t.TypeOf; export const descriptionOrUndefined = t.union([description, t.undefined]); diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts new file mode 100644 index 0000000000000..bcbd528c4e749 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/create_action_request_body_schema.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +import { query, agentSelection } from '../../common/schemas'; + +export const createActionRequestBodySchema = t.type({ + agentSelection, + query, +}); + +export type CreateActionRequestBodySchema = t.OutputOf; diff --git a/x-pack/plugins/osquery/common/schemas/routes/action/index.ts b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts new file mode 100644 index 0000000000000..286aa2e5128b2 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/action/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './create_action_request_body_schema'; diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts index 7cad8ca3fc498..1f6da0b3a2a0e 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_results.ts +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -8,6 +8,7 @@ import { flatten, reverse, uniqBy } from 'lodash/fp'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,7 @@ export interface ResultsArgs { totalCount: number; } -interface UseActionResults { +export interface UseActionResults { actionId: string; activePage: number; agentIds?: string[]; @@ -55,7 +56,10 @@ export const useActionResults = ({ skip = false, isLive = false, }: UseActionResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionResults', { actionId }], @@ -120,6 +124,12 @@ export const useActionResults = ({ refetchInterval: isLive ? 1000 : false, keepPreviousData: true, enabled: !skip && !!agentIds?.length, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_results.fetchError', { + defaultMessage: 'Error while fetching action results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_action_details.ts b/x-pack/plugins/osquery/public/actions/use_action_details.ts index 2e5fa79cae992..bb260cd78ca76 100644 --- a/x-pack/plugins/osquery/public/actions/use_action_details.ts +++ b/x-pack/plugins/osquery/public/actions/use_action_details.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -32,7 +33,10 @@ interface UseActionDetails { } export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actionDetails', { actionId, filterQuery }], @@ -57,6 +61,12 @@ export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseAct }, { enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.action_details.fetchError', { + defaultMessage: 'Error while fetching action details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts index a58f45b8e99a2..375d108c4dd8b 100644 --- a/x-pack/plugins/osquery/public/actions/use_all_actions.ts +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -47,7 +48,10 @@ export const useAllActions = ({ filterQuery, skip = false, }: UseAllActions) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['actions', { activePage, direction, limit, sortField }], @@ -78,6 +82,12 @@ export const useAllActions = ({ { keepPreviousData: true, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.all_actions.fetchError', { + defaultMessage: 'Error while fetching actions', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts index 95323dd23f4d2..d4bd0a1f4277f 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, @@ -15,7 +16,10 @@ import { } from '../../../fleet/common'; export const useAgentPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentPolicies'], @@ -30,6 +34,12 @@ export const useAgentPolicies = () => { placeholderData: [], keepPreviousData: true, select: (response) => response.items, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_policies.fetchError', { + defaultMessage: 'Error while fetching agent policies', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts index 5fdc317d3f6f1..e87d8d1c9f28e 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policy.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentPolicy { } export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentPolicy', { policyId }], @@ -25,6 +29,12 @@ export const useAgentPolicy = ({ policyId, skip }: UseAgentPolicy) => { enabled: !skip, keepPreviousData: true, select: (response) => response.item, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.agent_policy_details.fetchError', { + defaultMessage: 'Error while fetching agent policy details', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts index 0853891f1919d..44737af9d3477 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_groups.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_groups.ts @@ -6,6 +6,7 @@ */ import { useState } from 'react'; import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { useAgentPolicies } from './use_agent_policies'; @@ -24,7 +25,10 @@ interface UseAgentGroups { } export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseAgentGroups) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; const { agentPoliciesLoading, agentPolicyById } = useAgentPolicies(osqueryPolicies); const [platforms, setPlatforms] = useState([]); @@ -96,6 +100,12 @@ export const useAgentGroups = ({ osqueryPolicies, osqueryPoliciesLoading }: UseA }, { enabled: !osqueryPoliciesLoading && !agentPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_groups.fetchError', { + defaultMessage: 'Error while fetching agent groups', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts index c8b3ef064c038..ecb95fff8838e 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_policies.ts @@ -7,17 +7,27 @@ import { mapKeys } from 'lodash'; import { useQueries, UseQueryResult } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { agentPolicyRouteService, GetOneAgentPolicyResponse } from '../../../fleet/common'; export const useAgentPolicies = (policyIds: string[] = []) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const agentResponse = useQueries( policyIds.map((policyId) => ({ queryKey: ['agentPolicy', policyId], queryFn: () => http.get(agentPolicyRouteService.getInfoPath(policyId)), enabled: policyIds.length > 0, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.action_policy_details.fetchError', { + defaultMessage: 'Error while fetching policy details', + }), + }), })) ) as Array>; diff --git a/x-pack/plugins/osquery/public/agents/use_agent_status.ts b/x-pack/plugins/osquery/public/agents/use_agent_status.ts index c26adb908f6be..4954eb0dc80c4 100644 --- a/x-pack/plugins/osquery/public/agents/use_agent_status.ts +++ b/x-pack/plugins/osquery/public/agents/use_agent_status.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentStatusResponse, agentRouteService } from '../../../fleet/common'; @@ -16,7 +17,10 @@ interface UseAgentStatus { } export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['agentStatus', policyId], @@ -34,6 +38,12 @@ export const useAgentStatus = ({ policyId, skip }: UseAgentStatus) => { { enabled: !skip, select: (response) => response.results, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agent_status.fetchError', { + defaultMessage: 'Error while fetching agent status', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index e10bc2a0d9bf6..674deb3b339bd 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { useQuery } from 'react-query'; import { GetAgentsResponse, agentRouteService } from '../../../fleet/common'; @@ -27,7 +28,10 @@ export const useAllAgents = ( opts: RequestOptions = { perPage: 9000 } ) => { const { perPage } = opts; - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; const { isLoading: agentsLoading, data: agentData } = useQuery( ['agents', osqueryPolicies, searchValue, perPage], () => { @@ -52,6 +56,12 @@ export const useAllAgents = ( }, { enabled: !osqueryPoliciesLoading, + onError: (error) => + toasts.addError(error as Error, { + title: i18n.translate('xpack.osquery.agents.fetchError', { + defaultMessage: 'Error while fetching agents', + }), + }), } ); diff --git a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts index 2937c57b50a3d..0eb94af73e3a8 100644 --- a/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts +++ b/x-pack/plugins/osquery/public/agents/use_osquery_policies.ts @@ -5,15 +5,21 @@ * 2.0. */ +import { uniq } from 'lodash'; import { useQuery } from 'react-query'; +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; import { packagePolicyRouteService, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../fleet/common'; import { OSQUERY_INTEGRATION_NAME } from '../../common'; export const useOsqueryPolicies = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; - const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies } = useQuery( + const { isLoading: osqueryPoliciesLoading, data: osqueryPolicies = [] } = useQuery( ['osqueryPolicies'], () => http.get(packagePolicyRouteService.getListPath(), { @@ -21,8 +27,19 @@ export const useOsqueryPolicies = () => { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, }, }), - { select: (data) => data.items.map((p: { policy_id: string }) => p.policy_id) } + { + select: (response) => + uniq(response.items.map((p: { policy_id: string }) => p.policy_id)), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_policies.fetchError', { + defaultMessage: 'Error while fetching osquery policies', + }), + }), + } ); - - return { osqueryPoliciesLoading, osqueryPolicies }; + return useMemo(() => ({ osqueryPoliciesLoading, osqueryPolicies }), [ + osqueryPoliciesLoading, + osqueryPolicies, + ]); }; diff --git a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx index d8bed30b969ad..ccfb407eab58b 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_osquery_integration.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { find } from 'lodash/fp'; import { useQuery } from 'react-query'; @@ -13,7 +14,10 @@ import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { useKibana } from '../lib/kibana'; export const useOsqueryIntegration = () => { - const { http } = useKibana().services; + const { + http, + notifications: { toasts }, + } = useKibana().services; return useQuery( 'integrations', @@ -26,6 +30,12 @@ export const useOsqueryIntegration = () => { { select: ({ response }: GetPackagesResponse) => find(['name', OSQUERY_INTEGRATION_NAME], response), + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.osquery_integration.fetchError', { + defaultMessage: 'Error while fetching osquery integration', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/common/validations.ts b/x-pack/plugins/osquery/public/common/validations.ts new file mode 100644 index 0000000000000..7ab9de52e35ad --- /dev/null +++ b/x-pack/plugins/osquery/public/common/validations.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../shared_imports'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const queryFieldValidation: ValidationFunc = fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyQueryError', { + defaultMessage: 'Query is a required field', + }) +); diff --git a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx index 272e65d9cc0fa..d1ef18e2e12ea 100644 --- a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx @@ -22,7 +22,7 @@ const QueryAgentResultsComponent = () => { {data?.actionDetails._source?.data?.query} - + ); }; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 056bbc75f3b76..5d1b616c7d88a 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -12,14 +12,18 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useMutation } from 'react-query'; -import { UseField, Form, FormData, useForm, useFormData } from '../../shared_imports'; +import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports'; import { AgentsTableField } from './agents_table_field'; import { LiveQueryQueryField } from './live_query_query_field'; import { useKibana } from '../../common/lib/kibana'; import { ResultTabs } from '../../queries/edit/tabs'; +import { queryFieldValidation } from '../../common/validations'; +import { fieldValidators } from '../../shared_imports'; const FORM_ID = 'liveQueryForm'; +export const MAX_QUERY_LENGTH = 2000; + interface LiveQueryFormProps { defaultValue?: Partial | undefined; onSubmit?: (payload: Record) => Promise; @@ -50,9 +54,27 @@ const LiveQueryFormComponent: React.FC = ({ } ); + const formSchema = { + query: { + type: FIELD_TYPES.TEXT, + validations: [ + { + validator: fieldValidators.maxLengthField({ + length: MAX_QUERY_LENGTH, + message: i18n.translate('xpack.osquery.liveQuery.queryForm.largeQueryError', { + defaultMessage: 'Query is too large (max {maxLength} characters)', + values: { maxLength: MAX_QUERY_LENGTH }, + }), + }), + }, + { validator: queryFieldValidation }, + ], + }, + }; + const { form } = useForm({ id: FORM_ID, - // schema: formSchema, + schema: formSchema, onSubmit: (payload) => { return mutateAsync(payload); }, @@ -60,10 +82,7 @@ const LiveQueryFormComponent: React.FC = ({ stripEmptyFields: false, }, defaultValue: defaultValue ?? { - query: { - id: null, - query: '', - }, + query: '', }, }); @@ -85,16 +104,16 @@ const LiveQueryFormComponent: React.FC = ({ [agentSelection] ); - const queryValueProvided = useMemo(() => !!query?.query?.length, [query]); + const queryValueProvided = useMemo(() => !!query?.length, [query]); const queryStatus = useMemo(() => { if (!agentSelected) return 'disabled'; - if (isError) return 'danger'; + if (isError || !form.getFields().query.isValid) return 'danger'; if (isLoading) return 'loading'; if (isSuccess) return 'complete'; return 'incomplete'; - }, [agentSelected, isError, isLoading, isSuccess]); + }, [agentSelected, isError, isLoading, isSuccess, form]); const resultsStatus = useMemo(() => (queryStatus === 'complete' ? 'incomplete' : 'disabled'), [ queryStatus, diff --git a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx index 68207200dc789..07c13b930e143 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/live_query_query_field.tsx @@ -5,86 +5,32 @@ * 2.0. */ -// import { find } from 'lodash/fp'; -// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; -// import { useQuery } from 'react-query'; +import { EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../shared_imports'; -// import { useKibana } from '../../common/lib/kibana'; import { OsqueryEditor } from '../../editor'; interface LiveQueryQueryFieldProps { disabled?: boolean; - field: FieldHook<{ - id: string | null; - query: string; - }>; + field: FieldHook; } const LiveQueryQueryFieldComponent: React.FC = ({ disabled, field }) => { - // const { http } = useKibana().services; - // const { data } = useQuery('savedQueryList', () => - // http.get('/internal/osquery/saved_query', { - // query: { - // pageIndex: 0, - // pageSize: 100, - // sortField: 'updated_at', - // sortDirection: 'desc', - // }, - // }) - // ); - - // const queryOptions = - // // @ts-expect-error update types - // data?.saved_objects.map((savedQuery) => ({ - // value: savedQuery, - // inputDisplay: savedQuery.attributes.name, - // dropdownDisplay: ( - // <> - // {savedQuery.attributes.name} - // - //

{savedQuery.attributes.description}

- //
- // - // {savedQuery.attributes.query} - // - // - // ), - // })) ?? []; - - const { value, setValue } = field; - - // const handleSavedQueryChange = useCallback( - // (newValue) => { - // setValue({ - // id: newValue.id, - // query: newValue.attributes.query, - // }); - // }, - // [setValue] - // ); + const { value, setValue, errors } = field; + const error = errors[0]?.message; const handleEditorChange = useCallback( (newValue) => { - setValue({ - id: null, - query: newValue, - }); + setValue(newValue); }, [setValue] ); return ( - <> - {/* - */} - - + + + ); }; diff --git a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx index 1a6b317653c98..f86762e76834b 100644 --- a/x-pack/plugins/osquery/public/queries/edit/tabs.tsx +++ b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx @@ -36,7 +36,7 @@ const ResultTabsComponent: React.FC = ({ actionId, agentIds, is content: ( <> - + ), }, diff --git a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx index a56e747355c5b..77ffdc4457d3d 100644 --- a/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx @@ -31,15 +31,16 @@ const OsquerySchemaLink = React.memo(() => ( OsquerySchemaLink.displayName = 'OsquerySchemaLink'; const CodeEditorFieldComponent: React.FC = ({ field }) => { - const { value, label, labelAppend, helpText, setValue } = field; + const { value, label, labelAppend, helpText, setValue, errors } = field; + const error = errors[0]?.message; return ( } helpText={helpText} - // isInvalid={typeof error === 'string'} - // error={error} + isInvalid={typeof error === 'string'} + error={error} fullWidth > diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index d82c45d802520..8b613a336ae73 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -12,6 +12,10 @@ import { EuiDataGridProps, EuiDataGridColumn, EuiLink, + EuiTextColor, + EuiBasicTable, + EuiBasicTableColumn, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; @@ -20,16 +24,89 @@ import { pagePathGetters } from '../../../fleet/public'; import { useAllResults } from './use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; import { useKibana } from '../common/lib/kibana'; +import { useActionResults } from '../action_results/use_action_results'; +import { generateEmptyDataMessage } from './translations'; const DataContext = createContext([]); interface ResultsTableComponentProps { actionId: string; - agentId?: string; + selectedAgent?: string; + agentIds?: string[]; isLive?: boolean; } -const ResultsTableComponent: React.FC = ({ actionId, isLive }) => { +interface SummaryTableValue { + total: number | string; + pending: number | string; + responded: number; + failed: number; +} + +const ResultsTableComponent: React.FC = ({ + actionId, + agentIds, + isLive, +}) => { + const { + // @ts-expect-error update types + data: { aggregations }, + } = useActionResults({ + actionId, + activePage: 0, + agentIds, + limit: 0, + direction: Direction.asc, + sortField: '@timestamp', + isLive, + }); + + const notRespondedCount = useMemo(() => { + if (!agentIds || !aggregations.totalResponded) { + return '-'; + } + + return agentIds.length - aggregations.totalResponded; + }, [aggregations.totalResponded, agentIds]); + + const summaryColumns: Array> = useMemo( + () => [ + { + field: 'total', + name: 'Agents queried', + }, + { + field: 'responded', + name: 'Successful', + }, + { + field: 'pending', + name: 'Not yet responded', + }, + { + field: 'failed', + name: 'Failed', + // eslint-disable-next-line react/display-name + render: (failed: number) => ( + {failed} + ), + }, + ], + [] + ); + + const summaryItems = useMemo( + () => [ + { + total: agentIds?.length ?? '-', + pending: notRespondedCount, + responded: aggregations.totalResponded, + failed: aggregations.failed, + }, + ], + [aggregations, agentIds, notRespondedCount] + ); + const { getUrlForApp } = useKibana().services.application; const getFleetAppUrl = useCallback( @@ -115,30 +192,41 @@ const ResultsTableComponent: React.FC = ({ actionId, const newColumns = keys(allResultsData?.edges[0]?.fields) .sort() - .reduce((acc, fieldName) => { - if (fieldName === 'agent.name') { - acc.push({ - id: fieldName, - displayAsText: i18n.translate('xpack.osquery.liveQueryResults.table.agentColumnTitle', { - defaultMessage: 'agent', - }), - defaultSortDirection: Direction.asc, - }); + .reduce( + (acc, fieldName) => { + const { data, seen } = acc; + if (fieldName === 'agent.name') { + data.push({ + id: fieldName, + displayAsText: i18n.translate( + 'xpack.osquery.liveQueryResults.table.agentColumnTitle', + { + defaultMessage: 'agent', + } + ), + defaultSortDirection: Direction.asc, + }); - return acc; - } - - if (fieldName.startsWith('osquery.')) { - acc.push({ - id: fieldName, - displayAsText: fieldName.split('.')[1], - defaultSortDirection: Direction.asc, - }); - return acc; - } + return acc; + } - return acc; - }, [] as EuiDataGridColumn[]); + if (fieldName.startsWith('osquery.')) { + const displayAsText = fieldName.split('.')[1]; + if (!seen.has(displayAsText)) { + data.push({ + id: fieldName, + displayAsText, + defaultSortDirection: Direction.asc, + }); + seen.add(displayAsText); + } + return acc; + } + + return acc; + }, + { data: [], seen: new Set() } as { data: EuiDataGridColumn[]; seen: Set } + ).data; if (!isEqual(columns, newColumns)) { setColumns(newColumns); @@ -149,16 +237,24 @@ const ResultsTableComponent: React.FC = ({ actionId, return ( // @ts-expect-error update types - + + + {columns.length > 0 ? ( + + ) : ( +
+ {generateEmptyDataMessage(aggregations.totalResponded)} +
+ )}
); }; diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts index 0f785f0c1f4d1..8e77e78ec76e2 100644 --- a/x-pack/plugins/osquery/public/results/translations.ts +++ b/x-pack/plugins/osquery/public/results/translations.ts @@ -7,6 +7,14 @@ import { i18n } from '@kbn/i18n'; +export const generateEmptyDataMessage = (agentsResponded: number): string => { + return i18n.translate('xpack.osquery.results.multipleAgentsResponded', { + defaultMessage: + '{agentsResponded, plural, one {# agent has} other {# agents have}} responded, but no osquery data has been reported.', + values: { agentsResponded }, + }); +}; + export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { defaultMessage: `An error has occurred on all results search`, }); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts index 7140f80f510f4..afeb7dadb030c 100644 --- a/x-pack/plugins/osquery/public/results/use_all_results.ts +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -7,6 +7,7 @@ import { useQuery } from 'react-query'; +import { i18n } from '@kbn/i18n'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { @@ -51,7 +52,10 @@ export const useAllResults = ({ skip = false, isLive = false, }: UseAllResults) => { - const { data } = useKibana().services; + const { + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( ['allActionResults', { actionId, activePage, direction, limit, sortField }], @@ -82,6 +86,12 @@ export const useAllResults = ({ { refetchInterval: isLive ? 1000 : false, enabled: !skip, + onError: (error: Error) => + toasts.addError(error, { + title: i18n.translate('xpack.osquery.results.fetchError', { + defaultMessage: 'Error while fetching results', + }), + }), } ); }; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx index b2cfa05e0fc63..808431b68c4ba 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/add_query_flyout.tsx @@ -23,6 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { CodeEditorField } from '../../queries/form/code_editor_field'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; import { Form, useForm, FormData, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; const FORM_ID = 'addQueryFlyoutForm'; @@ -50,12 +51,14 @@ const AddQueryFlyoutComponent: React.FC = ({ onSave, onClos label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -65,6 +68,7 @@ const AddQueryFlyoutComponent: React.FC = ({ onSave, onClos defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx index e686038430829..65379c9e23626 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/confirmation_modal.tsx @@ -74,7 +74,7 @@ const ConfirmDeployAgentPolicyModalComponent: React.FC ); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx index 41846636eccd4..767eda01c06df 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/edit_query_flyout.tsx @@ -25,6 +25,7 @@ import { i18n } from '@kbn/i18n'; import { PackagePolicyInputStream } from '../../../../fleet/common'; import { CodeEditorField } from '../../queries/form/code_editor_field'; import { Form, useForm, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; +import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; const FORM_ID = 'editQueryFlyoutForm'; @@ -64,12 +65,14 @@ export const EditQueryFlyout: React.FC = ({ label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), + validations: idFieldValidations.map((validator) => ({ validator })), }, query: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.queryFieldLabel', { defaultMessage: 'Query', }), + validations: [{ validator: queryFieldValidation }], }, interval: { type: FIELD_TYPES.NUMBER, @@ -79,6 +82,7 @@ export const EditQueryFlyout: React.FC = ({ defaultMessage: 'Interval (s)', } ), + validations: [{ validator: intervalFieldValidation }], }, }, }); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts new file mode 100644 index 0000000000000..5d00d60ffd8b8 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/translations.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INVALID_ID_ERROR = i18n.translate('xpack.osquery.agents.failSearchDescription', { + defaultMessage: `Failed to fetch agents`, +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts new file mode 100644 index 0000000000000..95e3000476a08 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/validations.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { ValidationFunc, fieldValidators } from '../../shared_imports'; +export { queryFieldValidation } from '../../common/validations'; + +const idPattern = /^[a-zA-Z0-9-_]+$/; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const idSchemaValidation: ValidationFunc = ({ value }) => { + const valueIsValid = idPattern.test(value); + if (!valueIsValid) { + return { + message: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIdError', { + defaultMessage: 'Characters must be alphanumeric, _, or -', + }), + }; + } +}; + +export const idFieldValidations = [ + fieldValidators.emptyField( + i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyIdError', { + defaultMessage: 'ID is required', + }) + ), + idSchemaValidation, +]; + +export const intervalFieldValidation: ValidationFunc< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + string, + number +> = fieldValidators.numberGreaterThanField({ + than: 0, + message: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queryFlyoutForm.invalidIntervalField', + { + defaultMessage: 'A positive interval value is required', + } + ), +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index d501f56b789d7..90ec7e0c2717b 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -148,7 +148,7 @@ const ScheduledQueryGroupQueriesTableComponent: React.FC Promise<{ results: string[]; total: number }> +) => { + const { results, total } = await generator(1, PER_PAGE); + const totalPages = Math.ceil(total / PER_PAGE); + let currPage = 2; + while (currPage <= totalPages) { + const { results: additionalResults } = await generator(currPage++, PER_PAGE); + results.push(...additionalResults); + } + return uniq(results); +}; + export const parseAgentSelection = async ( esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, context: OsqueryAppContext, agentSelection: AgentSelection ) => { - let selectedAgents: string[] = []; + const selectedAgents: Set = new Set(); + const addAgent = selectedAgents.add.bind(selectedAgents); const { allAgentsSelected, platformsSelected, policiesSelected, agents } = agentSelection; const agentService = context.service.getAgentService(); - if (agentService) { + const packagePolicyService = context.service.getPackagePolicyService(); + const kueryFragments = ['active:true']; + + if (agentService && packagePolicyService) { + const osqueryPolicies = await aggregateResults(async (page, perPage) => { + const { items, total } = await packagePolicyService.list(soClient, { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage, + page, + }); + return { results: items.map((it) => it.policy_id), total }; + }); + kueryFragments.push(`policy_id:(${uniq(osqueryPolicies).join(',')})`); if (allAgentsSelected) { - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - perPage: 9000, - showInactive: true, + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } else { if (platformsSelected.length > 0 || policiesSelected.length > 0) { - const kueryFragments = []; + const groupFragments = []; if (platformsSelected.length) { - kueryFragments.push( - ...platformsSelected.map((platform) => `local_metadata.os.platform:${platform}`) - ); + groupFragments.push(`local_metadata.os.platform:(${platformsSelected.join(',')})`); } if (policiesSelected.length) { - kueryFragments.push(...policiesSelected.map((policy) => `policy_id:${policy}`)); + groupFragments.push(`policy_id:(${policiesSelected.join(',')})`); } - const kuery = kueryFragments.join(' or '); - // TODO: actually fetch all the agents - const { agents: fetchedAgents } = await agentService.listAgents(esClient, { - kuery, - perPage: 9000, - showInactive: true, + kueryFragments.push(`(${groupFragments.join(' or ')})`); + const kuery = kueryFragments.join(' and '); + const fetchedAgents = await aggregateResults(async (page, perPage) => { + const res = await agentService.listAgents(esClient, { + perPage, + page, + kuery, + showInactive: true, + }); + return { results: res.agents.map((agent) => agent.id), total: res.total }; }); - selectedAgents.push(...fetchedAgents.map((a) => a.id)); + fetchedAgents.forEach(addAgent); } - selectedAgents.push(...agents); - selectedAgents = Array.from(new Set(selectedAgents)); } } - return selectedAgents; + + agents.forEach(addAgent); + + return Array.from(selectedAgents); }; diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 8e741c6a9e3ca..9dcd020f0734e 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -7,29 +7,42 @@ import uuid from 'uuid'; import moment from 'moment'; -import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { parseAgentSelection, AgentSelection } from '../../lib/parse_agent_groups'; +import { buildRouteValidation } from '../../utils/build_validation/route_validation'; +import { + createActionRequestBodySchema, + CreateActionRequestBodySchema, +} from '../../../common/schemas/routes/action/create_action_request_body_schema'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( { path: '/internal/osquery/action', validate: { - params: schema.object({}, { unknowns: 'allow' }), - body: schema.object({}, { unknowns: 'allow' }), - }, - options: { - tags: ['access:osquery', 'access:osquery_write'], + body: buildRouteValidation< + typeof createActionRequestBodySchema, + CreateActionRequestBodySchema + >(createActionRequestBodySchema), }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; const { agentSelection } = request.body as { agentSelection: AgentSelection }; - const selectedAgents = await parseAgentSelection(esClient, osqueryContext, agentSelection); + const selectedAgents = await parseAgentSelection( + esClient, + soClient, + osqueryContext, + agentSelection + ); + + if (!selectedAgents.length) { + throw new Error('No agents found for selection, aborting.'); + } const action = { action_id: uuid.v4(), @@ -39,10 +52,8 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon input_type: 'osquery', agents: selectedAgents, data: { - // @ts-expect-error update validation - id: request.body.query.id ?? uuid.v4(), - // @ts-expect-error update validation - query: request.body.query.query, + id: uuid.v4(), + query: request.body.query, }, }; const actionResponse = await esClient.index<{}, {}>({ From 71145a6778a7a5a1784a6b5eee3c549606f6c05a Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 28 Apr 2021 21:31:13 -0700 Subject: [PATCH 65/68] skip flaky suite (#32240) --- src/cli/serve/integration_tests/invalid_config.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/serve/integration_tests/invalid_config.test.ts b/src/cli/serve/integration_tests/invalid_config.test.ts index 517c8aa946590..b593aa9a73196 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.ts +++ b/src/cli/serve/integration_tests/invalid_config.test.ts @@ -18,7 +18,8 @@ interface LogEntry { type: string; } -describe('cli invalid config support', function () { +// FLAKY: https://github.com/elastic/kibana/issues/32240 +describe.skip('cli invalid config support', function () { it( 'exits with statusCode 64 and logs a single line when config is invalid', function () { From fe48ae396bc1741ee5fb27817318e7868e5c8a3f Mon Sep 17 00:00:00 2001 From: Diana Derevyankina <54894989+DziyanaDzeraviankina@users.noreply.github.com> Date: Thu, 29 Apr 2021 10:38:57 +0300 Subject: [PATCH 66/68] [Timelion] Support of Runtime Fields (#96700) * [Timelion] Support of Runtime Fields * Replace call of getScriptedFields() with getComputedFields().runtimeFields, refactor buildAggBody and es.test.js * Refactor index.js and agg_body.js Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/helpers/arg_value_suggestions.ts | 15 +--- .../server/series_functions/es/es.test.js | 73 ++++++++++--------- .../server/series_functions/es/index.js | 5 +- .../series_functions/es/lib/agg_body.js | 18 +---- .../series_functions/es/lib/build_request.js | 7 +- .../es/lib/create_date_agg.js | 4 +- 6 files changed, 50 insertions(+), 72 deletions(-) diff --git a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts index d8ec46eba004f..8685ed3102fa6 100644 --- a/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts +++ b/src/plugins/vis_type_timelion/public/helpers/arg_value_suggestions.ts @@ -10,13 +10,7 @@ import { get } from 'lodash'; import { getIndexPatterns } from './plugin_services'; import { TimelionFunctionArgs } from '../../common/types'; import { TimelionExpressionFunction, TimelionExpressionArgument } from '../../common/parser'; -import { - IndexPatternField, - indexPatterns as indexPatternsUtils, - KBN_FIELD_TYPES, -} from '../../../data/public'; - -const isRuntimeField = (field: IndexPatternField) => Boolean(field.runtimeField); +import { indexPatterns as indexPatternsUtils, KBN_FIELD_TYPES } from '../../../data/public'; export function getArgValueSuggestions() { const indexPatterns = getIndexPatterns(); @@ -77,7 +71,6 @@ export function getArgValueSuggestions() { .getByType(KBN_FIELD_TYPES.NUMBER) .filter( (field) => - !isRuntimeField(field) && field.aggregatable && containsFieldName(valueSplit[1], field) && !indexPatternsUtils.isNestedField(field) @@ -101,7 +94,6 @@ export function getArgValueSuggestions() { .getAll() .filter( (field) => - !isRuntimeField(field) && field.aggregatable && [ KBN_FIELD_TYPES.NUMBER, @@ -124,10 +116,7 @@ export function getArgValueSuggestions() { return indexPattern.fields .getByType(KBN_FIELD_TYPES.DATE) .filter( - (field) => - !isRuntimeField(field) && - containsFieldName(partial, field) && - !indexPatternsUtils.isNestedField(field) + (field) => containsFieldName(partial, field) && !indexPatternsUtils.isNestedField(field) ) .map((field) => ({ name: field.name, insertText: field.name })); }, diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js index 3ace745604660..c2940c6d7731a 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/es.test.js @@ -120,7 +120,7 @@ describe('es', () => { }); describe('metric aggs', () => { - const emptyScriptedFields = []; + const emptyScriptFields = {}; test('adds a metric agg for each metric', () => { config.metric = [ @@ -133,7 +133,7 @@ describe('es', () => { 'percentiles:\\:bytes\\:123:20.0,50.0,100.0', 'percentiles:a:2', ]; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(agg.time_buckets.aggs['sum(beer)']).toEqual({ sum: { field: 'beer' } }); expect(agg.time_buckets.aggs['avg(bytes)']).toEqual({ avg: { field: 'bytes' } }); expect(agg.time_buckets.aggs['percentiles(bytes)']).toEqual({ @@ -156,14 +156,15 @@ describe('es', () => { test('adds a scripted metric agg for each scripted metric', () => { config.metric = ['avg:scriptedBytes']; - const scriptedFields = [ - { - name: 'scriptedBytes', - script: 'doc["bytes"].value', - lang: 'painless', + const scriptFields = { + scriptedBytes: { + script: { + source: 'doc["bytes"].value', + lang: 'painless', + }, }, - ]; - agg = createDateAgg(config, tlConfig, scriptedFields); + }; + agg = createDateAgg(config, tlConfig, scriptFields); expect(agg.time_buckets.aggs['avg(scriptedBytes)']).toEqual({ avg: { script: { @@ -176,14 +177,14 @@ describe('es', () => { test('has a special `count` metric that uses a script', () => { config.metric = ['count']; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); }); test('has a special `count` metric with redundant field which use a script', () => { config.metric = ['count:beer']; - agg = createDateAgg(config, tlConfig, emptyScriptedFields); + agg = createDateAgg(config, tlConfig, emptyScriptFields); expect(typeof agg.time_buckets.aggs.count.bucket_script).toBe('object'); expect(agg.time_buckets.aggs.count.bucket_script.buckets_path).toEqual('_count'); }); @@ -192,7 +193,7 @@ describe('es', () => { describe('buildRequest', () => { const fn = buildRequest; - const emptyScriptedFields = []; + const emptyScriptFields = {}; let tlConfig; let config; beforeEach(() => { @@ -206,20 +207,20 @@ describe('es', () => { test('sets the index on the request', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.index).toEqual('beer'); }); test('always sets body.size to 0', () => { - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.size).toEqual(0); }); test('creates a filters agg that contains each of the queries passed', () => { config.q = ['foo', 'bar']; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.aggs.q.meta.type).toEqual('split'); @@ -231,14 +232,14 @@ describe('es', () => { describe('timeouts', () => { test('sets the timeout on the request', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields, 30000); + const request = fn(config, tlConfig, emptyScriptFields, {}, 30000); expect(request.params.timeout).toEqual('30000ms'); }); test('sets no timeout if elasticsearch.shardTimeout is set to 0', () => { config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields, 0); + const request = fn(config, tlConfig, emptyScriptFields, {}, 0); expect(request.params).not.toHaveProperty('timeout'); }); @@ -258,7 +259,7 @@ describe('es', () => { test('sets ignore_throttled=true on the request', () => { config.index = 'beer'; tlConfig.settings[UI_SETTINGS.SEARCH_INCLUDE_FROZEN] = false; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.ignore_throttled).toEqual(true); }); @@ -266,7 +267,7 @@ describe('es', () => { test('sets no timeout if elasticsearch.shardTimeout is set to 0', () => { tlConfig.settings[UI_SETTINGS.SEARCH_INCLUDE_FROZEN] = true; config.index = 'beer'; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.ignore_throttled).toEqual(false); }); @@ -301,7 +302,7 @@ describe('es', () => { test('adds the contents of body.extended.es.filter to a filter clause of the bool', () => { config.kibana = true; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); const filter = request.params.body.query.bool.filter.bool; expect(filter.must.length).toEqual(1); expect(filter.must_not.length).toEqual(2); @@ -309,12 +310,12 @@ describe('es', () => { test('does not include filters if config.kibana = false', () => { config.kibana = false; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.filter).toEqual(undefined); }); test('adds a time filter to the bool querys must clause', () => { - let request = fn(config, tlConfig, emptyScriptedFields); + let request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.must.length).toEqual(1); expect(request.params.body.query.bool.must[0]).toEqual({ range: { @@ -327,7 +328,7 @@ describe('es', () => { }); config.kibana = true; - request = fn(config, tlConfig, emptyScriptedFields); + request = fn(config, tlConfig, emptyScriptFields); expect(request.params.body.query.bool.must.length).toEqual(1); }); }); @@ -335,7 +336,7 @@ describe('es', () => { describe('config.split', () => { test('adds terms aggs, in order, under the filters agg', () => { config.split = ['beer:5', 'wine:10', ':lemo:nade::15', ':jui:ce:723::45']; - const request = fn(config, tlConfig, emptyScriptedFields); + const request = fn(config, tlConfig, {}); let aggs = request.params.body.aggs.q.aggs; @@ -362,19 +363,21 @@ describe('es', () => { test('adds scripted terms aggs, in order, under the filters agg', () => { config.split = ['scriptedBeer:5', 'scriptedWine:10']; - const scriptedFields = [ - { - name: 'scriptedBeer', - script: 'doc["beer"].value', - lang: 'painless', + const scriptFields = { + scriptedBeer: { + script: { + source: 'doc["beer"].value', + lang: 'painless', + }, }, - { - name: 'scriptedWine', - script: 'doc["wine"].value', - lang: 'painless', + scriptedWine: { + script: { + source: 'doc["wine"].value', + lang: 'painless', + }, }, - ]; - const request = fn(config, tlConfig, scriptedFields); + }; + const request = fn(config, tlConfig, scriptFields); const aggs = request.params.body.aggs.q.aggs; diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/index.js b/src/plugins/vis_type_timelion/server/series_functions/es/index.js index 75b16fa25c9cd..663d7714774c2 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/index.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/index.js @@ -101,11 +101,10 @@ export default new Datasource('es', { (index) => index.title === config.index ); - const scriptedFields = indexPatternSpec?.getScriptedFields() ?? []; - + const { scriptFields = {}, runtimeFields = {} } = indexPatternSpec?.getComputedFields() ?? {}; const esShardTimeout = tlConfig.esShardTimeout; - const body = buildRequest(config, tlConfig, scriptedFields, esShardTimeout); + const body = buildRequest(config, tlConfig, scriptFields, runtimeFields, esShardTimeout); const resp = await tlConfig.context.search .search( diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js index cbdc834dd6611..db66cd1efc012 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/agg_body.js @@ -6,21 +6,7 @@ * Side Public License, v 1. */ -export function buildAggBody(fieldName, scriptedFields) { - const scriptedField = scriptedFields.find((field) => { - return field.name === fieldName; - }); - - if (scriptedField) { - return { - script: { - source: scriptedField.script, - lang: scriptedField.lang, - }, - }; - } - - return { +export const buildAggBody = (fieldName, scriptFields) => + scriptFields[fieldName] ?? { field: fieldName, }; -} diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js index a30b197e46067..7d55a772c7fc1 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/build_request.js @@ -12,7 +12,7 @@ import { buildAggBody } from './agg_body'; import createDateAgg from './create_date_agg'; import { UI_SETTINGS } from '../../../../../data/server'; -export default function buildRequest(config, tlConfig, scriptedFields, timeout) { +export default function buildRequest(config, tlConfig, scriptFields, runtimeFields, timeout) { const bool = { must: [] }; const timeFilter = { @@ -51,7 +51,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) (config.split || []).forEach((clause) => { const [field, arg] = clause.split(/:(\d+$)/); if (field && arg) { - const termsAgg = buildAggBody(field, scriptedFields); + const termsAgg = buildAggBody(field, scriptFields); termsAgg.size = parseInt(arg, 10); aggCursor[field] = { meta: { type: 'split' }, @@ -64,7 +64,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) } }); - _.assign(aggCursor, createDateAgg(config, tlConfig, scriptedFields)); + _.assign(aggCursor, createDateAgg(config, tlConfig, scriptFields)); const request = { index: config.index, @@ -75,6 +75,7 @@ export default function buildRequest(config, tlConfig, scriptedFields, timeout) }, aggs: aggs, size: 0, + runtime_mappings: runtimeFields, }, }; diff --git a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js index 55538fbff4e79..bd6cf8a4b7c5e 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js +++ b/src/plugins/vis_type_timelion/server/series_functions/es/lib/create_date_agg.js @@ -11,7 +11,7 @@ import { search, METRIC_TYPES } from '../../../../../data/server'; const { dateHistogramInterval } = search.aggs; -export default function createDateAgg(config, tlConfig, scriptedFields) { +export default function createDateAgg(config, tlConfig, scriptFields) { const dateAgg = { time_buckets: { meta: { type: 'time_buckets' }, @@ -47,7 +47,7 @@ export default function createDateAgg(config, tlConfig, scriptedFields) { const percentArgs = splittedArgs[1]; const metricKey = metricName + '(' + field + ')'; - metricBody[metricKey] = { [metricName]: buildAggBody(field, scriptedFields) }; + metricBody[metricKey] = { [metricName]: buildAggBody(field, scriptFields) }; if (metricName === METRIC_TYPES.PERCENTILES && percentArgs) { let percentList = percentArgs.split(','); From 42d361c644f0a7b6620178a3002be2952279f49e Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 29 Apr 2021 10:17:22 +0200 Subject: [PATCH 67/68] [ML] Adds waiting state for transforms. (#98592) When no transform nodes are available, existing continuous transform end up in a waiting state. This PR adds support for this state in the transforms UI. Without the fix, transforms in a waiting state would fail to show up in the transform list. --- x-pack/plugins/transform/common/api_schemas/common.ts | 2 ++ x-pack/plugins/transform/common/constants.ts | 3 ++- .../components/transform_list/use_columns.tsx | 8 ++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 3651af69359a9..b84dcd2a4b749 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -17,6 +17,7 @@ export const transformIdsSchema = schema.arrayOf( export type TransformIdsSchema = TypeOf; +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.ABORTING), schema.literal(TRANSFORM_STATE.FAILED), @@ -24,6 +25,7 @@ export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.STARTED), schema.literal(TRANSFORM_STATE.STOPPED), schema.literal(TRANSFORM_STATE.STOPPING), + schema.literal(TRANSFORM_STATE.WAITING), ]); export const indexPatternTitleSchema = schema.object({ diff --git a/x-pack/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts index ce61f27ef2553..423b2d001381c 100644 --- a/x-pack/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -77,7 +77,7 @@ export const APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES = [ export const APP_INDEX_PRIVILEGES = ['monitor']; -// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/dataframe/transforms/DataFrameTransformStats.java#L243 +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 export const TRANSFORM_STATE = { ABORTING: 'aborting', FAILED: 'failed', @@ -85,6 +85,7 @@ export const TRANSFORM_STATE = { STARTED: 'started', STOPPED: 'stopped', STOPPING: 'stopping', + WAITING: 'waiting', } as const; const transformStates = Object.values(TRANSFORM_STATE); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index a8f6a9a233c62..e186acf31d34f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -30,6 +30,7 @@ import { TRANSFORM_STATE } from '../../../../../../common/constants'; import { getTransformProgress, TransformListRow, TRANSFORM_LIST_COLUMN } from '../../../../common'; import { useActions } from './use_actions'; +// reflects https://github.com/elastic/elasticsearch/blob/master/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/transform/transforms/TransformStats.java#L250 const STATE_COLOR = { aborting: 'warning', failed: 'danger', @@ -37,6 +38,7 @@ const STATE_COLOR = { started: 'primary', stopped: 'hollow', stopping: 'hollow', + waiting: 'hollow', } as const; export const getTaskStateBadge = ( @@ -202,13 +204,15 @@ export const useColumns = ( {!isBatchTransform && ( - {/* If not stopped or failed show the animated progress bar */} + {/* If not stopped, failed or waiting show the animated progress bar */} {item.stats.state !== TRANSFORM_STATE.STOPPED && + item.stats.state !== TRANSFORM_STATE.WAITING && item.stats.state !== TRANSFORM_STATE.FAILED && ( )} - {/* If stopped or failed show an empty (0%) progress bar */} + {/* If stopped, failed or waiting show an empty (0%) progress bar */} {(item.stats.state === TRANSFORM_STATE.STOPPED || + item.stats.state === TRANSFORM_STATE.WAITING || item.stats.state === TRANSFORM_STATE.FAILED) && ( )} From 3f48479376e6cdf50a28feeb677f22b9bd9b054b Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 29 Apr 2021 10:22:15 +0200 Subject: [PATCH 68/68] [Security Solutions][Timeline] Added createFrom in action to hide (#98144) * added createFrom in action to hide * prettier configured * tests to check timeline modal table actions * test changes and contant extract * removed unused dependency * prevent adding empty column to timeline table when no action need * test updated Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/rules/query_bar/index.test.tsx | 58 ++++++- .../components/rules/query_bar/index.tsx | 6 +- .../flyout/add_timeline_button/index.test.tsx | 142 ++++++++++++++---- .../flyout/add_timeline_button/index.tsx | 7 +- .../open_timeline/timelines_table/index.tsx | 16 +- 5 files changed, 188 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx index 7ef698ae05b36..1e8525f0519ed 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.test.tsx @@ -6,14 +6,38 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { QueryBarDefineRule } from './index'; -import { useFormFieldMock } from '../../../../common/mock'; +import { + TestProviders, + useFormFieldMock, + mockOpenTimelineQueryResults, +} from '../../../../common/mock'; +import { mockHistory, Router } from '../../../../cases/components/__mock__/router'; +import { useGetAllTimeline, getAllTimeline } from '../../../../timelines/containers/all'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../timelines/containers/all', () => { + const originalModule = jest.requireActual('../../../../timelines/containers/all'); + return { + ...originalModule, + useGetAllTimeline: jest.fn(), + }; +}); + describe('QueryBarDefineRule', () => { + beforeEach(() => { + ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ + fetchAllTimeline: jest.fn(), + timelines: getAllTimeline('', mockOpenTimelineQueryResults.timeline ?? []), + loading: false, + totalCount: mockOpenTimelineQueryResults.totalCount, + refetch: jest.fn(), + }); + }); + it('renders correctly', () => { const Component = () => { const field = useFormFieldMock(); @@ -32,7 +56,35 @@ describe('QueryBarDefineRule', () => { ); }; const wrapper = shallow(); - expect(wrapper.dive().find('[data-test-subj="query-bar-define-rule"]')).toHaveLength(1); }); + + it('renders import query from saved timeline modal actions hidden correctly', () => { + const Component = () => { + const field = useFormFieldMock(); + + return ( + + ); + }; + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="create-from-template"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx index f45ff5f1ea1a1..6bda4a0e0f6b8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_bar/index.tsx @@ -6,7 +6,7 @@ */ import { EuiFormRow, EuiMutationObserver } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Subscription } from 'rxjs'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -50,6 +50,8 @@ interface QueryBarDefineRuleProps { onValidityChange?: (arg: boolean) => void; } +const actionTimelineToHide: ActionTimelineToShow[] = ['duplicate', 'createFrom']; + const StyledEuiFormRow = styled(EuiFormRow)` .kbnTypeahead__items { max-height: 45vh !important; @@ -253,8 +255,6 @@ export const QueryBarDefineRule = ({ } }; - const actionTimelineToHide = useMemo(() => ['duplicate'], []); - return ( <> { + const originalModule = jest.requireActual('../../open_timeline/use_timeline_status'); + return { + ...originalModule, + useTimelineStatus: jest.fn().mockReturnValue({ + timelineStatus: 'active', + templateTimelineFilter: [], + installPrepackagedTimelines: jest.fn(), + }), + }; +}); -jest.mock('../../../../common/lib/kibana', () => ({ - useKibana: jest.fn(), - useUiSetting$: jest.fn().mockReturnValue([]), -})); +jest.mock('../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest.fn(), + useUiSetting$: jest.fn().mockReturnValue([]), + }; +}); + +jest.mock('../../../containers/all', () => { + const originalModule = jest.requireActual('../../../containers/all'); + return { + ...originalModule, + useGetAllTimeline: jest.fn(), + }; +}); jest.mock('../../timeline/properties/new_template_timeline', () => ({ NewTemplateTimeline: jest.fn(() =>
), @@ -35,8 +62,7 @@ jest.mock('../../../../common/components/inspect', () => ({ InspectButtonContainer: jest.fn(({ children }) =>
{children}
), })); -// FLAKY: https://github.com/elastic/kibana/issues/96691 -describe.skip('AddTimelineButton', () => { +describe('AddTimelineButton', () => { let wrapper: ReactWrapper; const props = { timelineId: TimelineId.active, @@ -67,24 +93,24 @@ describe.skip('AddTimelineButton', () => { }); test('it renders create timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy() + ); }); test('it renders create timeline template btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy() + ); }); test('it renders Open timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); }); }); @@ -113,24 +139,86 @@ describe.skip('AddTimelineButton', () => { }); test('it renders create timeline btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy() + ); }); test('it renders create timeline template btn', async () => { - await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); - expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy(); - }); + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toBeTruthy() + ); }); test('it renders Open timeline btn', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); + }); + }); + + describe('open modal', () => { + beforeEach(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + getUrlForApp: jest.fn(), + capabilities: { + siem: { + crud: true, + }, + }, + }, + }, + }); + + ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ + fetchAllTimeline: jest.fn(), + timelines: getAllTimeline('', mockOpenTimelineQueryResults.timeline ?? []), + loading: false, + totalCount: mockOpenTimelineQueryResults.totalCount, + refetch: jest.fn(), + }); + + wrapper = mount( + + + + + + ); + }); + + afterEach(() => { + (useKibana as jest.Mock).mockReset(); + }); + + it('should render timelines table', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); await waitFor(() => { - wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy(); }); + + wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="timelines-table"]').exists()).toBeTruthy(); + }); + }); + + it('should render correct actions', async () => { + wrapper.find('[data-test-subj="settings-plus-in-circle"]').last().simulate('click'); + await waitFor(() => + expect(wrapper.find('[data-test-subj="open-timeline-button"]').exists()).toBeTruthy() + ); + + wrapper.find('[data-test-subj="open-timeline-button"]').first().simulate('click'); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="create-from-template"]').exists()).toBeFalsy(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx index 90b1cf09cb6cd..5ea1b60e4f156 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; +import { ActionTimelineToShow } from '../../open_timeline/types'; import * as i18n from '../../timeline/properties/translations'; import { NewTimeline } from '../../timeline/properties/helpers'; import { NewTemplateTimeline } from '../../timeline/properties/new_template_timeline'; @@ -20,6 +21,8 @@ interface AddTimelineButtonComponentProps { export const ADD_TIMELINE_BUTTON_CLASS_NAME = 'add-timeline-button'; +const actionTimelineToHide: ActionTimelineToShow[] = ['createFrom']; + const AddTimelineButtonComponent: React.FC = ({ timelineId }) => { const [showActions, setShowActions] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); @@ -83,7 +86,9 @@ const AddTimelineButtonComponent: React.FC = ({ - {showTimelineModal ? : null} + {showTimelineModal ? ( + + ) : null} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index c1b30f3e68cf4..4aa6fd469de26 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -83,13 +83,15 @@ export const getTimelinesTableColumns = ({ }), ...getExtendedColumns(showExtendedColumns), ...getIconHeaderColumns({ timelineType }), - ...getActionsColumns({ - actionTimelineToShow, - deleteTimelines, - enableExportTimelineDownloader, - onOpenDeleteTimelineModal, - onOpenTimeline, - }), + ...(actionTimelineToShow.length + ? getActionsColumns({ + actionTimelineToShow, + deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, + }) + : []), ]; };