mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Search Sessions] Search session example app (#89583)
This commit is contained in:
parent
28b5e63874
commit
a82fe33ed7
16 changed files with 1510 additions and 491 deletions
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
|
65
examples/search_examples/public/common/example_page.tsx
Normal file
65
examples/search_examples/public/common/example_page.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
433
examples/search_examples/public/search/app.tsx
Normal file
433
examples/search_examples/public/search/app.tsx
Normal 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>
|
||||
);
|
||||
};
|
BIN
examples/search_examples/public/search_examples.png
Normal file
BIN
examples/search_examples/public/search_examples.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 212 KiB |
768
examples/search_examples/public/search_sessions/app.tsx
Normal file
768
examples/search_examples/public/search_sessions/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue