[Search Sessions] Search session example app (#89583)

This commit is contained in:
Anton Dosov 2021-02-22 13:36:59 +01:00 committed by GitHub
parent 28b5e63874
commit a82fe33ed7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1510 additions and 491 deletions

View file

@ -4,7 +4,7 @@
"kibanaVersion": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils"],
"requiredPlugins": ["navigation", "data", "developerExamples", "kibanaUtils", "share"],
"optionalPlugins": [],
"requiredBundles": []
"requiredBundles": ["kibanaReact"]
}

View file

@ -8,26 +8,67 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, Redirect } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { AppMountParameters, CoreStart } from '../../../src/core/public';
import { AppPluginStartDependencies } from './types';
import { SearchExamplesApp } from './components/app';
import { SearchExamplePage, ExampleLink } from './common/example_page';
import { SearchExamplesApp } from './search/app';
import { SearchSessionsExampleApp } from './search_sessions/app';
import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public';
const LINKS: ExampleLink[] = [
{
path: '/search',
title: 'Search',
},
{
path: '/search-sessions',
title: 'Search Sessions',
},
{
path: 'https://github.com/elastic/kibana/blob/master/src/plugins/data/README.mdx',
title: 'README (GitHub)',
},
];
export const renderApp = (
{ notifications, savedObjects, http }: CoreStart,
{ navigation, data }: AppPluginStartDependencies,
{ appBasePath, element }: AppMountParameters
{ notifications, savedObjects, http, application }: CoreStart,
{ data, navigation }: AppPluginStartDependencies,
{ element, history }: AppMountParameters
) => {
ReactDOM.render(
<I18nProvider>
<RedirectAppLinks application={application}>
<SearchExamplePage exampleLinks={LINKS} basePath={http.basePath}>
<Router history={history}>
<Route path={LINKS[0].path}>
<SearchExamplesApp
basename={appBasePath}
notifications={notifications}
savedObjectsClient={savedObjects.client}
navigation={navigation}
data={data}
http={http}
/>,
/>
</Route>
<Route path={LINKS[1].path}>
<SearchSessionsExampleApp
navigation={navigation}
notifications={notifications}
data={data}
/>
</Route>
<Route path="/" exact={true}>
<Redirect to={LINKS[0].path} />
</Route>
</Router>
</SearchExamplePage>
</RedirectAppLinks>
</I18nProvider>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
return () => {
data.search.session.clear();
ReactDOM.unmountComponentAtNode(element);
};
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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, { PropsWithChildren } from 'react';
import { EuiPage, EuiPageSideBar, EuiSideNav } from '@elastic/eui';
import { IBasePath } from 'kibana/public';
import { PLUGIN_ID } from '../../common';
export interface ExampleLink {
title: string;
path: string;
}
interface NavProps {
exampleLinks: ExampleLink[];
basePath: IBasePath;
}
const SideNav: React.FC<NavProps> = ({ exampleLinks, basePath }: NavProps) => {
const navItems = exampleLinks.map((example) => ({
id: example.path,
name: example.title,
'data-test-subj': example.path,
href: example.path.startsWith('http')
? example.path
: basePath.prepend(`/app/${PLUGIN_ID}${example.path}`),
}));
return (
<EuiSideNav
items={[
{
name: 'Search Examples',
id: 'home',
items: [...navItems],
},
]}
/>
);
};
interface Props {
exampleLinks: ExampleLink[];
basePath: IBasePath;
}
export const SearchExamplePage: React.FC<Props> = ({
children,
exampleLinks,
basePath,
}: PropsWithChildren<Props>) => {
return (
<EuiPage>
<EuiPageSideBar>
<SideNav exampleLinks={exampleLinks} basePath={basePath} />
</EuiPageSideBar>
{children}
</EuiPage>
);
};

View file

@ -1,459 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
EuiButtonEmpty,
EuiCodeBlock,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiTitle,
EuiText,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiCheckbox,
EuiSpacer,
EuiCode,
EuiComboBox,
EuiFormLabel,
} from '@elastic/eui';
import { CoreStart } from '../../../../src/core/public';
import { mountReactNode } from '../../../../src/core/public/utils';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import {
PLUGIN_ID,
PLUGIN_NAME,
IMyStrategyResponse,
SERVER_SEARCH_ROUTE_PATH,
} from '../../common';
import {
DataPublicPluginStart,
IndexPattern,
IndexPatternField,
isCompleteResponse,
isErrorResponse,
} from '../../../../src/plugins/data/public';
interface SearchExamplesAppDeps {
basename: string;
notifications: CoreStart['notifications'];
http: CoreStart['http'];
savedObjectsClient: CoreStart['savedObjects']['client'];
navigation: NavigationPublicPluginStart;
data: DataPublicPluginStart;
}
function getNumeric(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.filter((f) => f.type === 'number' && f.aggregatable);
}
function formatFieldToComboBox(field?: IndexPatternField | null) {
if (!field) return [];
return formatFieldsToComboBox([field]);
}
function formatFieldsToComboBox(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.map((field) => {
return {
label: field.displayName || field.name,
};
});
}
export const SearchExamplesApp = ({
http,
basename,
notifications,
savedObjectsClient,
navigation,
data,
}: SearchExamplesAppDeps) => {
const { IndexPatternSelect } = data.ui;
const [getCool, setGetCool] = useState<boolean>(false);
const [timeTook, setTimeTook] = useState<number | undefined>();
const [indexPattern, setIndexPattern] = useState<IndexPattern | null>();
const [fields, setFields] = useState<IndexPatternField[]>();
const [selectedFields, setSelectedFields] = useState<IndexPatternField[]>([]);
const [selectedNumericField, setSelectedNumericField] = useState<
IndexPatternField | null | undefined
>();
const [request, setRequest] = useState<Record<string, any>>({});
const [response, setResponse] = useState<Record<string, any>>({});
// Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted.
useEffect(() => {
const setDefaultIndexPattern = async () => {
const defaultIndexPattern = await data.indexPatterns.getDefault();
setIndexPattern(defaultIndexPattern);
};
setDefaultIndexPattern();
}, [data]);
// Update the fields list every time the index pattern is modified.
useEffect(() => {
setFields(indexPattern?.fields);
}, [indexPattern]);
useEffect(() => {
setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null);
}, [fields]);
const doAsyncSearch = async (strategy?: string) => {
if (!indexPattern || !selectedNumericField) return;
// Constuct the query portion of the search request
const query = data.query.getEsQuery(indexPattern);
// Constuct the aggregations portion of the search request by using the `data.search.aggs` service.
const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }];
const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl();
const req = {
params: {
index: indexPattern.title,
body: {
aggs: aggsDsl,
query,
},
},
// Add a custom request parameter to be consumed by `MyStrategy`.
...(strategy ? { get_cool: getCool } : {}),
};
// Submit the search request using the `data.search` service.
setRequest(req.params.body);
const searchSubscription$ = data.search
.search(req, {
strategy,
})
.subscribe({
next: (res) => {
if (isCompleteResponse(res)) {
setResponse(res.rawResponse);
setTimeTook(res.rawResponse.took);
const avgResult: number | undefined = res.rawResponse.aggregations
? res.rawResponse.aggregations[1].value
: undefined;
const message = (
<EuiText>
Searched {res.rawResponse.hits.total} documents. <br />
The average of {selectedNumericField!.name} is{' '}
{avgResult ? Math.floor(avgResult) : 0}.
<br />
Is it Cool? {String((res as IMyStrategyResponse).cool)}
</EuiText>
);
notifications.toasts.addSuccess({
title: 'Query result',
text: mountReactNode(message),
});
searchSubscription$.unsubscribe();
} else if (isErrorResponse(res)) {
// TODO: Make response error status clearer
notifications.toasts.addWarning('An error has occurred');
searchSubscription$.unsubscribe();
}
},
error: () => {
notifications.toasts.addDanger('Failed to run search');
},
});
};
const doSearchSourceSearch = async () => {
if (!indexPattern) return;
const query = data.query.queryString.getQuery();
const filters = data.query.filterManager.getFilters();
const timefilter = data.query.timefilter.timefilter.createFilter(indexPattern);
if (timefilter) {
filters.push(timefilter);
}
try {
const searchSource = await data.search.searchSource.create();
searchSource
.setField('index', indexPattern)
.setField('filter', filters)
.setField('query', query)
.setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']);
if (selectedNumericField) {
searchSource.setField('aggs', () => {
return data.search.aggs
.createAggConfigs(indexPattern, [
{ type: 'avg', params: { field: selectedNumericField.name } },
])
.toDsl();
});
}
setRequest(await searchSource.getSearchRequestBody());
const res = await searchSource.fetch();
setResponse(res);
const message = <EuiText>Searched {res.hits.total} documents.</EuiText>;
notifications.toasts.addSuccess({
title: 'Query result',
text: mountReactNode(message),
});
} catch (e) {
setResponse(e.body);
notifications.toasts.addWarning(`An error has occurred: ${e.message}`);
}
};
const onClickHandler = () => {
doAsyncSearch();
};
const onMyStrategyClickHandler = () => {
doAsyncSearch('myStrategy');
};
const onServerClickHandler = async () => {
if (!indexPattern || !selectedNumericField) return;
try {
const res = await http.get(SERVER_SEARCH_ROUTE_PATH, {
query: {
index: indexPattern.title,
field: selectedNumericField!.name,
},
});
notifications.toasts.addSuccess(`Server returned ${JSON.stringify(res)}`);
} catch (e) {
notifications.toasts.addDanger('Failed to run search');
}
};
const onSearchSourceClickHandler = () => {
doSearchSourceSearch();
};
return (
<Router basename={basename}>
<I18nProvider>
<>
<navigation.ui.TopNavMenu
appName={PLUGIN_ID}
showSearchBar={true}
useDefaultBehaviors={true}
indexPatterns={indexPattern ? [indexPattern] : undefined}
/>
<EuiPage>
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="searchExamples.helloWorldText"
defaultMessage="{name}"
values={{ name: PLUGIN_NAME }}
/>
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<EuiFlexGrid columns={3}>
<EuiFlexItem style={{ width: '40%' }}>
<EuiText>
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFormLabel>Index Pattern</EuiFormLabel>
<IndexPatternSelect
placeholder={i18n.translate(
'searchSessionExample.selectIndexPatternPlaceholder',
{
defaultMessage: 'Select index pattern',
}
)}
indexPatternId={indexPattern?.id || ''}
onChange={async (newIndexPatternId: any) => {
const newIndexPattern = await data.indexPatterns.get(
newIndexPatternId
);
setIndexPattern(newIndexPattern);
}}
isClearable={false}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>Numeric Field to Aggregate</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getNumeric(fields))}
selectedOptions={formatFieldToComboBox(selectedNumericField)}
singleSelection={true}
onChange={(option) => {
const fld = indexPattern?.getFieldByName(option[0].label);
setSelectedNumericField(fld || null);
}}
sortMatchesBy="startsWith"
/>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormLabel>
Fields to query (leave blank to include all fields)
</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(fields)}
selectedOptions={formatFieldsToComboBox(selectedFields)}
singleSelection={false}
onChange={(option) => {
const flds = option
.map((opt) => indexPattern?.getFieldByName(opt?.label))
.filter((f) => f);
setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []);
}}
sortMatchesBy="startsWith"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>
Searching Elasticsearch using <EuiCode>data.search</EuiCode>
</h3>
</EuiTitle>
<EuiText>
If you want to fetch data from Elasticsearch, you can use the different
services provided by the <EuiCode>data</EuiCode> plugin. These help you get
the index pattern and search bar configuration, format them into a DSL query
and send it to Elasticsearch.
<EuiSpacer />
<EuiButtonEmpty size="xs" onClick={onClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.buttonText"
defaultMessage="Request from low-level client (data.search.search)"
/>
</EuiButtonEmpty>
<EuiButtonEmpty
size="xs"
onClick={onSearchSourceClickHandler}
iconType="play"
>
<FormattedMessage
id="searchExamples.searchSource.buttonText"
defaultMessage="Request from high-level client (data.search.searchSource)"
/>
</EuiButtonEmpty>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>Writing a custom search strategy</h3>
</EuiTitle>
<EuiText>
If you want to do some pre or post processing on the server, you might want
to create a custom search strategy. This example uses such a strategy,
passing in custom input and receiving custom output back.
<EuiSpacer />
<EuiCheckbox
id="GetCool"
label={
<FormattedMessage
id="searchExamples.getCoolCheckbox"
defaultMessage="Get cool parameter?"
/>
}
checked={getCool}
onChange={(event) => setGetCool(event.target.checked)}
/>
<EuiButtonEmpty
size="xs"
onClick={onMyStrategyClickHandler}
iconType="play"
>
<FormattedMessage
id="searchExamples.myStrategyButtonText"
defaultMessage="Request from low-level client via My Strategy"
/>
</EuiButtonEmpty>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>Using search on the server</h3>
</EuiTitle>
<EuiText>
You can also run your search request from the server, without registering a
search strategy. This request does not take the configuration of{' '}
<EuiCode>TopNavMenu</EuiCode> into account, but you could pass those down to
the server as well.
<EuiSpacer />
<EuiButtonEmpty size="xs" onClick={onServerClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.myServerButtonText"
defaultMessage="Request from low-level client on the server"
/>
</EuiButtonEmpty>
</EuiText>
</EuiFlexItem>
<EuiFlexItem style={{ width: '30%' }}>
<EuiTitle size="xs">
<h4>Request</h4>
</EuiTitle>
<EuiText size="xs">Search body sent to ES</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(request, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem style={{ width: '30%' }}>
<EuiTitle size="xs">
<h4>Response</h4>
</EuiTitle>
<EuiText size="xs">
<FormattedMessage
id="searchExamples.timestampText"
defaultMessage="Took: {time} ms"
values={{ time: timeTook || 'Unknown' }}
/>
</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(response, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGrid>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</>
</I18nProvider>
</Router>
);
};

View file

@ -19,7 +19,9 @@ import {
AppPluginSetupDependencies,
AppPluginStartDependencies,
} from './types';
import { createSearchSessionsExampleUrlGenerator } from './search_sessions/url_generator';
import { PLUGIN_NAME } from '../common';
import img from './search_examples.png';
export class SearchExamplesPlugin
implements
@ -31,14 +33,14 @@ export class SearchExamplesPlugin
> {
public setup(
core: CoreSetup<AppPluginStartDependencies>,
{ developerExamples }: AppPluginSetupDependencies
{ developerExamples, share }: AppPluginSetupDependencies
): SearchExamplesPluginSetup {
// Register an application into the side navigation menu
core.application.register({
id: 'searchExamples',
title: PLUGIN_NAME,
navLinkStatus: AppNavLinkStatus.hidden,
async mount(params: AppMountParameters) {
mount: async (params: AppMountParameters) => {
// Load application bundle
const { renderApp } = await import('./application');
// Get start services as specified in kibana.json
@ -51,9 +53,28 @@ export class SearchExamplesPlugin
developerExamples.register({
appId: 'searchExamples',
title: 'Search Examples',
description: `Search Examples`,
description: `Examples on searching elasticsearch using data plugin: low-level search client (data.search.search), high-level search client (SearchSource), search sessions (data.search.sessions)`,
image: img,
links: [
{
label: 'README',
href: 'https://github.com/elastic/kibana/tree/master/src/plugins/data/README.mdx',
iconType: 'logoGithub',
target: '_blank',
size: 's',
},
],
});
// we need an URL generator for search session examples for restoring a search session
share.urlGenerators.registerUrlGenerator(
createSearchSessionsExampleUrlGenerator(() => {
return core
.getStartServices()
.then(([coreStart]) => ({ appBasePath: coreStart.http.basePath.get() }));
})
);
return {};
}

View file

@ -0,0 +1,433 @@
/*
* 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, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiCodeBlock,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiTitle,
EuiText,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiCheckbox,
EuiSpacer,
EuiCode,
EuiComboBox,
EuiFormLabel,
} from '@elastic/eui';
import { CoreStart } from '../../../../src/core/public';
import { mountReactNode } from '../../../../src/core/public/utils';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import {
PLUGIN_ID,
PLUGIN_NAME,
IMyStrategyResponse,
SERVER_SEARCH_ROUTE_PATH,
} from '../../common';
import {
DataPublicPluginStart,
IndexPattern,
IndexPatternField,
isCompleteResponse,
isErrorResponse,
} from '../../../../src/plugins/data/public';
interface SearchExamplesAppDeps {
notifications: CoreStart['notifications'];
http: CoreStart['http'];
navigation: NavigationPublicPluginStart;
data: DataPublicPluginStart;
}
function getNumeric(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.filter((f) => f.type === 'number' && f.aggregatable);
}
function formatFieldToComboBox(field?: IndexPatternField | null) {
if (!field) return [];
return formatFieldsToComboBox([field]);
}
function formatFieldsToComboBox(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.map((field) => {
return {
label: field.displayName || field.name,
};
});
}
export const SearchExamplesApp = ({
http,
notifications,
navigation,
data,
}: SearchExamplesAppDeps) => {
const { IndexPatternSelect } = data.ui;
const [getCool, setGetCool] = useState<boolean>(false);
const [timeTook, setTimeTook] = useState<number | undefined>();
const [indexPattern, setIndexPattern] = useState<IndexPattern | null>();
const [fields, setFields] = useState<IndexPatternField[]>();
const [selectedFields, setSelectedFields] = useState<IndexPatternField[]>([]);
const [selectedNumericField, setSelectedNumericField] = useState<
IndexPatternField | null | undefined
>();
const [request, setRequest] = useState<Record<string, any>>({});
const [response, setResponse] = useState<Record<string, any>>({});
// Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted.
useEffect(() => {
const setDefaultIndexPattern = async () => {
const defaultIndexPattern = await data.indexPatterns.getDefault();
setIndexPattern(defaultIndexPattern);
};
setDefaultIndexPattern();
}, [data]);
// Update the fields list every time the index pattern is modified.
useEffect(() => {
setFields(indexPattern?.fields);
}, [indexPattern]);
useEffect(() => {
setSelectedNumericField(fields?.length ? getNumeric(fields)[0] : null);
}, [fields]);
const doAsyncSearch = async (strategy?: string) => {
if (!indexPattern || !selectedNumericField) return;
// Construct the query portion of the search request
const query = data.query.getEsQuery(indexPattern);
// Construct the aggregations portion of the search request by using the `data.search.aggs` service.
const aggs = [{ type: 'avg', params: { field: selectedNumericField!.name } }];
const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl();
const req = {
params: {
index: indexPattern.title,
body: {
aggs: aggsDsl,
query,
},
},
// Add a custom request parameter to be consumed by `MyStrategy`.
...(strategy ? { get_cool: getCool } : {}),
};
// Submit the search request using the `data.search` service.
setRequest(req.params.body);
const searchSubscription$ = data.search
.search(req, {
strategy,
})
.subscribe({
next: (res) => {
if (isCompleteResponse(res)) {
setResponse(res.rawResponse);
setTimeTook(res.rawResponse.took);
const avgResult: number | undefined = res.rawResponse.aggregations
? res.rawResponse.aggregations[1].value
: undefined;
const message = (
<EuiText>
Searched {res.rawResponse.hits.total} documents. <br />
The average of {selectedNumericField!.name} is{' '}
{avgResult ? Math.floor(avgResult) : 0}.
<br />
Is it Cool? {String((res as IMyStrategyResponse).cool)}
</EuiText>
);
notifications.toasts.addSuccess({
title: 'Query result',
text: mountReactNode(message),
});
searchSubscription$.unsubscribe();
} else if (isErrorResponse(res)) {
// TODO: Make response error status clearer
notifications.toasts.addWarning('An error has occurred');
searchSubscription$.unsubscribe();
}
},
error: () => {
notifications.toasts.addDanger('Failed to run search');
},
});
};
const doSearchSourceSearch = async () => {
if (!indexPattern) return;
const query = data.query.queryString.getQuery();
const filters = data.query.filterManager.getFilters();
const timefilter = data.query.timefilter.timefilter.createFilter(indexPattern);
if (timefilter) {
filters.push(timefilter);
}
try {
const searchSource = await data.search.searchSource.create();
searchSource
.setField('index', indexPattern)
.setField('filter', filters)
.setField('query', query)
.setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']);
if (selectedNumericField) {
searchSource.setField('aggs', () => {
return data.search.aggs
.createAggConfigs(indexPattern, [
{ type: 'avg', params: { field: selectedNumericField.name } },
])
.toDsl();
});
}
setRequest(await searchSource.getSearchRequestBody());
const res = await searchSource.fetch();
setResponse(res);
const message = <EuiText>Searched {res.hits.total} documents.</EuiText>;
notifications.toasts.addSuccess({
title: 'Query result',
text: mountReactNode(message),
});
} catch (e) {
setResponse(e.body);
notifications.toasts.addWarning(`An error has occurred: ${e.message}`);
}
};
const onClickHandler = () => {
doAsyncSearch();
};
const onMyStrategyClickHandler = () => {
doAsyncSearch('myStrategy');
};
const onServerClickHandler = async () => {
if (!indexPattern || !selectedNumericField) return;
try {
const res = await http.get(SERVER_SEARCH_ROUTE_PATH, {
query: {
index: indexPattern.title,
field: selectedNumericField!.name,
},
});
notifications.toasts.addSuccess(`Server returned ${JSON.stringify(res)}`);
} catch (e) {
notifications.toasts.addDanger('Failed to run search');
}
};
const onSearchSourceClickHandler = () => {
doSearchSourceSearch();
};
return (
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="searchExamples.helloWorldText"
defaultMessage="{name}"
values={{ name: PLUGIN_NAME }}
/>
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<navigation.ui.TopNavMenu
appName={PLUGIN_ID}
showSearchBar={true}
useDefaultBehaviors={true}
indexPatterns={indexPattern ? [indexPattern] : undefined}
/>
<EuiFlexGrid columns={3}>
<EuiFlexItem style={{ width: '40%' }}>
<EuiText>
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiFormLabel>Index Pattern</EuiFormLabel>
<IndexPatternSelect
placeholder={i18n.translate(
'searchSessionExample.selectIndexPatternPlaceholder',
{
defaultMessage: 'Select index pattern',
}
)}
indexPatternId={indexPattern?.id || ''}
onChange={async (newIndexPatternId: any) => {
const newIndexPattern = await data.indexPatterns.get(newIndexPatternId);
setIndexPattern(newIndexPattern);
}}
isClearable={false}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormLabel>Numeric Field to Aggregate</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getNumeric(fields))}
selectedOptions={formatFieldToComboBox(selectedNumericField)}
singleSelection={true}
onChange={(option) => {
const fld = indexPattern?.getFieldByName(option[0].label);
setSelectedNumericField(fld || null);
}}
sortMatchesBy="startsWith"
/>
</EuiFlexItem>
</EuiFlexGrid>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormLabel>Fields to query (leave blank to include all fields)</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(fields)}
selectedOptions={formatFieldsToComboBox(selectedFields)}
singleSelection={false}
onChange={(option) => {
const flds = option
.map((opt) => indexPattern?.getFieldByName(opt?.label))
.filter((f) => f);
setSelectedFields(flds.length ? (flds as IndexPatternField[]) : []);
}}
sortMatchesBy="startsWith"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>
Searching Elasticsearch using <EuiCode>data.search</EuiCode>
</h3>
</EuiTitle>
<EuiText>
If you want to fetch data from Elasticsearch, you can use the different services
provided by the <EuiCode>data</EuiCode> plugin. These help you get the index pattern
and search bar configuration, format them into a DSL query and send it to
Elasticsearch.
<EuiSpacer />
<EuiButtonEmpty size="xs" onClick={onClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.buttonText"
defaultMessage="Request from low-level client (data.search.search)"
/>
</EuiButtonEmpty>
<EuiButtonEmpty size="xs" onClick={onSearchSourceClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.searchSource.buttonText"
defaultMessage="Request from high-level client (data.search.searchSource)"
/>
</EuiButtonEmpty>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>Writing a custom search strategy</h3>
</EuiTitle>
<EuiText>
If you want to do some pre or post processing on the server, you might want to
create a custom search strategy. This example uses such a strategy, passing in
custom input and receiving custom output back.
<EuiSpacer />
<EuiCheckbox
id="GetCool"
label={
<FormattedMessage
id="searchExamples.getCoolCheckbox"
defaultMessage="Get cool parameter?"
/>
}
checked={getCool}
onChange={(event) => setGetCool(event.target.checked)}
/>
<EuiButtonEmpty size="xs" onClick={onMyStrategyClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.myStrategyButtonText"
defaultMessage="Request from low-level client via My Strategy"
/>
</EuiButtonEmpty>
</EuiText>
<EuiSpacer />
<EuiTitle size="s">
<h3>Using search on the server</h3>
</EuiTitle>
<EuiText>
You can also run your search request from the server, without registering a search
strategy. This request does not take the configuration of{' '}
<EuiCode>TopNavMenu</EuiCode> into account, but you could pass those down to the
server as well.
<EuiSpacer />
<EuiButtonEmpty size="xs" onClick={onServerClickHandler} iconType="play">
<FormattedMessage
id="searchExamples.myServerButtonText"
defaultMessage="Request from low-level client on the server"
/>
</EuiButtonEmpty>
</EuiText>
</EuiFlexItem>
<EuiFlexItem style={{ width: '30%' }}>
<EuiTitle size="xs">
<h4>Request</h4>
</EuiTitle>
<EuiText size="xs">Search body sent to ES</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(request, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem style={{ width: '30%' }}>
<EuiTitle size="xs">
<h4>Response</h4>
</EuiTitle>
<EuiText size="xs">
<FormattedMessage
id="searchExamples.timestampText"
defaultMessage="Took: {time} ms"
values={{ time: timeTook ?? 'Unknown' }}
/>
</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(response, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGrid>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View file

@ -0,0 +1,768 @@
/*
* 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import useObservable from 'react-use/lib/useObservable';
import {
EuiAccordion,
EuiButtonEmpty,
EuiCallOut,
EuiCode,
EuiCodeBlock,
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiLoadingSpinner,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { catchError, map, tap } from 'rxjs/operators';
import { of } from 'rxjs';
import { CoreStart } from '../../../../src/core/public';
import { mountReactNode } from '../../../../src/core/public/utils';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import { PLUGIN_ID } from '../../common';
import {
connectToQueryState,
DataPublicPluginStart,
IEsSearchRequest,
IEsSearchResponse,
IndexPattern,
IndexPatternField,
isCompleteResponse,
isErrorResponse,
QueryState,
SearchSessionState,
TimeRange,
} from '../../../../src/plugins/data/public';
import {
createStateContainer,
useContainerState,
} from '../../../../src/plugins/kibana_utils/public';
import {
getInitialStateFromUrl,
SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR,
SearchSessionExamplesUrlGeneratorState,
} from './url_generator';
interface SearchSessionsExampleAppDeps {
notifications: CoreStart['notifications'];
navigation: NavigationPublicPluginStart;
data: DataPublicPluginStart;
}
/**
* This example is an app with a step by step guide
* walking through search session lifecycle
* These enum represents all important steps in this demo
*/
enum DemoStep {
ConfigureQuery,
RunSession,
SaveSession,
RestoreSessionOnScreen,
RestoreSessionViaManagement,
}
interface State extends QueryState {
indexPatternId?: string;
numericFieldName?: string;
/**
* If landed into the app with restore URL
*/
restoreSessionId?: string;
}
export const SearchSessionsExampleApp = ({
notifications,
navigation,
data,
}: SearchSessionsExampleAppDeps) => {
const { IndexPatternSelect } = data.ui;
const [isSearching, setIsSearching] = useState<boolean>(false);
const [request, setRequest] = useState<IEsSearchRequest | null>(null);
const [response, setResponse] = useState<IEsSearchResponse | null>(null);
const [tookMs, setTookMs] = useState<number | null>(null);
const nextRequestIdRef = useRef<number>(0);
const [restoreRequest, setRestoreRequest] = useState<IEsSearchRequest | null>(null);
const [restoreResponse, setRestoreResponse] = useState<IEsSearchResponse | null>(null);
const [restoreTookMs, setRestoreTookMs] = useState<number | null>(null);
const sessionState = useObservable(data.search.session.state$) || SearchSessionState.None;
const demoStep: DemoStep = (() => {
switch (sessionState) {
case SearchSessionState.None:
case SearchSessionState.Canceled:
return DemoStep.ConfigureQuery;
case SearchSessionState.Loading:
case SearchSessionState.Completed:
return DemoStep.RunSession;
case SearchSessionState.BackgroundCompleted:
case SearchSessionState.BackgroundLoading:
return DemoStep.SaveSession;
case SearchSessionState.Restored:
return DemoStep.RestoreSessionOnScreen;
}
})();
const {
numericFieldName,
indexPattern,
selectedField,
fields,
setIndexPattern,
setNumericFieldName,
state,
} = useAppState({ data });
const isRestoring = !!state.restoreSessionId;
const enableSessionStorage = useCallback(() => {
data.search.session.enableStorage({
getName: async () => 'Search sessions example',
getUrlGeneratorData: async () => ({
initialState: {
time: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.getQuery(),
indexPatternId: indexPattern?.id,
numericFieldName,
} as SearchSessionExamplesUrlGeneratorState,
restoreState: {
time: data.query.timefilter.timefilter.getAbsoluteTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.getQuery(),
indexPatternId: indexPattern?.id,
numericFieldName,
searchSessionId: data.search.session.getSessionId(),
} as SearchSessionExamplesUrlGeneratorState,
urlGeneratorId: SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR,
}),
});
}, [
data.query.filterManager,
data.query.queryString,
data.query.timefilter.timefilter,
data.search.session,
indexPattern?.id,
numericFieldName,
]);
const reset = useCallback(() => {
setRequest(null);
setResponse(null);
setRestoreRequest(null);
setRestoreResponse(null);
setTookMs(null);
setRestoreTookMs(null);
setIsSearching(false);
data.search.session.clear();
enableSessionStorage();
nextRequestIdRef.current = 0;
}, [
setRequest,
setResponse,
setRestoreRequest,
setRestoreResponse,
setIsSearching,
data.search.session,
enableSessionStorage,
]);
useEffect(() => {
enableSessionStorage();
return () => {
data.search.session.clear();
};
}, [data.search.session, enableSessionStorage]);
useEffect(() => {
reset();
}, [reset, state]);
const search = useCallback(
(restoreSearchSessionId?: string) => {
if (!indexPattern) return;
if (!numericFieldName) return;
setIsSearching(true);
const requestId = ++nextRequestIdRef.current;
doSearch({ indexPattern, numericFieldName, restoreSearchSessionId }, { data, notifications })
.then(({ response: res, request: req, tookMs: _tookMs }) => {
if (requestId !== nextRequestIdRef.current) return; // no longer interested in this result
if (restoreSearchSessionId) {
setRestoreRequest(req);
setRestoreResponse(res);
setRestoreTookMs(_tookMs ?? null);
} else {
setRequest(req);
setResponse(res);
setTookMs(_tookMs ?? null);
}
})
.finally(() => {
if (requestId !== nextRequestIdRef.current) return; // no longer interested in this result
setIsSearching(false);
});
},
[data, notifications, indexPattern, numericFieldName]
);
useEffect(() => {
if (state.restoreSessionId) {
search(state.restoreSessionId);
}
}, [search, state.restoreSessionId]);
return (
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Search session example</h1>
</EuiTitle>
<EuiSpacer />
{!isShardDelayEnabled(data) && (
<>
<NoShardDelayCallout />
<EuiSpacer />
</>
)}
<EuiText>
<p>
This example shows how you can use <EuiCode>data.search.session</EuiCode> service to
group your searches into a search session and allow user to save search results for
later. <br />
Start a long-running search, save the session and then restore it. See how fast search
is completed when restoring the session comparing to when doing initial search. <br />
<br />
Follow this demo step-by-step:{' '}
<b>configure the query, start the search and then save your session.</b> You can save
your session both when search is still in progress or when it is completed. After you
save the session and when initial search is completed you can{' '}
<b>restore the session</b>: the search will re-run reusing previous results. It will
finish a lot faster then the initial search. You can also{' '}
<b>go to search sessions management</b> and <b>get back to the stored results</b> from
there.
</p>
</EuiText>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
{!isRestoring && (
<>
<EuiTitle size="s">
<h2>1. Configure the search query (OK to leave defaults)</h2>
</EuiTitle>
<navigation.ui.TopNavMenu
appName={PLUGIN_ID}
showSearchBar={true}
useDefaultBehaviors={true}
indexPatterns={indexPattern ? [indexPattern] : undefined}
onQuerySubmit={reset}
/>
<EuiFlexGroup justifyContent={'flexStart'}>
<EuiFlexItem grow={false}>
<EuiFormLabel>Index Pattern</EuiFormLabel>
<IndexPatternSelect
placeholder={i18n.translate(
'searchSessionExample.selectIndexPatternPlaceholder',
{
defaultMessage: 'Select index pattern',
}
)}
indexPatternId={indexPattern?.id ?? ''}
onChange={(id) => {
if (!id) return;
setIndexPattern(id);
}}
isClearable={false}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormLabel>Numeric Field to Aggregate</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getNumeric(fields))}
selectedOptions={formatFieldToComboBox(selectedField)}
singleSelection={true}
onChange={(option) => {
const fld = indexPattern?.getFieldByName(option[0].label);
if (!fld) return;
setNumericFieldName(fld?.name);
}}
sortMatchesBy="startsWith"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'xl'} />
<EuiTitle size="s">
<h2>
2. Start the search using <EuiCode>data.search</EuiCode> service
</h2>
</EuiTitle>
<EuiText style={{ maxWidth: 600 }}>
In this example each search creates a new session by calling{' '}
<EuiCode>data.search.session.start()</EuiCode> that returns a{' '}
<EuiCode>searchSessionId</EuiCode>. Then this <EuiCode>searchSessionId</EuiCode> is
passed into a search request.
<EuiSpacer />
<div>
{demoStep === DemoStep.ConfigureQuery && (
<EuiButtonEmpty
size="xs"
onClick={() => search()}
iconType="play"
disabled={isSearching}
>
Start the search from low-level client (data.search.search)
</EuiButtonEmpty>
)}
{isSearching && <EuiLoadingSpinner />}
{response && request && (
<SearchInspector
accordionId={'1'}
request={request}
response={response}
tookMs={tookMs}
/>
)}
</div>
</EuiText>
<EuiSpacer size={'xl'} />
{(demoStep === DemoStep.RunSession ||
demoStep === DemoStep.RestoreSessionOnScreen ||
demoStep === DemoStep.SaveSession) && (
<>
<EuiTitle size="s">
<h2>3. Save your session</h2>
</EuiTitle>
<EuiText style={{ maxWidth: 600 }}>
Use the search session indicator in the Kibana header to save the search
session.
<div>
<EuiButtonEmpty
size="xs"
iconType={'save'}
onClick={() => {
// hack for demo purposes:
document
.querySelector('[data-test-subj="searchSessionIndicator"]')
?.querySelector('button')
?.click();
}}
isDisabled={
demoStep === DemoStep.RestoreSessionOnScreen ||
demoStep === DemoStep.SaveSession
}
>
Try saving the session using the search session indicator in the header.
</EuiButtonEmpty>
</div>
</EuiText>
</>
)}
{(demoStep === DemoStep.RestoreSessionOnScreen ||
demoStep === DemoStep.SaveSession) && (
<>
<EuiSpacer size={'xl'} />
<EuiTitle size="s">
<h2>4. Restore the session</h2>
</EuiTitle>
<EuiText style={{ maxWidth: 600 }}>
Now you can restore your saved session. The same search request completes
significantly faster because it reuses stored results.
<EuiSpacer />
<div>
{!isSearching && !restoreResponse && (
<EuiButtonEmpty
size="xs"
iconType={'refresh'}
onClick={() => {
search(data.search.session.getSessionId());
}}
>
Restore the search session
</EuiButtonEmpty>
)}
{isSearching && <EuiLoadingSpinner />}
{restoreRequest && restoreResponse && (
<SearchInspector
accordionId={'2'}
request={restoreRequest}
response={restoreResponse}
tookMs={restoreTookMs}
/>
)}
</div>
</EuiText>
</>
)}
{demoStep === DemoStep.RestoreSessionOnScreen && (
<>
<EuiSpacer size={'xl'} />
<EuiTitle size="s">
<h2>5. Restore from Management</h2>
</EuiTitle>
<EuiText style={{ maxWidth: 600 }}>
You can also get back to your session from the Search Session Management.
<div>
<EuiButtonEmpty
size="xs"
onClick={() => {
// hack for demo purposes:
document
.querySelector('[data-test-subj="searchSessionIndicator"]')
?.querySelector('button')
?.click();
}}
>
Use Search Session indicator to navigate to management
</EuiButtonEmpty>
</div>
</EuiText>
</>
)}
</>
)}
{isRestoring && (
<>
<EuiTitle size="s">
<h2>You restored the search session!</h2>
</EuiTitle>
<EuiSpacer />
<EuiText style={{ maxWidth: 600 }}>
{isSearching && <EuiLoadingSpinner />}
{restoreRequest && restoreResponse && (
<SearchInspector
accordionId={'2'}
request={restoreRequest}
response={restoreResponse}
tookMs={restoreTookMs}
/>
)}
</EuiText>
</>
)}
<EuiSpacer size={'xl'} />
<EuiButtonEmpty
onClick={() => {
// hack to quickly reset all the state and remove state stuff from the URL
window.location.assign(window.location.href.split('?')[0]);
}}
>
Start again
</EuiButtonEmpty>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
};
function SearchInspector({
accordionId,
response,
request,
tookMs,
}: {
accordionId: string;
response: IEsSearchResponse;
request: IEsSearchRequest;
tookMs: number | null;
}) {
return (
<div>
The search took: {tookMs ? Math.round(tookMs) : 'unknown'}ms
<EuiAccordion id={accordionId} buttonContent="Request / response">
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>Request</h4>
</EuiTitle>
<EuiText size="xs">Search body sent to ES</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(request, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>Response</h4>
</EuiTitle>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={450}
isCopyable
>
{JSON.stringify(response, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
</EuiAccordion>
</div>
);
}
function useAppState({ data }: { data: DataPublicPluginStart }) {
const stateContainer = useMemo(() => {
const {
filters,
time,
searchSessionId,
numericFieldName,
indexPatternId,
query,
} = getInitialStateFromUrl();
if (filters) {
data.query.filterManager.setFilters(filters);
}
if (query) {
data.query.queryString.setQuery(query);
}
if (time) {
data.query.timefilter.timefilter.setTime(time);
}
return createStateContainer<State>({
restoreSessionId: searchSessionId,
numericFieldName,
indexPatternId,
});
}, [data.query.filterManager, data.query.queryString, data.query.timefilter.timefilter]);
const setState = useCallback(
(state: Partial<State>) => stateContainer.set({ ...stateContainer.get(), ...state }),
[stateContainer]
);
const state = useContainerState(stateContainer);
useEffect(() => {
return connectToQueryState(data.query, stateContainer, {
time: true,
query: true,
filters: true,
refreshInterval: false,
});
}, [stateContainer, data.query]);
const [fields, setFields] = useState<IndexPatternField[]>();
const [indexPattern, setIndexPattern] = useState<IndexPattern | null>();
// Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted.
useEffect(() => {
let canceled = false;
const loadIndexPattern = async () => {
const loadedIndexPattern = state.indexPatternId
? await data.indexPatterns.get(state.indexPatternId)
: await data.indexPatterns.getDefault();
if (canceled) return;
if (!loadedIndexPattern) return;
if (!state.indexPatternId) {
setState({
indexPatternId: loadedIndexPattern.id,
});
}
setIndexPattern(loadedIndexPattern);
};
loadIndexPattern();
return () => {
canceled = true;
};
}, [data, setState, state.indexPatternId]);
// Update the fields list every time the index pattern is modified.
useEffect(() => {
setFields(indexPattern?.fields);
}, [indexPattern]);
useEffect(() => {
if (state.numericFieldName) return;
setState({ numericFieldName: fields?.length ? getNumeric(fields)[0]?.name : undefined });
}, [setState, fields, state.numericFieldName]);
const selectedField: IndexPatternField | undefined = useMemo(
() => indexPattern?.fields.find((field) => field.name === state.numericFieldName),
[indexPattern?.fields, state.numericFieldName]
);
return {
selectedField,
indexPattern,
numericFieldName: state.numericFieldName,
fields,
setNumericFieldName: (field: string) => setState({ numericFieldName: field }),
setIndexPattern: (indexPatternId: string) => setState({ indexPatternId }),
state,
};
}
function doSearch(
{
indexPattern,
numericFieldName,
restoreSearchSessionId,
}: {
indexPattern: IndexPattern;
numericFieldName: string;
restoreSearchSessionId?: string;
},
{
data,
notifications,
}: { data: DataPublicPluginStart; notifications: CoreStart['notifications'] }
): Promise<{ request: IEsSearchRequest; response: IEsSearchResponse; tookMs?: number }> {
if (!indexPattern) return Promise.reject('Select an index patten');
if (!numericFieldName) return Promise.reject('Select a field to aggregate on');
// start a new session or restore an existing one
let restoreTimeRange: TimeRange | undefined;
if (restoreSearchSessionId) {
// when restoring need to make sure we are forcing absolute time range
restoreTimeRange = data.query.timefilter.timefilter.getAbsoluteTime();
data.search.session.restore(restoreSearchSessionId);
}
const sessionId = restoreSearchSessionId ? restoreSearchSessionId : data.search.session.start();
// Construct the query portion of the search request
const query = data.query.getEsQuery(indexPattern, restoreTimeRange);
// Construct the aggregations portion of the search request by using the `data.search.aggs` service.
const aggs = isShardDelayEnabled(data)
? [
{ type: 'avg', params: { field: numericFieldName } },
{ type: 'shard_delay', params: { delay: '5s' } },
]
: [{ type: 'avg', params: { field: numericFieldName } }];
const aggsDsl = data.search.aggs.createAggConfigs(indexPattern, aggs).toDsl();
const req = {
params: {
index: indexPattern.title,
body: {
aggs: aggsDsl,
query,
},
},
};
const startTs = performance.now();
// Submit the search request using the `data.search` service.
return data.search
.search(req, { sessionId })
.pipe(
tap((res) => {
if (isCompleteResponse(res)) {
const avgResult: number | undefined = res.rawResponse.aggregations
? res.rawResponse.aggregations[1]?.value ?? res.rawResponse.aggregations[2]?.value
: undefined;
const message = (
<EuiText>
Searched {res.rawResponse.hits.total} documents. <br />
The average of {numericFieldName} is {avgResult ? Math.floor(avgResult) : 0}
.
<br />
</EuiText>
);
notifications.toasts.addSuccess({
title: 'Query result',
text: mountReactNode(message),
});
} else if (isErrorResponse(res)) {
notifications.toasts.addWarning('An error has occurred');
}
}),
map((res) => ({ response: res, request: req, tookMs: performance.now() - startTs })),
catchError((e) => {
notifications.toasts.addDanger('Failed to run search');
return of({ request: req, response: e });
})
)
.toPromise();
}
function getNumeric(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.filter((f) => f.type === 'number' && f.aggregatable);
}
function formatFieldToComboBox(field?: IndexPatternField | null) {
if (!field) return [];
return formatFieldsToComboBox([field]);
}
function formatFieldsToComboBox(fields?: IndexPatternField[]) {
if (!fields) return [];
return fields?.map((field) => {
return {
label: field.displayName || field.name,
};
});
}
/**
* To make this demo more convincing it uses `shardDelay` agg which adds artificial delay to a search request,
* to enable `shardDelay` make sure to set `data.search.aggs.shardDelay.enabled: true` in your kibana.dev.yml
*/
function isShardDelayEnabled(data: DataPublicPluginStart): boolean {
try {
return !!data.search.aggs.types.get('shard_delay');
} catch (e) {
return false;
}
}
function NoShardDelayCallout() {
return (
<EuiCallOut
title={
<>
<EuiCode>shardDelay</EuiCode> is missing!
</>
}
color="warning"
iconType="help"
>
<p>
This demo works best with <EuiCode>shardDelay</EuiCode> aggregation which simulates slow
queries. <br />
We recommend to enable it in your <EuiCode>kibana.dev.yml</EuiCode>:
</p>
<EuiCodeBlock isCopyable={true}>data.search.aggs.shardDelay.enabled: true</EuiCodeBlock>
</EuiCallOut>
);
}

View file

@ -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 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 { TimeRange, Filter, Query, esFilters } from '../../../../src/plugins/data/public';
import { getStatesFromKbnUrl, setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public';
import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public';
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
export const SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR =
'SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR';
export interface AppUrlState {
filters?: Filter[];
query?: Query;
indexPatternId?: string;
numericFieldName?: string;
searchSessionId?: string;
}
export interface GlobalUrlState {
filters?: Filter[];
time?: TimeRange;
}
export type SearchSessionExamplesUrlGeneratorState = AppUrlState & GlobalUrlState;
export const createSearchSessionsExampleUrlGenerator = (
getStartServices: () => Promise<{
appBasePath: string;
}>
): UrlGeneratorsDefinition<typeof SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR> => ({
id: SEARCH_SESSIONS_EXAMPLES_APP_URL_GENERATOR,
createUrl: async (state: SearchSessionExamplesUrlGeneratorState) => {
const startServices = await getStartServices();
const appBasePath = startServices.appBasePath;
const path = `${appBasePath}/app/searchExamples/search-sessions`;
let url = setStateToKbnUrl<AppUrlState>(
STATE_STORAGE_KEY,
{
query: state.query,
filters: state.filters?.filter((f) => !esFilters.isFilterPinned(f)),
indexPatternId: state.indexPatternId,
numericFieldName: state.numericFieldName,
searchSessionId: state.searchSessionId,
} as AppUrlState,
{ useHash: false, storeInHashQuery: false },
path
);
url = setStateToKbnUrl<GlobalUrlState>(
GLOBAL_STATE_STORAGE_KEY,
{
time: state.time,
filters: state.filters?.filter((f) => esFilters.isFilterPinned(f)),
} as GlobalUrlState,
{ useHash: false, storeInHashQuery: false },
url
);
return url;
},
});
export function getInitialStateFromUrl(): SearchSessionExamplesUrlGeneratorState {
const {
_a: { numericFieldName, indexPatternId, searchSessionId, filters: aFilters, query } = {},
_g: { filters: gFilters, time } = {},
} = getStatesFromKbnUrl<{ _a: AppUrlState; _g: GlobalUrlState }>(
window.location.href,
['_a', '_g'],
{
getFromHashQuery: false,
}
);
return {
numericFieldName,
searchSessionId,
time,
filters: [...(gFilters ?? []), ...(aFilters ?? [])],
indexPatternId,
query,
};
}

View file

@ -9,6 +9,7 @@
import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public';
import { DataPublicPluginStart } from '../../../src/plugins/data/public';
import { DeveloperExamplesSetup } from '../../developer_examples/public';
import { SharePluginSetup } from '../../../src/plugins/share/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchExamplesPluginSetup {}
@ -17,6 +18,7 @@ export interface SearchExamplesPluginStart {}
export interface AppPluginSetupDependencies {
developerExamples: DeveloperExamplesSetup;
share: SharePluginSetup;
}
export interface AppPluginStartDependencies {

View file

@ -15,7 +15,7 @@ import { TimefilterService, TimefilterSetup } from './timefilter';
import { createSavedQueryService } from './saved_query/saved_query_service';
import { createQueryStateObservable } from './state_sync/create_global_query_observable';
import { QueryStringManager, QueryStringContract } from './query_string';
import { buildEsQuery, getEsQueryConfig } from '../../common';
import { buildEsQuery, getEsQueryConfig, TimeRange } from '../../common';
import { getUiSettings } from '../services';
import { NowProviderInternalContract } from '../now_provider';
import { IndexPattern } from '..';
@ -80,8 +80,8 @@ export class QueryService {
savedQueries: createSavedQueryService(savedObjectsClient),
state$: this.state$,
timefilter: this.timefilter,
getEsQuery: (indexPattern: IndexPattern) => {
const timeFilter = this.timefilter.timefilter.createFilter(indexPattern);
getEsQuery: (indexPattern: IndexPattern, timeRange?: TimeRange) => {
const timeFilter = this.timefilter.timefilter.createFilter(indexPattern, timeRange);
return buildEsQuery(
indexPattern,

View file

@ -53,6 +53,22 @@ describe('kbn_url_storage', () => {
expect(retrievedState2).toEqual(state2);
});
it('should set expanded state to url before hash', () => {
let newUrl = setStateToKbnUrl('_s', state1, { useHash: false, storeInHashQuery: false }, url);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')#/yourApp"`
);
const retrievedState1 = getStateFromKbnUrl('_s', newUrl, { getFromHashQuery: false });
expect(retrievedState1).toEqual(state1);
newUrl = setStateToKbnUrl('_s', state2, { useHash: false, storeInHashQuery: false }, newUrl);
expect(newUrl).toMatchInlineSnapshot(
`"http://localhost:5601/oxf/app/kibana?_s=(test:'123')#/yourApp"`
);
const retrievedState2 = getStateFromKbnUrl('_s', newUrl, { getFromHashQuery: false });
expect(retrievedState2).toEqual(state2);
});
it('should set hashed state to url', () => {
let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url);
expect(newUrl).toMatchInlineSnapshot(

View file

@ -15,7 +15,7 @@ import { replaceUrlHashQuery, replaceUrlQuery } from './format';
import { url as urlUtils } from '../../../common';
/**
* Parses a kibana url and retrieves all the states encoded into url,
* Parses a kibana url and retrieves all the states encoded into the URL,
* Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage)
* e.g.:
*
@ -23,22 +23,31 @@ import { url as urlUtils } from '../../../common';
* http://localhost:5601/oxf/app/kibana#/yourApp?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')
* will return object:
* {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}};
*
*
* By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL:
* http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE}
*
* { getFromHashQuery: false } option should be used in case state is stored in a main query (not in a hash):
* http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp
*
*/
export function getStatesFromKbnUrl(
export function getStatesFromKbnUrl<State extends object = Record<string, unknown>>(
url: string = window.location.href,
keys?: string[]
): Record<string, unknown> {
const query = parseUrlHash(url)?.query;
keys?: Array<keyof State>,
{ getFromHashQuery = true }: { getFromHashQuery: boolean } = { getFromHashQuery: true }
): State {
const query = getFromHashQuery ? parseUrlHash(url)?.query : parseUrl(url).query;
if (!query) return {};
if (!query) return {} as State;
const decoded: Record<string, unknown> = {};
Object.entries(query)
.filter(([key]) => (keys ? keys.includes(key) : true))
.filter(([key]) => (keys ? keys.includes(key as keyof State) : true))
.forEach(([q, value]) => {
decoded[q] = decodeState(value as string);
});
return decoded;
return decoded as State;
}
/**
@ -50,12 +59,20 @@ export function getStatesFromKbnUrl(
* and key '_a'
* will return object:
* {tab: 'indexedFields'}
*
*
* By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL:
* http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE}
*
* { getFromHashQuery: false } option should be used in case state is stored in a main query (not in a hash):
* http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp
*/
export function getStateFromKbnUrl<State>(
key: string,
url: string = window.location.href
url: string = window.location.href,
{ getFromHashQuery = true }: { getFromHashQuery: boolean } = { getFromHashQuery: true }
): State | null {
return (getStatesFromKbnUrl(url, [key])[key] as State) || null;
return (getStatesFromKbnUrl(url, [key], { getFromHashQuery })[key] as State) || null;
}
/**
@ -69,6 +86,12 @@ export function getStateFromKbnUrl<State>(
*
* will return url:
* http://localhost:5601/oxf/app/kibana#/yourApp?_a=(tab:other)&_b=(f:test,i:'',l:'')
*
* By default due to Kibana legacy reasons assumed that state is stored in a query inside a hash part of the URL:
* http://localhost:5601/oxf/app/kibana#/yourApp?_a={STATE}
*
* { storeInHashQuery: false } option should be used in you want to store you state in a main query (not in a hash):
* http://localhost:5601/oxf/app/kibana?_a={STATE}#/yourApp
*/
export function setStateToKbnUrl<State>(
key: string,

View file

@ -92,6 +92,7 @@ export class DataEnhancedPlugin
createConnectedSearchSessionIndicator({
sessionService: plugins.data.search.session,
application: core.application,
basePath: core.http.basePath,
timeFilter: plugins.data.query.timefilter.timefilter,
storage: this.storage,
disableSaveAfterSessionCompletesTimeout: moment

View file

@ -27,6 +27,7 @@ import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins
const coreStart = coreMock.createStart();
const application = coreStart.application;
const basePath = coreStart.http.basePath;
const dataStart = dataPluginMock.createStartContract();
const sessionService = dataStart.search.session as jest.Mocked<ISessionService>;
let storage: Storage;
@ -63,6 +64,7 @@ test("shouldn't show indicator in case no active search session", async () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const { getByTestId, container } = render(
<Container>
@ -91,6 +93,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const { getByTestId, container } = render(
<Container>
@ -121,6 +124,7 @@ test('should show indicator in case there is an active search session', async ()
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const { getByTestId } = render(
<Container>
@ -146,6 +150,7 @@ test('should be disabled in case uiConfig says so ', async () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
render(
@ -169,6 +174,7 @@ test('should be disabled in case not enough permissions', async () => {
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
basePath,
});
render(
@ -195,6 +201,7 @@ test('should be disabled during auto-refresh', async () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
render(
@ -233,6 +240,7 @@ describe('Completed inactivity', () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
render(
@ -294,6 +302,7 @@ describe('tour steps', () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const rendered = render(
<Container>
@ -335,6 +344,7 @@ describe('tour steps', () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const rendered = render(
<Container>
@ -370,6 +380,7 @@ describe('tour steps', () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const rendered = render(
<Container>
@ -397,6 +408,7 @@ describe('tour steps', () => {
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
});
const rendered = render(
<Container>

View file

@ -18,7 +18,7 @@ import {
SearchUsageCollector,
} from '../../../../../../../src/plugins/data/public';
import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public';
import { ApplicationStart } from '../../../../../../../src/core/public';
import { ApplicationStart, IBasePath } from '../../../../../../../src/core/public';
import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public';
import { useSearchSessionTour } from './search_session_tour';
@ -26,6 +26,7 @@ export interface SearchSessionIndicatorDeps {
sessionService: ISessionService;
timeFilter: TimefilterContract;
application: ApplicationStart;
basePath: IBasePath;
storage: IStorageWrapper;
/**
* Controls for how long we allow to save a session,
@ -42,7 +43,9 @@ export const createConnectedSearchSessionIndicator = ({
storage,
disableSaveAfterSessionCompletesTimeout,
usageCollector,
basePath,
}: SearchSessionIndicatorDeps): React.FC => {
const searchSessionsManagementUrl = basePath.prepend('/app/management/kibana/search_sessions');
const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause;
const isAutoRefreshEnabled$ = timeFilter
.getRefreshIntervalUpdate$()
@ -185,6 +188,7 @@ export const createConnectedSearchSessionIndicator = ({
onCancel={onCancel}
onOpened={onOpened}
onViewSearchSessions={onViewSearchSessions}
viewSearchSessionsLink={searchSessionsManagementUrl}
/>
</RedirectAppLinks>
);