kibana/examples/search_examples/public/search_sessions/app.tsx
Kate Sosedova 0d2125427f
Remapping iInCircle and questionInCircle and deprecating help icon (#223142)
## Summary

This PR closes the issue https://github.com/elastic/kibana/issues/221380
and does 3 things:
1. Replaces all current usages of `iInCircle` with `info` (no visual
changes)
2. Replaces all current usages of `questionInCircle` with `question`(no
visual changes)
3. Replaces all current `help` icon usages with `question`(lifesaver
icon replaced with question)

## Screenshots:
![CleanShot 2025-06-18 at 10 34
01@2x](https://github.com/user-attachments/assets/4379e51e-7422-4570-b452-c17ee26f2d64)
![CleanShot 2025-06-18 at 10 34
13@2x](https://github.com/user-attachments/assets/270056c7-4502-47ef-874f-862149fa27ec)
![CleanShot 2025-06-18 at 10 34
04@2x](https://github.com/user-attachments/assets/1dff8faf-65b7-4208-b568-7718b1a6b729)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Anton Dosov <anton.dosov@elastic.co>
2025-06-25 14:52:04 -05: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'];
userProfile: CoreStart['userProfile'];
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'];
userProfile: CoreStart['userProfile'];
}
): 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,
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="question"
>
<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="question">
<p>This demo requires at least one data view.</p>
</EuiCallOut>
);
}