kibana/examples/search_examples/public/search_sessions/app.tsx
Gerard Soldevila b24fdf5d3f
Sustainable Kibana Architecture: Categorise straightforward packages (#199630)
## Summary

This PR is part of the Kibana Sustainable Architecture effort.

The goal is to start categorising Kibana packages into _generic
platform_ (`group: "platform"`) vs _solution-specific_.

```
group?: 'search' | 'security' | 'observability' | 'platform'
visibility?: 'private' | 'shared'
```
Uncategorised modules are considered to be `group: 'common', visibility:
'shared'` by default.

We want to prevent code from solution A to depend on code from solution
B.
Thus, the rules are pretty simple:

* Modules can only depend on:
  * Modules in the same group
  * OR modules with 'shared' visibility
* Modules in `'observability', 'security', 'search'` groups are
mandatorily `visibility: "private"`.

Long term, the goal is to re-organise packages into dedicated folders,
e.g.:

```
x-pack/platform/plugins/private
x-pack/observability/packages
```

For this first wave, we have categorised packages that seem
"straightforward":
* Any packages that have:
  * at least one dependant module
  * all dependants belong to the same group
* Categorise all Core packages:
  * `@kbn/core-...-internal` => _platform/private_
  * everything else => _platform/shared_
* Categorise as _platform/shared_ those packages that:
  * Have at least one dependant in the _platform_ group.
  * Don't have any `devOnly: true` dependants.

### What we ask from you, as CODEOWNERS of the _package manifests_, is
that you confirm that the categorisation is correct:

* `group: "platform", visibility: "private"` if it's a package that
should only be used from platform code, not from any solution code. It
will be loaded systematically in all serverless flavors, but solution
plugins and packages won't be able to `import` from it.
* `group: "platform", visibility: "shared"` if it's a package that can
be consumed by both platform and solutions code. It will be loaded
systematically in all serverless flavors, and anybody can import / use
code from it.
* `group: "observability" | "security" | "search", visibility:
"private"` if it's a package that is intented to be used exclusively
from a given solution. It won't be accessible nor loaded from other
solutions nor platform code.

Please refer to
[#kibana-sustainable-architecture](https://elastic.slack.com/archives/C07TCKTA22E)
for any related questions.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
2024-11-22 10:33:25 +01:00

812 lines
27 KiB
TypeScript

/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", 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,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCode,
EuiCodeBlock,
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiLoadingSpinner,
EuiPageTemplate,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { catchError, map, tap } from 'rxjs';
import { lastValueFrom, of } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { TimeRange } from '@kbn/es-query';
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { IEsSearchRequest, IEsSearchResponse } from '@kbn/search-types';
import {
connectToQueryState,
DataPublicPluginStart,
isRunningResponse,
QueryState,
SearchSessionState,
} from '@kbn/data-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { createStateContainer, useContainerState } from '@kbn/kibana-utils-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { PLUGIN_ID } from '../../common';
import { getInitialStateFromUrl, SEARCH_SESSIONS_EXAMPLES_APP_LOCATOR } from './app_locator';
interface SearchSessionsExampleAppDeps {
notifications: CoreStart['notifications'];
analytics: CoreStart['analytics'];
i18n: CoreStart['i18n'];
theme: CoreStart['theme'];
navigation: NavigationPublicPluginStart;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}
/**
* 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 {
dataViewId?: string;
numericFieldName?: string;
/**
* If landed into the app with restore URL
*/
restoreSessionId?: string;
}
export const SearchSessionsExampleApp = ({
navigation,
data,
unifiedSearch,
...startServices
}: SearchSessionsExampleAppDeps) => {
const { IndexPatternSelect } = unifiedSearch.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,
dataView,
selectedField,
fields,
setDataView,
setNumericFieldName,
state,
} = useAppState({ data });
const isRestoring = !!state.restoreSessionId;
const enableSessionStorage = useCallback(() => {
data.search.session.enableStorage({
getName: async () => 'Search sessions example',
getLocatorData: async () => ({
initialState: {
time: data.query.timefilter.timefilter.getTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.getQuery(),
dataViewId: dataView?.id,
numericFieldName,
},
restoreState: {
time: data.query.timefilter.timefilter.getAbsoluteTime(),
filters: data.query.filterManager.getFilters(),
query: data.query.queryString.getQuery(),
dataViewId: dataView?.id,
numericFieldName,
searchSessionId: data.search.session.getSessionId(),
},
id: SEARCH_SESSIONS_EXAMPLES_APP_LOCATOR,
}),
});
}, [
data.query.filterManager,
data.query.queryString,
data.query.timefilter.timefilter,
data.search.session,
dataView?.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 (!dataView) return;
if (!numericFieldName) return;
setIsSearching(true);
const requestId = ++nextRequestIdRef.current;
doSearch({ dataView, numericFieldName, restoreSearchSessionId }, { data, ...startServices })
.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, dataView, numericFieldName, startServices]
);
useEffect(() => {
if (state.restoreSessionId) {
search(state.restoreSessionId);
}
}, [search, state.restoreSessionId]);
return (
<>
<EuiPageTemplate.Header pageTitle="Search session example" />
<EuiPageTemplate.Section grow={false}>
{!isShardDelayEnabled(data) && (
<>
<NoShardDelayCallout />
<EuiSpacer />
</>
)}
{!dataView && (
<>
<NoDataViewsCallout />
<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>
</EuiPageTemplate.Section>
<>
{!isRestoring && (
<>
<EuiPageTemplate.Section grow={false}>
<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={dataView ? [dataView] : undefined}
onQuerySubmit={reset}
/>
<EuiFlexGroup justifyContent={'flexStart'}>
<EuiFlexItem grow={false}>
<EuiFormLabel>Data view</EuiFormLabel>
<IndexPatternSelect
placeholder={i18n.translate('searchSessionExample.selectDataViewPlaceholder', {
defaultMessage: 'Select data view',
})}
indexPatternId={dataView?.id ?? ''}
onChange={(id) => {
if (!id) return;
setDataView(id);
}}
isClearable={false}
data-test-subj="dataViewSelector"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormLabel>Numeric Field to Aggregate</EuiFormLabel>
<EuiComboBox
options={formatFieldsToComboBox(getNumeric(fields))}
selectedOptions={formatFieldToComboBox(selectedField)}
singleSelection={true}
onChange={(option) => {
const fld = dataView?.getFieldByName(option[0].label);
if (!fld) return;
setNumericFieldName(fld?.name);
}}
sortMatchesBy="startsWith"
data-test-subj="searchMetricField"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate.Section>
<EuiPageTemplate.Section grow={false}>
<EuiTitle size="s">
<h2>
2. Start the search using <EuiCode>data.search</EuiCode> service
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
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.
<div>
{demoStep === DemoStep.ConfigureQuery && (
<EuiButtonEmpty
flush="both"
onClick={() => search()}
iconType="play"
disabled={isSearching || !dataView || !numericFieldName}
data-test-subj={'startSearch'}
>
Start the search from low-level client (data.search.search)
</EuiButtonEmpty>
)}
{isSearching && (
<>
<EuiSpacer />
<EuiLoadingSpinner />
</>
)}
{response && request && (
<>
<EuiSpacer />
<SearchInspector
accordionId={'1'}
request={request}
response={response}
tookMs={tookMs}
/>
</>
)}
</div>
</EuiText>
</EuiPageTemplate.Section>
{(demoStep === DemoStep.RunSession ||
demoStep === DemoStep.RestoreSessionOnScreen ||
demoStep === DemoStep.SaveSession) && (
<EuiPageTemplate.Section grow={false}>
<EuiTitle size="s">
<h2>3. Save your session</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
Use the search session indicator in the Kibana header to save the search session.
<div>
<EuiButtonEmpty
flush="both"
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>
</EuiPageTemplate.Section>
)}
{(demoStep === DemoStep.RestoreSessionOnScreen ||
demoStep === DemoStep.SaveSession) && (
<EuiPageTemplate.Section grow={false}>
<EuiTitle size="s">
<h2>4. Restore the session</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
Now you can restore your saved session. The same search request completes
significantly faster because it reuses stored results.
<div>
{!isSearching && !restoreResponse && (
<EuiButtonEmpty
flush="both"
iconType="refresh"
onClick={() => {
search(data.search.session.getSessionId());
}}
data-test-subj={'restoreSearch'}
>
Restore the search session
</EuiButtonEmpty>
)}
{isSearching && (
<>
<EuiSpacer />
<EuiLoadingSpinner />
</>
)}
{restoreRequest && restoreResponse && (
<>
<EuiSpacer />
<SearchInspector
accordionId={'2'}
request={restoreRequest}
response={restoreResponse}
tookMs={restoreTookMs}
/>
</>
)}
</div>
</EuiText>
</EuiPageTemplate.Section>
)}
{demoStep === DemoStep.RestoreSessionOnScreen && (
<EuiPageTemplate.Section grow={false}>
<EuiTitle size="s">
<h2>5. Restore from Management</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
You can also get back to your session from the Search Session Management.
<div>
<EuiButtonEmpty
flush="both"
iconType="refresh"
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>
</EuiPageTemplate.Section>
)}
</>
)}
{isRestoring && (
<EuiPageTemplate.Section grow={false}>
<EuiTitle size="s">
<h2>You restored the search session!</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText>
{isSearching && <EuiLoadingSpinner />}
{restoreRequest && restoreResponse && (
<SearchInspector
accordionId={'2'}
request={restoreRequest}
response={restoreResponse}
tookMs={restoreTookMs}
/>
)}
</EuiText>
</EuiPageTemplate.Section>
)}
<EuiPageTemplate.Section>
<EuiButton
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
</EuiButton>
</EuiPageTemplate.Section>
</>
</>
);
};
function SearchInspector({
accordionId,
response,
request,
tookMs,
}: {
accordionId: string;
response: IEsSearchResponse;
request: IEsSearchRequest;
tookMs: number | null;
}) {
return (
<div data-test-subj={`searchResults-${accordionId}`}>
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, dataViewId, 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,
dataViewId,
});
}, [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<DataViewField[]>();
const [dataView, setDataView] = useState<DataView | null>();
// Fetch the default data view using the `data.dataViews` service, as the component is mounted.
useEffect(() => {
let canceled = false;
const loadDataView = async () => {
// eslint-disable-next-line no-console
console.warn('Loading default data view');
let loadedDataView = state.dataViewId
? await data.dataViews.get(state.dataViewId)
: await data.dataViews.getDefault();
if (!loadedDataView) {
// try to find any available data view
const [id] = await data.dataViews.getIds(true);
if (id) {
loadedDataView = await data.dataViews.get(id);
}
}
if (canceled) return;
if (!loadedDataView) {
// eslint-disable-next-line no-console
console.warn('No data view to pick from');
return;
}
if (!state.dataViewId) {
setState({
dataViewId: loadedDataView.id,
});
}
setDataView(loadedDataView);
};
loadDataView();
return () => {
canceled = true;
};
}, [data, setState, state.dataViewId]);
// Update the fields list every time the data view is modified.
useEffect(() => {
setFields(dataView?.fields);
}, [dataView]);
useEffect(() => {
if (state.numericFieldName) return;
setState({ numericFieldName: fields?.length ? getNumeric(fields)[0]?.name : undefined });
}, [setState, fields, state.numericFieldName]);
const selectedField: DataViewField | undefined = useMemo(
() => dataView?.fields.find((field) => field.name === state.numericFieldName),
[dataView?.fields, state.numericFieldName]
);
return {
selectedField,
dataView,
numericFieldName: state.numericFieldName,
fields,
setNumericFieldName: (field: string) => setState({ numericFieldName: field }),
setDataView: (dataViewId: string) => setState({ dataViewId }),
state,
};
}
function doSearch(
{
dataView,
numericFieldName,
restoreSearchSessionId,
}: {
dataView: DataView;
numericFieldName: string;
restoreSearchSessionId?: string;
},
{
data,
notifications,
...startServices
}: {
data: DataPublicPluginStart;
notifications: CoreStart['notifications'];
analytics: CoreStart['analytics'];
i18n: CoreStart['i18n'];
theme: CoreStart['theme'];
}
): Promise<{ request: IEsSearchRequest; response: IEsSearchResponse; tookMs?: number }> {
if (!dataView) return Promise.reject('Select a data view');
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(dataView, 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(dataView, aggs).toDsl();
const req = {
params: {
index: dataView.title,
body: {
aggs: aggsDsl,
query,
},
},
};
const startTs = performance.now();
// Submit the search request using the `data.search` service.
return lastValueFrom(
data.search.search(req, { sessionId }).pipe(
tap((res) => {
if (!isRunningResponse(res)) {
const avgResult: number | undefined = res.rawResponse.aggregations
? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response
res.rawResponse.aggregations[1]?.value ?? res.rawResponse.aggregations[2]?.value
: undefined;
const message = (
<KibanaRenderContextProvider {...startServices}>
<EuiText>
Searched {res.rawResponse.hits.total as number} documents. <br />
The average of {numericFieldName} is {avgResult ? Math.floor(avgResult) : 0}
.
<br />
</EuiText>
</KibanaRenderContextProvider>
);
notifications.toasts.addSuccess({
title: 'Query result',
text: toMountPoint(message, startServices),
});
}
}),
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 });
})
)
);
}
function getNumeric(fields?: DataViewField[]) {
if (!fields) return [];
return fields?.filter((f) => f.type === 'number' && f.aggregatable);
}
function formatFieldToComboBox(field?: DataViewField | null) {
if (!field) return [];
return formatFieldsToComboBox([field]);
}
function formatFieldsToComboBox(fields?: DataViewField[]) {
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} language="yaml">
data.search.aggs.shardDelay.enabled: true
</EuiCodeBlock>
</EuiCallOut>
);
}
function NoDataViewsCallout() {
return (
<EuiCallOut title={<>Missing data views!</>} color="warning" iconType="help">
<p>This demo requires at least one data view.</p>
</EuiCallOut>
);
}