[Security Solution][Feat] Integrate Discover Tab in timeline (#160036)

## Summary

First step for https://github.com/elastic/security-team/issues/6677

Aim of this PR is embed Discover in Security Solution. Discover must be
embedded as a complete app with certain set of capabilities working. The
set capabilities that need to working are listed here :
https://github.com/elastic/security-team/issues/6673

Release notes should be based on
https://github.com/elastic/security-team/issues/6673

### ⚠️ Note
- These changes are only available in serverless mode of security
solution behind a feature-flag called `discoverInTimeline`. Adds below
options to `serverless.security.yml`:

```yaml

# Serverless security specific options
xpack.securitySolution.enableExperimental:
   - discoverInTimeline

```
You can use below command to run serverless instance of security
solution :
```bash
yarn serverless-security
```
  

This Implements following changes for each plugin.

### Discover
1. Exports Discover App as Lazy component.
2. Ability to override Discover Services.
3. Adds a parameter `mode` which switches off/on certain options based
on the `mode`. `Mode` has possible values of `embedded` and
`standalone`. For example, `embedded` switches off Discover breadcrumb
syncing, because consuming app may not need it.

### Unified Search
1. Ability to export a Search bar with custom depedency instances.
2. For example, today Unified Search uses a singleton global
`dataService` which store global KQL filters and queries. This
customization, let consumers of unified search to pass a new instance of
`dataService`.
4. Please see below diagram for more clarity.


### Navigation
1. Ability to export a custom stateful TopNav Menu which includes:
    - DataView picker
    - KQL Search Bar
    - TimeRange Selector
2. Currently navigation consumes an instance of unified service which
uses a global singleton `data` service.
3. This PR creates a new instance of unified search which is then passed
to navigation to get a custom instance of `TopNav` Menu.

### Security Solution
1. Imports Discover Container Component
2. Uses customization point to pass a custom query bar. 
3. Implements Custom KQL Query Bar with below customizations

![image](5313c108-0976-4a00-80b7-d03b9f69d15c)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Davis McPhee <davis.mcphee@elastic.co>
This commit is contained in:
Jatin Kathuria 2023-07-25 01:55:23 -07:00 committed by GitHub
parent 7143dcf2c3
commit 181eb39b70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 767 additions and 180 deletions

View file

@ -22,3 +22,8 @@ xpack.serverless.plugin.developer.projectSwitcher.currentType: 'security'
# Specify in telemetry the project type
telemetry.labels.serverless: security
# Serverless security specific options
xpack.securitySolution.enableExperimental:
- discoverInTimeline

View file

@ -19,6 +19,7 @@ import { DiscoverMainProvider } from '../../services/discover_state_provider';
import type { SearchBarCustomization, TopNavCustomization } from '../../../../customizations';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
import { useDiscoverCustomization } from '../../../../customizations';
setHeaderActionMenuMounter(jest.fn());
jest.mock('@kbn/kibana-react-plugin/public', () => ({
@ -28,6 +29,9 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
}),
}));
const MockCustomSearchBar: typeof mockDiscoverService.navigation.ui.AggregateQueryTopNavMenu =
() => <div data-test-subj="custom-search-bar" />;
const mockTopNavCustomization: TopNavCustomization = {
id: 'top_nav',
};
@ -37,24 +41,16 @@ const mockSearchBarCustomization: SearchBarCustomization = {
CustomDataViewPicker: jest.fn(() => <div data-test-subj="custom-data-view-picker" />),
};
const mockSearchBarCustomizationWithCustomSearchBar: SearchBarCustomization = {
id: 'search_bar',
CustomSearchBar: MockCustomSearchBar,
};
let mockUseCustomizations = false;
jest.mock('../../../../customizations', () => ({
...jest.requireActual('../../../../customizations'),
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
if (!mockUseCustomizations) {
return undefined;
}
switch (id) {
case 'top_nav':
return mockTopNavCustomization;
case 'search_bar':
return mockSearchBarCustomization;
default:
throw new Error(`Unknown customization id: ${id}`);
}
}),
useDiscoverCustomization: jest.fn(),
}));
function getProps(savePermissions = true): DiscoverTopNavProps {
@ -79,6 +75,23 @@ describe('Discover topnav component', () => {
mockTopNavCustomization.getMenuItems = undefined;
mockUseCustomizations = false;
jest.clearAllMocks();
(useDiscoverCustomization as jest.Mock).mockImplementation(
jest.fn((id: DiscoverCustomizationId) => {
if (!mockUseCustomizations) {
return undefined;
}
switch (id) {
case 'top_nav':
return mockTopNavCustomization;
case 'search_bar':
return mockSearchBarCustomization;
default:
throw new Error(`Unknown customization id: ${id}`);
}
})
);
});
test('generated config of TopNavMenu config is correct when discover save permissions are assigned', () => {
@ -175,6 +188,23 @@ describe('Discover topnav component', () => {
});
describe('search bar customization', () => {
it('should render custom Search Bar', () => {
(useDiscoverCustomization as jest.Mock).mockImplementation((id: DiscoverCustomizationId) => {
if (id === 'search_bar') {
return mockSearchBarCustomizationWithCustomSearchBar;
}
});
const props = getProps();
const component = mountWithIntl(
<DiscoverMainProvider value={props.stateContainer}>
<DiscoverTopNav {...props} />
</DiscoverMainProvider>
);
expect(component.find({ 'data-test-subj': 'custom-search-bar' })).toHaveLength(1);
});
it('should render CustomDataViewPicker', () => {
mockUseCustomizations = true;
const props = getProps();

View file

@ -201,8 +201,13 @@ export const DiscoverTopNav = ({
const searchBarCustomization = useDiscoverCustomization('search_bar');
const SearchBar = useMemo(
() => searchBarCustomization?.CustomSearchBar ?? AggregateQueryTopNavMenu,
[searchBarCustomization?.CustomSearchBar, AggregateQueryTopNavMenu]
);
return (
<AggregateQueryTopNavMenu
<SearchBar
appName="discover"
config={topNavMenu}
indexPatterns={[dataView]}

View file

@ -18,6 +18,7 @@ import { useSavedSearchAliasMatchRedirect } from '../../hooks/saved_search_alias
import { useSavedSearchInitial } from './services/discover_state_provider';
import { useAdHocDataViews } from './hooks/use_adhoc_data_views';
import { useTextBasedQueryLanguage } from './hooks/use_text_based_query_language';
import type { DiscoverDisplayMode } from '../types';
import { addLog } from '../../utils/add_log';
const DiscoverLayoutMemoized = React.memo(DiscoverLayout);
@ -27,10 +28,11 @@ export interface DiscoverMainProps {
* Central state container
*/
stateContainer: DiscoverStateContainer;
mode?: DiscoverDisplayMode;
}
export function DiscoverMainApp(props: DiscoverMainProps) {
const { stateContainer } = props;
const { stateContainer, mode = 'standalone' } = props;
const savedSearch = useSavedSearchInitial();
const services = useDiscoverServices();
const { chrome, docLinks, data, spaces, history } = services;
@ -60,13 +62,15 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
}, [stateContainer]);
/**
* SavedSearch dependend initializing
* SavedSearch dependent initializing
*/
useEffect(() => {
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
chrome.docTitle.change(`Discover${pageTitleSuffix}`);
setBreadcrumbsTitle({ title: savedSearch.title, services });
}, [chrome.docTitle, savedSearch.id, savedSearch.title, services]);
if (mode === 'standalone') {
const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : '';
chrome.docTitle.change(`Discover${pageTitleSuffix}`);
setBreadcrumbsTitle({ title: savedSearch.title, services });
}
}, [mode, chrome.docTitle, savedSearch.id, savedSearch.title, services]);
useEffect(() => {
addHelpMenuToAppChrome(chrome, docLinks);

View file

@ -34,6 +34,7 @@ import {
DiscoverCustomizationProvider,
useDiscoverCustomizationService,
} from '../../customizations';
import type { DiscoverDisplayMode } from '../types';
const DiscoverMainAppMemoized = memo(DiscoverMainApp);
@ -44,9 +45,14 @@ interface DiscoverLandingParams {
export interface MainRouteProps {
customizationCallbacks: CustomizationCallback[];
isDev: boolean;
mode?: DiscoverDisplayMode;
}
export function DiscoverMainRoute({ customizationCallbacks, isDev }: MainRouteProps) {
export function DiscoverMainRoute({
customizationCallbacks,
isDev,
mode = 'standalone',
}: MainRouteProps) {
const history = useHistory();
const services = useDiscoverServices();
const {
@ -146,21 +152,21 @@ export function DiscoverMainRoute({ customizationCallbacks, isDev }: MainRoutePr
dataView: nextDataView,
dataViewSpec: historyLocationState?.dataViewSpec,
});
if (mode === 'standalone') {
if (currentSavedSearch?.id) {
chrome.recentlyAccessed.add(
getSavedSearchFullPathUrl(currentSavedSearch.id),
currentSavedSearch.title ?? '',
currentSavedSearch.id
);
}
if (currentSavedSearch?.id) {
chrome.recentlyAccessed.add(
getSavedSearchFullPathUrl(currentSavedSearch.id),
currentSavedSearch.title ?? '',
currentSavedSearch.id
chrome.setBreadcrumbs(
currentSavedSearch && currentSavedSearch.title
? getSavedSearchBreadcrumbs({ id: currentSavedSearch.title, services })
: getRootBreadcrumbs({ services })
);
}
chrome.setBreadcrumbs(
currentSavedSearch && currentSavedSearch.title
? getSavedSearchBreadcrumbs({ id: currentSavedSearch.title, services })
: getRootBreadcrumbs({ services })
);
setLoading(false);
if (services.analytics) {
const loadSavedSearchDuration = window.performance.now() - loadSavedSearchStartTime;
@ -205,6 +211,7 @@ export function DiscoverMainRoute({ customizationCallbacks, isDev }: MainRoutePr
core.theme,
basePath,
toastNotifications,
mode,
]
);
@ -278,8 +285,10 @@ export function DiscoverMainRoute({ customizationCallbacks, isDev }: MainRoutePr
return (
<DiscoverCustomizationProvider value={customizationService}>
<DiscoverMainProvider value={stateContainer}>
<DiscoverMainAppMemoized stateContainer={stateContainer} />
<DiscoverMainAppMemoized stateContainer={stateContainer} mode={mode} />
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
);
}
// eslint-disable-next-line import/no-default-export
export default DiscoverMainRoute;

View file

@ -13,3 +13,5 @@ export enum FetchStatus {
COMPLETE = 'complete',
ERROR = 'error',
}
export type DiscoverDisplayMode = 'embedded' | 'standalone';

View file

@ -0,0 +1,93 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { DiscoverServices } from '../../build_services';
import DiscoverContainerInternal, { DiscoverContainerInternalProps } from './discover_container';
import { ScopedHistory } from '@kbn/core-application-browser';
import { discoverServiceMock } from '../../__mocks__/services';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
const mockOverrideService = {};
const getDiscoverServicesMock = jest.fn(
() => new Promise<DiscoverServices>((resolve) => resolve(discoverServiceMock))
);
jest.mock('../../application/main', () => {
return {
DiscoverMainRoute: () => <></>,
};
});
jest.mock('@kbn/kibana-react-plugin/public');
const { history } = discoverServiceMock;
const customizeMock = jest.fn();
const TestComponent = (props: Partial<DiscoverContainerInternalProps>) => {
return (
<DiscoverContainerInternal
overrideServices={props.overrideServices ?? mockOverrideService}
customize={props.customize ?? customizeMock}
isDev={props.isDev ?? false}
scopedHistory={props.scopedHistory ?? (history() as ScopedHistory<unknown>)}
getDiscoverServices={getDiscoverServicesMock}
/>
);
};
const TEST_IDS = {
DISCOVER_CONTAINER_INTERNAL: 'data-container-internal-wrapper',
};
describe('DiscoverContainerInternal should render properly', () => {
beforeAll(() => {
(KibanaContextProvider as jest.Mock).mockImplementation(() => <></>);
});
afterEach(() => jest.clearAllMocks());
it('should render', async () => {
const { getByTestId, queryByTestId } = render(<TestComponent />);
expect(queryByTestId(TEST_IDS.DISCOVER_CONTAINER_INTERNAL)).not.toBeInTheDocument();
expect(getDiscoverServicesMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getByTestId(TEST_IDS.DISCOVER_CONTAINER_INTERNAL)).toBeInTheDocument();
});
});
it('should render with overrideServices', async () => {
const overrideServices: Partial<DiscoverServices> = {
data: {
...dataPluginMock.createStartContract(),
// @ts-expect-error
_name: 'custom',
},
};
render(<TestComponent overrideServices={overrideServices} />);
await waitFor(() => {
expect(KibanaContextProvider as jest.Mock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
services: expect.objectContaining({
data: expect.objectContaining({
_name: 'custom',
}),
}),
}),
{}
);
});
});
});

View file

@ -0,0 +1,95 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { ScopedHistory } from '@kbn/core/public';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import React, { useEffect, useMemo, useState } from 'react';
import { DiscoverMainRoute } from '../../application/main';
import type { DiscoverServices } from '../../build_services';
import type { CustomizationCallback } from '../../customizations';
import { setHeaderActionMenuMounter, setScopedHistory } from '../../kibana_services';
import { LoadingIndicator } from '../common/loading_indicator';
export interface DiscoverContainerInternalProps {
/*
* Any override that user of this hook
* wants discover to use. Need to keep in mind that this
* param is only for overrides for the services that Discover
* already consumes.
*/
overrideServices: Partial<DiscoverServices>;
getDiscoverServices: () => Promise<DiscoverServices>;
scopedHistory: ScopedHistory;
customize: CustomizationCallback;
isDev: boolean;
}
const DiscoverContainerWrapper = euiStyled(EuiFlexGroup)`
width: 100%;
height: 100%;
// override the embedded discover page height
// to fit in the container
.dscPage {
height: 100%
}
`;
export const DiscoverContainerInternal = ({
overrideServices,
scopedHistory,
customize,
isDev,
getDiscoverServices,
}: DiscoverContainerInternalProps) => {
const [discoverServices, setDiscoverServices] = useState<DiscoverServices | undefined>();
const customizationCallbacks = useMemo(() => [customize], [customize]);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
getDiscoverServices().then((svcs) => setDiscoverServices(svcs));
}, [getDiscoverServices]);
useEffect(() => {
setScopedHistory(scopedHistory);
setHeaderActionMenuMounter(() => {});
setInitialized(true);
}, [scopedHistory]);
const services = useMemo(() => {
if (!discoverServices) return;
return { ...discoverServices, ...overrideServices };
}, [discoverServices, overrideServices]);
if (!initialized || !services) {
return (
<DiscoverContainerWrapper>
<LoadingIndicator type="spinner" />
</DiscoverContainerWrapper>
);
}
return (
<DiscoverContainerWrapper data-test-subj="data-container-internal-wrapper">
<EuiFlexItem>
<KibanaContextProvider services={services}>
<DiscoverMainRoute
customizationCallbacks={customizationCallbacks}
mode="embedded"
isDev={isDev}
/>
</KibanaContextProvider>
</EuiFlexItem>
</DiscoverContainerWrapper>
);
};
// eslint-disable-next-line import/no-default-export
export default DiscoverContainerInternal;

View file

@ -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 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 { withSuspense } from '@kbn/shared-ux-utility';
import { lazy } from 'react';
import type { DiscoverContainerInternalProps } from './discover_container';
export type DiscoverContainerProps = Omit<
DiscoverContainerInternalProps,
'isDev' | 'getDiscoverServices'
>;
export const DiscoverContainerInternal = withSuspense(lazy(() => import('./discover_container')));

View file

@ -6,9 +6,12 @@
* Side Public License, v 1.
*/
import type { ComponentType } from 'react';
import type { AggregateQuery } from '@kbn/es-query';
import type { TopNavMenuProps } from '@kbn/navigation-plugin/public';
import type { ComponentType, ReactElement } from 'react';
export interface SearchBarCustomization {
id: 'search_bar';
CustomDataViewPicker?: ComponentType;
CustomSearchBar?: (props: TopNavMenuProps<AggregateQuery>) => ReactElement;
}

View file

@ -16,6 +16,7 @@ export function plugin(initializerContext: PluginInitializerContext) {
export type { ISearchEmbeddable, SearchInput } from './embeddable';
export type { DiscoverStateContainer } from './application/main/services/discover_state';
export type { DiscoverContainerProps } from './components/discover_container';
export type {
CustomizationCallback,
DiscoverProfileId,

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { DiscoverSetup, DiscoverStart } from '.';
@ -25,6 +26,7 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => {
const startContract: Start = {
locator: sharePluginMock.createLocator(),
DiscoverContainer: jest.fn().mockImplementation(() => <></>),
registerCustomizationProfile: jest.fn(),
};
return startContract;

View file

@ -7,7 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import React, { ComponentType } from 'react';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import {
AppMountParameters,
@ -78,6 +78,7 @@ import {
createProfileRegistry,
} from './customizations/profile_registry';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER } from './embeddable/constants';
import { DiscoverContainerInternal, DiscoverContainerProps } from './components/discover_container';
const DocViewerLegacyTable = React.lazy(
() => import('./services/doc_views/components/doc_viewer_table/legacy')
@ -162,6 +163,7 @@ export interface DiscoverStart {
* ```
*/
readonly locator: undefined | DiscoverAppLocator;
readonly DiscoverContainer: ComponentType<DiscoverContainerProps>;
readonly registerCustomizationProfile: RegisterCustomizationProfile;
}
@ -420,18 +422,31 @@ export class DiscoverPlugin
// initializeServices are assigned at start and used
// when the application/embeddable is mounted
const { uiActions } = plugins;
uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER);
const viewSavedSearchAction = new ViewSavedSearchAction(core.application);
uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction);
setUiActions(plugins.uiActions);
plugins.uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction);
plugins.uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER);
setUiActions(plugins.uiActions);
injectTruncateStyles(core.uiSettings.get(TRUNCATE_MAX_HEIGHT));
const isDev = this.initializerContext.env.mode.dev;
const getDiscoverServicesInternal = () => {
return this.getDiscoverServices(core, plugins);
};
return {
locator: this.locator,
DiscoverContainer: ({ overrideServices, ...restProps }: DiscoverContainerProps) => {
return (
<DiscoverContainerInternal
overrideServices={overrideServices}
getDiscoverServices={getDiscoverServicesInternal}
isDev={isDev}
{...restProps}
/>
);
},
registerCustomizationProfile: createRegisterCustomizationProfile(this.profileRegistry),
};
}
@ -442,6 +457,23 @@ export class DiscoverPlugin
}
}
private getDiscoverServices = async (core: CoreStart, plugins: DiscoverStartPlugins) => {
const { locator, contextLocator, singleDocLocator } = await getProfileAwareLocators({
locator: this.locator!,
contextLocator: this.contextLocator!,
singleDocLocator: this.singleDocLocator!,
});
return buildServices(
core,
plugins,
this.initializerContext,
locator,
contextLocator,
singleDocLocator
);
};
private registerEmbeddable(core: CoreSetup<DiscoverStartPlugins>, plugins: DiscoverSetupPlugins) {
const getStartServices = async () => {
const [coreStart, deps] = await core.getStartServices();
@ -451,25 +483,12 @@ export class DiscoverPlugin
};
};
const getDiscoverServices = async () => {
const [coreStart, discoverStartPlugins] = await core.getStartServices();
const { locator, contextLocator, singleDocLocator } = await getProfileAwareLocators({
locator: this.locator!,
contextLocator: this.contextLocator!,
singleDocLocator: this.singleDocLocator!,
});
return buildServices(
coreStart,
discoverStartPlugins,
this.initializerContext,
locator,
contextLocator,
singleDocLocator
);
const getDiscoverServicesInternal = async () => {
const [coreStart, deps] = await core.getStartServices();
return this.getDiscoverServices(coreStart, deps);
};
const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServices);
const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServicesInternal);
plugins.embeddable.registerEmbeddableFactory(factory.type, factory);
}
}

View file

@ -3,7 +3,13 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*", ".storybook/**/*"],
"include": [
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*",
".storybook/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/charts-plugin",
@ -55,9 +61,11 @@
"@kbn/unified-field-list",
"@kbn/core-saved-objects-api-server",
"@kbn/cell-actions",
"@kbn/discover-utils",
"@kbn/shared-ux-utility",
"@kbn/core-application-browser",
"@kbn/discover-utils"
],
"exclude": [
"target/**/*",
"target/**/*"
]
}

View file

@ -23,6 +23,7 @@ const createStartContract = (): jest.Mocked<Start> => {
const startContract = {
ui: {
TopNavMenu: jest.fn(),
createTopNavWithCustomContext: jest.fn().mockImplementation(() => jest.fn()),
AggregateQueryTopNavMenu: jest.fn(),
},
};

View file

@ -7,12 +7,14 @@
*/
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import {
NavigationPublicPluginSetup,
NavigationPublicPluginStart,
NavigationPluginStartDependencies,
} from './types';
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
export class NavigationPublicPlugin
implements Plugin<NavigationPublicPluginSetup, NavigationPublicPluginStart>
@ -36,10 +38,31 @@ export class NavigationPublicPlugin
): NavigationPublicPluginStart {
const extensions = this.topNavMenuExtensionsRegistry.getAll();
/*
*
* This helps clients of navigation to create
* a TopNav Search Bar which does not uses global unifiedSearch/data/query service
*
* Useful in creating multiple stateful SearchBar in the same app without affecting
* global filters
*
* */
const createCustomTopNav = (
/*
* Custom instance of unified search if it needs to be overridden
*
* */
customUnifiedSearch?: UnifiedSearchPublicPluginStart,
customExtensions?: RegisteredTopNavMenuData[]
) => {
return createTopNav(customUnifiedSearch ?? unifiedSearch, customExtensions ?? extensions);
};
return {
ui: {
TopNavMenu: createTopNav(unifiedSearch, extensions),
AggregateQueryTopNavMenu: createTopNav(unifiedSearch, extensions),
createTopNavWithCustomContext: createCustomTopNav,
},
};
}

View file

@ -8,7 +8,8 @@
import { AggregateQuery, Query } from '@kbn/es-query';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup } from './top_nav_menu';
import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
export interface NavigationPublicPluginSetup {
registerMenuItem: TopNavMenuExtensionsRegistrySetup['register'];
@ -18,6 +19,10 @@ export interface NavigationPublicPluginStart {
ui: {
TopNavMenu: (props: TopNavMenuProps<Query>) => React.ReactElement;
AggregateQueryTopNavMenu: (props: TopNavMenuProps<AggregateQuery>) => React.ReactElement;
createTopNavWithCustomContext: (
customUnifiedSearch?: UnifiedSearchPublicPluginStart,
customExtensions?: RegisteredTopNavMenuData[]
) => ReturnType<typeof createTopNav>;
};
}

View file

@ -28,6 +28,7 @@ export type { DataViewPickerProps } from './dataview_picker';
export type { ApplyGlobalFilterActionContext } from './actions';
export { ACTION_GLOBAL_APPLY_FILTER, UPDATE_FILTER_REFERENCES_ACTION } from './actions';
export { UPDATE_FILTER_REFERENCES_TRIGGER } from './triggers';
export { createSearchBar } from './search_bar/create_search_bar';
/*
* Autocomplete query suggestions:

View file

@ -34,6 +34,7 @@ const createStartContract = (): Start => {
autocomplete: autocompleteStartMock,
ui: {
IndexPatternSelect: jest.fn(),
getCustomSearchBar: jest.fn(),
SearchBar: jest.fn().mockReturnValue(null),
AggregateQuerySearchBar: jest.fn().mockReturnValue(null),
FiltersBuilderLazy: jest.fn(),

View file

@ -20,6 +20,7 @@ import type {
UnifiedSearchSetupDependencies,
UnifiedSearchPluginSetup,
UnifiedSearchPublicPluginStart,
UnifiedSearchPublicPluginStartUi,
} from './types';
import { createFilterAction } from './actions/apply_filter_action';
import { createUpdateFilterReferencesAction } from './actions/update_filter_references_action';
@ -73,16 +74,28 @@ export class UnifiedSearchPublicPlugin
setIndexPatterns(dataViews);
const autocompleteStart = this.autocomplete.start();
const SearchBar = createSearchBar({
core,
data,
storage: this.storage,
usageCollection: this.usageCollection,
isScreenshotMode: Boolean(screenshotMode?.isScreenshotMode()),
unifiedSearch: {
autocomplete: autocompleteStart,
},
});
/*
*
* unifiedsearch uses global data service to create stateful search bar.
* This function helps in creating a search bar with different instances of data service
* so that it can be easy to use multiple stateful searchbars in the single applications
*
* */
const getCustomSearchBar: UnifiedSearchPublicPluginStartUi['getCustomSearchBar'] = (
customDataService
) =>
createSearchBar({
core,
data: customDataService ?? data,
storage: this.storage,
usageCollection: this.usageCollection,
isScreenshotMode: Boolean(screenshotMode?.isScreenshotMode()),
unifiedSearch: {
autocomplete: autocompleteStart,
},
});
const SearchBar = getCustomSearchBar();
uiActions.attachAction(APPLY_FILTER_TRIGGER, ACTION_GLOBAL_APPLY_FILTER);
@ -92,6 +105,7 @@ export class UnifiedSearchPublicPlugin
ui: {
IndexPatternSelect: createIndexPatternSelect(dataViews),
SearchBar,
getCustomSearchBar,
AggregateQuerySearchBar: SearchBar,
FiltersBuilderLazy,
},

View file

@ -23,7 +23,7 @@ import { useSavedQuery } from './lib/use_saved_query';
import { useQueryStringManager } from './lib/use_query_string_manager';
import type { UnifiedSearchPublicPluginStart } from '../types';
interface StatefulSearchBarDeps {
export interface StatefulSearchBarDeps {
core: CoreStart;
data: DataPublicPluginStart;
storage: IStorageWrapper;

View file

@ -20,6 +20,7 @@ import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management
import { AutocompleteSetup, AutocompleteStart } from './autocomplete';
import type { IndexPatternSelectProps, StatefulSearchBarProps } from '.';
import type { FiltersBuilderProps } from './filters_builder/filters_builder';
import { StatefulSearchBarDeps } from './search_bar/create_search_bar';
export interface UnifiedSearchSetupDependencies {
uiActions: UiActionsSetup;
@ -39,15 +40,18 @@ export interface UnifiedSearchStartDependencies {
screenshotMode?: ScreenshotModePluginStart;
}
type AggQuerySearchBarComp = <QT extends Query | AggregateQuery = Query>(
props: StatefulSearchBarProps<QT>
) => React.ReactElement;
/**
* Unified search plugin prewired UI components
*/
export interface UnifiedSearchPublicPluginStartUi {
IndexPatternSelect: React.ComponentType<IndexPatternSelectProps>;
getCustomSearchBar: (customDataService?: StatefulSearchBarDeps['data']) => AggQuerySearchBarComp;
SearchBar: (props: StatefulSearchBarProps<Query>) => React.ReactElement;
AggregateQuerySearchBar: <QT extends Query | AggregateQuery = Query>(
props: StatefulSearchBarProps<QT>
) => React.ReactElement;
AggregateQuerySearchBar: AggQuerySearchBarComp;
FiltersBuilderLazy: React.ComponentType<FiltersBuilderProps>;
}

View file

@ -111,6 +111,12 @@ export const allowedExperimentalValues = Object.freeze({
* Enables experimental Entity Analytics HTTP endpoints
*/
riskScoringRoutesEnabled: false,
/*
*
* Enables Discover embedded within timeline
*
* */
discoverInTimeline: true,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -37,6 +37,7 @@ export enum TimelineTabs {
eql = 'eql',
session = 'session',
securityAssistant = 'securityAssistant',
discover = 'discover',
}
/*

View file

@ -13,6 +13,7 @@ import {
USER_RISK_PREVIEW_TABLE_ROWS,
RISK_PREVIEW_ERROR,
RISK_PREVIEW_ERROR_BUTTON,
LOCAL_QUERY_BAR_SELECTOR,
} from '../../screens/entity_analytics_management';
import { login, visit, visitWithoutDateRange } from '../../tasks/login';
@ -52,7 +53,7 @@ describe('Entity analytics management page', () => {
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
updateDateRangeInLocalDatePickers(START_DATE, END_DATE);
updateDateRangeInLocalDatePickers(LOCAL_QUERY_BAR_SELECTOR, START_DATE, END_DATE);
cy.get(HOST_RISK_PREVIEW_TABLE).contains('No items found');
cy.get(USER_RISK_PREVIEW_TABLE).contains('No items found');
@ -63,7 +64,7 @@ describe('Entity analytics management page', () => {
cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5);
fillLocalSearchBar('host.name: "test-host1"');
submitLocalSearch();
submitLocalSearch(LOCAL_QUERY_BAR_SELECTOR);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1);
cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).contains('test-host1');

View file

@ -6,6 +6,7 @@
*/
import { getDataTestSubjectSelector } from '../helpers/common';
import { GLOBAL_FILTERS_CONTAINER } from './date_picker';
export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]';
@ -65,7 +66,7 @@ export const MANAGE_ALERT_DETECTION_RULES_BTN = '[data-test-subj="manage-alert-d
export const MARK_ALERT_ACKNOWLEDGED_BTN = '[data-test-subj="acknowledged-alert-status"]';
export const ALERTS_REFRESH_BTN = '[data-test-subj="querySubmitButton"]';
export const ALERTS_REFRESH_BTN = `${GLOBAL_FILTERS_CONTAINER} [data-test-subj="querySubmitButton"]`;
export const ALERTS_HISTOGRAM_PANEL_LOADER = '[data-test-subj="loadingPanelAlertsHistogram"]';

View file

@ -9,7 +9,9 @@ export const DATE_PICKER_ABSOLUTE_INPUT = '[data-test-subj="superDatePickerAbsol
export const LOCAL_DATE_PICKER_APPLY_BUTTON = 'button[data-test-subj="querySubmitButton"]';
export const DATE_PICKER_APPLY_BUTTON = `[data-test-subj="globalDatePicker"] ${LOCAL_DATE_PICKER_APPLY_BUTTON}`;
export const GLOBAL_FILTERS_CONTAINER = `[data-test-subj="filters-global-container"]`;
export const DATE_PICKER_APPLY_BUTTON = `${GLOBAL_FILTERS_CONTAINER} ${LOCAL_DATE_PICKER_APPLY_BUTTON}`;
export const LOCAL_DATE_PICKER_APPLY_BUTTON_TIMELINE =
'button[data-test-subj="superDatePickerApplyTimeButton"]';
@ -25,7 +27,9 @@ export const DATE_PICKER_NOW_BUTTON = '[data-test-subj="superDatePickerNowButton
export const LOCAL_DATE_PICKER_END_DATE_POPOVER_BUTTON =
'[data-test-subj="superDatePickerendDatePopoverButton"]';
export const DATE_PICKER_END_DATE_POPOVER_BUTTON = `[data-test-subj="globalDatePicker"] ${LOCAL_DATE_PICKER_END_DATE_POPOVER_BUTTON}`;
export const DATE_PICKER_END_DATE_POPOVER_BUTTON = `${GLOBAL_FILTERS_CONTAINER} ${LOCAL_DATE_PICKER_END_DATE_POPOVER_BUTTON}`;
export const DATE_PICKER_CONTAINER = `${GLOBAL_FILTERS_CONTAINER} .euiSuperDatePicker`;
export const DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE =
'[data-test-subj="timeline-date-picker-container"] [data-test-subj="superDatePickerendDatePopoverButton"]';
@ -33,11 +37,12 @@ export const DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE =
export const LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON =
'button[data-test-subj="superDatePickerstartDatePopoverButton"]';
export const DATE_PICKER_START_DATE_POPOVER_BUTTON = `div[data-test-subj="globalDatePicker"] ${LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON}`;
export const DATE_PICKER_START_DATE_POPOVER_BUTTON = `${GLOBAL_FILTERS_CONTAINER} ${LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON}`;
export const GLOBAL_FILTERS_CONTAINER = '[data-test-subj="globalDatePicker"]';
export const SHOW_DATES_BUTTON = `${GLOBAL_FILTERS_CONTAINER} [data-test-subj="superDatePickerShowDatesButton"]`;
export const SHOW_DATES_BUTTON = '[data-test-subj="superDatePickerShowDatesButton"]';
export const GET_LOCAL_SHOW_DATES_BUTTON = (localQueryBarSelector: string) =>
`${localQueryBarSelector} [data-test-subj="superDatePickerShowDatesButton"]`;
export const DATE_PICKER_SHOW_DATE_POPOVER_BUTTON = `${GLOBAL_FILTERS_CONTAINER} ${SHOW_DATES_BUTTON}`;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { getDataTestSubjectSelector } from '../helpers/common';
export const PAGE_TITLE = '[data-test-subj="entityAnalyticsManagmentPageTitle"]';
export const HOST_RISK_PREVIEW_TABLE = '[data-test-subj="host-risk-preview-table"]';
@ -18,3 +20,5 @@ export const USER_RISK_PREVIEW_TABLE_ROWS = '[data-test-subj="user-risk-preview-
export const RISK_PREVIEW_ERROR = '[data-test-subj="risk-preview-error"]';
export const RISK_PREVIEW_ERROR_BUTTON = '[data-test-subj="risk-preview-error-button"]';
export const LOCAL_QUERY_BAR_SELECTOR = getDataTestSubjectSelector('risk-score-preview-search-bar');

View file

@ -5,12 +5,15 @@
* 2.0.
*/
export const GLOBAL_KQL_WRAPPER = '[data-test-subj="filters-global-container"]';
export const GLOBAL_SEARCH_BAR_ADD_FILTER =
'[data-test-subj="globalDatePicker"] [data-test-subj="addFilter"]';
export const LOCAL_SEACH_BAR_SUBMMIT_BUTTON = '[data-test-subj="querySubmitButton"]';
export const GLOBAL_SEARCH_BAR_SUBMIT_BUTTON = `${GLOBAL_KQL_WRAPPER} [data-test-subj="querySubmitButton"]`;
export const GLOBAL_SEARCH_BAR_SUBMIT_BUTTON = `[data-test-subj="globalDatePicker"] ${LOCAL_SEACH_BAR_SUBMMIT_BUTTON}`;
export const GET_LOCAL_SEARCH_BAR_SUBMIT_BUTTON = (localSearchBarSelector: string) =>
`${localSearchBarSelector ?? ''} [data-test-subj="querySubmitButton"]`;
export const ADD_FILTER_FORM_FIELD_INPUT =
'[data-test-subj="filterFieldSuggestionList"] input[data-test-subj="comboBoxSearchInput"]';
@ -36,8 +39,6 @@ export const GLOBAL_SEARCH_BAR_FILTER_ITEM_DELETE = '#popoverFor_filter0 button[
export const GLOBAL_SEARCH_BAR_PINNED_FILTER = '.globalFilterItem-isPinned';
export const GLOBAL_KQL_WRAPPER = '[data-test-subj="filters-global-container"]';
export const LOCAL_KQL_INPUT = `[data-test-subj="unifiedQueryInput"] textarea`;
export const GLOBAL_KQL_INPUT = `[data-test-subj="filters-global-container"] ${LOCAL_KQL_INPUT}`;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { GLOBAL_KQL_WRAPPER } from './search_bar';
// main links
export const DASHBOARDS = '[data-test-subj="solutionSideNavItemLink-dashboards"]';
export const DASHBOARDS_PANEL_BTN = '[data-test-subj="solutionSideNavItemButton-dashboards"]';
@ -68,9 +70,9 @@ export const EXCEPTIONS = '[data-test-subj="solutionSideNavPanelLink-exceptions"
// other
export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a';
export const KQL_INPUT = '[data-test-subj="queryInput"]';
export const KQL_INPUT = `${GLOBAL_KQL_WRAPPER} [data-test-subj="queryInput"]`;
export const REFRESH_BUTTON = '[data-test-subj="querySubmitButton"]';
export const REFRESH_BUTTON = `${GLOBAL_KQL_WRAPPER} [data-test-subj="querySubmitButton"]`;
export const LOADING_INDICATOR = '[data-test-subj="globalLoadingIndicator"]';
export const LOADING_INDICATOR_HIDDEN = '[data-test-subj="globalLoadingIndicator-hidden"]';

View file

@ -9,7 +9,8 @@ import type { TimelineFilter } from '../objects/timeline';
export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]';
export const ADD_FILTER = '[data-test-subj="timeline"] [data-test-subj="addFilter"]';
export const ADD_FILTER =
'[data-test-subj="timeline-search-or-filter"] [data-test-subj="addFilter"]';
export const ATTACH_TIMELINE_TO_CASE_BUTTON = '[data-test-subj="attach-timeline-case-button"]';

View file

@ -13,7 +13,6 @@ import {
DATE_PICKER_END_DATE_POPOVER_BUTTON,
DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE,
DATE_PICKER_START_DATE_POPOVER_BUTTON,
GLOBAL_FILTERS_CONTAINER,
SHOW_DATES_BUTTON,
DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE,
DATE_PICKER_SHOW_DATE_POPOVER_BUTTON,
@ -21,6 +20,8 @@ import {
DATE_PICKER_NOW_BUTTON,
LOCAL_DATE_PICKER_APPLY_BUTTON,
LOCAL_DATE_PICKER_END_DATE_POPOVER_BUTTON,
DATE_PICKER_CONTAINER,
GET_LOCAL_SHOW_DATES_BUTTON,
} from '../screens/date_picker';
export const setEndDate = (date: string) => {
@ -42,8 +43,7 @@ export const setEndDateNow = () => {
};
export const setStartDate = (date: string) => {
cy.get(GLOBAL_FILTERS_CONTAINER);
cy.get('.euiSuperDatePicker');
cy.get(DATE_PICKER_CONTAINER).should('be.visible');
cy.get('body').then(($container) => {
if ($container.find(SHOW_DATES_BUTTON).length > 0) {
cy.get(DATE_PICKER_SHOW_DATE_POPOVER_BUTTON).click({ force: true });
@ -101,8 +101,12 @@ export const updateTimelineDates = () => {
cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).first().should('not.have.text', 'Updating');
};
export const updateDateRangeInLocalDatePickers = (startDate: string, endDate: string) => {
cy.get(SHOW_DATES_BUTTON).click();
export const updateDateRangeInLocalDatePickers = (
localQueryBarSelector: string,
startDate: string,
endDate: string
) => {
cy.get(GET_LOCAL_SHOW_DATES_BUTTON(localQueryBarSelector)).click();
cy.get(DATE_PICKER_ABSOLUTE_TAB).first().click();
cy.get(DATE_PICKER_ABSOLUTE_INPUT).click();

View file

@ -18,7 +18,7 @@ import {
ADD_FILTER_FORM_FILTER_VALUE_INPUT,
GLOBAL_KQL_INPUT,
LOCAL_KQL_INPUT,
LOCAL_SEACH_BAR_SUBMMIT_BUTTON,
GET_LOCAL_SEARCH_BAR_SUBMIT_BUTTON,
} from '../screens/search_bar';
export const openAddFilterPopover = () => {
@ -72,6 +72,6 @@ export const fillLocalSearchBar = (query: string) => {
cy.get(LOCAL_KQL_INPUT).type(query);
};
export const submitLocalSearch = () => {
cy.get(LOCAL_SEACH_BAR_SUBMMIT_BUTTON).click();
export const submitLocalSearch = (localSearchBarSelector: string) => {
cy.get(GET_LOCAL_SEARCH_BAR_SUBMIT_BUTTON(localSearchBarSelector)).click();
};

View file

@ -6,7 +6,10 @@
"id": "securitySolution",
"server": true,
"browser": true,
"configPath": ["xpack", "securitySolution"],
"configPath": [
"xpack",
"securitySolution"
],
"requiredPlugins": [
"actions",
"alerting",
@ -29,6 +32,7 @@
"lens",
"licensing",
"maps",
"navigation",
"ruleRegistry",
"sessionView",
"spaces",
@ -40,8 +44,11 @@
"unifiedSearch",
"files",
"controls",
"dataViewEditor",
"savedObjectsManagement",
"stackConnectors"
"stackConnectors",
"discover",
"notifications"
],
"optionalPlugins": [
"cloudExperiments",
@ -69,6 +76,8 @@
"unifiedSearch",
"cloudChat"
],
"extraPublicDirs": ["common"]
"extraPublicDirs": [
"common"
]
}
}

View file

@ -10,6 +10,8 @@
import React from 'react';
import type { RecursivePartial } from '@elastic/eui/src/components/common';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { navigationPluginMock } from '@kbn/navigation-plugin/public/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { coreMock, themeServiceMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
@ -102,11 +104,14 @@ export const createStartServicesMock = (
const { storage } = createSecuritySolutionStorageMock();
const apm = mockApm();
const data = dataPluginMock.createStartContract();
const customDataService = dataPluginMock.createStartContract();
const security = securityMock.createSetup();
const urlService = new MockUrlService();
const locator = urlService.locators.create(new MlLocatorDefinition());
const fleet = fleetMock.createStartMock();
const unifiedSearch = unifiedSearchPluginMock.createStartContract();
const navigation = navigationPluginMock.createStartContract();
const discover = discoverPluginMock.createStartContract();
const cases = mockCasesContract();
const dataViewServiceMock = dataViewPluginMocks.createStartContract();
cases.helpers.getUICapabilities.mockReturnValue(noCasesPermissions());
@ -119,6 +124,8 @@ export const createStartServicesMock = (
apm,
cases,
unifiedSearch,
navigation,
discover,
dataViews: dataViewServiceMock,
data: {
...data,
@ -193,6 +200,7 @@ export const createStartServicesMock = (
guidedOnboarding,
isSidebarEnabled$: of(true),
upselling: new UpsellingService(),
customDataService,
} as unknown as StartServices;
};

View file

@ -156,7 +156,7 @@ export const RiskScorePreviewSection = () => {
<EuiSpacer size={'s'} />
<EuiText>{i18n.PREVIEW_DESCRIPTION}</EuiText>
<EuiSpacer />
<EuiFormRow fullWidth>
<EuiFormRow fullWidth data-test-subj="risk-score-preview-search-bar">
{indexPattern && (
<SearchBar
appName="siem"

View file

@ -171,6 +171,8 @@ export const getEndpointDetectionAlertsQueryForAgentId = (endpointAgentId: strin
};
export const changeAlertsFilter = (text: string) => {
cy.getByTestSubj('queryInput').click().type(text);
cy.getByTestSubj('querySubmitButton').click();
cy.getByTestSubj('filters-global-container').within(() => {
cy.getByTestSubj('queryInput').click().type(text);
cy.getByTestSubj('querySubmitButton').click();
});
};

View file

@ -16,6 +16,8 @@ import type {
PluginInitializerContext,
Plugin as IPlugin,
} from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { FilterManager, NowProvider, QueryService } from '@kbn/data-plugin/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type {
@ -79,6 +81,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
private telemetry: TelemetryService;
readonly experimentalFeatures: ExperimentalFeatures;
private queryService: QueryService = new QueryService();
private nowProvider: NowProvider = new NowProvider();
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<SecuritySolutionUiConfigType>();
@ -90,6 +94,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.prebuiltRulesPackageVersion = this.config.prebuiltRulesPackageVersion;
this.contract = new PluginContract();
this.telemetry = new TelemetryService();
this.storage = new Storage(window.localStorage);
}
private appUpdater$ = new Subject<AppUpdater>();
@ -124,6 +129,12 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
};
this.telemetry.setup({ analytics: core.analytics }, telemetryContext);
this.queryService?.setup({
uiSettings: core.uiSettings,
storage: this.storage,
nowProvider: this.nowProvider,
});
if (plugins.home) {
plugins.home.featureCatalogue.registerSolution({
id: APP_ID,
@ -150,6 +161,23 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
const { savedObjectsTaggingOss, ...startPlugins } = startPluginsDeps;
const query = this.queryService.start({
uiSettings: core.uiSettings,
storage: this.storage,
http: core.http,
});
const filterManager = new FilterManager(core.uiSettings);
// used for creating a custom stateful KQL Query Bar
const customDataService: DataPublicPluginStart = {
...startPlugins.data,
query: {
...query,
filterManager,
},
};
const services: StartServices = {
...coreStart,
...startPlugins,
@ -165,6 +193,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
savedObjectsManagement: startPluginsDeps.savedObjectsManagement,
telemetry: this.telemetry.start(),
discoverFilterManager: filterManager,
customDataService,
};
return services;
};
@ -295,6 +325,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
}
public stop() {
this.queryService.stop();
licenseService.stop();
return this.contract.getStopContract();
}

View file

@ -45,6 +45,7 @@ describe('Pane', () => {
<Pane timelineId={TimelineId.test} />
</TestProviders>
);
await waitFor(() => {
expect(EmptyComponent.getByTestId('flyout-pane')).toHaveStyle('display: block');
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import DiscoverTabContent from '.';
import { render, screen, waitFor } from '@testing-library/react';
const TestComponent = () => {
return (
<TestProviders>
<DiscoverTabContent />
</TestProviders>
);
};
describe('Discover Tab Content', () => {
it('renders', async () => {
render(<TestComponent />);
await waitFor(() => {
expect(screen.getByTestId('timeline-embedded-discover')).toBeInTheDocument();
});
});
});

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import type { CustomizationCallback } from '@kbn/discover-plugin/public/customizations/types';
import styled, { createGlobalStyle } from 'styled-components';
import type { ScopedHistory } from '@kbn/core/public';
import { useKibana } from '../../../../common/lib/kibana';
import { useGetStatefulQueryBar } from './use_get_stateful_query_bar';
const HideSearchSessionIndicatorBreadcrumbIcon = createGlobalStyle`
[data-test-subj='searchSessionIndicator'] {
display: none;
}
`;
const EmbeddedDiscoverContainer = styled.div`
width: 100%;
height: 100%;
overflow: scroll;
display: grid,
place-items: center
`;
export const DiscoverTabContent = () => {
const history = useHistory();
const {
services: { customDataService: discoverDataService, discover, discoverFilterManager },
} = useKibana();
const { CustomStatefulTopNavKqlQueryBar } = useGetStatefulQueryBar();
const customize: CustomizationCallback = useCallback(
({ customizations }) => {
customizations.set({
id: 'search_bar',
CustomSearchBar: CustomStatefulTopNavKqlQueryBar,
});
},
[CustomStatefulTopNavKqlQueryBar]
);
const services = useMemo(
() => ({
filterManager: discoverFilterManager,
data: discoverDataService,
}),
[discoverDataService, discoverFilterManager]
);
const DiscoverContainer = discover.DiscoverContainer;
return (
<EmbeddedDiscoverContainer data-test-subj="timeline-embedded-discover">
<HideSearchSessionIndicatorBreadcrumbIcon />
<DiscoverContainer
overrideServices={services}
scopedHistory={history as ScopedHistory}
customize={customize}
/>
</EmbeddedDiscoverContainer>
);
};
// eslint-disable-next-line import/no-default-export
export default DiscoverTabContent;

View file

@ -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 { TestProviders } from '../../../../common/mock';
import { renderHook } from '@testing-library/react-hooks';
import { useGetStatefulQueryBar } from './use_get_stateful_query_bar';
describe('useGetStatefulQueryBar', () => {
it('returns custom QueryBar', async () => {
const { result } = renderHook(() => useGetStatefulQueryBar(), {
wrapper: TestProviders,
});
expect(result.current).toHaveProperty('CustomStatefulTopNavKqlQueryBar');
expect(result.current.CustomStatefulTopNavKqlQueryBar).not.toBeUndefined();
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { useKibana } from '../../../../common/lib/kibana';
export const useGetStatefulQueryBar = () => {
const {
services: {
navigation: {
ui: { createTopNavWithCustomContext },
},
unifiedSearch,
customDataService,
},
} = useKibana();
const {
ui: { getCustomSearchBar },
} = unifiedSearch;
const CustomSearchBar = useMemo(
() => getCustomSearchBar(customDataService),
[customDataService, getCustomSearchBar]
);
const CustomStatefulTopNavKqlQueryBar = useMemo(() => {
const customUnifiedSearch = {
...unifiedSearch,
ui: {
...unifiedSearch.ui,
SearchBar: CustomSearchBar,
AggregateQuerySearchBar: CustomSearchBar,
},
};
return createTopNavWithCustomContext(customUnifiedSearch);
}, [CustomSearchBar, createTopNavWithCustomContext, unifiedSearch]);
return {
CustomStatefulTopNavKqlQueryBar,
};
};

View file

@ -9,10 +9,12 @@ import { EuiBadge, EuiSkeletonText, EuiTabs, EuiTab } from '@elastic/eui';
import { css } from '@emotion/react';
import { Assistant } from '@kbn/elastic-assistant';
import { isEmpty } from 'lodash/fp';
import type { Ref, ReactElement, ComponentType } from 'react';
import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useConversationStore } from '../../../../assistant/use_conversation_store';
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
import type { SessionViewConfig } from '../../../../../common/types';
@ -52,17 +54,37 @@ const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScr
flex: 1;
`;
/**
* A HOC which supplies React.Suspense with a fallback component
* @param Component A component deferred by `React.lazy`
* @param fallback A fallback component to render while things load. Default is EuiSekeleton for all tabs
*/
const tabWithSuspense = <P extends {}, R = {}>(
Component: ComponentType<P>,
fallback: ReactElement | null = <EuiSkeletonText lines={10} />
) => {
const Comp = React.forwardRef((props: P, ref: Ref<R>) => (
<Suspense fallback={fallback}>
<Component {...props} ref={ref} />
</Suspense>
));
Comp.displayName = `${Component.displayName ?? 'Tab'}WithSuspense`;
return Comp;
};
const AssistantTabContainer = styled.div`
overflow-y: auto;
width: 100%;
`;
const QueryTabContent = lazy(() => import('../query_tab_content'));
const EqlTabContent = lazy(() => import('../eql_tab_content'));
const GraphTabContent = lazy(() => import('../graph_tab_content'));
const NotesTabContent = lazy(() => import('../notes_tab_content'));
const PinnedTabContent = lazy(() => import('../pinned_tab_content'));
const SessionTabContent = lazy(() => import('../session_tab_content'));
const QueryTab = tabWithSuspense(lazy(() => import('../query_tab_content')));
const EqlTab = tabWithSuspense(lazy(() => import('../eql_tab_content')));
const GraphTab = tabWithSuspense(lazy(() => import('../graph_tab_content')));
const NotesTab = tabWithSuspense(lazy(() => import('../notes_tab_content')));
const PinnedTab = tabWithSuspense(lazy(() => import('../pinned_tab_content')));
const SessionTab = tabWithSuspense(lazy(() => import('../session_tab_content')));
const DiscoverTab = tabWithSuspense(lazy(() => import('../discover_tab_content')));
interface BasicTimelineTab {
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
@ -75,72 +97,6 @@ interface BasicTimelineTab {
timelineDescription: string;
}
const QueryTab: React.FC<{
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
timelineId: TimelineId;
}> = memo(({ renderCellValue, rowRenderers, timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<QueryTabContent
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</Suspense>
));
QueryTab.displayName = 'QueryTab';
const EqlTab: React.FC<{
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
timelineId: TimelineId;
}> = memo(({ renderCellValue, rowRenderers, timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<EqlTabContent
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</Suspense>
));
EqlTab.displayName = 'EqlTab';
const GraphTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<GraphTabContent timelineId={timelineId} />
</Suspense>
));
GraphTab.displayName = 'GraphTab';
const NotesTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<NotesTabContent timelineId={timelineId} />
</Suspense>
));
NotesTab.displayName = 'NotesTab';
const SessionTab: React.FC<{ timelineId: TimelineId }> = memo(({ timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<SessionTabContent timelineId={timelineId} />
</Suspense>
));
SessionTab.displayName = 'SessionTab';
const PinnedTab: React.FC<{
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
timelineId: TimelineId;
}> = memo(({ renderCellValue, rowRenderers, timelineId }) => (
<Suspense fallback={<EuiSkeletonText lines={10} />}>
<PinnedTabContent
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</Suspense>
));
PinnedTab.displayName = 'PinnedTab';
const AssistantTab: React.FC<{
isAssistantEnabled: boolean;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
@ -177,6 +133,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
timelineType,
showTimeline,
}) => {
const isDiscoverInTimelineEnabled = useIsExperimentalFeatureEnabled('discoverInTimeline');
const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability();
const getTab = useCallback(
(tab: TimelineTabs) => {
@ -276,6 +233,14 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
)}
</HideShowContainer>
)}
{isDiscoverInTimelineEnabled && (
<HideShowContainer
$isVisible={TimelineTabs.discover === activeTimelineTab}
data-test-subj={`timeline-tab-content-${TimelineTabs.discover}`}
>
<DiscoverTab />
</HideShowContainer>
)}
</>
);
}
@ -313,6 +278,7 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
sessionViewConfig,
timelineDescription,
}) => {
const isDiscoverInTimelineEnabled = useIsExperimentalFeatureEnabled('discoverInTimeline');
const { hasAssistantPrivilege } = useAssistantAvailability();
const dispatch = useDispatch();
const getActiveTab = useMemo(() => getActiveTabSelector(), []);
@ -388,6 +354,10 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
setActiveTab(TimelineTabs.securityAssistant);
}, [setActiveTab]);
const setDiscoverAsActiveTab = useCallback(() => {
setActiveTab(TimelineTabs.discover);
}, [setActiveTab]);
useEffect(() => {
if (!graphEventId && activeTab === TimelineTabs.graph) {
setQueryAsActiveTab();
@ -479,6 +449,17 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
<span>{i18n.SECURITY_ASSISTANT}</span>
</StyledEuiTab>
)}
{isDiscoverInTimelineEnabled && (
<StyledEuiTab
data-test-subj={`timelineTabs-${TimelineTabs.discover}`}
onClick={setDiscoverAsActiveTab}
isSelected={activeTab === TimelineTabs.discover}
disabled={false}
key={TimelineTabs.discover}
>
<span>{i18n.DISCOVER_IN_TIMELINE_TAB}</span>
</StyledEuiTab>
)}
</EuiTabs>
)}

View file

@ -46,6 +46,13 @@ export const SECURITY_ASSISTANT = i18n.translate(
}
);
export const DISCOVER_IN_TIMELINE_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.discoverInTimeline',
{
defaultMessage: 'Discover',
}
);
export const SESSION_TAB = i18n.translate(
'xpack.securitySolution.timeline.tabs.sessionTabTimelineTitle',
{

View file

@ -9,7 +9,7 @@ import type { Observable } from 'rxjs';
import type { AppLeaveHandler, CoreStart } from '@kbn/core/public';
import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataPublicPluginStart, FilterManager } from '@kbn/data-plugin/public';
import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common';
import type { EmbeddableStart } from '@kbn/embeddable-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
@ -50,6 +50,9 @@ import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/
import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public';
import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { DiscoverStart } from '@kbn/discover-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -122,6 +125,9 @@ export interface StartPlugins {
cloudExperiments?: CloudExperimentsPluginStart;
dataViews: DataViewsServicePublic;
fieldFormats: FieldFormatsStartCommon;
discover: DiscoverStart;
navigation: NavigationPublicPluginStart;
dataViewEditor: DataViewEditorStart;
}
export interface StartPluginsDependencies extends StartPlugins {
@ -153,6 +159,8 @@ export type StartServices = CoreStart &
};
savedObjectsManagement: SavedObjectsManagementPluginStart;
telemetry: TelemetryClientStart;
discoverFilterManager: FilterManager;
customDataService: DataPublicPluginStart;
};
export interface PluginSetup {

View file

@ -161,6 +161,9 @@
"@kbn/field-formats-plugin",
"@kbn/dev-proc-runner",
"@kbn/cloud-chat-plugin",
"@kbn/discover-plugin",
"@kbn/navigation-plugin",
"@kbn/data-view-editor-plugin",
"@kbn/alerts-ui-shared"
]
}