From f955947d4e66ad0be65649c31ae852c80bbefafd Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 6 Sep 2021 01:10:52 +0100 Subject: [PATCH 01/17] skip flaky suite (#110970) --- .../saved_objects/migrationsv2/test_helpers/retry.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts b/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts index 246f61c71ae4d..ff5bf3d01c641 100644 --- a/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts +++ b/src/core/server/saved_objects/migrationsv2/test_helpers/retry.test.ts @@ -8,7 +8,8 @@ import { retryAsync } from './retry_async'; -describe('retry', () => { +// FLAKY: https://github.com/elastic/kibana/issues/110970 +describe.skip('retry', () => { it('retries throwing functions until they succeed', async () => { let i = 0; await expect( From 907a34076f83a0995dcec8bbaf4854c0085c0184 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 6 Sep 2021 01:17:07 +0100 Subject: [PATCH 02/17] skip failing es promotion suites (#111240) --- .../apis/uptime/rest/telemetry_collectors_fleet.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/uptime/rest/telemetry_collectors_fleet.ts b/x-pack/test/api_integration/apis/uptime/rest/telemetry_collectors_fleet.ts index 768b65453fabc..49fcdb8eba4f1 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/telemetry_collectors_fleet.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/telemetry_collectors_fleet.ts @@ -14,7 +14,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const client = getService('es'); - describe('telemetry collectors fleet', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/111240 + describe.skip('telemetry collectors fleet', () => { before('generating data', async () => { await getService('esArchiver').load( 'x-pack/test/functional/es_archives/uptime/blank_data_stream' From 4416a31aa7bc6e619ee732b72534aac153688f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Mon, 6 Sep 2021 09:10:04 +0200 Subject: [PATCH 03/17] [Osquery] Fix support for disabled security (#110547) --- package.json | 2 +- .../action_results/use_action_privileges.tsx | 12 --- .../osquery/public/common/page_paths.ts | 2 - .../plugins/osquery/public/components/app.tsx | 9 ++ .../osquery/public/components/empty_state.tsx | 86 +++++++++++++++++++ .../components/manage_integration_link.tsx | 16 ++-- .../public/live_queries/form/index.tsx | 16 ++-- .../public/packs/common/add_pack_query.tsx | 4 +- .../osquery/public/packs/common/pack_form.tsx | 4 +- .../public/routes/saved_queries/edit/form.tsx | 5 +- .../public/routes/saved_queries/new/form.tsx | 5 +- .../scheduled_query_groups/edit/index.tsx | 5 +- .../osquery/public/saved_queries/constants.ts | 1 + .../saved_queries/saved_query_flyout.tsx | 3 +- .../public/saved_queries/use_saved_query.ts | 3 +- .../saved_queries/use_update_saved_query.ts | 3 +- .../scheduled_query_groups/form/index.tsx | 6 +- .../queries/ecs_mapping_editor_field.tsx | 3 +- .../queries/query_flyout.tsx | 7 +- ...duled_query_group_queries_status_table.tsx | 70 ++++++++++++--- .../use_scheduled_query_group_query_errors.ts | 9 +- ...cheduled_query_group_query_last_results.ts | 84 +++++++++++------- .../privileges_check_route.ts | 32 +++---- yarn.lock | 8 +- 24 files changed, 280 insertions(+), 115 deletions(-) create mode 100644 x-pack/plugins/osquery/public/components/empty_state.tsx diff --git a/package.json b/package.json index e603190c72698..5aabfc66e4637 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.21.0", + "react-query": "^3.21.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", diff --git a/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx b/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx index 2c80c874e89fa..6d0477b22edee 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx +++ b/x-pack/plugins/osquery/public/action_results/use_action_privileges.tsx @@ -6,28 +6,16 @@ */ import { useQuery } from 'react-query'; - -import { i18n } from '@kbn/i18n'; import { useKibana } from '../common/lib/kibana'; -import { useErrorToast } from '../common/hooks/use_error_toast'; export const useActionResultsPrivileges = () => { const { http } = useKibana().services; - const setErrorToast = useErrorToast(); return useQuery( ['actionResultsPrivileges'], () => http.get('/internal/osquery/privileges_check'), { keepPreviousData: true, - select: (response) => response?.has_all_requested ?? false, - onSuccess: () => setErrorToast(), - onError: (error: Error) => - setErrorToast(error, { - title: i18n.translate('xpack.osquery.action_results_privileges.fetchError', { - defaultMessage: 'Error while fetching action results privileges', - }), - }), } ); }; diff --git a/x-pack/plugins/osquery/public/common/page_paths.ts b/x-pack/plugins/osquery/public/common/page_paths.ts index 0e0d8310ae8be..8df1006da181a 100644 --- a/x-pack/plugins/osquery/public/common/page_paths.ts +++ b/x-pack/plugins/osquery/public/common/page_paths.ts @@ -27,8 +27,6 @@ export interface DynamicPagePathValues { [key: string]: string; } -export const BASE_PATH = '/app/fleet'; - // If routing paths are changed here, please also check to see if // `pagePathGetters()`, below, needs any modifications export const PAGE_ROUTING_PATHS = { diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx index 44407139ab492..33fb6ac6a2adf 100644 --- a/x-pack/plugins/osquery/public/components/app.tsx +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable react-hooks/rules-of-hooks */ + import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab } from '@elastic/eui'; @@ -14,10 +16,17 @@ import { Container, Nav, Wrapper } from './layouts'; import { OsqueryAppRoutes } from '../routes'; import { useRouterNavigate } from '../common/lib/kibana'; import { ManageIntegrationLink } from './manage_integration_link'; +import { useOsqueryIntegrationStatus } from '../common/hooks'; +import { OsqueryAppEmptyState } from './empty_state'; const OsqueryAppComponent = () => { const location = useLocation(); const section = useMemo(() => location.pathname.split('/')[1] ?? 'overview', [location.pathname]); + const { data: osqueryIntegration, isFetched } = useOsqueryIntegrationStatus(); + + if (isFetched && osqueryIntegration.install_status !== 'installed') { + return ; + } return ( diff --git a/x-pack/plugins/osquery/public/components/empty_state.tsx b/x-pack/plugins/osquery/public/components/empty_state.tsx new file mode 100644 index 0000000000000..1ee0d496c0ddc --- /dev/null +++ b/x-pack/plugins/osquery/public/components/empty_state.tsx @@ -0,0 +1,86 @@ +/* + * 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, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton } from '@elastic/eui'; + +import { KibanaPageTemplate } from '../../../../../src/plugins/kibana_react/public'; +import { INTEGRATIONS_PLUGIN_ID } from '../../../fleet/common'; +import { pagePathGetters } from '../../../fleet/public'; +import { isModifiedEvent, isLeftClickEvent, useKibana } from '../common/lib/kibana'; +import { OsqueryIcon } from './osquery_icon'; +import { useBreadcrumbs } from '../common/hooks/use_breadcrumbs'; +import { OSQUERY_INTEGRATION_NAME } from '../../common'; + +const OsqueryAppEmptyStateComponent = () => { + useBreadcrumbs('base'); + + const { + application: { getUrlForApp, navigateToApp }, + } = useKibana().services; + + const integrationHref = useMemo(() => { + return getUrlForApp(INTEGRATIONS_PLUGIN_ID, { + path: pagePathGetters.integration_details_overview({ + pkgkey: OSQUERY_INTEGRATION_NAME, + })[1], + }); + }, [getUrlForApp]); + + const integrationClick = useCallback( + (event) => { + if (!isModifiedEvent(event) && isLeftClickEvent(event)) { + event.preventDefault(); + return navigateToApp(INTEGRATIONS_PLUGIN_ID, { + path: pagePathGetters.integration_details_overview({ + pkgkey: OSQUERY_INTEGRATION_NAME, + })[1], + }); + } + }, + [navigateToApp] + ); + + const pageHeader = useMemo( + () => ({ + iconType: OsqueryIcon, + pageTitle: ( + + ), + description: ( + + ), + rightSideItems: [ + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + , + ], + }), + [integrationClick, integrationHref] + ); + + return ; +}; + +export const OsqueryAppEmptyState = React.memo(OsqueryAppEmptyStateComponent); diff --git a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx index 44b923860e1a8..32779ded46c50 100644 --- a/x-pack/plugins/osquery/public/components/manage_integration_link.tsx +++ b/x-pack/plugins/osquery/public/components/manage_integration_link.tsx @@ -24,11 +24,9 @@ const ManageIntegrationLinkComponent = () => { const integrationHref = useMemo(() => { if (osqueryIntegration) { return getUrlForApp(INTEGRATIONS_PLUGIN_ID, { - path: - '#' + - pagePathGetters.integration_details_policies({ - pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - })[1], + path: pagePathGetters.integration_details_policies({ + pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, + })[1], }); } }, [getUrlForApp, osqueryIntegration]); @@ -39,11 +37,9 @@ const ManageIntegrationLinkComponent = () => { event.preventDefault(); if (osqueryIntegration) { return navigateToApp(INTEGRATIONS_PLUGIN_ID, { - path: - '#' + - pagePathGetters.integration_details_policies({ - pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, - })[1], + path: pagePathGetters.integration_details_policies({ + pkgkey: `${osqueryIntegration.name}-${osqueryIntegration.version}`, + })[1], }); } } 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 987be904c87e6..69b02dee8b9f7 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -114,7 +114,7 @@ const LiveQueryFormComponent: React.FC = ({ ), }); - const { setFieldValue, submit } = form; + const { setFieldValue, submit, isSubmitting } = form; const actionId = useMemo(() => data?.actions[0].action_id, [data?.actions]); const agentIds = useMemo(() => data?.actions[0].agents, [data?.actions]); @@ -185,7 +185,10 @@ const LiveQueryFormComponent: React.FC = ({ )} - + = ({ ), [ - agentSelected, - permissions.writeSavedQueries, - handleShowSaveQueryFlout, queryComponentProps, + singleAgentMode, + permissions.writeSavedQueries, + agentSelected, queryValueProvided, resultsStatus, - singleAgentMode, + handleShowSaveQueryFlout, + isSubmitting, submit, ] ); diff --git a/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx index 2d58e2dfe9522..d1115898b4e40 100644 --- a/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx +++ b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx @@ -51,7 +51,7 @@ const AddPackQueryFormComponent = ({ handleSubmit }) => { }, }, }); - const { submit } = form; + const { submit, isSubmitting } = form; const createSavedQueryMutation = useMutation( (payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), @@ -108,7 +108,7 @@ const AddPackQueryFormComponent = ({ handleSubmit }) => { - + {'Add query'} diff --git a/x-pack/plugins/osquery/public/packs/common/pack_form.tsx b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx index 86d4d8dff6ba6..ab0984e808943 100644 --- a/x-pack/plugins/osquery/public/packs/common/pack_form.tsx +++ b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx @@ -40,7 +40,7 @@ const PackFormComponent = ({ data, handleSubmit }) => { }, }, }); - const { submit } = form; + const { submit, isSubmitting } = form; return (
@@ -50,7 +50,7 @@ const PackFormComponent = ({ data, handleSubmit }) => { - + {'Save pack'} diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx index a7596575b90c4..617d83821d08d 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/form.tsx @@ -38,6 +38,7 @@ const EditSavedQueryFormComponent: React.FC = ({ defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return (
@@ -58,12 +59,12 @@ const EditSavedQueryFormComponent: React.FC = ({ = ({ defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return ( @@ -54,12 +55,12 @@ const NewSavedQueryFormComponent: React.FC = ({ { const { data } = useScheduledQueryGroup({ scheduledQueryGroupId }); - useBreadcrumbs('scheduled_query_group_edit', { scheduledQueryGroupName: data?.name ?? '' }); + useBreadcrumbs('scheduled_query_group_edit', { + scheduledQueryGroupId: data?.id ?? '', + scheduledQueryGroupName: data?.name ?? '', + }); const LeftColumn = useMemo( () => ( diff --git a/x-pack/plugins/osquery/public/saved_queries/constants.ts b/x-pack/plugins/osquery/public/saved_queries/constants.ts index 69ca805e3e8fa..8edcfd00d1788 100644 --- a/x-pack/plugins/osquery/public/saved_queries/constants.ts +++ b/x-pack/plugins/osquery/public/saved_queries/constants.ts @@ -6,3 +6,4 @@ */ export const SAVED_QUERIES_ID = 'savedQueryList'; +export const SAVED_QUERY_ID = 'savedQuery'; diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx index 6d14943a6bc84..8c35a359a9baf 100644 --- a/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/saved_query_flyout.tsx @@ -42,6 +42,7 @@ const SavedQueryFlyoutComponent: React.FC = ({ defaultValue defaultValue, handleSubmit, }); + const { submit, isSubmitting } = form; return ( @@ -72,7 +73,7 @@ const SavedQueryFlyoutComponent: React.FC = ({ defaultValue - + { queryClient.invalidateQueries(SAVED_QUERIES_ID); + queryClient.invalidateQueries([SAVED_QUERY_ID, { savedQueryId }]); navigateToApp(PLUGIN_ID, { path: pagePathGetters.saved_queries() }); toasts.addSuccess( i18n.translate('xpack.osquery.editSavedQuery.successToastMessageText', { diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx index 685960ecd202e..3598a9fd2e44c 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/index.tsx @@ -88,7 +88,7 @@ const ScheduledQueryGroupFormComponent: React.FC = `scheduled_query_groups/${editMode ? defaultValue?.id : ''}` ); - const { isLoading, mutateAsync } = useMutation( + const { mutateAsync } = useMutation( (payload: Record) => editMode && defaultValue?.id ? http.put(packagePolicyRouteService.getUpdatePath(defaultValue.id), { @@ -248,7 +248,7 @@ const ScheduledQueryGroupFormComponent: React.FC = ), }); - const { setFieldValue, submit } = form; + const { setFieldValue, submit, isSubmitting } = form; const policyIdEuiFieldProps = useMemo( () => ({ isDisabled: !!defaultValue, options: agentPolicyOptions }), @@ -368,7 +368,7 @@ const ScheduledQueryGroupFormComponent: React.FC = ( ) )(args); - if (fieldRequiredError && (!!(!editForm && args.formData.value?.field.length) || editForm)) { + // @ts-expect-error update types + if (fieldRequiredError && ((!editForm && args.formData['value.field'].length) || editForm)) { return fieldRequiredError; } diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx index cae9711694f29..d38c1b2118f24 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { EuiCallOut, EuiFlyout, @@ -66,7 +67,7 @@ const QueryFlyoutComponent: React.FC = ({ if (isValid && ecsFieldValue) { onSave({ ...payload, - ecs_mapping: ecsFieldValue, + ...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }), }); onClose(); } @@ -81,7 +82,7 @@ const QueryFlyoutComponent: React.FC = ({ [integrationPackageVersion] ); - const { submit, setFieldValue, reset } = form; + const { submit, setFieldValue, reset, isSubmitting } = form; const [{ query }] = useFormData({ form, @@ -245,7 +246,7 @@ const QueryFlyoutComponent: React.FC = ({ - + = ({ toggleErrors, expanded, }) => { + const data = useKibana().services.data; + const [logsIndexPattern, setLogsIndexPattern] = useState(undefined); + const { data: lastResultsData, isFetched } = useScheduledQueryGroupQueryLastResults({ actionId, agentIds, interval, + logsIndexPattern, }); const { data: errorsData, isFetched: errorsFetched } = useScheduledQueryGroupQueryErrors({ actionId, agentIds, interval, + logsIndexPattern, }); const handleErrorsToggle = useCallback(() => toggleErrors({ queryId, interval }), [ @@ -409,20 +414,41 @@ const ScheduledQueryLastResults: React.FC = ({ toggleErrors, ]); + useEffect(() => { + const fetchLogsIndexPattern = async () => { + const indexPattern = await data.indexPatterns.find('logs-*'); + + setLogsIndexPattern(indexPattern[0]); + }; + fetchLogsIndexPattern(); + }, [data.indexPatterns]); + if (!isFetched || !errorsFetched) { return ; } - if (!lastResultsData) { + if (!lastResultsData && !errorsData?.total) { return <>{'-'}; } return ( - {lastResultsData.first_event_ingested_time?.value ? ( - - <>{moment(lastResultsData.first_event_ingested_time?.value).fromNow()} + {lastResultsData?.['@timestamp'] ? ( + + {' '} + + + } + > + ) : ( '-' @@ -432,10 +458,17 @@ const ScheduledQueryLastResults: React.FC = ({ - {lastResultsData?.doc_count ?? 0} + {lastResultsData?.docCount ?? 0} - {'Documents'} + + + @@ -443,10 +476,17 @@ const ScheduledQueryLastResults: React.FC = ({ - {lastResultsData?.unique_agents?.value ?? 0} + {lastResultsData?.uniqueAgentsCount ?? 0} - {'Agents'} + + + @@ -458,7 +498,15 @@ const ScheduledQueryLastResults: React.FC = ({ - {'Errors'} + + {' '} + + { const data = useKibana().services.data; @@ -28,9 +30,8 @@ export const useScheduledQueryGroupQueryErrors = ({ return useQuery( ['scheduledQueryErrors', { actionId, interval }], async () => { - const indexPattern = await data.indexPatterns.find('logs-*'); const searchSource = await data.search.searchSource.create({ - index: indexPattern[0], + index: logsIndexPattern, fields: ['*'], sort: [ { @@ -80,7 +81,7 @@ export const useScheduledQueryGroupQueryErrors = ({ }, { keepPreviousData: true, - enabled: !!(!skip && actionId && interval && agentIds?.length), + enabled: !!(!skip && actionId && interval && agentIds?.length && logsIndexPattern), select: (response) => response.rawResponse.hits ?? [], refetchOnReconnect: false, refetchOnWindowFocus: false, diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts index f972640e25986..7cfd6be461e05 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group_query_last_results.ts @@ -6,13 +6,14 @@ */ import { useQuery } from 'react-query'; - +import { IndexPattern } from '../../../../../src/plugins/data/common'; import { useKibana } from '../common/lib/kibana'; interface UseScheduledQueryGroupQueryLastResultsProps { actionId: string; agentIds?: string[]; interval: number; + logsIndexPattern?: IndexPattern; skip?: boolean; } @@ -20,6 +21,7 @@ export const useScheduledQueryGroupQueryLastResults = ({ actionId, agentIds, interval, + logsIndexPattern, skip = false, }: UseScheduledQueryGroupQueryLastResultsProps) => { const data = useKibana().services.data; @@ -27,23 +29,9 @@ export const useScheduledQueryGroupQueryLastResults = ({ return useQuery( ['scheduledQueryLastResults', { actionId }], async () => { - const indexPattern = await data.indexPatterns.find('logs-*'); - const searchSource = await data.search.searchSource.create({ - index: indexPattern[0], - size: 0, - aggs: { - runs: { - terms: { - field: 'response_id', - order: { first_event_ingested_time: 'desc' }, - size: 1, - }, - aggs: { - first_event_ingested_time: { min: { field: '@timestamp' } }, - unique_agents: { cardinality: { field: 'agent.id' } }, - }, - }, - }, + const lastResultsSearchSource = await data.search.searchSource.create({ + index: logsIndexPattern, + size: 1, query: { // @ts-expect-error update types bool: { @@ -59,26 +47,62 @@ export const useScheduledQueryGroupQueryLastResults = ({ action_id: actionId, }, }, - { - range: { - '@timestamp': { - gte: `now-${interval * 2}s`, - lte: 'now', - }, - }, - }, ], }, }, }); - return searchSource.fetch$().toPromise(); + const lastResultsResponse = await lastResultsSearchSource.fetch$().toPromise(); + + const responseId = lastResultsResponse.rawResponse?.hits?.hits[0]?._source?.response_id; + + if (responseId) { + const aggsSearchSource = await data.search.searchSource.create({ + index: logsIndexPattern, + size: 0, + aggs: { + unique_agents: { cardinality: { field: 'agent.id' } }, + }, + query: { + // @ts-expect-error update types + bool: { + should: agentIds?.map((agentId) => ({ + match_phrase: { + 'agent.id': agentId, + }, + })), + minimum_should_match: 1, + filter: [ + { + match_phrase: { + action_id: actionId, + }, + }, + { + match_phrase: { + response_id: responseId, + }, + }, + ], + }, + }, + }); + + const aggsResponse = await aggsSearchSource.fetch$().toPromise(); + + return { + '@timestamp': lastResultsResponse.rawResponse?.hits?.hits[0]?.fields?.['@timestamp'], + // @ts-expect-error update types + uniqueAgentsCount: aggsResponse.rawResponse.aggregations?.unique_agents?.value, + docCount: aggsResponse.rawResponse?.hits?.total, + }; + } + + return null; }, { keepPreviousData: true, - enabled: !!(!skip && actionId && interval && agentIds?.length), - // @ts-expect-error update types - select: (response) => response.rawResponse.aggregations?.runs?.buckets[0] ?? [], + enabled: !!(!skip && actionId && interval && agentIds?.length && logsIndexPattern), refetchOnReconnect: false, refetchOnWindowFocus: false, } diff --git a/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts b/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts index 80c335c1c46d3..d9683d23deb13 100644 --- a/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts +++ b/x-pack/plugins/osquery/server/routes/privileges_check/privileges_check_route.ts @@ -9,7 +9,6 @@ import { OSQUERY_INTEGRATION_NAME, PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const privilegesCheckRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( { @@ -20,23 +19,26 @@ export const privilegesCheckRoute = (router: IRouter, osqueryContext: OsqueryApp }, }, async (context, request, response) => { - const esClient = context.core.elasticsearch.client.asCurrentUser; - - const privileges = ( - await esClient.security.hasPrivileges({ - body: { - index: [ - { - names: [`logs-${OSQUERY_INTEGRATION_NAME}.result*`], - privileges: ['read'], - }, - ], + if (osqueryContext.security.authz.mode.useRbacForRequest(request)) { + const checkPrivileges = osqueryContext.security.authz.checkPrivilegesDynamicallyWithRequest( + request + ); + const { hasAllRequested } = await checkPrivileges({ + elasticsearch: { + cluster: [], + index: { + [`logs-${OSQUERY_INTEGRATION_NAME}.result*`]: ['read'], + }, }, - }) - ).body; + }); + + return response.ok({ + body: `${hasAllRequested}`, + }); + } return response.ok({ - body: privileges, + body: 'true', }); } ); diff --git a/yarn.lock b/yarn.lock index 4d49a2f06e1e9..f0a1ff1278f4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23379,10 +23379,10 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" -react-query@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.21.0.tgz#2e099a7906c38eeeb750e8b9b12121a21fa8d9ef" - integrity sha512-5rY5J8OD9f4EdkytjSsdCO+pqbJWKwSIMETfh/UyxqyjLURHE0IhlB+IPNPrzzu/dzK0rRxi5p0IkcCdSfizDQ== +react-query@^3.21.1: + version "3.21.1" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.21.1.tgz#8fe4df90bf6c6a93e0552ea9baff211d1b28f6e0" + integrity sha512-aKFLfNJc/m21JBXJk7sR9tDUYPjotWA4EHAKvbZ++GgxaY+eI0tqBxXmGBuJo0Pisis1W4pZWlZgoRv9yE8yjA== dependencies: "@babel/runtime" "^7.5.5" broadcast-channel "^3.4.1" From eef094bafb4ce265126495754f8cb1cf760bb614 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 6 Sep 2021 11:13:38 +0300 Subject: [PATCH 04/17] [Canvas] `TagCloud` (#106858) * Added `tagCloud` to canvas. * Added `icon` to the `tagCloud` element. * Added column name support at `tag_cloud`. * Added condition to `vis_dimension` not to pass invalid index. Added check of accessor index, if such column exists at vis_dimension. Removed checks of column existance from TagCloudChart. Added test for accessing data by column name in addition to a column number. Updated tag_cloud element in Canvas. Fixed types. Removed almost all `any` and `as` types. * Added test suites for `vis_dimension` function. * Added tests for DatatableColumn accessors at tag_cloud_fn and to_ast. * Refactored metrics, tagcloud and tests. Added valid functional tests to metrics and tag_cloud. Fixed types of metrics_vis. Added handling of empty data at tag_cloud renderer. * Added storybook ( still doesn't work ). * Fixed some mistakes. * Added working storybook with mocks. * Added clear storybook for tag_cloud_vis_renderer. * Updated the location of vis_dimension test after movement of the function. * Fixed unused type. * Fixed tests and added handling of the column name at `visualizations/**/*/prepare_log_table.ts` * Reduced the complexity of checking the accessor at `tag_cloud_chart.tsx` * Added comments at unclear places of code. * Added the logic for disabling elements for renderers from disabled plugins. * removed garbage from `kibana.yml`. * Fixed element_strings.test error. * Made changes, based on nits. * Fixed mistake. * Removed `disabled` flag for `expression_*` plugins. * recovered lost comments at the unclear places. * removed dead code. * fixed test errors. * Fixed test error, I hope. * fixed more tests. * fixed code, based on nits. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/dev/storybook/aliases.ts | 1 + .../expression_tagcloud/.storybook/main.js | 23 +++ .../tagcloud_function.test.ts.snap | 102 +++++++++++- .../tagcloud_function.test.ts | 63 ++++++- .../expression_functions/tagcloud_function.ts | 6 +- .../common/types/expression_functions.ts | 17 +- .../public/__mocks__/format_service.ts | 13 ++ .../public/__mocks__/palettes.ts | 52 ++++++ .../__stories__/tagcloud_renderer.stories.tsx | 125 ++++++++++++++ .../public/components/tag_cloud.scss | 2 + .../components/tagcloud_component.test.tsx | 155 +++++++++++------- .../public/components/tagcloud_component.tsx | 44 +++-- .../tagcloud_renderer.tsx | 44 +++-- .../public/{services.ts => format_service.ts} | 0 .../expression_tagcloud/public/plugin.ts | 2 +- .../components/metric_vis_component.tsx | 19 ++- .../vis_types/metric/public/metric_vis_fn.ts | 21 +-- src/plugins/vis_types/metric/public/types.ts | 6 +- .../public/__snapshots__/to_ast.test.ts.snap | 104 +++++++++++- .../vis_types/tagcloud/public/to_ast.test.ts | 57 ++++++- .../vis_types/tagcloud/public/types.ts | 14 +- .../vis_dimension.test.ts | 70 ++++++++ .../expression_functions/vis_dimension.ts | 14 +- .../common/prepare_log_table.test.ts | 5 +- .../common/prepare_log_table.ts | 23 ++- .../screenshots/baseline/metric_all_data.png | Bin 22339 -> 29776 bytes .../baseline/metric_empty_data.png | Bin 0 -> 5163 bytes .../baseline/metric_invalid_data.png | Bin 3763 -> 1993 bytes .../baseline/tagcloud_empty_data.png | Bin 0 -> 4467 bytes .../metric_empty_data.json} | 2 +- .../baseline/metric_invalid_data.json | 2 +- .../tagcloud_empty_data.json} | 2 +- .../baseline/tagcloud_invalid_data.json | 2 +- .../session/tagcloud_empty_data.json | 1 + .../session/tagcloud_invalid_data.json | 2 +- .../test_suites/run_pipeline/metric.ts | 16 +- .../test_suites/run_pipeline/tag_cloud.ts | 34 ++-- .../canvas_plugin_src/elements/index.ts | 3 +- .../elements/tag_cloud/index.ts | 21 +++ .../canvas/i18n/elements/element_strings.ts | 8 + x-pack/plugins/canvas/public/plugin.tsx | 1 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 43 files changed, 891 insertions(+), 187 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js create mode 100644 src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts create mode 100644 src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts create mode 100644 src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx rename src/plugins/chart_expressions/expression_tagcloud/public/{services.ts => format_service.ts} (100%) create mode 100644 src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts create mode 100644 test/interpreter_functional/screenshots/baseline/metric_empty_data.png create mode 100644 test/interpreter_functional/screenshots/baseline/tagcloud_empty_data.png rename test/interpreter_functional/snapshots/{session/metric_single_metric_data.json => baseline/metric_empty_data.json} (89%) rename test/interpreter_functional/snapshots/{session/partial_test_1.json => baseline/tagcloud_empty_data.json} (65%) create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_empty_data.json create mode 100644 x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 965a716098f33..9395c5fdf8834 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -23,6 +23,7 @@ export const storybookAliases = { expression_repeat_image: 'src/plugins/expression_repeat_image/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook', + expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', infra: 'x-pack/plugins/infra/.storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', diff --git a/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js b/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js new file mode 100644 index 0000000000000..cb483d5394285 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/.storybook/main.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { defaultConfig } from '@kbn/storybook'; +import webpackMerge from 'webpack-merge'; +import { resolve } from 'path'; + +const mockConfig = { + resolve: { + alias: { + '../format_service': resolve(__dirname, '../public/__mocks__/format_service.ts'), + }, + }, +}; + +module.exports = { + ...defaultConfig, + webpackFinal: (config) => webpackMerge(config, mockConfig), +}; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap index 56b24f0ae004f..da116bc50f370 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/__snapshots__/tagcloud_function.test.ts.snap @@ -4,23 +4,35 @@ exports[`interpreter/functions#tagcloud logs correct datatable to inspector 1`] Object { "columns": Array [ Object { - "id": "col-0-1", + "id": "Count", "meta": Object { "dimensionName": "Tag size", }, "name": "Count", }, + Object { + "id": "country", + "meta": Object { + "dimensionName": "Tags", + }, + "name": "country", + }, ], "rows": Array [ Object { - "col-0-1": 0, + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", }, ], "type": "datatable", } `; -exports[`interpreter/functions#tagcloud returns an object with the correct structure 1`] = ` +exports[`interpreter/functions#tagcloud returns an object with the correct structure for number accessors 1`] = ` Object { "as": "tagcloud", "type": "render", @@ -29,13 +41,22 @@ Object { "visData": Object { "columns": Array [ Object { - "id": "col-0-1", + "id": "Count", "name": "Count", }, + Object { + "id": "country", + "name": "country", + }, ], "rows": Array [ Object { - "col-0-1": 0, + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", }, ], "type": "datatable", @@ -43,16 +64,81 @@ Object { "visParams": Object { "bucket": Object { "accessor": 1, + }, + "maxFontSize": 72, + "metric": Object { + "accessor": 0, + }, + "minFontSize": 18, + "orientation": "single", + "palette": Object { + "name": "default", + "type": "palette", + }, + "scale": "linear", + "showLabel": true, + }, + "visType": "tagcloud", + }, +} +`; + +exports[`interpreter/functions#tagcloud returns an object with the correct structure for string accessors 1`] = ` +Object { + "as": "tagcloud", + "type": "render", + "value": Object { + "syncColors": false, + "visData": Object { + "columns": Array [ + Object { + "id": "Count", + "name": "Count", + }, + Object { + "id": "country", + "name": "country", + }, + ], + "rows": Array [ + Object { + "Count": 0, + "country": "US", + }, + Object { + "Count": 10, + "country": "UK", + }, + ], + "type": "datatable", + }, + "visParams": Object { + "bucket": Object { + "accessor": Object { + "id": "country", + "meta": Object { + "type": "string", + }, + "name": "country", + }, "format": Object { - "id": "number", + "params": Object {}, }, + "type": "vis_dimension", }, "maxFontSize": 72, "metric": Object { - "accessor": 0, + "accessor": Object { + "id": "Count", + "meta": Object { + "type": "number", + }, + "name": "Count", + }, "format": Object { - "id": "number", + "params": Object {}, }, + "type": "vis_dimension", }, "minFontSize": 18, "orientation": "single", diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index 2c6e021b5107a..8abdc36704b45 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -9,14 +9,23 @@ import { tagcloudFunction } from './tagcloud_function'; import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Datatable } from '../../../../expressions/common/expression_types/specs'; describe('interpreter/functions#tagcloud', () => { const fn = functionWrapper(tagcloudFunction()); + const column1 = 'Count'; + const column2 = 'country'; const context = { type: 'datatable', - rows: [{ 'col-0-1': 0 }], - columns: [{ id: 'col-0-1', name: 'Count' }], + columns: [ + { id: column1, name: column1 }, + { id: column2, name: column2 }, + ], + rows: [ + { [column1]: 0, [column2]: 'US' }, + { [column1]: 10, [column2]: 'UK' }, + ], }; const visConfig = { scale: 'linear', @@ -24,12 +33,52 @@ describe('interpreter/functions#tagcloud', () => { minFontSize: 18, maxFontSize: 72, showLabel: true, - metric: { accessor: 0, format: { id: 'number' } }, - bucket: { accessor: 1, format: { id: 'number' } }, }; - it('returns an object with the correct structure', () => { - const actual = fn(context, visConfig, undefined); + const numberAccessors = { + metric: { accessor: 0 }, + bucket: { accessor: 1 }, + }; + + const stringAccessors: { + metric: ExpressionValueVisDimension; + bucket: ExpressionValueVisDimension; + } = { + metric: { + type: 'vis_dimension', + accessor: { + id: column1, + name: column1, + meta: { + type: 'number', + }, + }, + format: { + params: {}, + }, + }, + bucket: { + type: 'vis_dimension', + accessor: { + id: column2, + name: column2, + meta: { + type: 'string', + }, + }, + format: { + params: {}, + }, + }, + }; + + it('returns an object with the correct structure for number accessors', () => { + const actual = fn(context, { ...visConfig, ...numberAccessors }, undefined); + expect(actual).toMatchSnapshot(); + }); + + it('returns an object with the correct structure for string accessors', () => { + const actual = fn(context, { ...visConfig, ...stringAccessors }, undefined); expect(actual).toMatchSnapshot(); }); @@ -44,7 +93,7 @@ describe('interpreter/functions#tagcloud', () => { }, }, }; - await fn(context, visConfig, handlers as any); + await fn(context, { ...visConfig, ...numberAccessors }, handlers as any); expect(loggedTable!).toMatchSnapshot(); }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts index c3553c4660ce9..2ce50e94aeda3 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { prepareLogTable, Dimension } from '../../../../visualizations/common/prepare_log_table'; -import { TagCloudVisParams } from '../types'; +import { TagCloudRendererParams } from '../types'; import { ExpressionTagcloudFunction } from '../types'; import { EXPRESSION_NAME } from '../constants'; @@ -125,7 +125,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { }, }, fn(input, args, handlers) { - const visParams = { + const visParams: TagCloudRendererParams = { scale: args.scale, orientation: args.orientation, minFontSize: args.minFontSize, @@ -139,7 +139,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { type: 'palette', name: args.palette, }, - } as TagCloudVisParams; + }; if (handlers?.inspectorAdapters?.tables) { const argsTable: Dimension[] = [[[args.metric], dimension.tagSize]]; diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts index b1aba30380b59..1ee0434e1603e 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/types/expression_functions.ts @@ -10,19 +10,10 @@ import { Datatable, ExpressionFunctionDefinition, ExpressionValueRender, - SerializedFieldFormat, } from '../../../../expressions'; import { ExpressionValueVisDimension } from '../../../../visualizations/common'; import { EXPRESSION_NAME } from '../constants'; -interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} - interface TagCloudCommonParams { scale: 'linear' | 'log' | 'square root'; orientation: 'single' | 'right angled' | 'multiple'; @@ -36,16 +27,16 @@ export interface TagCloudVisConfig extends TagCloudCommonParams { bucket?: ExpressionValueVisDimension; } -export interface TagCloudVisParams extends TagCloudCommonParams { +export interface TagCloudRendererParams extends TagCloudCommonParams { palette: PaletteOutput; - metric: Dimension; - bucket?: Dimension; + metric: ExpressionValueVisDimension; + bucket?: ExpressionValueVisDimension; } export interface TagcloudRendererConfig { visType: typeof EXPRESSION_NAME; visData: Datatable; - visParams: TagCloudVisParams; + visParams: TagCloudRendererParams; syncColors: boolean; } diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts new file mode 100644 index 0000000000000..77f6d8eb0bf37 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/format_service.ts @@ -0,0 +1,13 @@ +/* + * 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 const getFormatService = () => ({ + deserialize: (target: any) => ({ + convert: (text: string, format: string) => text, + }), +}); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts new file mode 100644 index 0000000000000..7ca00b58f5624 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__mocks__/palettes.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PaletteDefinition, SeriesLayer } from '../../../../charts/public'; +import { random } from 'lodash'; + +export const getPaletteRegistry = () => { + const colors = [ + '#54B399', + '#6092C0', + '#D36086', + '#9170B8', + '#CA8EAE', + '#D6BF57', + '#B9A888', + '#DA8B45', + '#AA6556', + '#E7664C', + ]; + const mockPalette: PaletteDefinition = { + id: 'default', + title: 'My Palette', + getCategoricalColor: (_: SeriesLayer[]) => colors[random(0, colors.length - 1)], + getCategoricalColors: (num: number) => colors, + toExpression: () => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + }), + }; + + return { + get: (name: string) => mockPalette, + getAll: () => [mockPalette], + }; +}; + +export const palettes = { + getPalettes: async () => getPaletteRegistry(), +}; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx new file mode 100644 index 0000000000000..1e0dc2600d1a1 --- /dev/null +++ b/src/plugins/chart_expressions/expression_tagcloud/public/__stories__/tagcloud_renderer.stories.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { tagcloudRenderer } from '../expression_renderers'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { TagcloudRendererConfig } from '../../common/types'; +import { palettes } from '../__mocks__/palettes'; + +const config: TagcloudRendererConfig = { + visType: 'tagcloud', + visData: { + type: 'datatable', + rows: [ + { country: 'US', Count: 14 }, + { country: 'JP', Count: 13 }, + { country: 'UK', Count: 13 }, + { country: 'CN', Count: 8 }, + { country: 'TZ', Count: 14 }, + { country: 'NL', Count: 11 }, + { country: 'AZ', Count: 14 }, + { country: 'BR', Count: 11 }, + { country: 'DE', Count: 16 }, + { country: 'SA', Count: 11 }, + { country: 'RU', Count: 9 }, + { country: 'IN', Count: 9 }, + { country: 'PH', Count: 7 }, + ], + columns: [ + { id: 'country', name: 'country', meta: { type: 'string' } }, + { id: 'Count', name: 'Count', meta: { type: 'number' } }, + ], + }, + visParams: { + scale: 'linear', + orientation: 'single', + minFontSize: 18, + maxFontSize: 72, + showLabel: true, + metric: { + type: 'vis_dimension', + accessor: { id: 'Count', name: 'Count', meta: { type: 'number' } }, + format: { id: 'string', params: {} }, + }, + bucket: { + type: 'vis_dimension', + accessor: { id: 'country', name: 'country', meta: { type: 'string' } }, + format: { id: 'string', params: {} }, + }, + palette: { type: 'palette', name: 'default' }, + }, + syncColors: false, +}; + +const containerSize = { + width: '700px', + height: '700px', +}; + +storiesOf('renderers/tag_cloud_vis', module) + .add('Default', () => { + return ( + tagcloudRenderer({ palettes })} config={config} {...containerSize} /> + ); + }) + .add('With log scale', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, scale: 'log' } }} + {...containerSize} + /> + ); + }) + .add('With square root scale', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, scale: 'square root' } }} + {...containerSize} + /> + ); + }) + .add('With right angled orientation', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, orientation: 'right angled' } }} + {...containerSize} + /> + ); + }) + .add('With multiple orientations', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, orientation: 'multiple' } }} + {...containerSize} + /> + ); + }) + .add('With hidden label', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visParams: { ...config.visParams, showLabel: false } }} + {...containerSize} + /> + ); + }) + .add('With empty results', () => { + return ( + tagcloudRenderer({ palettes })} + config={{ ...config, visData: { ...config.visData, rows: [] } }} + {...containerSize} + /> + ); + }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss b/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss index 51b5e9dedd844..8a017150fe195 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tag_cloud.scss @@ -9,6 +9,8 @@ flex: 1 1 0; display: flex; flex-direction: column; + // it is used for rendering at `Canvas`. + height: 100%; } .tgcChart__wrapper text { diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx index 542a9c1cd9bf7..f65630e422cce 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.test.tsx @@ -6,15 +6,15 @@ * Side Public License, v 1. */ import React from 'react'; -import { Wordcloud, Settings } from '@elastic/charts'; +import { Wordcloud, Settings, WordcloudSpec } from '@elastic/charts'; import { chartPluginMock } from '../../../../charts/public/mocks'; import type { Datatable } from '../../../../expressions/public'; import { mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import TagCloudChart, { TagCloudChartProps } from './tagcloud_component'; -import { TagCloudVisParams } from '../../common/types'; +import { TagCloudRendererParams } from '../../common/types'; -jest.mock('../services', () => ({ +jest.mock('../format_service', () => ({ getFormatService: jest.fn(() => { return { deserialize: jest.fn(), @@ -23,29 +23,34 @@ jest.mock('../services', () => ({ })); const palettesRegistry = chartPluginMock.createPaletteRegistry(); -const visData = ({ +const geoDestId = 'geo.dest'; +const countId = 'Count'; +const visData: Datatable = { + type: 'datatable', columns: [ { - id: 'col-0', - name: 'geo.dest: Descending', + id: geoDestId, + name: `${geoDestId}: Descending`, + meta: { type: 'string' }, }, { - id: 'col-1', + id: countId, name: 'Count', + meta: { type: 'number' }, }, ], rows: [ - { 'col-0': 'CN', 'col-1': 26 }, - { 'col-0': 'IN', 'col-1': 17 }, - { 'col-0': 'US', 'col-1': 6 }, - { 'col-0': 'DE', 'col-1': 4 }, - { 'col-0': 'BR', 'col-1': 3 }, + { [geoDestId]: 'CN', [countId]: 26 }, + { [geoDestId]: 'IN', [countId]: 17 }, + { [geoDestId]: 'US', [countId]: 6 }, + { [geoDestId]: 'DE', [countId]: 4 }, + { [geoDestId]: 'BR', [countId]: 3 }, ], -} as unknown) as Datatable; +}; -const visParams = { - bucket: { accessor: 0, format: {} }, - metric: { accessor: 1, format: {} }, +const visParams: TagCloudRendererParams = { + bucket: { type: 'vis_dimension', accessor: 0, format: { params: {} } }, + metric: { type: 'vis_dimension', accessor: 1, format: { params: {} } }, scale: 'linear', orientation: 'single', palette: { @@ -55,13 +60,42 @@ const visParams = { minFontSize: 12, maxFontSize: 70, showLabel: true, -} as TagCloudVisParams; +}; + +const formattedData: WordcloudSpec['data'] = [ + { + color: 'black', + text: 'CN', + weight: 1, + }, + { + color: 'black', + text: 'IN', + weight: 0.6086956521739131, + }, + { + color: 'black', + text: 'US', + weight: 0.13043478260869565, + }, + { + color: 'black', + text: 'DE', + weight: 0.043478260869565216, + }, + { + color: 'black', + text: 'BR', + weight: 0, + }, +]; describe('TagCloudChart', function () { - let wrapperProps: TagCloudChartProps; + let wrapperPropsWithIndexes: TagCloudChartProps; + let wrapperPropsWithColumnNames: TagCloudChartProps; beforeAll(() => { - wrapperProps = { + wrapperPropsWithIndexes = { visData, visParams, palettesRegistry, @@ -70,68 +104,77 @@ describe('TagCloudChart', function () { syncColors: false, visType: 'tagcloud', }; + + wrapperPropsWithColumnNames = { + visData, + visParams: { + ...visParams, + bucket: { + type: 'vis_dimension', + accessor: { + id: geoDestId, + name: geoDestId, + meta: { type: 'string' }, + }, + format: { id: 'string', params: {} }, + }, + metric: { + type: 'vis_dimension', + accessor: { + id: countId, + name: countId, + meta: { type: 'number' }, + }, + format: { id: 'number', params: {} }, + }, + }, + palettesRegistry, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + syncColors: false, + visType: 'tagcloud', + }; }); - it('renders the Wordcloud component', async () => { - const component = mount(); + it('renders the Wordcloud component with', async () => { + const component = mount(); expect(component.find(Wordcloud).length).toBe(1); }); it('renders the label correctly', async () => { - const component = mount(); + const component = mount(); const label = findTestSubject(component, 'tagCloudLabel'); expect(label.text()).toEqual('geo.dest: Descending - Count'); }); it('not renders the label if showLabel setting is off', async () => { const newVisParams = { ...visParams, showLabel: false }; - const newProps = { ...wrapperProps, visParams: newVisParams }; + const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); const label = findTestSubject(component, 'tagCloudLabel'); expect(label.length).toBe(0); }); - it('receives the data on the correct format', () => { - const component = mount(); - expect(component.find(Wordcloud).prop('data')).toStrictEqual([ - { - color: 'black', - text: 'CN', - weight: 1, - }, - { - color: 'black', - text: 'IN', - weight: 0.6086956521739131, - }, - { - color: 'black', - text: 'US', - weight: 0.13043478260869565, - }, - { - color: 'black', - text: 'DE', - weight: 0.043478260869565216, - }, - { - color: 'black', - text: 'BR', - weight: 0, - }, - ]); + it('receives the data in the correct format for bucket and metric accessors of type number', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual(formattedData); + }); + + it('receives the data in the correct format for bucket and metric accessors of type DatatableColumn', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual(formattedData); }); it('sets the angles correctly', async () => { - const newVisParams = { ...visParams, orientation: 'right angled' } as TagCloudVisParams; - const newProps = { ...wrapperProps, visParams: newVisParams }; + const newVisParams: TagCloudRendererParams = { ...visParams, orientation: 'right angled' }; + const newProps = { ...wrapperPropsWithIndexes, visParams: newVisParams }; const component = mount(); expect(component.find(Wordcloud).prop('endAngle')).toBe(90); expect(component.find(Wordcloud).prop('angleCount')).toBe(2); }); it('calls filter callback', () => { - const component = mount(); + const component = mount(); component.find(Settings).prop('onElementClick')!([ [ { @@ -145,6 +188,6 @@ describe('TagCloudChart', function () { }, ], ]); - expect(wrapperProps.fireEvent).toHaveBeenCalled(); + expect(wrapperPropsWithIndexes.fireEvent).toHaveBeenCalled(); }); }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 163a2e8ce38ac..b7d38c71f5867 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -12,8 +12,13 @@ import { throttle } from 'lodash'; import { EuiIconTip, EuiResizeObserver } from '@elastic/eui'; import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts'; import type { PaletteRegistry } from '../../../../charts/public'; -import type { IInterpreterRenderHandlers } from '../../../../expressions/public'; -import { getFormatService } from '../services'; +import { + Datatable, + DatatableColumn, + IInterpreterRenderHandlers, +} from '../../../../expressions/public'; +import { getFormatService } from '../format_service'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { TagcloudRendererConfig } from '../../common/types'; import './tag_cloud.scss'; @@ -68,6 +73,17 @@ const ORIENTATIONS = { }, }; +const getColumn = ( + accessor: ExpressionValueVisDimension['accessor'], + columns: Datatable['columns'] +): DatatableColumn => { + if (typeof accessor === 'number') { + return columns[accessor]; + } + + return columns.filter(({ id }) => id === accessor.id)[0]; +}; + export const TagCloudChart = ({ visData, visParams, @@ -81,18 +97,18 @@ export const TagCloudChart = ({ const bucketFormatter = bucket ? getFormatService().deserialize(bucket.format) : null; const tagCloudData = useMemo(() => { - const tagColumn = bucket ? visData.columns[bucket.accessor].id : -1; - const metricColumn = visData.columns[metric.accessor]?.id; + const tagColumn = bucket ? getColumn(bucket.accessor, visData.columns).id : null; + const metricColumn = getColumn(metric.accessor, visData.columns).id; const metrics = visData.rows.map((row) => row[metricColumn]); - const values = bucket ? visData.rows.map((row) => row[tagColumn]) : []; + const values = bucket && tagColumn !== null ? visData.rows.map((row) => row[tagColumn]) : []; const maxValue = Math.max(...metrics); const minValue = Math.min(...metrics); return visData.rows.map((row) => { - const tag = row[tagColumn] === undefined ? 'all' : row[tagColumn]; + const tag = tagColumn === null ? 'all' : row[tagColumn]; return { - text: (bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag) as string, + text: bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag, weight: tag === 'all' || visData.rows.length <= 1 ? 1 @@ -112,7 +128,9 @@ export const TagCloudChart = ({ ]); const label = bucket - ? `${visData.columns[bucket.accessor].name} - ${visData.columns[metric.accessor].name}` + ? `${getColumn(bucket.accessor, visData.columns).name} - ${ + getColumn(metric.accessor, visData.columns).name + }` : ''; const onRenderChange = useCallback( @@ -133,17 +151,17 @@ export const TagCloudChart = ({ ); const handleWordClick = useCallback( - (d) => { + (elements) => { if (!bucket) { return; } - const termsBucket = visData.columns[bucket.accessor]; - const clickedValue = d[0][0].text; + const termsBucketId = getColumn(bucket.accessor, visData.columns).id; + const clickedValue = elements[0][0].text; const rowIndex = visData.rows.findIndex((row) => { const formattedValue = bucketFormatter - ? bucketFormatter.convert(row[termsBucket.id], 'text') - : row[termsBucket.id]; + ? bucketFormatter.convert(row[termsBucketId], 'text') + : row[termsBucketId]; return formattedValue === clickedValue; }); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx index 58e177dac6775..294371b3a5703 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/expression_renderers/tagcloud_renderer.tsx @@ -5,15 +5,16 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import React, { lazy } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { ClassNames } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { ExpressionRenderDefinition } from '../../../../expressions/common'; import { VisualizationContainer } from '../../../../visualizations/public'; -import { withSuspense } from '../../../../presentation_util/public'; -import { TagcloudRendererConfig } from '../../common/types'; +import { ExpressionRenderDefinition } from '../../../../expressions/common/expression_renderers'; import { ExpressioTagcloudRendererDependencies } from '../plugin'; +import { TagcloudRendererConfig } from '../../common/types'; import { EXPRESSION_NAME } from '../../common'; export const strings = { @@ -27,8 +28,11 @@ export const strings = { }), }; -const LazyTagcloudComponent = lazy(() => import('../components/tagcloud_component')); -const TagcloudComponent = withSuspense(LazyTagcloudComponent); +const tagCloudVisClass = { + height: '100%', +}; + +const TagCloudChart = lazy(() => import('../components/tagcloud_component')); export const tagcloudRenderer: ( deps: ExpressioTagcloudRendererDependencies @@ -43,17 +47,29 @@ export const tagcloudRenderer: ( }); const palettesRegistry = await palettes.getPalettes(); + const showNoResult = config.visData.rows.length === 0; + render( - - - + + {({ css, cx }) => ( + + + + )} + , domNode ); diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/services.ts b/src/plugins/chart_expressions/expression_tagcloud/public/format_service.ts similarity index 100% rename from src/plugins/chart_expressions/expression_tagcloud/public/services.ts rename to src/plugins/chart_expressions/expression_tagcloud/public/format_service.ts diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts b/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts index 7cbc9ac7c6706..9ffb910bde213 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/public/plugin.ts @@ -12,7 +12,7 @@ import { ChartsPluginSetup } from '../../../charts/public'; import { tagcloudRenderer } from './expression_renderers'; import { tagcloudFunction } from '../common/expression_functions'; import { FieldFormatsStart } from '../../../field_formats/public'; -import { setFormatService } from './services'; +import { setFormatService } from './format_service'; interface SetupDeps { expressions: ExpressionsSetup; diff --git a/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx b/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx index c3735bdc0d79a..837ec5ff60dc5 100644 --- a/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx +++ b/src/plugins/vis_types/metric/public/components/metric_vis_component.tsx @@ -16,7 +16,7 @@ import { Datatable } from '../../../../expressions/public'; import { getHeatmapColors } from '../../../../charts/public'; import { VisParams, MetricVisMetric } from '../types'; import { getFormatService } from '../services'; -import { SchemaConfig } from '../../../../visualizations/public'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { Range } from '../../../../expressions/public'; import './metric_vis.scss'; @@ -98,6 +98,16 @@ class MetricVisComponent extends Component { return fieldFormatter.convert(value, format); }; + private getColumn( + accessor: ExpressionValueVisDimension['accessor'], + columns: Datatable['columns'] = [] + ) { + if (typeof accessor === 'number') { + return columns[accessor]; + } + return columns.filter(({ id }) => accessor.id === id)[0]; + } + private processTableGroups(table: Datatable) { const config = this.props.visParams.metric; const dimensions = this.props.visParams.dimensions; @@ -112,13 +122,12 @@ class MetricVisComponent extends Component { let bucketFormatter: IFieldFormat; if (dimensions.bucket) { - bucketColumnId = table.columns[dimensions.bucket.accessor].id; + bucketColumnId = this.getColumn(dimensions.bucket.accessor, table.columns).id; bucketFormatter = getFormatService().deserialize(dimensions.bucket.format); } - dimensions.metrics.forEach((metric: SchemaConfig) => { - const columnIndex = metric.accessor; - const column = table?.columns[columnIndex]; + dimensions.metrics.forEach((metric: ExpressionValueVisDimension) => { + const column = this.getColumn(metric.accessor, table?.columns); const formatter = getFormatService().deserialize(metric.format); table.rows.forEach((row, rowIndex) => { let title = column.name; diff --git a/src/plugins/vis_types/metric/public/metric_vis_fn.ts b/src/plugins/vis_types/metric/public/metric_vis_fn.ts index 9a144defed4e7..210552732bc0a 100644 --- a/src/plugins/vis_types/metric/public/metric_vis_fn.ts +++ b/src/plugins/vis_types/metric/public/metric_vis_fn.ts @@ -15,9 +15,10 @@ import { Render, Style, } from '../../../expressions/public'; -import { visType, DimensionsVisParam, VisParams } from './types'; +import { visType, VisParams } from './types'; import { prepareLogTable, Dimension } from '../../../visualizations/public'; import { ColorSchemas, vislibColorMaps, ColorMode } from '../../../charts/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; export type Input = Datatable; @@ -32,8 +33,8 @@ interface Arguments { subText: string; colorRange: Range[]; font: Style; - metric: any[]; // these aren't typed yet - bucket: any; // these aren't typed yet + metric: ExpressionValueVisDimension[]; + bucket: ExpressionValueVisDimension; } export interface MetricVisRenderValue { @@ -150,14 +151,6 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ }, }, fn(input, args, handlers) { - const dimensions: DimensionsVisParam = { - metrics: args.metric, - }; - - if (args.bucket) { - dimensions.bucket = args.bucket; - } - if (args.percentageMode && (!args.colorRange || args.colorRange.length === 0)) { throw new Error('colorRange must be provided when using percentageMode'); } @@ -184,6 +177,7 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ const logTable = prepareLogTable(input, argsTable); handlers.inspectorAdapters.tables.logDatatable('default', logTable); } + return { type: 'render', as: 'metric_vis', @@ -209,7 +203,10 @@ export const createMetricVisFn = (): MetricVisExpressionFunctionDefinition => ({ fontSize, }, }, - dimensions, + dimensions: { + metrics: args.metric, + ...(args.bucket ? { bucket: args.bucket } : {}), + }, }, }, }; diff --git a/src/plugins/vis_types/metric/public/types.ts b/src/plugins/vis_types/metric/public/types.ts index 1baaa25959f31..8e86c0217bba6 100644 --- a/src/plugins/vis_types/metric/public/types.ts +++ b/src/plugins/vis_types/metric/public/types.ts @@ -7,14 +7,14 @@ */ import { Range } from '../../../expressions/public'; -import { SchemaConfig } from '../../../visualizations/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; import { ColorMode, Labels, Style, ColorSchemas } from '../../../charts/public'; export const visType = 'metric'; export interface DimensionsVisParam { - metrics: SchemaConfig[]; - bucket?: SchemaConfig; + metrics: ExpressionValueVisDimension[]; + bucket?: ExpressionValueVisDimension; } export interface MetricVisParam { diff --git a/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap index fed6fb54288f2..9e4c3071db8d6 100644 --- a/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/tagcloud/public/__snapshots__/to_ast.test.ts.snap @@ -1,6 +1,108 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled 1`] = ` +exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled with DatatableColumn vis_dimension.accessor at metric 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + false, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "bucket": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\"}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "maxFontSize": Array [ + 15, + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "minFontSize": Array [ + 5, + ], + "orientation": Array [ + "single", + ], + "palette": Array [ + "default", + ], + "scale": Array [ + "linear", + ], + "showLabel": Array [ + true, + ], + }, + "function": "tagcloud", + "type": "function", + }, + ], + "type": "expression", +} +`; + +exports[`tagcloud vis toExpressionAst function should match snapshot params fulfilled with number vis_dimension.accessor at metric 1`] = ` Object { "chain": Array [ Object { diff --git a/src/plugins/vis_types/tagcloud/public/to_ast.test.ts b/src/plugins/vis_types/tagcloud/public/to_ast.test.ts index c70448ab113cb..6de1d4fb3e75d 100644 --- a/src/plugins/vis_types/tagcloud/public/to_ast.test.ts +++ b/src/plugins/vis_types/tagcloud/public/to_ast.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { Vis } from 'src/plugins/visualizations/public'; +import { Vis, VisToExpressionAstParams } from '../../../visualizations/public'; import { toExpressionAst } from './to_ast'; import { TagCloudVisParams } from './types'; -const mockSchemas = { +const mockedSchemas = { metric: [{ accessor: 1, format: { id: 'number' }, params: {}, label: 'Count', aggType: 'count' }], segment: [ { @@ -31,14 +31,14 @@ const mockSchemas = { }; jest.mock('../../../visualizations/public', () => ({ - getVisSchemas: () => mockSchemas, + getVisSchemas: () => mockedSchemas, })); describe('tagcloud vis toExpressionAst function', () => { let vis: Vis; beforeEach(() => { - vis = { + vis = ({ isHierarchical: () => false, type: {}, params: { @@ -51,15 +51,15 @@ describe('tagcloud vis toExpressionAst function', () => { aggs: [], }, }, - } as any; + } as unknown) as Vis; }); it('should match snapshot without params', () => { - const actual = toExpressionAst(vis, {} as any); + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); expect(actual).toMatchSnapshot(); }); - it('should match snapshot params fulfilled', () => { + it('should match snapshot params fulfilled with number vis_dimension.accessor at metric', () => { vis.params = { scale: 'linear', orientation: 'single', @@ -70,9 +70,48 @@ describe('tagcloud vis toExpressionAst function', () => { type: 'palette', name: 'default', }, - metric: { accessor: 0, format: { id: 'number' } }, + metric: { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: { + id: 'number', + }, + }, + }, + }; + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); + expect(actual).toMatchSnapshot(); + }); + + it('should match snapshot params fulfilled with DatatableColumn vis_dimension.accessor at metric', () => { + vis.params = { + scale: 'linear', + orientation: 'single', + minFontSize: 5, + maxFontSize: 15, + showLabel: true, + palette: { + type: 'palette', + name: 'default', + }, + metric: { + type: 'vis_dimension', + accessor: { + id: 'count', + name: 'count', + meta: { type: 'number' }, + }, + format: { + id: 'number', + params: { + id: 'number', + }, + }, + }, }; - const actual = toExpressionAst(vis, {} as any); + const actual = toExpressionAst(vis, {} as VisToExpressionAstParams); expect(actual).toMatchSnapshot(); }); }); diff --git a/src/plugins/vis_types/tagcloud/public/types.ts b/src/plugins/vis_types/tagcloud/public/types.ts index 28a7c6506eb31..996555ae99f83 100644 --- a/src/plugins/vis_types/tagcloud/public/types.ts +++ b/src/plugins/vis_types/tagcloud/public/types.ts @@ -6,15 +6,7 @@ * Side Public License, v 1. */ import type { ChartsPluginSetup, PaletteOutput } from '../../../charts/public'; -import type { SerializedFieldFormat } from '../../../expressions/public'; - -interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} +import { ExpressionValueVisDimension } from '../../../visualizations/public'; interface TagCloudCommonParams { scale: 'linear' | 'log' | 'square root'; @@ -26,8 +18,8 @@ interface TagCloudCommonParams { export interface TagCloudVisParams extends TagCloudCommonParams { palette: PaletteOutput; - metric: Dimension; - bucket?: Dimension; + metric: ExpressionValueVisDimension; + bucket?: ExpressionValueVisDimension; } export interface TagCloudTypeProps { diff --git a/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts b/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts new file mode 100644 index 0000000000000..249c796afeac3 --- /dev/null +++ b/src/plugins/visualizations/common/expression_functions/vis_dimension.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Arguments, visDimension } from './vis_dimension'; +import { functionWrapper } from '../../../expressions/common/expression_functions/specs/tests/utils'; +import { Datatable } from '../../../expressions/common'; +import moment from 'moment'; + +describe('interpreter/functions#vis_dimension', () => { + const fn = functionWrapper(visDimension()); + const column1 = 'username'; + const column2 = '@timestamp'; + + const input: Datatable = { + type: 'datatable', + columns: [ + { id: column1, name: column1, meta: { type: 'string' } }, + { id: column2, name: column2, meta: { type: 'date' } }, + ], + rows: [ + { [column1]: 'user1', [column2]: moment().toISOString() }, + { [column1]: 'user2', [column2]: moment().toISOString() }, + ], + }; + + it('should return vis_dimension accessor in number format when type of the passed accessor is number', () => { + const accessor = 0; + const args: Arguments = { accessor }; + + const result = fn(input, args); + expect(result).toHaveProperty('type', 'vis_dimension'); + expect(result).toHaveProperty('accessor', accessor); + expect(result).toHaveProperty('format'); + expect(result.format).toBeDefined(); + expect(typeof result.format === 'object').toBeTruthy(); + }); + + it('should return vis_dimension accessor in DatatableColumn format when type of the passed accessor is string', () => { + const accessor = column2; + const args: Arguments = { accessor }; + const searchingObject = input.columns.filter(({ id }) => id === accessor)[0]; + + const result = fn(input, args); + expect(result).toHaveProperty('type', 'vis_dimension'); + expect(result).toHaveProperty('accessor'); + expect(result.accessor).toMatchObject(searchingObject); + expect(result).toHaveProperty('format'); + expect(result.format).toBeDefined(); + expect(typeof result.format === 'object').toBeTruthy(); + }); + + it('should throw error when the passed number accessor is out of columns array boundary', () => { + const accessor = input.columns.length; + const args: Arguments = { accessor }; + + expect(() => fn(input, args)).toThrowError('Column name or index provided is invalid'); + }); + + it("should throw error when the passed column doesn't exist in columns", () => { + const accessor = column1 + '_modified'; + const args: Arguments = { accessor }; + + expect(() => fn(input, args)).toThrowError('Column name or index provided is invalid'); + }); +}); diff --git a/src/plugins/visualizations/common/expression_functions/vis_dimension.ts b/src/plugins/visualizations/common/expression_functions/vis_dimension.ts index 6886fa94f878e..60d3fc78ac553 100644 --- a/src/plugins/visualizations/common/expression_functions/vis_dimension.ts +++ b/src/plugins/visualizations/common/expression_functions/vis_dimension.ts @@ -14,7 +14,7 @@ import { DatatableColumn, } from '../../../expressions/common'; -interface Arguments { +export interface Arguments { accessor: string | number; format?: string; formatParams?: string; @@ -31,6 +31,12 @@ export type ExpressionValueVisDimension = ExpressionValueBoxed< } >; +const getAccessorByIndex = (accessor: number, columns: Datatable['columns']) => + columns.length > accessor ? accessor : undefined; + +const getAccessorById = (accessor: DatatableColumn['id'], columns: Datatable['columns']) => + columns.find((c) => c.id === accessor); + export const visDimension = (): ExpressionFunctionDefinition< 'visdimension', Datatable, @@ -69,13 +75,13 @@ export const visDimension = (): ExpressionFunctionDefinition< fn: (input, args) => { const accessor = typeof args.accessor === 'number' - ? args.accessor - : input.columns.find((c) => c.id === args.accessor); + ? getAccessorByIndex(args.accessor, input.columns) + : getAccessorById(args.accessor, input.columns); if (accessor === undefined) { throw new Error( i18n.translate('visualizations.function.visDimension.error.accessor', { - defaultMessage: 'Column name provided is invalid', + defaultMessage: 'Column name or index provided is invalid', }) ); } diff --git a/src/plugins/visualizations/common/prepare_log_table.test.ts b/src/plugins/visualizations/common/prepare_log_table.test.ts index dc02adbd458ee..7176ba46c40ec 100644 --- a/src/plugins/visualizations/common/prepare_log_table.test.ts +++ b/src/plugins/visualizations/common/prepare_log_table.test.ts @@ -19,13 +19,14 @@ describe('prepareLogTable', () => { meta: {}, }, { + id: 'd3', meta: {}, }, ], }; const logTable = prepareLogTable(datatable as any, [ [[{ accessor: 0 } as any], 'dimension1'], - [[{ accessor: 2 } as any], 'dimension3'], + [[{ accessor: { id: 'd3' } } as any], 'dimension3'], [[{ accessor: 1 } as any], 'dimension2'], ]); expect(logTable).toMatchInlineSnapshot( @@ -42,6 +43,7 @@ describe('prepareLogTable', () => { }, }, { + id: 'd3', meta: { dimensionName: 'dimension3', }, @@ -62,6 +64,7 @@ describe('prepareLogTable', () => { }, }, Object { + "id": "d3", "meta": Object { "dimensionName": "dimension3", }, diff --git a/src/plugins/visualizations/common/prepare_log_table.ts b/src/plugins/visualizations/common/prepare_log_table.ts index 0018a18ce7f10..b3f74c8611af5 100644 --- a/src/plugins/visualizations/common/prepare_log_table.ts +++ b/src/plugins/visualizations/common/prepare_log_table.ts @@ -8,16 +8,31 @@ import { ExpressionValueVisDimension } from './expression_functions/vis_dimension'; import { ExpressionValueXYDimension } from './expression_functions/xy_dimension'; -import { Datatable } from '../../expressions/common/expression_types/specs'; +import { Datatable, DatatableColumn } from '../../expressions/common/expression_types/specs'; export type Dimension = [ Array | undefined, string ]; -const getDimensionName = (columnIndex: number, dimensions: Dimension[]) => { +const isColumnEqualToAccessor = ( + column: DatatableColumn, + columnIndex: number, + accessor: ExpressionValueVisDimension['accessor'] | ExpressionValueXYDimension['accessor'] +) => { + if (typeof accessor === 'number') { + return accessor === columnIndex; + } + return accessor.id === column.id; +}; + +const getDimensionName = ( + column: DatatableColumn, + columnIndex: number, + dimensions: Dimension[] +) => { for (const dimension of dimensions) { - if (dimension[0]?.find((d) => d.accessor === columnIndex)) { + if (dimension[0]?.find((d) => isColumnEqualToAccessor(column, columnIndex, d.accessor))) { return dimension[1]; } } @@ -31,7 +46,7 @@ export const prepareLogTable = (datatable: Datatable, dimensions: Dimension[]) = ...column, meta: { ...column.meta, - dimensionName: getDimensionName(columnIndex, dimensions), + dimensionName: getDimensionName(column, columnIndex, dimensions), }, }; }), diff --git a/test/interpreter_functional/screenshots/baseline/metric_all_data.png b/test/interpreter_functional/screenshots/baseline/metric_all_data.png index 54ee1f4da6684ab4f12fd87bc84d3439aa0dd763..66357a371a5be96013f5a3cc4f33400f78f4392d 100644 GIT binary patch literal 29776 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 D<^8a} literal 22339 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 literal 0 HcmV?d00001 diff --git a/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png b/test/interpreter_functional/screenshots/baseline/metric_invalid_data.png index b1448cd7cb2effd015e309ddc666fde950297f6b..b8ffa6e8576fe7a717bdcba664b67cb77f05438f 100644 GIT binary patch literal 1993 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�?&fuO)Z3IwJ3&5(5+a^f+0aMR+7TBC^y56`k9PSr4~mQ4xh{F(L?MvRU* z6WpGHvuXlizij1f1wnhy!VVx24Tk|@I5L@RYac@6a5(G|0S4vx%{MoY#uB-$au|u_ zKll!RSJrt@_$LTDTn!=|`m!&tT-Xwq!GeRldDC4^%^Fr8xj;-gywr3SJCJskf#J># z6bc&2A}s{Dy1}tJ+S-X|BO~={pN;-&xvk;d*;MzKXsfZ|U?jYZs1P-C6# zafApvo6jvj?i3MGCeXl}0<2L48Sh2F6cosvm9VSJpkA4-N5YQq!2qyg3Wn1W#<0d8 z`!WBwN=7tr1!N~~3DCtwNX63*0#obX1$E_N@8aU;fSdr(pYDTTC-(z@>g%~+pGW6m zz5SQ^t`zqG`sC);!al_PzQ_M0N;s}4HxT6Br_H}5aTE^dBMLas%K$fTHRjP@1ydAw zesEn-&5$v4tzuD6}hoMU#Ffh)(&>l}_6}fy-+9RXg4jMXS=kYu=n+U-1`Y} zxjLn%xU?)jxO{z;qvOOLn4!`A56+ZaKPb*;wktbau&a|)1$MO`Z4zjN%`vG~M5ATv zT#Lvb1YNag4RyT|KGjr-#A}C-*dE|*M4eG(7QJlAh~ceJ+g2syM>*}I8WxA7t@j}{KUX%i z3I^QoGly;$hm{5t9*&y7x4bdlgO`2#HA#MfynXuMj8`e_$!AQKGgdKuyE3n|K$@3< zGZ}loP}Ton6{TIF3Ogwj-?Yb$TXd7jK6N^`_H$Ak1m*1lD)agbHcKB9;{g0<{oKZpu$H5I8Meq2T8*tJVgie;UnHBiY014ca@K~R zf?pA0stF->TQmTmRArr})gwO3YQt1UmK)Zm9%Zpy5>YKHN9zjPR7C2mPlSg2FV`WK+}iJ zXsx>Y%*q68-)n@+sx@s|^ zQl7qTokj6AQeEa!(T4g=MN2{?z?Ss3czZ}WEXBt13x0ZB8vlz^>1tiyES_8&Ry@U zRhs(Td2pv%jrlvLQRw_Fv^kGvRw?_rtz!DVck8%0wkr+nNh`bTzdsC?fh+yUV z;AG}N77(@%3axHYa?b~a71EDShzqm#2NSRhJSxy~w5+!*VW>lr{yHuwWJ|aL%+ZDu z==}MiqH39@&Y?e|tap^aMQ1wl@uO7%w#p?J=HOKFvZ|;NGgLu-QYk<1nyBELb?SXi z2_0sZJ(7HZ6^eF6drHzX(pOm-WUJm>+r9;C-6k>Jv~f0QH#B?tkppz)(4#QZR^LYV z9+vr&SCRdhevJY*?vBW^w*#!2dt4(zppKq|sJ zLzjX8WFs19NeSE>7qa)6Yu5yt7L~Zv9Af0!Uo^4X8T(@qYK-+14qs z6I}+W>PsfuFlx~5rsMhpQ;Fk>};!o}%R4Xj>_;hTVc|&T?|! z!M(uq?kbrH3!bIxpd1S8r8VqJ-FAq-MwGQin@vXVzlDePirQNX{XJrbFEQx1cE*Xc zkM@WAs?6#@Dh)iD-sa=SYo$r9c@@W|4mY>Ju_ literal 0 HcmV?d00001 diff --git a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json similarity index 89% rename from test/interpreter_functional/snapshots/session/metric_single_metric_data.json rename to test/interpreter_functional/snapshots/baseline/metric_empty_data.json index f4a8cd1f14e18..c318121535c8f 100644 --- a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json b/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json index c7b4a0325dc91..f23b9b0915774 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_invalid_data.json @@ -1 +1 @@ -{"as":"metric_vis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"}]},"metric":{"colorSchema":"Green to Red","colorsRange":[{"from":0,"to":10000,"type":"range"}],"invertColors":false,"labels":{"show":true},"metricColorMode":"None","percentageMode":false,"style":{"bgColor":false,"bgFill":"#000","fontSize":60,"labelColor":false,"subText":""},"useRanges":false}},"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +"[metricVis] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json similarity index 65% rename from test/interpreter_functional/snapshots/session/partial_test_1.json rename to test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index 082c7b934c17c..6dd90a4a6ca03 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json index 3e594380588dc..b5ae1a2cb59fc 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_invalid_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +"[tagcloud] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json new file mode 100644 index 0000000000000..6dd90a4a6ca03 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json b/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json index 3e594380588dc..b5ae1a2cb59fc 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[],"meta":{},"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"default","type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +"[tagcloud] > [visdimension] > Column name or index provided is invalid" \ No newline at end of file diff --git a/test/interpreter_functional/test_suites/run_pipeline/metric.ts b/test/interpreter_functional/test_suites/run_pipeline/metric.ts index bbaf0486f4fbb..5483e09d6671b 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/metric.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/metric.ts @@ -30,10 +30,13 @@ export default function ({ dataContext = await expectExpression('partial_metric_test', expression).getResponse(); }); - it('with invalid data', async () => { + it('with empty data', async () => { const expression = 'metricVis metric={visdimension 0}'; await ( - await expectExpression('metric_invalid_data', expression).toMatchSnapshot() + await expectExpression('metric_empty_data', expression, { + ...dataContext, + rows: [], + }).toMatchSnapshot() ).toMatchScreenshot(); }); @@ -78,5 +81,14 @@ export default function ({ ).toMatchScreenshot(); }); }); + + describe('throws error at metric', () => { + it('with invalid data', async () => { + const expression = 'metricVis metric={visdimension 0}'; + await ( + await expectExpression('metric_invalid_data', expression).toMatchSnapshot() + ).toMatchScreenshot(); + }); + }); }); } diff --git a/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts b/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts index 05bbd33fedad7..3358e45dc02d4 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/tag_cloud.ts @@ -15,24 +15,25 @@ export default function ({ }: FtrProviderContext & { updateBaselines: boolean }) { let expectExpression: ExpectExpression; describe('tag cloud pipeline expression tests', () => { - before(() => { + let dataContext: ExpressionResult; + before(async () => { expectExpression = expectExpressionProvider({ getService, updateBaselines }); + + const expression = `kibana | kibana_context | esaggs index={indexPatternLoad id='logstash-*'} + aggs={aggCount id="1" enabled=true schema="metric"} + aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"}`; + // we execute the part of expression that fetches the data and store its response + dataContext = await expectExpression('partial_tagcloud_test', expression).getResponse(); }); describe('correctly renders tagcloud', () => { - let dataContext: ExpressionResult; - before(async () => { - const expression = `kibana | kibana_context | esaggs index={indexPatternLoad id='logstash-*'} - aggs={aggCount id="1" enabled=true schema="metric"} - aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"}`; - // we execute the part of expression that fetches the data and store its response - dataContext = await expectExpression('partial_tagcloud_test', expression).getResponse(); - }); - - it('with invalid data', async () => { + it('with empty data', async () => { const expression = 'tagcloud metric={visdimension 0}'; await ( - await expectExpression('tagcloud_invalid_data', expression).toMatchSnapshot() + await expectExpression('tagcloud_empty_data', expression, { + ...dataContext, + rows: [], + }).toMatchSnapshot() ).toMatchScreenshot(); }); @@ -66,5 +67,14 @@ export default function ({ ).toMatchScreenshot(); }); }); + + describe('throws error at tagcloud', () => { + it('with invalid data', async () => { + const expression = 'tagcloud metric={visdimension 0}'; + await ( + await expectExpression('tagcloud_invalid_data', expression).toMatchSnapshot() + ).toMatchScreenshot(); + }); + }); }); } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/elements/index.ts index 4925849045216..667854bf3e7e2 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/elements/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/index.ts @@ -31,6 +31,7 @@ import { timeFilter } from './time_filter'; import { verticalBarChart } from './vert_bar_chart'; import { verticalProgressBar } from './vertical_progress_bar'; import { verticalProgressPill } from './vertical_progress_pill'; +import { tagCloud } from './tag_cloud'; import { SetupInitializer } from '../plugin'; import { ElementFactory } from '../../types'; @@ -60,6 +61,7 @@ const elementSpecs = [ verticalBarChart, verticalProgressBar, verticalProgressPill, + tagCloud, ]; const initializeElementFactories = [metricElementInitializer]; @@ -69,6 +71,5 @@ export const initializeElements: SetupInitializer = (core, plu ...elementSpecs, ...initializeElementFactories.map((factory) => factory(core, plugins)), ]; - return applyElementStrings(specs); }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts new file mode 100644 index 0000000000000..a0b464390fa22 --- /dev/null +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/tag_cloud/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ElementFactory } from '../../../types'; + +export const tagCloud: ElementFactory = () => ({ + name: 'tagCloud', + displayName: 'Tag Cloud', + type: 'chart', + help: 'Tagcloud visualization', + icon: 'visTagCloud', + expression: `filters + | demodata + | head 150 + | ply by="country" expression={math "count(country)" | as "Count"} + | tagcloud metric={visdimension "Count"} bucket={visdimension "country"} + | render`, +}); diff --git a/x-pack/plugins/canvas/i18n/elements/element_strings.ts b/x-pack/plugins/canvas/i18n/elements/element_strings.ts index 87879c4c753c9..e1540572f4af6 100644 --- a/x-pack/plugins/canvas/i18n/elements/element_strings.ts +++ b/x-pack/plugins/canvas/i18n/elements/element_strings.ts @@ -222,4 +222,12 @@ export const getElementStrings = (): ElementStringDict => ({ defaultMessage: 'Displays progress as a portion of a vertical pill', }), }, + tagCloud: { + displayName: i18n.translate('xpack.canvas.elements.tagCloudDisplayName', { + defaultMessage: 'Tag Cloud', + }), + help: i18n.translate('xpack.canvas.elements.tagCloudHelpText', { + defaultMessage: 'Tagcloud visualization', + }), + }, }); diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index c149c67544865..b8f0b17f814d8 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -105,6 +105,7 @@ export class CanvasPlugin mount: async (params: AppMountParameters) => { const { CanvasSrcPlugin } = await import('../canvas_plugin_src/plugin'); const srcPlugin = new CanvasSrcPlugin(); + srcPlugin.setup(coreSetup, { canvas: canvasApi }); setupExpressions({ coreSetup, setupPlugins }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ffd3adce378f5..0a8d10d01469e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5021,7 +5021,6 @@ "visualizations.function.range.help": "範囲オブジェクトを生成します", "visualizations.function.range.to.help": "範囲の終了", "visualizations.function.visDimension.accessor.help": "使用するデータセット内の列 (列インデックスまたは列名) ", - "visualizations.function.visDimension.error.accessor": "入力された列名は無効です。", "visualizations.function.visDimension.format.help": "フォーマット", "visualizations.function.visDimension.formatParams.help": "フォーマットパラメーター", "visualizations.function.visDimension.help": "visConfig ディメンションオブジェクトを生成します", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0fc6f0ec119eb..d29307eddd422 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5041,7 +5041,6 @@ "visualizations.function.range.help": "生成范围对象", "visualizations.function.range.to.help": "范围结束", "visualizations.function.visDimension.accessor.help": "要使用的数据集列(列索引或列名称)", - "visualizations.function.visDimension.error.accessor": "提供的列名称无效", "visualizations.function.visDimension.format.help": "格式", "visualizations.function.visDimension.formatParams.help": "格式参数", "visualizations.function.visDimension.help": "生成 visConfig 维度对象", From 6f31422d9f2b24a7b64f762f65762de27f365170 Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Mon, 6 Sep 2021 11:38:53 +0200 Subject: [PATCH 05/17] adds missing field when creating the email connector (#111251) --- x-pack/plugins/security_solution/cypress/objects/connector.ts | 2 ++ .../security_solution/cypress/screens/create_new_rule.ts | 2 ++ .../plugins/security_solution/cypress/tasks/create_new_rule.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/cypress/objects/connector.ts b/x-pack/plugins/security_solution/cypress/objects/connector.ts index a5244583bf494..5c2abeab06026 100644 --- a/x-pack/plugins/security_solution/cypress/objects/connector.ts +++ b/x-pack/plugins/security_solution/cypress/objects/connector.ts @@ -12,6 +12,7 @@ export interface EmailConnector { port: string; user: string; password: string; + service: string; } export const getEmailConnector = (): EmailConnector => ({ @@ -21,4 +22,5 @@ export const getEmailConnector = (): EmailConnector => ({ port: '80', user: 'username', password: 'password', + service: 'Other', }); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 551857ca3bfca..4748a48dbeb11 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -40,6 +40,8 @@ export const EMAIL_CONNECTOR_USER_INPUT = '[data-test-subj="emailUserInput"]'; export const EMAIL_CONNECTOR_PASSWORD_INPUT = '[data-test-subj="emailPasswordInput"]'; +export const EMAIL_CONNECTOR_SERVICE_SELECTOR = '[data-test-subj="emailServiceSelectInput"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index e2d27a11ed717..c1210bf457b69 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -92,6 +92,7 @@ import { EMAIL_CONNECTOR_PORT_INPUT, EMAIL_CONNECTOR_USER_INPUT, EMAIL_CONNECTOR_PASSWORD_INPUT, + EMAIL_CONNECTOR_SERVICE_SELECTOR, } from '../screens/create_new_rule'; import { LOADING_INDICATOR } from '../screens/security_header'; import { TOAST_ERROR } from '../screens/shared'; @@ -402,6 +403,7 @@ export const fillIndexAndIndicatorIndexPattern = ( export const fillEmailConnectorForm = (connector: EmailConnector = getEmailConnector()) => { cy.get(CONNECTOR_NAME_INPUT).type(connector.name); + cy.get(EMAIL_CONNECTOR_SERVICE_SELECTOR).select(connector.service); cy.get(EMAIL_CONNECTOR_FROM_INPUT).type(connector.from); cy.get(EMAIL_CONNECTOR_HOST_INPUT).type(connector.host); cy.get(EMAIL_CONNECTOR_PORT_INPUT).type(connector.port); From 00fac96d370fdb6df71c9f8aef2114e19d76717a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Mon, 6 Sep 2021 12:20:35 +0200 Subject: [PATCH 06/17] [APM] Uses doc link service instead of ElasticDocsLink for linking metadata (#110992) --- .../kibana-plugin-core-public.doclinksstart.links.md | 1 + .../public/kibana-plugin-core-public.doclinksstart.md | 2 +- src/core/public/doc_links/doc_links_service.ts | 3 +++ src/core/public/public.api.md | 1 + .../apm/public/components/shared/MetadataTable/index.tsx | 8 +++++--- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index bc6075176cd22..253b0671cdd52 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -12,6 +12,7 @@ readonly links: { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly metaData: string; }; readonly canvas: { readonly guide: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index aa3f958018041..7e409f23790f0 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index f3ef7c550e57d..73ba816ff9b4b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -24,6 +24,7 @@ export class DocLinksService { const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`; const FLEET_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/fleet/${DOC_LINK_VERSION}/`; const PLUGIN_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`; + const APM_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/apm/`; return deepFreeze({ DOC_LINK_VERSION, @@ -33,6 +34,7 @@ export class DocLinksService { apm: { kibanaSettings: `${KIBANA_DOCS}apm-settings-in-kibana.html`, supportedServiceMaps: `${KIBANA_DOCS}service-maps.html#service-maps-supported`, + metaData: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/metadata.html`, }, canvas: { guide: `${KIBANA_DOCS}canvas.html`, @@ -458,6 +460,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly metaData: string; }; readonly canvas: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index f18e1dc26bd87..d9c64f29eb684 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,6 +476,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly metaData: string; }; readonly canvas: { readonly guide: string; diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx index 938d5f4519431..45be525512d0a 100644 --- a/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLink, EuiSpacer, EuiText, EuiTitle, @@ -19,11 +20,11 @@ import { isEmpty } from 'lodash'; import React, { useCallback } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { HeightRetainer } from '../HeightRetainer'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { filterSectionsByTerm, SectionsWithRows } from './helper'; import { Section } from './Section'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; interface Props { sections: SectionsWithRows; @@ -34,6 +35,7 @@ export function MetadataTable({ sections }: Props) { const location = useLocation(); const { urlParams } = useUrlParams(); const { searchTerm = '' } = urlParams; + const { docLinks } = useApmPluginContext().core; const filteredSections = filterSectionsByTerm(sections, searchTerm); @@ -55,11 +57,11 @@ export function MetadataTable({ sections }: Props) { - + How to add labels and other data - + Date: Mon, 6 Sep 2021 13:44:47 +0200 Subject: [PATCH 07/17] [Canvas/Reporting] Migrate Canvas to V2 reporting (#109860) * first iteration of canvas reporting using v2 PDF generator * updated jest test * made v2 report URLs compatible with spaces and simplified some code * remove non-existent import * updated import of lib Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/share/public/index.ts | 1 + .../url_service/redirect/redirect_manager.ts | 12 +- x-pack/plugins/canvas/common/index.ts | 2 + x-pack/plugins/canvas/common/locator.ts | 45 ++++++ x-pack/plugins/canvas/kibana.json | 3 +- .../workpad_header/share_menu/share_menu.tsx | 4 +- .../workpad_header/share_menu/utils.test.ts | 132 +++++++++++++++--- .../workpad_header/share_menu/utils.ts | 25 ++-- x-pack/plugins/canvas/public/plugin.tsx | 11 +- .../public/services/kibana/reporting.ts | 2 +- .../canvas/public/services/reporting.ts | 2 +- x-pack/plugins/canvas/tsconfig.json | 11 +- .../reporting/common/build_kibana_path.ts | 18 +++ x-pack/plugins/reporting/common/constants.ts | 8 +- .../public/redirect/redirect_app.tsx | 2 +- .../chromium/driver/chromium_driver.ts | 2 +- .../common/v2/get_full_redirect_app_url.ts | 33 +++++ .../export_types/common/v2/get_full_urls.ts | 34 ----- .../server/export_types/png_v2/execute_job.ts | 7 +- .../printable_pdf_v2/execute_job.ts | 2 +- .../printable_pdf_v2/lib/generate_pdf.ts | 10 +- 21 files changed, 269 insertions(+), 97 deletions(-) create mode 100644 x-pack/plugins/canvas/common/locator.ts create mode 100644 x-pack/plugins/reporting/common/build_kibana_path.ts create mode 100644 x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts delete mode 100644 x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 1f999b59ddb61..74e849948d418 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -31,6 +31,7 @@ export { UrlGeneratorsService, } from './url_generators'; +export { RedirectOptions } from './url_service'; export { useLocatorUrl } from '../common/url_service/locators/use_locator_url'; import { SharePlugin } from './plugin'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts index cc45e0d3126af..a5d895c7cbcf0 100644 --- a/src/plugins/share/public/url_service/redirect/redirect_manager.ts +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -15,7 +15,15 @@ import type { UrlService } from '../../../common/url_service'; import { render } from './render'; import { parseSearchParams } from './util/parse_search_params'; -export interface RedirectOptions { +/** + * @public + * Serializable locator parameters that can be used by the redirect service to navigate to a location + * in Kibana. + * + * When passed to the public {@link SharePluginSetup['navigate']} function, locator params will also be + * migrated. + */ +export interface RedirectOptions

{ /** Locator ID. */ id: string; @@ -23,7 +31,7 @@ export interface RedirectOptions { version: string; /** Locator params. */ - params: unknown & SerializableRecord; + params: P; } export interface RedirectManagerDependencies { diff --git a/x-pack/plugins/canvas/common/index.ts b/x-pack/plugins/canvas/common/index.ts index 51a53586dee3c..5bae69e8601b2 100644 --- a/x-pack/plugins/canvas/common/index.ts +++ b/x-pack/plugins/canvas/common/index.ts @@ -8,3 +8,5 @@ export const UI_SETTINGS = { ENABLE_LABS_UI: 'labs:canvas:enable_ui', }; + +export { CANVAS_APP_LOCATOR, CanvasAppLocator, CanvasAppLocatorParams } from './locator'; diff --git a/x-pack/plugins/canvas/common/locator.ts b/x-pack/plugins/canvas/common/locator.ts new file mode 100644 index 0000000000000..147e4fd860982 --- /dev/null +++ b/x-pack/plugins/canvas/common/locator.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 type { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common'; + +import { CANVAS_APP } from './lib/constants'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CanvasAppLocatorParams = { + view: 'workpadPDF'; + id: string; + page: number; +}; + +export type CanvasAppLocator = LocatorPublic; + +export const CANVAS_APP_LOCATOR = 'CANVAS_APP_LOCATOR'; + +export class CanvasAppLocatorDefinition implements LocatorDefinition { + id = CANVAS_APP_LOCATOR; + + public async getLocation(params: CanvasAppLocatorParams) { + const app = CANVAS_APP; + + if (params.view === 'workpadPDF') { + const { id, page } = params; + + return { + app, + path: `#/export/workpad/pdf/${id}/page/${page}`, + state: {}, + }; + } + + return { + app, + path: '#/', + state: {}, + }; + } +} diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 201fb5ab8f78f..772c030e11539 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -25,7 +25,8 @@ "features", "inspector", "presentationUtil", - "uiActions" + "uiActions", + "share" ], "optionalPlugins": ["home", "reporting", "usageCollection"], "requiredBundles": [ diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx index ca8f5fd4e3e45..50a3890673ffa 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -46,9 +46,7 @@ export const ShareMenu = () => { ReportingPanelPDFComponent !== null ? ({ onClose }: { onClose: () => void }) => ( - getPdfJobParams(sharingData, platformService.getBasePathInterface()) - } + getJobParams={() => getPdfJobParams(sharingData, platformService.getKibanaVersion())} layoutOption="canvas" onClose={onClose} /> diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts index 51d1b9abc5664..18c348aec18ea 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.test.ts @@ -9,17 +9,11 @@ jest.mock('../../../../common/lib/fetch'); import { getPdfJobParams } from './utils'; import { workpads } from '../../../../__fixtures__/workpads'; -import { IBasePath } from 'kibana/public'; -const basePath = ({ - prepend: jest.fn().mockImplementation((s) => `basepath/s/spacey/${s}`), - get: () => 'basepath/s/spacey', - serverBasePath: `basepath`, -} as unknown) as IBasePath; const workpadSharingData = { workpad: workpads[0], pageCount: 12 }; test('getPdfJobParams returns the correct job params for canvas layout', () => { - const jobParams = getPdfJobParams(workpadSharingData, basePath); + const jobParams = getPdfJobParams(workpadSharingData, 'v99.99.99'); expect(jobParams).toMatchInlineSnapshot(` Object { "layout": Object { @@ -29,21 +23,117 @@ test('getPdfJobParams returns the correct job params for canvas layout', () => { }, "id": "canvas", }, - "objectType": "canvas workpad", - "relativeUrls": Array [ - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/1", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/2", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/3", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/4", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/5", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/6", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/7", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/8", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/9", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/10", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/11", - "/s/spacey/app/canvas#/export/workpad/pdf/base-workpad/page/12", + "locatorParams": Array [ + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 1, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 2, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 3, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 4, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 5, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 6, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 7, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 8, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 9, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 10, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 11, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, + Object { + "id": "CANVAS_APP_LOCATOR", + "params": Object { + "id": "base-workpad", + "page": 12, + "view": "workpadPDF", + }, + "version": "v99.99.99", + }, ], + "objectType": "canvas workpad", "title": "base workpad", } `); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts index bbd6b42a38333..311ef73e1c973 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/utils.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { IBasePath } from 'kibana/public'; -import rison from 'rison-node'; +import type { RedirectOptions } from 'src/plugins/share/public'; +import { CANVAS_APP_LOCATOR } from '../../../../common/locator'; +import { CanvasAppLocatorParams } from '../../../../common/locator'; import { CanvasWorkpad } from '../../../../types'; export interface CanvasWorkpadSharingData { @@ -16,11 +17,8 @@ export interface CanvasWorkpadSharingData { export function getPdfJobParams( { workpad: { id, name: title, width, height }, pageCount }: CanvasWorkpadSharingData, - basePath: IBasePath + version: string ) { - const urlPrefix = basePath.get().replace(basePath.serverBasePath, ''); // for Spaces prefix, which is included in basePath.get() - const canvasEntry = `${urlPrefix}/app/canvas#`; - // The viewport in Reporting by specifying the dimensions. In order for things to work, // we need a viewport that will include all of the pages in the workpad. The viewport // also needs to include any offset values from the 0,0 position, otherwise the cropped @@ -32,9 +30,18 @@ export function getPdfJobParams( // viewport size. // build a list of all page urls for exporting, they are captured one at a time - const workpadUrls = []; + + const locatorParams: Array> = []; for (let i = 1; i <= pageCount; i++) { - workpadUrls.push(rison.encode(`${canvasEntry}/export/workpad/pdf/${id}/page/${i}`)); + locatorParams.push({ + id: CANVAS_APP_LOCATOR, + version, + params: { + view: 'workpadPDF', + id, + page: i, + }, + }); } return { @@ -43,7 +50,7 @@ export function getPdfJobParams( id: 'canvas', }, objectType: 'canvas workpad', - relativeUrls: workpadUrls, + locatorParams, title, }; } diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index b8f0b17f814d8..3b4a6b6f1ee4b 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -6,6 +6,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import type { SharePluginSetup } from 'src/plugins/share/public'; import { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; import { ReportingStart } from '../../reporting/public'; import { @@ -20,7 +21,8 @@ import { import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { initLoadingIndicator } from './lib/loading_indicator'; import { getSessionStorage } from './lib/storage'; -import { SESSIONSTORAGE_LASTPATH } from '../common/lib/constants'; +import { SESSIONSTORAGE_LASTPATH, CANVAS_APP } from '../common/lib/constants'; +import { CanvasAppLocatorDefinition } from '../common/locator'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { ExpressionsSetup, ExpressionsStart } from '../../../../src/plugins/expressions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; @@ -43,6 +45,7 @@ export { CoreStart, CoreSetup }; // This interface will be built out as we require other plugins for setup export interface CanvasSetupDeps { data: DataPublicPluginSetup; + share: SharePluginSetup; expressions: ExpressionsSetup; home?: HomePublicPluginSetup; usageCollection?: UsageCollectionSetup; @@ -97,7 +100,7 @@ export class CanvasPlugin coreSetup.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, - id: 'canvas', + id: CANVAS_APP, title: 'Canvas', euiIconType: 'logoKibana', order: 3000, @@ -145,6 +148,10 @@ export class CanvasPlugin setupPlugins.home.featureCatalogue.register(featureCatalogueEntry); } + if (setupPlugins.share) { + setupPlugins.share.url.locators.create(new CanvasAppLocatorDefinition()); + } + canvasApi.addArgumentUIs(async () => { // @ts-expect-error const { argTypeSpecs } = await import('./expression_types/arg_types'); diff --git a/x-pack/plugins/canvas/public/services/kibana/reporting.ts b/x-pack/plugins/canvas/public/services/kibana/reporting.ts index 432fe5675b22f..02611acdea4da 100644 --- a/x-pack/plugins/canvas/public/services/kibana/reporting.ts +++ b/x-pack/plugins/canvas/public/services/kibana/reporting.ts @@ -22,7 +22,7 @@ export const reportingServiceFactory: CanvasReportingServiceFactory = ({ const { reporting } = startPlugins; const reportingEnabled = () => ({ - getReportingPanelPDFComponent: () => reporting?.components.ReportingPanelPDF || null, + getReportingPanelPDFComponent: () => reporting?.components.ReportingPanelPDFV2 || null, }); const reportingDisabled = () => ({ getReportingPanelPDFComponent: () => null }); diff --git a/x-pack/plugins/canvas/public/services/reporting.ts b/x-pack/plugins/canvas/public/services/reporting.ts index 5369dab32bf68..9ec5bd6a06a4c 100644 --- a/x-pack/plugins/canvas/public/services/reporting.ts +++ b/x-pack/plugins/canvas/public/services/reporting.ts @@ -7,7 +7,7 @@ import { ReportingStart } from '../../../reporting/public'; -type ReportingPanelPDFComponent = ReportingStart['components']['ReportingPanelPDF']; +type ReportingPanelPDFComponent = ReportingStart['components']['ReportingPanelPDFV2']; export interface CanvasReportingService { getReportingPanelPDFComponent: () => ReportingPanelPDFComponent | null; } diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 87fabe2730c16..5a5a1883240b7 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -7,7 +7,7 @@ "declarationMap": true, // the plugin contains some heavy json files - "resolveJsonModule": false, + "resolveJsonModule": false }, "include": [ "../../../typings/**/*", @@ -20,13 +20,14 @@ "shareable_runtime/**/*", "storybook/**/*", "tasks/mocks/*", - "types/**/*", + "types/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, - { "path": "../../../src/plugins/bfetch/tsconfig.json"}, + { "path": "../../../src/plugins/bfetch/tsconfig.json" }, { "path": "../../../src/plugins/charts/tsconfig.json" }, - { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/share/tsconfig.json" }, { "path": "../../../src/plugins/discover/tsconfig.json" }, { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/expressions/tsconfig.json" }, @@ -48,6 +49,6 @@ { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, - { "path": "../reporting/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" } ] } diff --git a/x-pack/plugins/reporting/common/build_kibana_path.ts b/x-pack/plugins/reporting/common/build_kibana_path.ts new file mode 100644 index 0000000000000..2cb37013300ca --- /dev/null +++ b/x-pack/plugins/reporting/common/build_kibana_path.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +interface Args { + basePath: string; + appPath: string; + spaceId?: string; +} + +export const buildKibanaPath = ({ basePath, appPath, spaceId }: Args) => { + return spaceId === undefined || spaceId.toLowerCase() === 'default' + ? `${basePath}${appPath}` + : `${basePath}/s/${spaceId}${appPath}`; +}; diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 0e7d8f1ffe9ca..9224a23fcb33f 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -110,12 +110,10 @@ export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATO /** * A way to get the client side route for the reporting redirect app. * - * This route currently expects a job ID and a locator that to use from that job so that it can redirect to the - * correct page. - * - * TODO: Accommodate 'forceNow' value that some visualizations may rely on + * TODO: Add a job ID and a locator to use so that we can redirect without expecting state to + * be injected to the page */ -export const getRedirectAppPathHome = () => { +export const getRedirectAppPath = () => { return '/app/management/insightsAndAlerting/reporting/r'; }; diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx index 60b51c0f07895..3024404dc07bf 100644 --- a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx @@ -49,7 +49,7 @@ export const RedirectApp: FunctionComponent = ({ share }) => { ]; if (!locatorParams) { - throw new Error('Could not find locator for report'); + throw new Error('Could not find locator params for report'); } share.navigate(locatorParams); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index f14bb249524e2..df91b6fe0ba47 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -121,7 +121,7 @@ export class HeadlessChromiumDriver { (key: string, value: unknown) => { Object.defineProperty(window, key, { configurable: false, - writable: false, + writable: true, enumerable: true, value, }); diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts new file mode 100644 index 0000000000000..bb640eff667e9 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { format } from 'url'; +import { ReportingConfig } from '../../..'; +import { getRedirectAppPath } from '../../../../common/constants'; +import { buildKibanaPath } from '../../../../common/build_kibana_path'; + +export function getFullRedirectAppUrl(config: ReportingConfig, spaceId?: string) { + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; + + const path = buildKibanaPath({ + basePath, + spaceId, + appPath: getRedirectAppPath(), + }); + + return format({ + protocol, + hostname, + port, + pathname: path, + }); +} diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts deleted file mode 100644 index bcfb06784a6dc..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts +++ /dev/null @@ -1,34 +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 { parse as urlParse, UrlWithStringQuery } from 'url'; -import { ReportingConfig } from '../../../'; -import { getAbsoluteUrlFactory } from '../get_absolute_url'; -import { validateUrls } from '../validate_urls'; - -export function getFullUrls(config: ReportingConfig, relativeUrls: string[]) { - const [basePath, protocol, hostname, port] = [ - config.kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - ] as string[]; - const getAbsoluteUrl = getAbsoluteUrlFactory({ basePath, protocol, hostname, port }); - - validateUrls(relativeUrls); - - const urls = relativeUrls.map((relativeUrl) => { - const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); - return getAbsoluteUrl({ - path: parsedRelative.pathname === null ? undefined : parsedRelative.pathname, - hash: parsedRelative.hash === null ? undefined : parsedRelative.hash, - search: parsedRelative.search === null ? undefined : parsedRelative.search, - }); - }); - - return urls; -} diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 06fdcd93e497c..5e3b3117f4bb5 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { PNG_JOB_TYPE_V2, getRedirectAppPathHome } from '../../../common/constants'; +import { PNG_JOB_TYPE_V2 } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { @@ -18,7 +18,7 @@ import { generatePngObservableFactory, setForceNow, } from '../common'; -import { getFullUrls } from '../common/v2/get_full_urls'; +import { getFullRedirectAppUrl } from '../common/v2/get_full_redirect_app_url'; import { TaskPayloadPNGV2 } from './types'; export const runTaskFnFactory: RunTaskFnFactory< @@ -39,8 +39,7 @@ export const runTaskFnFactory: RunTaskFnFactory< map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const relativeUrl = getRedirectAppPathHome(); - const [url] = getFullUrls(config, [relativeUrl]); + const url = getFullRedirectAppUrl(config, job.spaceId); const [locatorParams] = job.locatorParams.map(setForceNow(job.forceNow)); apmGetAssets?.end(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index 2211e7df08770..f2cf8026c901e 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -49,7 +49,7 @@ export const runTaskFnFactory: RunTaskFnFactory< apmGeneratePdf = apmTrans?.startSpan('generate_pdf_pipeline', 'execute'); return generatePdfObservable( jobLogger, - jobId, + job, title, locatorParams.map(setForceNow(job.forceNow)), browserTimezone, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index 9be95223a8864..424a347876a1d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -9,14 +9,14 @@ import { groupBy, zip } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; -import { getRedirectAppPathHome } from '../../../../common/constants'; import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; import { createLayout, LayoutParams } from '../../../lib/layouts'; import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; import { ConditionalHeaders } from '../../common'; import { PdfMaker } from '../../common/pdf'; -import { getFullUrls } from '../../common/v2/get_full_urls'; +import { getFullRedirectAppUrl } from '../../common/v2/get_full_redirect_app_url'; +import type { TaskPayloadPDFV2 } from '../types'; import { getTracker } from './tracker'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { @@ -36,7 +36,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { return function generatePdfObservable( logger: LevelLogger, - jobId: string, + job: TaskPayloadPDFV2, title: string, locatorParams: LocatorParams[], browserTimezone: string | undefined, @@ -56,9 +56,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { /** * For each locator we get the relative URL to the redirect app */ - const relativeUrls = locatorParams.map(() => getRedirectAppPathHome()); - const urls = getFullUrls(reporting.getConfig(), relativeUrls); - + const urls = locatorParams.map(() => getFullRedirectAppUrl(reporting.getConfig(), job.spaceId)); const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { logger, urlsOrUrlLocatorTuples: zip(urls, locatorParams) as UrlOrUrlLocatorTuple[], From 2e2b45116284e6496948c824894a28cab6ed127e Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Mon, 6 Sep 2021 14:15:53 +0200 Subject: [PATCH 08/17] [Security Solution][Endpoint] Trim Activity Log comments (#111163) * trim comments so empty comments do not show up fixes elastic/kibana/issues/111106 * not exclusive test * update test to be more specific Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../view/details/components/log_entry.tsx | 6 +-- .../pages/endpoint_hosts/view/index.test.tsx | 43 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index 4fe70039d1251..b15c6b9ba0020 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -45,7 +45,7 @@ const useLogEntryUIProps = ( if (logEntry.type === 'action') { avatarSize = 'm'; commentType = 'regular'; - commentText = logEntry.item.data.data.comment ?? ''; + commentText = logEntry.item.data.data.comment?.trim() ?? ''; displayResponseEvent = false; iconType = 'lockOpen'; username = logEntry.item.data.user_id; @@ -55,7 +55,7 @@ const useLogEntryUIProps = ( iconType = 'lock'; isIsolateAction = true; } - if (data.comment) { + if (commentText) { displayComment = true; } } @@ -154,7 +154,7 @@ export const LogEntry = memo(({ logEntry }: { logEntry: Immutable {displayComment ? ( - +

{commentText}

) : undefined} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index ea999334ee771..d053da18ce502 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -832,6 +832,15 @@ describe('when on the endpoint list page', () => { }); const actionData = fleetActionGenerator.generate({ agents: [agentId], + data: { + comment: 'some comment', + }, + }); + const isolatedActionData = fleetActionGenerator.generateIsolateAction({ + agents: [agentId], + data: { + comment: ' ', // has space for comment, + }, }); getMockData = () => ({ @@ -854,6 +863,13 @@ describe('when on the endpoint list page', () => { data: actionData, }, }, + { + type: 'action', + item: { + id: 'some_id_3', + data: isolatedActionData, + }, + }, ], }); @@ -890,7 +906,7 @@ describe('when on the endpoint list page', () => { dispatchEndpointDetailsActivityLogChanged('success', getMockData()); }); const logEntries = await renderResult.queryAllByTestId('timelineEntry'); - expect(logEntries.length).toEqual(2); + expect(logEntries.length).toEqual(3); expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null); expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null); }); @@ -947,7 +963,7 @@ describe('when on the endpoint list page', () => { dispatchEndpointDetailsActivityLogChanged('success', getMockData()); }); const logEntries = await renderResult.queryAllByTestId('timelineEntry'); - expect(logEntries.length).toEqual(2); + expect(logEntries.length).toEqual(3); }); it('should display a callout message if no log data', async () => { @@ -975,6 +991,29 @@ describe('when on the endpoint list page', () => { const activityLogCallout = await renderResult.findByTestId('activityLogNoDataCallout'); expect(activityLogCallout).not.toBeNull(); }); + + it('should correctly display non-empty comments only for actions', async () => { + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + history.push( + `${MANAGEMENT_PATH}/endpoints?page_index=0&page_size=10&selected_endpoint=1&show=activity_log` + ); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual( + '?page_index=0&page_size=10&selected_endpoint=1&show=activity_log' + ); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged('success', getMockData()); + }); + const commentTexts = await renderResult.queryAllByTestId('activityLogCommentText'); + expect(commentTexts.length).toEqual(1); + expect(commentTexts[0].textContent).toEqual('some comment'); + expect(commentTexts[0].parentElement?.parentElement?.className).toContain( + 'euiCommentEvent--regular' + ); + }); }); describe('when showing host Policy Response panel', () => { From 1a88d34ea258bd046e786c51450024a29a76d6fb Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 6 Sep 2021 14:27:53 +0200 Subject: [PATCH 09/17] [Lens] Reverse colors should not reverse palette picker previews (#110455) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../coloring/palette_configuration.test.tsx | 30 ++ .../coloring/palette_configuration.tsx | 37 +-- .../coloring/palette_picker.tsx | 2 +- .../embeddable/lens_embeddable_factory.ts | 10 + .../server/migrations/common_migrations.ts | 57 +++- .../saved_object_migrations.test.ts | 277 +++++++++++++++++- .../migrations/saved_object_migrations.ts | 11 + .../plugins/lens/server/migrations/types.ts | 16 +- .../api_integration/apis/maps/migrations.js | 2 +- 9 files changed, 414 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx index cda891871168e..33f6ac379cd80 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -14,6 +14,7 @@ import { ReactWrapper } from 'enzyme'; import type { CustomPaletteParams } from '../../../common'; import { applyPaletteParams } from './utils'; import { CustomizablePalette } from './palette_configuration'; +import { CUSTOM_PALETTE } from './constants'; import { act } from 'react-dom/test-utils'; // mocking random id generator function @@ -129,6 +130,21 @@ describe('palette panel', () => { }); }); + it('should restore the reverse initial state on transitioning', () => { + const instance = mountWithIntl(); + + changePaletteIn(instance, 'negative'); + + expect(props.setPalette).toHaveBeenCalledWith({ + type: 'palette', + name: 'negative', + params: expect.objectContaining({ + name: 'negative', + reverse: false, + }), + }); + }); + it('should rewrite the min/max range values on palette change', () => { const instance = mountWithIntl(); @@ -175,6 +191,20 @@ describe('palette panel', () => { }) ); }); + + it('should transition a predefined palette to a custom one on reverse click', () => { + const instance = mountWithIntl(); + + toggleReverse(instance, true); + + expect(props.setPalette).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + name: CUSTOM_PALETTE, + }), + }) + ); + }); }); describe('percentage / number modes', () => { diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx index 1d1e212b87c0c..019e83fb0aa59 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -106,6 +106,7 @@ export function CustomizablePalette({ ...activePalette.params, name: newPalette.name, colorStops: undefined, + reverse: false, // restore the reverse flag }; const newColorStops = getColorStops(palettes, [], activePalette, dataBounds); @@ -317,28 +318,20 @@ export function CustomizablePalette({ className="lnsPalettePanel__reverseButton" data-test-subj="lnsPalettePanel_dynamicColoring_reverse" onClick={() => { - const params: CustomPaletteParams = { reverse: !activePalette.params?.reverse }; - if (isCurrentPaletteCustom) { - params.colorStops = reversePalette(colorStopsToShow); - params.stops = getPaletteStops( - palettes, - { - ...(activePalette?.params || {}), - colorStops: params.colorStops, - }, - { dataBounds } - ); - } else { - params.stops = reversePalette( - activePalette?.params?.stops || - getPaletteStops( - palettes, - { ...activePalette.params, ...params }, - { prevPalette: activePalette.name, dataBounds } - ) - ); - } - setPalette(mergePaletteParams(activePalette, params)); + // when reversing a palette, the palette is automatically transitioned to a custom palette + const newParams = getSwitchToCustomParams( + palettes, + activePalette, + { + colorStops: reversePalette(colorStopsToShow), + steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS, + reverse: !activePalette.params?.reverse, // Store the reverse state + rangeMin: colorStopsToShow[0]?.stop, + rangeMax: colorStopsToShow[colorStopsToShow.length - 1]?.stop, + }, + dataBounds + ); + setPalette(newParams); }} > diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx index 2a415cd178925..b21b732820eaa 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -83,7 +83,7 @@ export function PalettePicker({ value: id, title, type: FIXED_PROGRESSION, - palette: activePalette?.params?.reverse ? colors.reverse() : colors, + palette: colors, 'data-test-subj': `${id}-palette`, }; }); diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 86a3a600b58ab..4423d9e659119 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -9,6 +9,7 @@ import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; import type { SerializableRecord } from '@kbn/utility-types'; import { DOC_TYPE } from '../../common'; import { + commonMakeReversePaletteAsCustom, commonRemoveTimezoneDateHistogramParam, commonRenameOperationsForFormula, commonUpdateVisLayerType, @@ -17,6 +18,7 @@ import { LensDocShape713, LensDocShape715, LensDocShapePre712, + VisState716, VisStatePre715, } from '../migrations/types'; import { extract, inject } from '../../common/embeddable_factory'; @@ -50,6 +52,14 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { attributes: migratedLensState, } as unknown) as SerializableRecord; }, + '7.16.0': (state) => { + const lensState = (state as unknown) as { attributes: LensDocShape715 }; + const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes); + return ({ + ...lensState, + attributes: migratedLensState, + } as unknown) as SerializableRecord; + }, }, extract, inject, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index fda4300e03ea9..5755416957440 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -6,6 +6,7 @@ */ import { cloneDeep } from 'lodash'; +import { PaletteOutput } from 'src/plugins/charts/common'; import { LensDocShapePre712, OperationTypePre712, @@ -15,8 +16,9 @@ import { LensDocShape715, VisStatePost715, VisStatePre715, + VisState716, } from './types'; -import { layerTypes } from '../../common'; +import { CustomPaletteParams, layerTypes } from '../../common'; export const commonRenameOperationsForFormula = ( attributes: LensDocShapePre712 @@ -98,3 +100,56 @@ export const commonUpdateVisLayerType = ( } return newAttributes as LensDocShape715; }; + +function moveDefaultPaletteToPercentCustomInPlace(palette?: PaletteOutput) { + if (palette?.params?.reverse && palette.params.name !== 'custom' && palette.params.stops) { + // change to palette type to custom and migrate to a percentage type of mode + palette.name = 'custom'; + palette.params.name = 'custom'; + // we can make strong assumptions here: + // because it was a default palette reversed it means that stops were the default ones + // so when migrating, because there's no access to active data, we could leverage the + // percent rangeType to define colorStops in percent. + // + // Stops should be defined, but reversed, as the previous code was rewriting them on reverse. + // + // The only change the user should notice should be the mode changing from number to percent + // but the final result *must* be identical + palette.params.rangeType = 'percent'; + const steps = palette.params.stops.length; + palette.params.rangeMin = 0; + palette.params.rangeMax = 80; + palette.params.steps = steps; + palette.params.colorStops = palette.params.stops.map(({ color }, index) => ({ + color, + stop: (index * 100) / steps, + })); + palette.params.stops = palette.params.stops.map(({ color }, index) => ({ + color, + stop: ((1 + index) * 100) / steps, + })); + } +} + +export const commonMakeReversePaletteAsCustom = ( + attributes: LensDocShape715 +): LensDocShape715 => { + const newAttributes = cloneDeep(attributes); + const vizState = (newAttributes as LensDocShape715).state.visualization; + if ( + attributes.visualizationType !== 'lnsDatatable' && + attributes.visualizationType !== 'lnsHeatmap' + ) { + return newAttributes; + } + if ('columns' in vizState) { + for (const column of vizState.columns) { + if (column.colorMode && column.colorMode !== 'none') { + moveDefaultPaletteToPercentCustomInPlace(column.palette); + } + } + } else { + moveDefaultPaletteToPercentCustomInPlace(vizState.palette); + } + return newAttributes; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index afc6e6c6a590c..c16c5b5169ac5 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -12,8 +12,9 @@ import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc, } from 'src/core/server'; -import { LensDocShape715, VisStatePost715, VisStatePre715 } from './types'; -import { layerTypes } from '../../common'; +import { LensDocShape715, VisState716, VisStatePost715, VisStatePre715 } from './types'; +import { CustomPaletteParams, layerTypes } from '../../common'; +import { PaletteOutput } from 'src/plugins/charts/common'; describe('Lens migrations', () => { describe('7.7.0 missing dimensions in XY', () => { @@ -1129,4 +1130,276 @@ describe('Lens migrations', () => { } }); }); + + describe('7.16.0 move reversed default palette to custom palette', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = ({ + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown) as SavedObjectUnsanitizedDoc>; + + it('should just return the same document for XY, partition and metric visualization types', () => { + for (const vizType of ['lnsXY', 'lnsPie', 'lnsMetric']) { + const exampleCopy = cloneDeep(example); + exampleCopy.attributes.visualizationType = vizType; + // add datatable state here, even with another viz (manual change?) + (exampleCopy.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: false } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](exampleCopy, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(exampleCopy); + } + }); + + it('should not change non reversed default palettes in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: false } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change custom palettes in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'custom' }, colorMode: 'cell' }, + { palette: { type: 'palette', name: 'custom' }, colorMode: 'text' }, + { + palette: { type: 'palette', name: 'custom', params: { reverse: true } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change a datatable with no conditional coloring', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [{ colorMode: 'none' }, {}], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should not change default palette if the colorMode is set to "none" in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'none' }, + { palette: { type: 'palette', name: 'temperature' }, colorMode: 'none' }, + { + palette: { type: 'palette', name: 'temperature', params: { reverse: true } }, + colorMode: 'cell', + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toEqual(datatableExample); + }); + + it('should change a default palette reversed in datatable', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + columns: [ + { + colorMode: 'cell', + palette: { + type: 'palette', + name: 'temperature1', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + }, + { + colorMode: 'text', + palette: { + type: 'palette', + name: 'temperature2', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + }, + ], + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715< + Extract + >).state.visualization; + for (const column of state.columns) { + expect(column.palette!.name).toBe('custom'); + expect(column.palette!.params!.name).toBe('custom'); + expect(column.palette!.params!.rangeMin).toBe(0); + expect(column.palette!.params!.rangeMax).toBe(80); + expect(column.palette!.params!.reverse).toBeTruthy(); // still true + expect(column.palette!.params!.rangeType).toBe('percent'); + expect(column.palette!.params!.stops).toEqual([ + { color: 'red', stop: 20 }, + { color: 'blue', stop: 40 }, + { color: 'pink', stop: 60 }, + { color: 'green', stop: 80 }, + { color: 'yellow', stop: 100 }, + ]); + expect(column.palette!.params!.colorStops).toEqual([ + { color: 'red', stop: 0 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 40 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 80 }, + ]); + } + }); + + it('should change a default palette reversed in heatmap', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsHeatmap'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + palette: { + type: 'palette', + name: 'temperature1', + params: { + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 10 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 50 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 70 }, + ], + }, + }, + } as unknown) as VisState716; + const result = migrations['7.16.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715< + Extract }> + >).state.visualization; + expect(state.palette!.name).toBe('custom'); + expect(state.palette!.params!.name).toBe('custom'); + expect(state.palette!.params!.rangeMin).toBe(0); + expect(state.palette!.params!.rangeMax).toBe(80); + expect(state.palette!.params!.reverse).toBeTruthy(); // still true + expect(state.palette!.params!.rangeType).toBe('percent'); + expect(state.palette!.params!.stops).toEqual([ + { color: 'red', stop: 20 }, + { color: 'blue', stop: 40 }, + { color: 'pink', stop: 60 }, + { color: 'green', stop: 80 }, + { color: 'yellow', stop: 100 }, + ]); + expect(state.palette!.params!.colorStops).toEqual([ + { color: 'red', stop: 0 }, + { color: 'blue', stop: 20 }, + { color: 'pink', stop: 40 }, + { color: 'green', stop: 60 }, + { color: 'yellow', stop: 80 }, + ]); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 7d08e76841cf5..901f0b5d6e684 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -23,11 +23,13 @@ import { LensDocShape715, VisStatePost715, VisStatePre715, + VisState716, } from './types'; import { commonRenameOperationsForFormula, commonRemoveTimezoneDateHistogramParam, commonUpdateVisLayerType, + commonMakeReversePaletteAsCustom, } from './common_migrations'; interface LensDocShapePre710 { @@ -430,6 +432,14 @@ const addLayerTypeToVisualization: SavedObjectMigrationFn< return { ...newDoc, attributes: commonUpdateVisLayerType(newDoc.attributes) }; }; +const moveDefaultReversedPaletteToCustom: SavedObjectMigrationFn< + LensDocShape715, + LensDocShape715 +> = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonMakeReversePaletteAsCustom(newDoc.attributes) }; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -442,4 +452,5 @@ export const migrations: SavedObjectMigrationMap = { '7.13.1': renameOperationsForFormula, // duplicate this migration in case a broken by value panel is added to the library '7.14.0': removeTimezoneDateHistogramParam, '7.15.0': addLayerTypeToVisualization, + '7.16.0': moveDefaultReversedPaletteToCustom, }; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 09b460ff8b8cd..2e6e66aed9949 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -5,8 +5,9 @@ * 2.0. */ +import type { PaletteOutput } from 'src/plugins/charts/common'; import { Query, Filter } from 'src/plugins/data/public'; -import type { LayerType } from '../../common'; +import type { CustomPaletteParams, LayerType } from '../../common'; export type OperationTypePre712 = | 'avg' @@ -192,3 +193,16 @@ export interface LensDocShape715 { filters: Filter[]; }; } + +export type VisState716 = + // Datatable + | { + columns: Array<{ + palette?: PaletteOutput; + colorMode?: 'none' | 'cell' | 'text'; + }>; + } + // Heatmap + | { + palette?: PaletteOutput; + }; diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index d121c79f6cfe1..89e9c0975f9f6 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -76,7 +76,7 @@ export default function ({ getService }) { } expect(panels.length).to.be(1); expect(panels[0].type).to.be('map'); - expect(panels[0].version).to.be('7.15.0'); + expect(panels[0].version).to.be('7.16.0'); }); }); }); From 4eefa8531c70ff6c0a617fa807df22fffaf1847a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Mon, 6 Sep 2021 14:52:46 +0200 Subject: [PATCH 10/17] [APM] Uses doc link service instead of ElasticDocsLink for linking upgrading info (#111155) --- .../kibana-plugin-core-public.doclinksstart.links.md | 1 + .../kibana-plugin-core-public.doclinksstart.md | 1 + src/core/public/doc_links/doc_links_service.ts | 2 ++ src/core/public/public.api.md | 1 + .../components/app/service_node_metrics/index.tsx | 12 +++++------- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 253b0671cdd52..94809d3ddfa96 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -12,6 +12,7 @@ readonly links: { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly upgrading: string; readonly metaData: string; }; readonly canvas: { diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 7e409f23790f0..4b6e1ad2631b4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,6 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly upgrading: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
} | | | [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 73ba816ff9b4b..05bea391c7d92 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -34,6 +34,7 @@ export class DocLinksService { apm: { kibanaSettings: `${KIBANA_DOCS}apm-settings-in-kibana.html`, supportedServiceMaps: `${KIBANA_DOCS}service-maps.html#service-maps-supported`, + upgrading: `${APM_DOCS}server/${DOC_LINK_VERSION}/upgrading.html`, metaData: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/metadata.html`, }, canvas: { @@ -460,6 +461,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly upgrading: string; readonly metaData: string; }; readonly canvas: { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d9c64f29eb684..8ded7be75ad8a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,6 +476,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly upgrading: string; readonly metaData: string; }; readonly canvas: { diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index c0578514ff9ad..29bc639ee9832 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -10,6 +10,7 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, + EuiLink, EuiPanel, EuiSpacer, EuiStat, @@ -24,6 +25,7 @@ import { SERVICE_NODE_NAME_MISSING, } from '../../../../common/service_nodes'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; @@ -33,7 +35,6 @@ import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric import { useTimeRange } from '../../../hooks/use_time_range'; import { truncate, unit } from '../../../utils/style'; import { MetricsChart } from '../../shared/charts/metrics_chart'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; const INITIAL_DATA = { host: '', @@ -99,6 +100,7 @@ export function ServiceNodeMetrics() { [kuery, serviceName, serviceNodeName, start, end] ); + const { docLinks } = useApmPluginContext().core; const isLoading = status === FETCH_STATUS.LOADING; const isAggregatedData = serviceNodeName === SERVICE_NODE_NAME_MISSING; @@ -120,16 +122,12 @@ export function ServiceNodeMetrics() { defaultMessage="We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue. For more information on upgrading, see the {link}. As an alternative, you can use the Kibana Query bar to filter by hostname, container ID or other fields." values={{ link: ( - + {i18n.translate( 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink', { defaultMessage: 'documentation of APM Server' } )} - + ), }} /> From 0d009438d1b26759dd853fa8756508ecf2e57c6d Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Mon, 6 Sep 2021 15:55:08 +0300 Subject: [PATCH 11/17] [Discover] Add permissions for `context size` test (#109391) * [Discover] add permissions for flaky context test * [Discover] apply test_logstash_reader role Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/apps/context/_size.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/functional/apps/context/_size.ts b/test/functional/apps/context/_size.ts index b11af7cd5c72f..52b16d2b9abe5 100644 --- a/test/functional/apps/context/_size.ts +++ b/test/functional/apps/context/_size.ts @@ -15,6 +15,7 @@ const TEST_STEP_SIZE = 2; export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); + const security = getService('security'); const retry = getService('retry'); const docTable = getService('docTable'); const browser = getService('browser'); @@ -23,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('context size', function contextSize() { before(async function () { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await kibanaServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, From e145ccdb87290accbe9c357a2dbe1f6a24811f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Mon, 6 Sep 2021 16:18:09 +0200 Subject: [PATCH 12/17] Add test service to manage observability test users (#110849) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability_security.ts | 96 ++++--------------- x-pack/test/functional/services/index.ts | 2 + .../services/observability/index.ts | 17 ++++ .../services/observability/users.ts | 92 ++++++++++++++++++ 4 files changed, 130 insertions(+), 77 deletions(-) create mode 100644 x-pack/test/functional/services/observability/index.ts create mode 100644 x-pack/test/functional/services/observability/users.ts diff --git a/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts b/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts index 1d52088ede3da..69bf995c49ae4 100644 --- a/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts +++ b/x-pack/test/functional/apps/observability/feature_controls/observability_security.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const security = getService('security'); + const observability = getService('observability'); const PageObjects = getPageObjects([ 'common', 'observability', @@ -20,6 +20,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ]); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); + describe('observability security feature controls', function () { this.tags(['skipFirefox']); before(async () => { @@ -32,39 +33,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('observability cases all privileges', () => { before(async () => { - await security.role.create('cases_observability_all_role', { - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { spaces: ['*'], base: [], feature: { observabilityCases: ['all'], logs: ['all'] } }, - ], - }); - - await security.user.create('cases_observability_all_user', { - password: 'cases_observability_all_user-password', - roles: ['cases_observability_all_role'], - full_name: 'test user', - }); - - await PageObjects.security.forceLogout(); - - await PageObjects.security.login( - 'cases_observability_all_user', - 'cases_observability_all_user-password', - { - expectSpaceSelector: false, - } + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + observabilityCases: ['all'], + logs: ['all'], + }) ); }); after(async () => { - await PageObjects.security.forceLogout(); - await Promise.all([ - security.role.delete('cases_observability_all_role'), - security.user.delete('cases_observability_all_user'), - ]); + await observability.users.restoreDefaultTestUserRole(); }); it('shows observability/cases navlink', async () => { + await PageObjects.common.navigateToActualUrl('observability'); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); expect(navLinks).to.contain('Cases'); }); @@ -101,38 +83,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('observability cases read-only privileges', () => { before(async () => { - await security.role.create('cases_observability_read_role', { - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { observabilityCases: ['read'], logs: ['all'] }, - }, - ], - }); - - await security.user.create('cases_observability_read_user', { - password: 'cases_observability_read_user-password', - roles: ['cases_observability_read_role'], - full_name: 'test user', - }); - - await PageObjects.security.login( - 'cases_observability_read_user', - 'cases_observability_read_user-password', - { - expectSpaceSelector: false, - } + await observability.users.setTestUserRole( + observability.users.defineBasicObservabilityRole({ + observabilityCases: ['read'], + logs: ['all'], + }) ); }); after(async () => { - await security.role.delete('cases_observability_read_role'); - await security.user.delete('cases_observability_read_user'); + await observability.users.restoreDefaultTestUserRole(); }); it('shows observability/cases navlink', async () => { + await PageObjects.common.navigateToActualUrl('observability'); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); expect(navLinks).to.contain('Cases'); }); @@ -170,36 +134,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('no observability privileges', () => { before(async () => { - await security.role.create('no_observability_privileges_role', { + await observability.users.setTestUserRole({ elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ - { - feature: { - discover: ['all'], - }, - spaces: ['*'], - }, - ], + kibana: [{ spaces: ['*'], base: [], feature: { discover: ['all'] } }], }); - - await security.user.create('no_observability_privileges_user', { - password: 'no_observability_privileges_user-password', - roles: ['no_observability_privileges_role'], - full_name: 'test user', - }); - - await PageObjects.security.login( - 'no_observability_privileges_user', - 'no_observability_privileges_user-password', - { - expectSpaceSelector: false, - } - ); }); after(async () => { - await security.role.delete('no_observability_privileges_role'); - await security.user.delete('no_observability_privileges_user'); + await observability.users.restoreDefaultTestUserRole(); }); it(`returns a 403`, async () => { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 273db212400ab..5e40eb040178b 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -60,6 +60,7 @@ import { DashboardPanelTimeRangeProvider, } from './dashboard'; import { SearchSessionsService } from './search_sessions'; +import { ObservabilityProvider } from './observability'; // define the name and providers for services that should be // available to your tests. If you don't specify anything here @@ -110,4 +111,5 @@ export const services = { dashboardPanelTimeRange: DashboardPanelTimeRangeProvider, reporting: ReportingFunctionalProvider, searchSessions: SearchSessionsService, + observability: ObservabilityProvider, }; diff --git a/x-pack/test/functional/services/observability/index.ts b/x-pack/test/functional/services/observability/index.ts new file mode 100644 index 0000000000000..14f931d93b56f --- /dev/null +++ b/x-pack/test/functional/services/observability/index.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 { FtrProviderContext } from '../../ftr_provider_context'; +import { ObservabilityUsersProvider } from './users'; + +export function ObservabilityProvider(context: FtrProviderContext) { + const users = ObservabilityUsersProvider(context); + + return { + users, + }; +} diff --git a/x-pack/test/functional/services/observability/users.ts b/x-pack/test/functional/services/observability/users.ts new file mode 100644 index 0000000000000..78e8b3346cc67 --- /dev/null +++ b/x-pack/test/functional/services/observability/users.ts @@ -0,0 +1,92 @@ +/* + * 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 { Role } from '../../../../plugins/security/common/model'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +type CreateRolePayload = Pick; + +const OBSERVABILITY_TEST_ROLE_NAME = 'observability-functional-test-role'; + +export function ObservabilityUsersProvider({ getPageObject, getService }: FtrProviderContext) { + const security = getService('security'); + const commonPageObject = getPageObject('common'); + + /** + * Creates a test role and set it as the test user's role. Performs a page + * reload to apply the role change, but doesn't require a re-login. + * + * @arg roleDefinition - the privileges of the test role + */ + const setTestUserRole = async (roleDefinition: CreateRolePayload) => { + // return to neutral grounds to avoid running into permission problems on reload + await commonPageObject.navigateToActualUrl('kibana'); + + await security.role.create(OBSERVABILITY_TEST_ROLE_NAME, roleDefinition); + + await security.testUser.setRoles([OBSERVABILITY_TEST_ROLE_NAME]); // performs a page reload + }; + + /** + * Deletes the test role and restores thedefault test user role. Performs a + * page reload to apply the role change, but doesn't require a re-login. + */ + const restoreDefaultTestUserRole = async () => { + await Promise.all([ + security.role.delete(OBSERVABILITY_TEST_ROLE_NAME), + security.testUser.restoreDefaults(), + ]); + }; + + return { + defineBasicObservabilityRole, + restoreDefaultTestUserRole, + setTestUserRole, + }; +} + +/** + * Generates a combination of Elasticsearch and Kibana privileges for given + * observability features. + */ +const defineBasicObservabilityRole = ( + features: Partial<{ + observabilityCases: string[]; + apm: string[]; + logs: string[]; + infrastructure: string[]; + uptime: string[]; + }> +): CreateRolePayload => { + return { + elasticsearch: { + cluster: ['all'], + indices: [ + ...((features.logs?.length ?? 0) > 0 + ? [{ names: ['filebeat-*', 'logs-*'], privileges: ['all'] }] + : []), + ...((features.infrastructure?.length ?? 0) > 0 + ? [{ names: ['metricbeat-*', 'metrics-*'], privileges: ['all'] }] + : []), + ...((features.apm?.length ?? 0) > 0 ? [{ names: ['apm-*'], privileges: ['all'] }] : []), + ...((features.uptime?.length ?? 0) > 0 + ? [{ names: ['heartbeat-*,synthetics-*'], privileges: ['all'] }] + : []), + ], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + // @ts-expect-error TypeScript doesn't distinguish between missing and + // undefined props yet + feature: features, + }, + ], + }; +}; From d7e14ff72c8ee8880713d2a825bc4b555c8abd9e Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Mon, 6 Sep 2021 10:37:19 -0400 Subject: [PATCH 13/17] [Security Solution] Update protection names in Policy config (#111202) --- .../pages/policy/view/policy_forms/protections/behavior.tsx | 4 ++-- .../pages/policy/view/policy_forms/protections/memory.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx index f52d8a4c70706..06cf666f2950e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx @@ -28,13 +28,13 @@ export const BehaviorProtection = React.memo(() => { const protectionLabel = i18n.translate( 'xpack.securitySolution.endpoint.policy.protections.behavior', { - defaultMessage: 'Behavior', + defaultMessage: 'Malicious behavior protections', } ); return ( { const protectionLabel = i18n.translate( 'xpack.securitySolution.endpoint.policy.protections.memory', { - defaultMessage: 'Memory manipulation', + defaultMessage: 'Memory threat protections', } ); return ( Date: Mon, 6 Sep 2021 10:37:44 -0400 Subject: [PATCH 14/17] [Security Solution] Add Windows kernel advanced policy options for 7.15 (#111182) --- .../policy/models/advanced_policy_schema.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 1add8bb9d6f76..4d7ca74ca19f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -584,6 +584,28 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'windows.advanced.kernel.fileaccess', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.fileaccess', + { + defaultMessage: + 'Report limited file access (read) events. Paths are not user-configurable. Default value is true.', + } + ), + }, + { + key: 'windows.advanced.kernel.registryaccess', + first_supported_version: '7.15', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.registryaccess', + { + defaultMessage: + 'Report limited registry access (queryvalue, savekey) events. Paths are not user-configurable. Default value is true.', + } + ), + }, { key: 'windows.advanced.diagnostic.enabled', first_supported_version: '7.11', From b97afb2c72cc8c25032261e2970109d5ddd9df7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 6 Sep 2021 11:09:38 -0400 Subject: [PATCH 15/17] [APM] Missing transaction type error when creating Latency threshold Alert (#110336) * redirect to page adding transaction type * skipping transaction type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerting/alerting_flyout/index.tsx | 2 +- .../service_inventory/service_list/index.tsx | 4 +- .../components/app/service_overview/index.tsx | 20 ++++++-- .../app/transaction_details/index.tsx | 21 ++++++-- .../app/transaction_overview/index.tsx | 49 ++++++------------- .../app/transaction_overview/useRedirect.ts | 20 -------- 6 files changed, 52 insertions(+), 64 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 7cf3100046d57..fa56c44d8d374 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -60,7 +60,7 @@ export function AlertingFlyout(props: Props) { metadata: { environment, serviceName, - transactionType, + ...(alertType === AlertType.ErrorCount ? {} : { transactionType }), start, end, } as AlertMetadata, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 8732084e6331e..a3820622f8c9d 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -99,14 +99,14 @@ export function getServiceColumns({ }), width: '40%', sortable: true, - render: (_, { serviceName, agentName }) => ( + render: (_, { serviceName, agentName, transactionType }) => ( } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 9af296e8a20b4..45372188994c7 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useHistory } from 'react-router-dom'; import { isRumAgentName, isIosAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; @@ -26,6 +27,7 @@ import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { replace } from '../../shared/Links/url_helpers'; /** * The height a chart should be if it's next to a table with 5 rows and a title. @@ -34,17 +36,29 @@ import { useTimeRange } from '../../../hooks/use_time_range'; export const chartHeight = 288; export function ServiceOverview() { - const { agentName, serviceName } = useApmServiceContext(); + const { agentName, serviceName, transactionType } = useApmServiceContext(); const { query, - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + }, } = useApmParams('/services/:serviceName/overview'); const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ kuery, }); - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + const history = useHistory(); + + // redirect to first transaction type + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } + // The default EuiFlexGroup breaks at 768, but we want to break at 992, so we // observe the window width and set the flex directions of rows accordingly const { isMedium } = useBreakPoints(); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index 06acaeeb5dd3b..ab59b60333e38 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -7,6 +7,8 @@ import { EuiSpacer, EuiTitle } from '@elastic/eui'; import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useApmParams } from '../../../hooks/use_apm_params'; @@ -15,18 +17,29 @@ import { useTimeRange } from '../../../hooks/use_time_range'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; - +import { replace } from '../../shared/Links/url_helpers'; import { TransactionDetailsTabs } from './transaction_details_tabs'; export function TransactionDetails() { const { path, query } = useApmParams( '/services/:serviceName/transactions/view' ); - const { transactionName, rangeFrom, rangeTo } = query; - + const { + transactionName, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + } = query; const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const apmRouter = useApmRouter(); + const { transactionType } = useApmServiceContext(); + + const history = useHistory(); + + // redirect to first transaction type + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } useBreadcrumb({ title: transactionName, diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index be12522920740..571ba99d9bf08 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -5,48 +5,27 @@ * 2.0. */ -import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { Location } from 'history'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import type { ApmUrlParams } from '../../../context/url_params_context/types'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; -import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; +import { replace } from '../../shared/Links/url_helpers'; import { TransactionsTable } from '../../shared/transactions_table'; -import { useRedirect } from './useRedirect'; - -function getRedirectLocation({ - location, - transactionType, - urlParams, -}: { - location: Location; - transactionType?: string; - urlParams: ApmUrlParams; -}): Location | undefined { - const transactionTypeFromUrlParams = urlParams.transactionType; - - if (!transactionTypeFromUrlParams && transactionType) { - return { - ...location, - search: fromQuery({ - ...toQuery(location.search), - transactionType, - }), - }; - } -} - export function TransactionOverview() { const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + transactionType: transactionTypeFromUrl, + }, } = useApmParams('/services/:serviceName/transactions'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -54,12 +33,14 @@ export function TransactionOverview() { const { fallbackToTransactions } = useFallbackToTransactionsFetcher({ kuery, }); - const location = useLocation(); - const { urlParams } = useUrlParams(); const { transactionType, serviceName } = useApmServiceContext(); + const history = useHistory(); + // redirect to first transaction type - useRedirect(getRedirectLocation({ location, transactionType, urlParams })); + if (!transactionTypeFromUrl && transactionType) { + replace(history, { query: { transactionType } }); + } // TODO: improve urlParams typings. // `serviceName` or `transactionType` will never be undefined here, and this check should not be needed diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts deleted file mode 100644 index fae80eec42f9b..0000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts +++ /dev/null @@ -1,20 +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 { Location } from 'history'; -import { useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; - -export function useRedirect(redirectLocation?: Location) { - const history = useHistory(); - - useEffect(() => { - if (redirectLocation) { - history.replace(redirectLocation); - } - }, [history, redirectLocation]); -} From 705fe22088ab82a0117d1e199d43de81f5cc6ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Mon, 6 Sep 2021 17:15:00 +0200 Subject: [PATCH 16/17] [APM] Uses doc link service instead of ElasticDocsLink for linking dropped transaction spans (#110964) --- ...kibana-plugin-core-public.doclinksstart.links.md | 1 + src/core/public/doc_links/doc_links_service.ts | 2 ++ src/core/public/public.api.md | 1 + .../transaction_flyout/DroppedSpansWarning.tsx | 13 +++++-------- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 94809d3ddfa96..0a1f2d1bf8b4e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -12,6 +12,7 @@ readonly links: { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; }; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 05bea391c7d92..94a568b380e64 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -34,6 +34,7 @@ export class DocLinksService { apm: { kibanaSettings: `${KIBANA_DOCS}apm-settings-in-kibana.html`, supportedServiceMaps: `${KIBANA_DOCS}service-maps.html#service-maps-supported`, + droppedTransactionSpans: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/transaction-spans.html#dropped-spans`, upgrading: `${APM_DOCS}server/${DOC_LINK_VERSION}/upgrading.html`, metaData: `${APM_DOCS}get-started/${DOC_LINK_VERSION}/metadata.html`, }, @@ -461,6 +462,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; }; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8ded7be75ad8a..7512bd723cfa0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -476,6 +476,7 @@ export interface DocLinksStart { readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; + readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; }; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx index 6fb1cdc45805e..2c6dbe99b6061 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/Waterfall/transaction_flyout/DroppedSpansWarning.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import { EuiCallOut, EuiHorizontalRule } from '@elastic/eui'; +import { EuiCallOut, EuiHorizontalRule, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; -import { ElasticDocsLink } from '../../../../../../shared/Links/ElasticDocsLink'; +import { useApmPluginContext } from '../../../../../../../context/apm_plugin/use_apm_plugin_context'; export function DroppedSpansWarning({ transactionDoc, }: { transactionDoc: Transaction; }) { + const { docLinks } = useApmPluginContext().core; const dropped = transactionDoc.transaction.span_count?.dropped; if (!dropped) { return null; @@ -32,18 +33,14 @@ export function DroppedSpansWarning({ values: { dropped }, } )}{' '} - + {i18n.translate( 'xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText', { defaultMessage: 'Learn more about dropped spans.', } )} - + From 219ff6c37bfe554f8260d2b5ebd1a4227f4de404 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Mon, 6 Sep 2021 20:04:38 +0100 Subject: [PATCH 17/17] Add verification code protection (#110856) * Add verification code protection * Fix bug where verification code could be less than 6 digits * Added suggestions from code review * fix type errors * Added suggestions from code review --- packages/kbn-optimizer/limits.yml | 2 +- .../interactive_setup/common/constants.ts | 9 ++ src/plugins/interactive_setup/common/index.ts | 1 + src/plugins/interactive_setup/public/app.tsx | 10 +- .../public/cluster_address_form.tsx | 2 +- .../public/cluster_configuration_form.tsx | 27 ++-- .../public/enrollment_token_form.tsx | 33 ++-- .../interactive_setup/public/plugin.tsx | 19 ++- .../public/single_chars_field.tsx | 137 ++++++++++++++++ .../interactive_setup/public/use_form.ts | 97 ++++++----- .../public/use_verification.tsx | 84 ++++++++++ .../public/use_visibility.ts | 19 +++ .../public/verification_code_form.tsx | 153 ++++++++++++++++++ .../server/kibana_config_writer.ts | 2 +- .../interactive_setup/server/plugin.ts | 17 +- .../server/routes/configure.test.ts | 52 +++++- .../server/routes/configure.ts | 9 +- .../server/routes/enroll.test.ts | 59 +++++-- .../interactive_setup/server/routes/enroll.ts | 6 + .../server/routes/index.mock.ts | 2 + .../interactive_setup/server/routes/index.ts | 8 +- .../server/routes/verify.test.ts | 85 ++++++++++ .../interactive_setup/server/routes/verify.ts | 41 +++++ .../server/verification_code.mock.ts | 19 +++ .../server/verification_code.test.ts | 71 ++++++++ .../server/verification_code.ts | 87 ++++++++++ 26 files changed, 946 insertions(+), 105 deletions(-) create mode 100644 src/plugins/interactive_setup/common/constants.ts create mode 100644 src/plugins/interactive_setup/public/single_chars_field.tsx create mode 100644 src/plugins/interactive_setup/public/use_verification.tsx create mode 100644 src/plugins/interactive_setup/public/use_visibility.ts create mode 100644 src/plugins/interactive_setup/public/verification_code_form.tsx create mode 100644 src/plugins/interactive_setup/server/routes/verify.test.ts create mode 100644 src/plugins/interactive_setup/server/routes/verify.ts create mode 100644 src/plugins/interactive_setup/server/verification_code.mock.ts create mode 100644 src/plugins/interactive_setup/server/verification_code.test.ts create mode 100644 src/plugins/interactive_setup/server/verification_code.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 77bbeabb7f73b..7e6a1a9350d81 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,7 +112,7 @@ pageLoadAssetSize: expressionImage: 19288 expressionMetric: 22238 expressionShape: 34008 - interactiveSetup: 70000 + interactiveSetup: 80000 expressionTagcloud: 27505 expressions: 239290 securitySolution: 231753 diff --git a/src/plugins/interactive_setup/common/constants.ts b/src/plugins/interactive_setup/common/constants.ts new file mode 100644 index 0000000000000..00a3efc316cd9 --- /dev/null +++ b/src/plugins/interactive_setup/common/constants.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 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 const VERIFICATION_CODE_LENGTH = 6; diff --git a/src/plugins/interactive_setup/common/index.ts b/src/plugins/interactive_setup/common/index.ts index ab8c00cfa5a8e..3833873eb2a18 100644 --- a/src/plugins/interactive_setup/common/index.ts +++ b/src/plugins/interactive_setup/common/index.ts @@ -8,3 +8,4 @@ export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types'; export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status'; +export { VERIFICATION_CODE_LENGTH } from './constants'; diff --git a/src/plugins/interactive_setup/public/app.tsx b/src/plugins/interactive_setup/public/app.tsx index 0c206cb4fa215..da1318d84cf03 100644 --- a/src/plugins/interactive_setup/public/app.tsx +++ b/src/plugins/interactive_setup/public/app.tsx @@ -20,7 +20,11 @@ import { ClusterConfigurationForm } from './cluster_configuration_form'; import { EnrollmentTokenForm } from './enrollment_token_form'; import { ProgressIndicator } from './progress_indicator'; -export const App: FunctionComponent = () => { +export interface AppProps { + onSuccess?(): void; +} + +export const App: FunctionComponent = ({ onSuccess }) => { const [page, setPage] = useState<'token' | 'manual' | 'success'>('token'); const [cluster, setCluster] = useState< Omit @@ -71,9 +75,7 @@ export const App: FunctionComponent = () => { /> )} - {page === 'success' && ( - window.location.replace(window.location.href)} /> - )} + {page === 'success' && } diff --git a/src/plugins/interactive_setup/public/cluster_address_form.tsx b/src/plugins/interactive_setup/public/cluster_address_form.tsx index ba7b1d46182a1..6f97680066373 100644 --- a/src/plugins/interactive_setup/public/cluster_address_form.tsx +++ b/src/plugins/interactive_setup/public/cluster_address_form.tsx @@ -51,7 +51,7 @@ export const ClusterAddressForm: FunctionComponent = ({ const [form, eventHandlers] = useForm({ defaultValues, validate: async (values) => { - const errors: ValidationErrors = {}; + const errors: ValidationErrors = {}; if (!values.host) { errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', { diff --git a/src/plugins/interactive_setup/public/cluster_configuration_form.tsx b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx index cd3541fe0318f..dfb5148ddb288 100644 --- a/src/plugins/interactive_setup/public/cluster_configuration_form.tsx +++ b/src/plugins/interactive_setup/public/cluster_configuration_form.tsx @@ -26,6 +26,7 @@ import { } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -37,6 +38,8 @@ import type { ValidationErrors } from './use_form'; import { useForm } from './use_form'; import { useHtmlId } from './use_html_id'; import { useHttp } from './use_http'; +import { useVerification } from './use_verification'; +import { useVisibility } from './use_visibility'; export interface ClusterConfigurationFormValues { username: string; @@ -66,10 +69,10 @@ export const ClusterConfigurationForm: FunctionComponent { const http = useHttp(); - + const { status, getCode } = useVerification(); const [form, eventHandlers] = useForm({ defaultValues, - validate: async (values) => { + validate: (values) => { const errors: ValidationErrors = {}; if (authRequired) { @@ -93,7 +96,7 @@ export const ClusterConfigurationForm: FunctionComponent(); const trustCaCertId = useHtmlId('clusterConfigurationForm', 'trustCaCert'); + useUpdateEffect(() => { + if (status === 'verified' && isVisible) { + form.submit(); + } + }, [status]); + return ( - {form.submitError && ( + {status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && ( <> )} - - {authRequired ? ( <> )} - {certificateChain && certificateChain.length > 0 && ( <> { const intermediateCa = certificateChain[Math.min(1, certificateChain.length - 1)]; - form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw); form.setTouched('caCert'); + form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw); }} > @@ -252,7 +259,6 @@ export const ClusterConfigurationForm: FunctionComponent )} - @@ -264,6 +270,7 @@ export const ClusterConfigurationForm: FunctionComponent = onSuccess, }) => { const http = useHttp(); + const { status, getCode } = useVerification(); const [form, eventHandlers] = useForm({ defaultValues, validate: (values) => { @@ -77,17 +80,25 @@ export const EnrollmentTokenForm: FunctionComponent = hosts: decoded.adr, apiKey: decoded.key, caFingerprint: decoded.fgr, + code: getCode(), }), }); onSuccess?.(); }, }); + const [isVisible, buttonRef] = useVisibility(); + + useUpdateEffect(() => { + if (status === 'verified' && isVisible) { + form.submit(); + } + }, [status]); const enrollmentToken = decodeEnrollmentToken(form.values.token); return ( - {form.submitError && ( + {status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && ( <> = = ( defaultMessage="Connect to" /> - - - - {token.adr[0]} - - - - - - + + {token.adr[0]} + diff --git a/src/plugins/interactive_setup/public/plugin.tsx b/src/plugins/interactive_setup/public/plugin.tsx index 00fd38d3e78a4..9d58479081234 100644 --- a/src/plugins/interactive_setup/public/plugin.tsx +++ b/src/plugins/interactive_setup/public/plugin.tsx @@ -15,6 +15,7 @@ import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public'; import { App } from './app'; import { HttpProvider } from './use_http'; +import { VerificationProvider } from './use_verification'; export class InteractiveSetupPlugin implements Plugin { public setup(core: CoreSetup) { @@ -24,9 +25,16 @@ export class InteractiveSetupPlugin implements Plugin { appRoute: '/', chromeless: true, mount: (params) => { + const url = new URL(window.location.href); + const defaultCode = url.searchParams.get('code') || undefined; + const onSuccess = () => { + url.searchParams.delete('code'); + window.location.replace(url.href); + }; + ReactDOM.render( - - + + , params.element ); @@ -40,10 +48,13 @@ export class InteractiveSetupPlugin implements Plugin { export interface ProvidersProps { http: HttpSetup; + defaultCode?: string; } -export const Providers: FunctionComponent = ({ http, children }) => ( +export const Providers: FunctionComponent = ({ defaultCode, http, children }) => ( - {children} + + {children} + ); diff --git a/src/plugins/interactive_setup/public/single_chars_field.tsx b/src/plugins/interactive_setup/public/single_chars_field.tsx new file mode 100644 index 0000000000000..8d5cd2854c0aa --- /dev/null +++ b/src/plugins/interactive_setup/public/single_chars_field.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { FunctionComponent, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import useList from 'react-use/lib/useList'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; + +export interface SingleCharsFieldProps { + defaultValue: string; + length: number; + separator?: number; + pattern?: RegExp; + onChange(value: string): void; + isInvalid?: boolean; + autoFocus?: boolean; +} + +export const SingleCharsField: FunctionComponent = ({ + defaultValue, + length, + separator, + pattern = /^[0-9]$/, + onChange, + isInvalid, + autoFocus = false, +}) => { + // Strip any invalid characters from input or clipboard and restrict length. + const sanitize = (str: string) => { + return str + .split('') + .filter((char) => char.match(pattern)) + .join('') + .substr(0, length); + }; + + const inputRefs = useRef>([]); + const [chars, { set, updateAt }] = useList(sanitize(defaultValue).split('')); + + const focusField = (i: number) => { + const input = inputRefs.current[i]; + if (input) { + input.focus(); + } + }; + + // Trigger `onChange` callback when characters change + useUpdateEffect(() => { + onChange(chars.join('')); + }, [chars]); + + // Focus first field on initial render + useEffect(() => { + if (autoFocus) { + focusField(0); + } + }, [autoFocus]); + + const children: ReactNode[] = []; + for (let i = 0; i < length; i++) { + if (separator && i !== 0 && i % separator === 0) { + children.push( + + ); + } + + children.push( + + { + inputRefs.current[i] = el; + }} + value={chars[i] ?? ''} + onChange={(event) => { + // Ensure event doesn't bubble up since we manage our own `onChange` event + event.stopPropagation(); + }} + onInput={(event) => { + // Ignore input if invalid character was entered (unless empty) + if (event.currentTarget.value !== '' && sanitize(event.currentTarget.value) === '') { + return event.preventDefault(); + } + updateAt(i, event.currentTarget.value); + // Do not focus the next field if value is empty (e.g. when hitting backspace) + if (event.currentTarget.value) { + focusField(i + 1); + } + }} + onKeyDown={(event) => { + if (event.key === 'Backspace') { + // Clear previous field if current field is already empty + if (event.currentTarget.value === '') { + updateAt(i - 1, event.currentTarget.value); + focusField(i - 1); + } + } else if (event.key === 'ArrowLeft') { + focusField(i - 1); + } else if (event.key === 'ArrowRight') { + focusField(i + 1); + } + }} + onPaste={(event) => { + const text = sanitize(event.clipboardData.getData('text')); + set(text.split('')); + focusField(Math.min(text.length, length - 1)); + event.preventDefault(); + }} + onFocus={(event) => { + const input = event.currentTarget; + setTimeout(() => input.select(), 0); + }} + maxLength={1} + isInvalid={isInvalid} + style={{ textAlign: 'center' }} + /> + + ); + } + + return ( + + {children} + + ); +}; diff --git a/src/plugins/interactive_setup/public/use_form.ts b/src/plugins/interactive_setup/public/use_form.ts index 8ed1d89ea087e..abd00edee6750 100644 --- a/src/plugins/interactive_setup/public/use_form.ts +++ b/src/plugins/interactive_setup/public/use_form.ts @@ -9,7 +9,7 @@ import { set } from '@elastic/safer-lodash-set'; import { cloneDeep, cloneDeepWith, get } from 'lodash'; import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react'; -import { useRef } from 'react'; +import { useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; export type FormReturnTuple = [FormState, FormProps]; @@ -81,12 +81,11 @@ export type ValidationErrors = DeepMap; export type TouchedFields = DeepMap; export interface FormState { - setValue(name: string, value: any, revalidate?: boolean): Promise; + setValue(name: string, value: any): Promise; setError(name: string, message: string): void; - setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise; - reset(values: Values): void; + setTouched(name: string): Promise; + reset(values?: Values): void; submit(): Promise; - validate(): Promise>; values: Values; errors: ValidationErrors; touched: TouchedFields; @@ -123,63 +122,75 @@ export function useFormState({ validate, defaultValues, }: FormOptions): FormState { - const valuesRef = useRef(defaultValues); - const errorsRef = useRef>({}); - const touchedRef = useRef>({}); - const submitCountRef = useRef(0); - - const [validationState, validateForm] = useAsyncFn(async (formValues: Values) => { + const [values, setValues] = useState(defaultValues); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); + + async function validateFormFn(formValues: Values): Promise; + async function validateFormFn(formValues: undefined): Promise; + async function validateFormFn(formValues: Values | undefined) { + // Allows resetting `useAsyncFn` state + if (!formValues) { + return Promise.resolve(undefined); + } const nextErrors = await validate(formValues); - errorsRef.current = nextErrors; + setErrors(nextErrors); if (Object.keys(nextErrors).length === 0) { - submitCountRef.current = 0; + setSubmitCount(0); } return nextErrors; - }, []); + } - const [submitState, submitForm] = useAsyncFn(async (formValues: Values) => { + async function submitFormFn(formValues: Values): Promise; + async function submitFormFn(formValues: undefined): Promise; + async function submitFormFn(formValues: Values | undefined) { + // Allows resetting `useAsyncFn` state + if (!formValues) { + return Promise.resolve(undefined); + } const nextErrors = await validateForm(formValues); - touchedRef.current = mapDeep(formValues, true); - submitCountRef.current += 1; + setTouched(mapDeep(formValues, true)); + setSubmitCount(submitCount + 1); if (Object.keys(nextErrors).length === 0) { return onSubmit(formValues); } - }, []); + } + + const [validationState, validateForm] = useAsyncFn(validateFormFn, [validate]); + const [submitState, submitForm] = useAsyncFn(submitFormFn, [validateForm, onSubmit]); return { - setValue: async (name, value, revalidate = true) => { - const nextValues = setDeep(valuesRef.current, name, value); - valuesRef.current = nextValues; - if (revalidate) { - await validateForm(nextValues); - } + setValue: async (name, value) => { + const nextValues = setDeep(values, name, value); + setValues(nextValues); + await validateForm(nextValues); }, - setTouched: async (name, touched = true, revalidate = true) => { - touchedRef.current = setDeep(touchedRef.current, name, touched); - if (revalidate) { - await validateForm(valuesRef.current); - } + setTouched: async (name, value = true) => { + setTouched(setDeep(touched, name, value)); + await validateForm(values); }, setError: (name, message) => { - errorsRef.current = setDeep(errorsRef.current, name, message); - touchedRef.current = setDeep(touchedRef.current, name, true); + setErrors(setDeep(errors, name, message)); + setTouched(setDeep(touched, name, true)); }, - reset: (nextValues) => { - valuesRef.current = nextValues; - errorsRef.current = {}; - touchedRef.current = {}; - submitCountRef.current = 0; + reset: (nextValues = defaultValues) => { + setValues(nextValues); + setErrors({}); + setTouched({}); + setSubmitCount(0); + validateForm(undefined); // Resets `validationState` + submitForm(undefined); // Resets `submitState` }, - submit: () => submitForm(valuesRef.current), - validate: () => validateForm(valuesRef.current), - values: valuesRef.current, - errors: errorsRef.current, - touched: touchedRef.current, + submit: () => submitForm(values), + values, + errors, + touched, isValidating: validationState.loading, isSubmitting: submitState.loading, submitError: submitState.error, - isInvalid: Object.keys(errorsRef.current).length > 0, - isSubmitted: submitCountRef.current > 0, + isInvalid: Object.keys(errors).length > 0, + isSubmitted: submitCount > 0, }; } diff --git a/src/plugins/interactive_setup/public/use_verification.tsx b/src/plugins/interactive_setup/public/use_verification.tsx new file mode 100644 index 0000000000000..62483ba9cb62e --- /dev/null +++ b/src/plugins/interactive_setup/public/use_verification.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiModal, EuiModalHeader } from '@elastic/eui'; +import constate from 'constate'; +import type { FunctionComponent } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + +import { useHttp } from './use_http'; +import { VerificationCodeForm } from './verification_code_form'; + +export interface VerificationProps { + defaultCode?: string; +} + +const [OuterVerificationProvider, useVerification] = constate( + ({ defaultCode }: VerificationProps) => { + const codeRef = useRef(defaultCode); + const [status, setStatus] = useState<'unknown' | 'unverified' | 'verified'>('unknown'); + + return { + status, + setStatus, + getCode() { + return codeRef.current; + }, + setCode(code: string | undefined) { + codeRef.current = code; + }, + }; + } +); + +const InnerVerificationProvider: FunctionComponent = ({ children }) => { + const http = useHttp(); + const { status, setStatus, setCode } = useVerification(); + + useEffect(() => { + return http.intercept({ + responseError: (error) => { + if (error.response?.status === 403) { + setStatus('unverified'); + setCode(undefined); + } + }, + }); + }, [http]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + {status === 'unverified' && ( + setStatus('unknown')}> + + { + setStatus('verified'); + setCode(values.code); + }} + /> + + + )} + {children} + + ); +}; + +export const VerificationProvider: FunctionComponent = ({ + defaultCode, + children, +}) => { + return ( + + {children} + + ); +}; + +export { useVerification }; diff --git a/src/plugins/interactive_setup/public/use_visibility.ts b/src/plugins/interactive_setup/public/use_visibility.ts new file mode 100644 index 0000000000000..f21b5669a36aa --- /dev/null +++ b/src/plugins/interactive_setup/public/use_visibility.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RefObject } from 'react'; +import { useRef } from 'react'; + +export type VisibilityReturnTuple = [boolean, RefObject]; + +export function useVisibility(): VisibilityReturnTuple { + const elementRef = useRef(null); + + // When an element is hidden using `display: none` or `hidden` attribute it has no offset parent. + return [!!elementRef.current?.offsetParent, elementRef]; +} diff --git a/src/plugins/interactive_setup/public/verification_code_form.tsx b/src/plugins/interactive_setup/public/verification_code_form.tsx new file mode 100644 index 0000000000000..8f4a9ea8c5d01 --- /dev/null +++ b/src/plugins/interactive_setup/public/verification_code_form.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiButton, + EuiCallOut, + EuiEmptyPrompt, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { IHttpFetchError } from 'kibana/public'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; +import { SingleCharsField } from './single_chars_field'; +import type { ValidationErrors } from './use_form'; +import { useForm } from './use_form'; +import { useHttp } from './use_http'; + +export interface VerificationCodeFormValues { + code: string; +} + +export interface VerificationCodeFormProps { + defaultValues?: VerificationCodeFormValues; + onSuccess?(values: VerificationCodeFormValues): void; +} + +export const VerificationCodeForm: FunctionComponent = ({ + defaultValues = { + code: '', + }, + onSuccess, +}) => { + const http = useHttp(); + const [form, eventHandlers] = useForm({ + defaultValues, + validate: async (values) => { + const errors: ValidationErrors = {}; + + if (!values.code) { + errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeRequiredError', { + defaultMessage: 'Enter a verification code.', + }); + } else if (values.code.length !== VERIFICATION_CODE_LENGTH) { + errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeMinLengthError', { + defaultMessage: 'Enter all six digits.', + }); + } + + return errors; + }, + onSubmit: async (values) => { + try { + await http.post('/internal/interactive_setup/verify', { + body: JSON.stringify({ + code: values.code, + }), + }); + } catch (error) { + if (error.response?.status === 403) { + form.setError('code', error.body?.message); + return; + } else { + throw error; + } + } + onSuccess?.(values); + }, + }); + + return ( + + + + + } + body={ + <> + {form.submitError && ( + <> + + {(form.submitError as IHttpFetchError).body?.message} + + + + )} + +

+ +

+
+ + + + form.setValue('code', value)} + isInvalid={form.touched.code && !!form.errors.code} + autoFocus + /> + + + } + actions={ + + + + } + /> +
+ ); +}; diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts index a59aa7640caa6..6271e2d78471f 100644 --- a/src/plugins/interactive_setup/server/kibana_config_writer.ts +++ b/src/plugins/interactive_setup/server/kibana_config_writer.ts @@ -75,7 +75,7 @@ export class KibanaConfigWriter { } } - const config: Record = { 'elasticsearch.hosts': [params.host] }; + const config: Record = { 'elasticsearch.hosts': [params.host] }; if ('serviceAccountToken' in params) { config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value; } else if ('username' in params) { diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 91a151e17b697..7f4d36385b28c 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -17,10 +17,12 @@ import type { ConfigSchema, ConfigType } from './config'; import { ElasticsearchService } from './elasticsearch_service'; import { KibanaConfigWriter } from './kibana_config_writer'; import { defineRoutes } from './routes'; +import { VerificationCode } from './verification_code'; export class InteractiveSetupPlugin implements PrebootPlugin { readonly #logger: Logger; readonly #elasticsearch: ElasticsearchService; + readonly #verificationCode: VerificationCode; #elasticsearchConnectionStatusSubscription?: Subscription; @@ -38,6 +40,9 @@ export class InteractiveSetupPlugin implements PrebootPlugin { this.#elasticsearch = new ElasticsearchService( this.initializerContext.logger.get('elasticsearch') ); + this.#verificationCode = new VerificationCode( + this.initializerContext.logger.get('verification') + ); } public setup(core: CorePreboot) { @@ -92,13 +97,18 @@ export class InteractiveSetupPlugin implements PrebootPlugin { this.#logger.debug( 'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.' ); - const serverInfo = core.http.getServerInfo(); - const url = `${serverInfo.protocol}://${serverInfo.hostname}:${serverInfo.port}`; - this.#logger.info(` + const { code } = this.#verificationCode; + const pathname = core.http.basePath.prepend('/'); + const { protocol, hostname, port } = core.http.getServerInfo(); + const url = `${protocol}://${hostname}:${port}${pathname}?code=${code}`; + + // eslint-disable-next-line no-console + console.log(` ${chalk.whiteBright.bold(`${chalk.cyanBright('i')} Kibana has not been configured.`)} Go to ${chalk.cyanBright.underline(url)} to get started. + `); } } @@ -118,6 +128,7 @@ Go to ${chalk.cyanBright.underline(url)} to get started. preboot: { ...core.preboot, completeSetup }, kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')), elasticsearch, + verificationCode: this.#verificationCode, getConfig: this.#getConfig.bind(this), }); }); diff --git a/src/plugins/interactive_setup/server/routes/configure.test.ts b/src/plugins/interactive_setup/server/routes/configure.test.ts index d6b7404fce516..ac4507331db4b 100644 --- a/src/plugins/interactive_setup/server/routes/configure.test.ts +++ b/src/plugins/interactive_setup/server/routes/configure.test.ts @@ -57,7 +57,11 @@ describe('Configure routes', () => { expect(() => bodySchema.validate({ host: 'localhost:9200' }) ).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`); - expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError(); + expect(bodySchema.validate({ host: 'http://localhost:9200' })).toMatchInlineSnapshot(` + Object { + "host": "http://localhost:9200", + } + `); expect(() => bodySchema.validate({ host: 'http://localhost:9200', username: 'elastic' }) ).toThrowErrorMatchingInlineSnapshot( @@ -71,21 +75,57 @@ describe('Configure routes', () => { expect(() => bodySchema.validate({ host: 'http://localhost:9200', password: 'password' }) ).toThrowErrorMatchingInlineSnapshot(`"[password]: a value wasn't expected to be present"`); - expect(() => + expect( bodySchema.validate({ host: 'http://localhost:9200', username: 'kibana_system', password: '', }) - ).not.toThrowError(); + ).toMatchInlineSnapshot(` + Object { + "host": "http://localhost:9200", + "password": "", + "username": "kibana_system", + } + `); expect(() => bodySchema.validate({ host: 'https://localhost:9200' }) ).toThrowErrorMatchingInlineSnapshot( `"[caCert]: expected value of type [string] but got [undefined]"` ); - expect(() => - bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' }) - ).not.toThrowError(); + expect(bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' })) + .toMatchInlineSnapshot(` + Object { + "caCert": "der", + "host": "https://localhost:9200", + } + `); + expect(bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der', code: '123456' })) + .toMatchInlineSnapshot(` + Object { + "caCert": "der", + "code": "123456", + "host": "https://localhost:9200", + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { host: 'host1' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + + expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); }); it('fails if setup is not on hold.', async () => { diff --git a/src/plugins/interactive_setup/server/routes/configure.ts b/src/plugins/interactive_setup/server/routes/configure.ts index a34af0296ea04..75499d048cf93 100644 --- a/src/plugins/interactive_setup/server/routes/configure.ts +++ b/src/plugins/interactive_setup/server/routes/configure.ts @@ -21,15 +21,13 @@ export function defineConfigureRoute({ logger, kibanaConfigWriter, elasticsearch, + verificationCode, preboot, }: RouteDefinitionParams) { router.post( { path: '/internal/interactive_setup/configure', validate: { - query: schema.object({ - code: schema.maybe(schema.string()), - }), body: schema.object({ host: schema.uri({ scheme: ['http', 'https'] }), username: schema.maybe( @@ -56,11 +54,16 @@ export function defineConfigureRoute({ schema.string(), schema.never() ), + code: schema.maybe(schema.string()), }), }, options: { authRequired: false }, }, async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden(); + } + if (!preboot.isSetupOnHold()) { logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); diff --git a/src/plugins/interactive_setup/server/routes/enroll.test.ts b/src/plugins/interactive_setup/server/routes/enroll.test.ts index e42248704134a..859c3fb70ce83 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.test.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.test.ts @@ -95,18 +95,55 @@ describe('Enroll routes', () => { ); expect( - bodySchema.validate( - bodySchema.validate({ - apiKey: 'some-key', - hosts: ['https://localhost:9200'], - caFingerprint: 'a'.repeat(64), - }) - ) - ).toEqual({ - apiKey: 'some-key', - hosts: ['https://localhost:9200'], - caFingerprint: 'a'.repeat(64), + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + }) + ).toMatchInlineSnapshot(` + Object { + "apiKey": "some-key", + "caFingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hosts": Array [ + "https://localhost:9200", + ], + } + `); + expect( + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + code: '123456', + }) + ).toMatchInlineSnapshot(` + Object { + "apiKey": "some-key", + "caFingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "code": "123456", + "hosts": Array [ + "https://localhost:9200", + ], + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' }, }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + + expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); }); it('fails if setup is not on hold.', async () => { diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index 41291246802e6..769d763a7d45d 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -23,6 +23,7 @@ export function defineEnrollRoutes({ logger, kibanaConfigWriter, elasticsearch, + verificationCode, preboot, }: RouteDefinitionParams) { router.post( @@ -35,11 +36,16 @@ export function defineEnrollRoutes({ }), apiKey: schema.string({ minLength: 1 }), caFingerprint: schema.string({ maxLength: 64, minLength: 64 }), + code: schema.maybe(schema.string()), }), }, options: { authRequired: false }, }, async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden(); + } + if (!preboot.isSetupOnHold()) { logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); diff --git a/src/plugins/interactive_setup/server/routes/index.mock.ts b/src/plugins/interactive_setup/server/routes/index.mock.ts index 249d1277269e7..15ec86031b6f2 100644 --- a/src/plugins/interactive_setup/server/routes/index.mock.ts +++ b/src/plugins/interactive_setup/server/routes/index.mock.ts @@ -11,6 +11,7 @@ import { coreMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mo import { ConfigSchema } from '../config'; import { elasticsearchServiceMock } from '../elasticsearch_service.mock'; import { kibanaConfigWriterMock } from '../kibana_config_writer.mock'; +import { verificationCodeMock } from '../verification_code.mock'; export const routeDefinitionParamsMock = { create: (config: Record = {}) => ({ @@ -21,6 +22,7 @@ export const routeDefinitionParamsMock = { preboot: { ...coreMock.createPreboot().preboot, completeSetup: jest.fn() }, getConfig: jest.fn().mockReturnValue(ConfigSchema.validate(config)), elasticsearch: elasticsearchServiceMock.createSetup(), + verificationCode: verificationCodeMock.create(), kibanaConfigWriter: kibanaConfigWriterMock.create(), }), }; diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts index 75c383176e7e9..fb9e06c4c2a18 100644 --- a/src/plugins/interactive_setup/server/routes/index.ts +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -6,15 +6,17 @@ * Side Public License, v 1. */ -import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core/server'; import type { ConfigType } from '../config'; import type { ElasticsearchServiceSetup } from '../elasticsearch_service'; import type { KibanaConfigWriter } from '../kibana_config_writer'; +import type { VerificationCode } from '../verification_code'; import { defineConfigureRoute } from './configure'; import { defineEnrollRoutes } from './enroll'; import { definePingRoute } from './ping'; +import { defineVerifyRoute } from './verify'; /** * Describes parameters used to define HTTP routes. @@ -28,11 +30,13 @@ export interface RouteDefinitionParams { }; readonly kibanaConfigWriter: PublicMethodsOf; readonly elasticsearch: ElasticsearchServiceSetup; + readonly verificationCode: PublicContract; readonly getConfig: () => ConfigType; } export function defineRoutes(params: RouteDefinitionParams) { - defineEnrollRoutes(params); defineConfigureRoute(params); + defineEnrollRoutes(params); definePingRoute(params); + defineVerifyRoute(params); } diff --git a/src/plugins/interactive_setup/server/routes/verify.test.ts b/src/plugins/interactive_setup/server/routes/verify.test.ts new file mode 100644 index 0000000000000..ff8a7753320c2 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/verify.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ObjectType } from '@kbn/config-schema'; +import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { routeDefinitionParamsMock } from './index.mock'; +import { defineVerifyRoute } from './verify'; + +describe('Configure routes', () => { + let router: jest.Mocked; + let mockRouteParams: ReturnType; + let mockContext: RequestHandlerContext; + beforeEach(() => { + mockRouteParams = routeDefinitionParamsMock.create(); + router = mockRouteParams.router; + + mockContext = ({} as unknown) as RequestHandlerContext; + + defineVerifyRoute(mockRouteParams); + }); + + describe('#verify', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + beforeEach(() => { + const [verifyRouteConfig, verifyRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/interactive_setup/verify' + )!; + + routeConfig = verifyRouteConfig; + routeHandler = verifyRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[code]: expected value of type [string] but got [undefined]"` + ); + expect(bodySchema.validate({ code: '123456' })).toMatchInlineSnapshot(` + Object { + "code": "123456", + } + `); + }); + + it('fails if verification code is invalid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { code: '123456' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 403, + }) + ); + }); + + it('succeeds if verification code is valid.', async () => { + mockRouteParams.verificationCode.verify.mockReturnValue(true); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { code: '123456' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual( + expect.objectContaining({ + status: 204, + }) + ); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/routes/verify.ts b/src/plugins/interactive_setup/server/routes/verify.ts new file mode 100644 index 0000000000000..ebdbb58ed9530 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/verify.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; + +import type { RouteDefinitionParams } from '.'; + +export function defineVerifyRoute({ router, verificationCode }: RouteDefinitionParams) { + router.post( + { + path: '/internal/interactive_setup/verify', + validate: { + body: schema.object({ + code: schema.string(), + }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + if (!verificationCode.verify(request.body.code)) { + return response.forbidden({ + body: { + message: verificationCode.remainingAttempts + ? 'Invalid verification code.' + : 'Maximum number of attempts exceeded. Restart Kibana to generate a new code and retry.', + attributes: { + remainingAttempts: verificationCode.remainingAttempts, + }, + }, + }); + } + + return response.noContent(); + } + ); +} diff --git a/src/plugins/interactive_setup/server/verification_code.mock.ts b/src/plugins/interactive_setup/server/verification_code.mock.ts new file mode 100644 index 0000000000000..d4e9fc2028590 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PublicContract } from '@kbn/utility-types'; + +import type { VerificationCode } from './verification_code'; + +export const verificationCodeMock = { + create: (): jest.Mocked> => ({ + code: '123456', + remainingAttempts: 5, + verify: jest.fn().mockReturnValue(true), + }), +}; diff --git a/src/plugins/interactive_setup/server/verification_code.test.ts b/src/plugins/interactive_setup/server/verification_code.test.ts new file mode 100644 index 0000000000000..7387f285a2f62 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; +import { VerificationCode } from './verification_code'; + +const loggerMock = loggingSystemMock.createLogger(); + +describe('VerificationCode', () => { + it('should generate a 6 digit code', () => { + for (let i = 0; i < 10; i++) { + const { code } = new VerificationCode(loggerMock); + expect(code).toHaveLength(VERIFICATION_CODE_LENGTH); + expect(code).toEqual(expect.stringMatching(/^[0-9]+$/)); + } + }); + + it('should verify code correctly', () => { + const verificationCode = new VerificationCode(loggerMock); + + expect(verificationCode.verify(undefined)).toBe(false); + expect(verificationCode.verify('')).toBe(false); + expect(verificationCode.verify('invalid')).toBe(false); + expect(verificationCode.verify(verificationCode.code)).toBe(true); + }); + + it('should track number of failed attempts', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + expect(verificationCode['failedAttempts']).toBe(3); // eslint-disable-line dot-notation + }); + + it('should reset number of failed attempts if valid code is entered', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + verificationCode.verify('invalid'); + expect(verificationCode.verify(verificationCode.code)).toBe(true); + expect(verificationCode['failedAttempts']).toBe(0); // eslint-disable-line dot-notation + }); + + it('should permanently fail once maximum number of failed attempts has been reached', () => { + const verificationCode = new VerificationCode(loggerMock); + + // eslint-disable-next-line dot-notation + for (let i = 0; i < verificationCode['maxFailedAttempts']; i++) { + verificationCode.verify('invalid'); + } + expect(verificationCode.verify(verificationCode.code)).toBe(false); + }); + + it('should ignore empty calls in number of failed attempts', () => { + const verificationCode = new VerificationCode(loggerMock); + + verificationCode.verify(undefined); + verificationCode.verify(undefined); + verificationCode.verify(undefined); + expect(verificationCode['failedAttempts']).toBe(0); // eslint-disable-line dot-notation + }); +}); diff --git a/src/plugins/interactive_setup/server/verification_code.ts b/src/plugins/interactive_setup/server/verification_code.ts new file mode 100644 index 0000000000000..849ece5f4e0b0 --- /dev/null +++ b/src/plugins/interactive_setup/server/verification_code.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import chalk from 'chalk'; +import crypto from 'crypto'; + +import type { Logger } from 'src/core/server'; + +import { VERIFICATION_CODE_LENGTH } from '../common'; + +export class VerificationCode { + public readonly code = VerificationCode.generate(VERIFICATION_CODE_LENGTH); + private failedAttempts = 0; + private readonly maxFailedAttempts = 5; + + constructor(private readonly logger: Logger) {} + + public get remainingAttempts() { + return this.maxFailedAttempts - this.failedAttempts; + } + + public verify(code: string | undefined) { + if (this.failedAttempts >= this.maxFailedAttempts) { + this.logger.error( + 'Maximum number of attempts exceeded. Restart Kibana to generate a new code and retry.' + ); + return false; + } + + const highlightedCode = chalk.black.bgCyanBright( + ` ${this.code.substr(0, 3)} ${this.code.substr(3)} ` + ); + + if (code === undefined) { + // eslint-disable-next-line no-console + console.log(` + +Your verification code is: ${highlightedCode} + +`); + return false; + } + + if (code !== this.code) { + this.failedAttempts++; + this.logger.error( + `Invalid verification code '${code}' provided. ${this.remainingAttempts} attempts left.` + ); + // eslint-disable-next-line no-console + console.log(` + +Your verification code is: ${highlightedCode} + +`); + return false; + } + + this.logger.debug(`Code '${code}' verified successfully`); + + this.failedAttempts = 0; + return true; + } + + /** + * Returns a cryptographically secure and random 6-digit code. + * + * Implementation notes: `secureRandomNumber` returns a random number like `0.05505769583xxxx`. To + * turn that into a 6 digit code we multiply it by `10^6` and result is `055057`. + */ + private static generate(length: number) { + return Math.floor(secureRandomNumber() * Math.pow(10, length)) + .toString() + .padStart(length, '0'); + } +} + +/** + * Cryptographically secure equivalent of `Math.random()`. + */ +function secureRandomNumber() { + return crypto.randomBytes(4).readUInt32LE() / 0x100000000; +}