From 1263c818186d1e0ba23b6003f5f91af58e25b364 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Sun, 5 Nov 2023 17:57:36 -0800 Subject: [PATCH 01/86] implement query area section w/ assistant bar and w/out functionality Signed-off-by: Paul Sebastian --- .../common/live_tail/live_tail_button.tsx | 12 +- .../components/common/search/query_area.tsx | 77 +++++ public/components/common/search/search.tsx | 280 +++++++++--------- 3 files changed, 228 insertions(+), 141 deletions(-) create mode 100644 public/components/common/search/query_area.tsx diff --git a/public/components/common/live_tail/live_tail_button.tsx b/public/components/common/live_tail/live_tail_button.tsx index 0e53152fa3..66bb91591a 100644 --- a/public/components/common/live_tail/live_tail_button.tsx +++ b/public/components/common/live_tail/live_tail_button.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -//Define pop over interval options for live tail button in your plugin +// Define pop over interval options for live tail button in your plugin -import { EuiButton } from "@elastic/eui"; -import React, { useMemo } from "react"; -import { LiveTailProps } from "common/types/explorer"; +import { EuiButton } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { LiveTailProps } from 'common/types/explorer'; -//Live Tail Button +// Live Tail Button export const LiveTailButton = ({ isLiveTailOn, isLiveTailPopoverOpen, @@ -20,7 +20,7 @@ export const LiveTailButton = ({ const liveButton = useMemo(() => { return ( setIsLiveTailPopoverOpen(!isLiveTailPopoverOpen)} data-test-subj={dataTestSubj} diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx new file mode 100644 index 0000000000..d72be00214 --- /dev/null +++ b/public/components/common/search/query_area.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiCodeEditor, + EuiContextMenuPanel, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiPopover, +} from '@elastic/eui'; +import React from 'react'; + +export function QueryArea({ + languagePopOverButton, + isLanguagePopoverOpen, + closeLanguagePopover, + languagePopOverItems, +}: any) { + return ( + + + + + + + + + + + index name here + + + + + + + + + + + + + + + + + Go + + + + + + + ); +} diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index de450d09c1..52eaed7f8b 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -35,6 +35,7 @@ import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { Autocomplete } from './autocomplete'; import { DatePicker } from './date_picker'; import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; +import { QueryArea } from './query_area'; export interface IQueryBarProps { query: string; tempQuery: string; @@ -217,145 +218,154 @@ export const Search = (props: any) => { return (
- - {appLogEvents && ( - - - - Base Query - - - - )} - {!appLogEvents && ( - - - - - - )} - - { - onQuerySearch(queryLang); - }} - dslService={dslService} - getSuggestions={getSuggestions} - onItemSelect={onItemSelect} - tabId={tabId} - /> - showFlyout()} - onClickAriaLabel={'pplLinkShowFlyout'} - > - PPL - - - - - {!isLiveTailOn && ( - handleTimePickerChange(timeRange)} - handleTimeRangePickerRefresh={() => { - onQuerySearch(queryLang); - }} - /> - )} - - {showSaveButton && !showSavePanelOptionsList && ( - - - - - - )} - {isLiveTailOn && ( - - - - )} - {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( - <> - - setIsSavePanelOpen(false)} + + + + {appLogEvents && ( + + + + Base Query + + + + )} + {appLogEvents && ( + - { + onQuerySearch(queryLang); + }} + dslService={dslService} + getSuggestions={getSuggestions} + onItemSelect={onItemSelect} + tabId={tabId} + /> + showFlyout()} + onClickAriaLabel={'pplLinkShowFlyout'} + > + PPL + + + )} + + + {!isLiveTailOn && ( + + handleTimePickerChange(timeRange) } + handleTimeRangePickerRefresh={() => { + onQuerySearch(queryLang); + }} /> - - - - setIsSavePanelOpen(false)} - data-test-subj="eventExplorer__querySaveCancel" - > - Cancel - - - - { - handleSavingObject(); - setIsSavePanelOpen(false); - }} - data-test-subj="eventExplorer__querySaveConfirm" - > - Save - - - - - + )} - + {showSaveButton && !showSavePanelOptionsList && ( + + + + + + )} + {isLiveTailOn && ( + + + + )} + {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( + <> + + setIsSavePanelOpen(false)} + > + + + + + setIsSavePanelOpen(false)} + data-test-subj="eventExplorer__querySaveCancel" + > + Cancel + + + + { + handleSavingObject(); + setIsSavePanelOpen(false); + }} + data-test-subj="eventExplorer__querySaveConfirm" + > + Save + + + + + + + + )} + + + {!appLogEvents && ( + + + )} {flyout} From ff840895aa8bce51ef14436b11140944946d16f0 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Sun, 5 Nov 2023 18:04:15 -0800 Subject: [PATCH 02/86] repace refresh button with run Signed-off-by: Paul Sebastian --- public/components/common/search/date_picker.tsx | 1 + public/components/common/search/search.tsx | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 75087c9f13..81f43a9218 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -22,6 +22,7 @@ export function DatePicker(props: IDatePickerProps) { onTimeChange={handleTimeChange} onRefresh={handleTimeRangePickerRefresh} className="osdQueryBar__datePicker" + showUpdateButton={false} /> ); } diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 52eaed7f8b..78b5fe34d0 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -101,6 +101,7 @@ export const Search = (props: any) => { const [isLanguagePopoverOpen, setLanguagePopoverOpen] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); + const [timePicker, setTimePicker] = useState(['now', 'now']); // TODO: make sure this default value won't interfere with anything const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; @@ -274,15 +275,25 @@ export const Search = (props: any) => { setIsOutputStale={setIsOutputStale} liveStreamChecked={props.liveStreamChecked} onLiveStreamChange={props.onLiveStreamChange} - handleTimePickerChange={(timeRange: string[]) => - handleTimePickerChange(timeRange) - } + handleTimePickerChange={(timeRange: string[]) => setTimePicker(timeRange)} handleTimeRangePickerRefresh={() => { onQuerySearch(queryLang); }} /> )} + + { + onQuerySearch(queryLang); + handleTimePickerChange(timePicker); + }} + > + Run + + {showSaveButton && !showSavePanelOptionsList && ( Date: Thu, 14 Sep 2023 21:21:47 +0000 Subject: [PATCH 03/86] initial poc to add assistant dependency Signed-off-by: Joshua Li --- opensearch_dashboards.json | 5 +- .../components/ppl_visualization_model.tsx | 78 +++++++++++++++++++ public/dependencies/register_assistant.tsx | 62 +++++++++++++++ public/framework/core_refs.ts | 11 ++- public/plugin.ts | 4 + public/types.ts | 8 ++ 6 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 public/dependencies/components/ppl_visualization_model.tsx create mode 100644 public/dependencies/register_assistant.tsx diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 7abafdabd9..4a80c99087 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,7 +1,7 @@ { "id": "observabilityDashboards", "version": "3.0.0.0", - "opensearchDashboardsVersion": "3.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", "server": true, "ui": true, "requiredPlugins": [ @@ -19,6 +19,7 @@ "visualizations" ], "optionalPlugins": [ + "assistantDashboards", "managementOverview" ] -} \ No newline at end of file +} diff --git a/public/dependencies/components/ppl_visualization_model.tsx b/public/dependencies/components/ppl_visualization_model.tsx new file mode 100644 index 0000000000..68a01da448 --- /dev/null +++ b/public/dependencies/components/ppl_visualization_model.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCodeBlock, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import React from 'react'; +import { SavedVisualization } from '../../../common/types/explorer'; +import { SavedObjectVisualization } from '../../components/visualizations/saved_object_visualization'; +import { PPLSavedVisualizationClient } from '../../services/saved_objects/saved_object_client/ppl'; + +interface PPLVisualizationModelProps { + savedVisualization: SavedVisualization; + onClose: () => void; +} + +export const PPLVisualizationModal: React.FC = (props) => { + return ( + <> + + + {props.savedVisualization.name} + + + + +
+ {props.savedVisualization.query} + +
+
+ + + { + const response = await savePPLVisualization(props.savedVisualization); + props.onClose(); + window.open(`./observability-logs#/explorer/${response.objectId}`, '_blank'); + }} + fill + > + Save + + Close + + + ); +}; + +const savePPLVisualization = (savedVisualization: SavedVisualization) => { + const createParams = { + query: savedVisualization.query, + name: savedVisualization.name, + dateRange: [ + savedVisualization.selected_date_range.start, + savedVisualization.selected_date_range.end, + ], + fields: [], + timestamp: '', + type: savedVisualization.type, + sub_type: 'visualization', + }; + return PPLSavedVisualizationClient.getInstance().create(createParams); +}; diff --git a/public/dependencies/register_assistant.tsx b/public/dependencies/register_assistant.tsx new file mode 100644 index 0000000000..c84eb95a0b --- /dev/null +++ b/public/dependencies/register_assistant.tsx @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { merge } from 'lodash'; +import React from 'react'; +import { toMountPoint } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { SavedVisualization } from '../../common/types/explorer'; +import { SavedObjectVisualization } from '../components/visualizations/saved_object_visualization'; +import { coreRefs } from '../framework/core_refs'; +import { AssistantSetup } from '../types'; +import { PPLVisualizationModal } from './components/ppl_visualization_model'; + +export const registerAsssitantDependencies = (setup?: AssistantSetup) => { + if (!setup) return; + setup.registerContentRenderer('ppl_visualization', (content) => { + const params = content as Partial; + const savedVisualization = createSavedVisualization(params); + return ( + + ); + }); + + setup.registerActionExecutor('view_ppl_visualization', async (params) => { + const savedVisualization = createSavedVisualization(params as Partial); + const modal = coreRefs.core!.overlays.openModal( + toMountPoint( + modal.close()} + /> + ) + ); + }); +}; + +const createSavedVisualization = (params: Partial) => { + return merge( + { + query: params.query, + selected_date_range: { start: 'now-14d', end: 'now', text: '' }, + selected_timestamp: { name: 'timestamp', type: 'timestamp' }, + selected_fields: { tokens: [], text: '' }, + name: params.name, + description: '', + type: 'line', + sub_type: 'visualization', + }, + { + selected_date_range: params.selected_date_range, + selected_timestamp: params.selected_timestamp, + type: params.type, + } + ) as SavedVisualization; +}; diff --git a/public/framework/core_refs.ts b/public/framework/core_refs.ts index 652bb3d621..3504323e21 100644 --- a/public/framework/core_refs.ts +++ b/public/framework/core_refs.ts @@ -3,13 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ApplicationStart, ChromeStart, HttpStart, IToasts } from '../../../../src/core/public'; -import { SavedObjectsClientContract } from '../../../../src/core/public'; +import { + ApplicationStart, + ChromeStart, + CoreStart, + HttpStart, + IToasts, + SavedObjectsClientContract, +} from '../../../../src/core/public'; import PPLService from '../services/requests/ppl'; class CoreRefs { private static _instance: CoreRefs; + public core?: CoreStart; public http?: HttpStart; public savedObjectsClient?: SavedObjectsClientContract; public pplService?: PPLService; diff --git a/public/plugin.ts b/public/plugin.ts index 9ac1fe5883..cd7dd49e54 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -79,6 +79,7 @@ import { S3DataSource } from './framework/datasources/s3_datasource'; import { DataSourcePluggable } from './framework/datasource_pluggables/datasource_pluggable'; import { DirectSearch } from './components/common/search/sql_search'; import { Search } from './components/common/search/search'; +import { registerAsssitantDependencies } from './dependencies/register_assistant'; export class ObservabilityPlugin implements @@ -306,6 +307,8 @@ export class ObservabilityPlugin }, }); + registerAsssitantDependencies(setupDeps.assistantDashboards); + // Return methods that should be available to other plugins return {}; } @@ -313,6 +316,7 @@ export class ObservabilityPlugin public start(core: CoreStart, startDeps: AppPluginStartDependencies): ObservabilityStart { const pplService: PPLService = new PPLService(core.http); + coreRefs.core = core; coreRefs.http = core.http; coreRefs.savedObjectsClient = core.savedObjects.client; coreRefs.pplService = pplService; diff --git a/public/types.ts b/public/types.ts index 4b6cd96a4e..e21789dd01 100644 --- a/public/types.ts +++ b/public/types.ts @@ -21,12 +21,20 @@ export interface AppPluginStartDependencies { data: DataPublicPluginStart; } +type ContentRenderer = (content: unknown) => React.ReactElement; +type ActionExecutor = (params: Record) => void; +export interface AssistantSetup { + registerContentRenderer: (contentType: string, render: ContentRenderer) => void; + registerActionExecutor: (actionType: string, execute: ActionExecutor) => void; +} + export interface SetupDependencies { embeddable: EmbeddableSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; uiActions: UiActionsStart; managementOverview?: ManagementOverViewPluginSetup; + assistantDashboards?: AssistantSetup; } // eslint-disable-next-line @typescript-eslint/no-empty-interface From 92fae57c732dbef6ed4b1ef5059e3153c693741c Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Wed, 27 Sep 2023 20:49:41 +0000 Subject: [PATCH 04/86] port changes for using assistant in event analytics Changes are from git diff 338fba7..7344cfa -- public ':!public/components/llm_chat' Signed-off-by: Joshua Li --- .../components/common/search/date_picker.tsx | 36 ++- public/components/common/search/search.tsx | 5 + .../event_analytics/explorer/explorer.tsx | 35 +- .../explorer/llm/feedback_modal.tsx | 303 ++++++++++++++++++ .../event_analytics/explorer/llm/input.tsx | 269 ++++++++++++++++ .../components/event_analytics/home/home.tsx | 4 +- .../redux/slices/query_slice.ts | 2 +- public/dependencies/register_assistant.tsx | 2 + public/framework/core_refs.ts | 1 + public/types.ts | 1 + 10 files changed, 638 insertions(+), 20 deletions(-) create mode 100644 public/components/event_analytics/explorer/llm/feedback_modal.tsx create mode 100644 public/components/event_analytics/explorer/llm/input.tsx diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 81f43a9218..146cf725b4 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -3,17 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { EuiSuperDatePicker, EuiToolTip } from '@elastic/eui'; import React from 'react'; -import { EuiSuperDatePicker } from '@elastic/eui'; -import { IDatePickerProps } from './search'; import { uiSettingsService } from '../../../../common/utils'; +import { coreRefs } from '../../../framework/core_refs'; +import { IDatePickerProps } from './search'; export function DatePicker(props: IDatePickerProps) { const { startTime, endTime, handleTimePickerChange, handleTimeRangePickerRefresh } = props; + const fixedStartTime = 'now-40y'; + const fixedEndTime = 'now'; - const handleTimeChange = (e: any) => handleTimePickerChange([e.start, e.end]); + const handleTimeChange = (e: any) => { + if (coreRefs.assistantEnabled) { + handleTimePickerChange([e.start, e.end]); + } else { + handleTimePickerChange(['now-40y', 'now']); + } + }; - return ( + return coreRefs.assistantEnabled ? ( + ) : ( + <> + + + + ); } diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 78b5fe34d0..e06cb0ce40 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -14,8 +14,10 @@ import { EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, + EuiLink, EuiPopover, EuiPopoverFooter, + EuiText, EuiToolTip, } from '@elastic/eui'; import { isEqual } from 'lodash'; @@ -28,6 +30,7 @@ import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; +import { LLMInput, SubmitPPLButton } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; @@ -36,6 +39,7 @@ import { Autocomplete } from './autocomplete'; import { DatePicker } from './date_picker'; import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { QueryArea } from './query_area'; +import './search.scss'; export interface IQueryBarProps { query: string; tempQuery: string; @@ -100,6 +104,7 @@ export const Search = (props: any) => { const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); const [isLanguagePopoverOpen, setLanguagePopoverOpen] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [isQueryBarVisible, setIsQueryBarVisible] = useState(!coreRefs.assistantEnabled); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); const [timePicker, setTimePicker] = useState(['now', 'now']); // TODO: make sure this default value won't interfere with anything const sqlService = new SQLService(coreRefs.http); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 1f4806b3c0..93f2e9a8f8 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -225,12 +225,14 @@ export const Explorer = ({ const liveTailNameRef = useRef('Live'); const savedObjectLoader = useRef(undefined); const isObjectIdUpdatedFromSave = useRef(false); // Flag to prevent reload when the current search's objectId changes due to a save operation. + const tempQueryRef = useRef(''); queryRef.current = query; selectedPanelNameRef.current = selectedPanelName; explorerFieldsRef.current = explorerFields; isLiveTailOnRef.current = isLiveTailOn; liveTailTabIdRef.current = liveTailTabId; liveTailNameRef.current = liveTailName; + tempQueryRef.current = tempQuery; const findAutoInterval = (start: string = '', end: string = '') => { const momentStart = dateMath.parse(start)!; @@ -683,20 +685,25 @@ export const Explorer = ({ ); }; - const handleQuerySearch = useCallback( - async (availability?: boolean) => { - // clear previous selected timestamp when index pattern changes - if (isIndexPatternChanged(tempQuery, query[RAW_QUERY])) { - await dispatch(changeQuery({ tabId, query: { [SELECTED_TIMESTAMP]: '' } })); - await setDefaultPatternsField('', ''); - } - if (availability !== true) { - await updateQueryInStore(tempQuery); - } - await fetchData(startTime, endTime); - }, - [tempQuery, query] - ); + const handleQuerySearch = async (availability?: boolean) => { + // clear previous selected timestamp when index pattern changes + const searchedQuery = tempQueryRef.current; + if (isIndexPatternChanged(searchedQuery, query[RAW_QUERY])) { + await dispatch( + changeQuery({ + tabId, + query: { + [SELECTED_TIMESTAMP]: '', + }, + }) + ); + await setDefaultPatternsField('', ''); + } + if (availability !== true) { + await updateQueryInStore(searchedQuery); + } + await fetchData(); + }; const handleQueryChange = async (newQuery: string) => setTempQuery(newQuery); diff --git a/public/components/event_analytics/explorer/llm/feedback_modal.tsx b/public/components/event_analytics/explorer/llm/feedback_modal.tsx new file mode 100644 index 0000000000..3926c2d8f7 --- /dev/null +++ b/public/components/event_analytics/explorer/llm/feedback_modal.tsx @@ -0,0 +1,303 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiRadioGroup, + EuiTextArea, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { HttpStart } from '../../../../../../../src/core/public'; +import { getPPLService } from '../../../../../common/utils'; +import { coreRefs } from '../../../../framework/core_refs'; + +export interface LabelData { + formHeader: string; + inputPlaceholder: string; + outputPlaceholder: string; +} + +export interface FeedbackFormData { + input: string; + output: string; + correct: boolean | undefined; + expectedOutput: string; + comment: string; +} + +interface FeedbackMetaData { + type: 'event_analytics' | 'chat' | 'ppl_submit'; + chatId?: string; + sessionId?: string; + error?: boolean; + selectedIndex?: string; +} + +interface FeedbackModelProps { + input?: string; + output?: string; + metadata: FeedbackMetaData; + onClose: () => void; +} + +export const FeedbackModal: React.FC = (props) => { + const [formData, setFormData] = useState({ + input: props.input ?? '', + output: props.output ?? '', + correct: undefined, + expectedOutput: '', + comment: '', + }); + return ( + + + + ); +}; + +interface FeedbackModalContentProps { + formData: FeedbackFormData; + setFormData: React.Dispatch>; + metadata: FeedbackMetaData; + displayLabels?: Partial> & Partial; + onClose: () => void; +} + +export const FeedbackModalContent: React.FC = (props) => { + const labels: NonNullable> = Object.assign( + { + formHeader: 'LLM Feedback', + inputPlaceholder: 'Your input question', + input: 'Input question', + outputPlaceholder: 'The LLM response', + output: 'Output', + correct: 'Does the output match your expectations?', + expectedOutput: 'Expected output', + comment: 'Comment', + }, + props.displayLabels + ); + const { loading, submitFeedback } = useSubmitFeedback( + props.formData, + props.metadata, + coreRefs.http! + ); + const [formErrors, setFormErrors] = useState< + Partial<{ [x in keyof FeedbackFormData]: string[] }> + >({ + input: [], + output: [], + expectedOutput: [], + }); + + const hasError = (key?: keyof FeedbackFormData) => { + if (!key) return Object.values(formErrors).some((e) => !!e.length); + return !!formErrors[key]?.length; + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const errors = { + input: validator + .input(props.formData.input) + .concat(await validator.validateQuery(props.formData.input, props.metadata.type)), + output: validator.output(props.formData.output), + correct: validator.correct(props.formData.correct), + expectedOutput: validator.expectedOutput( + props.formData.expectedOutput, + props.formData.correct === false + ), + }; + if (Object.values(errors).some((e) => !!e.length)) { + setFormErrors(errors); + return; + } + + try { + await submitFeedback(); + props.setFormData({ + input: '', + output: '', + correct: undefined, + expectedOutput: '', + comment: '', + }); + coreRefs.toasts?.addSuccess('Thanks for your feedback!'); + props.onClose(); + } catch (e) { + coreRefs.toasts?.addError(e, { title: 'Failed to submit feedback' }); + } + }; + + return ( + <> + + {labels.formHeader} + + + + + + props.setFormData({ ...props.formData, input: e.target.value })} + onBlur={(e) => { + setFormErrors({ ...formErrors, input: validator.input(e.target.value) }); + }} + isInvalid={hasError('input')} + /> + + + props.setFormData({ ...props.formData, output: e.target.value })} + onBlur={(e) => { + setFormErrors({ ...formErrors, output: validator.output(e.target.value) }); + }} + isInvalid={hasError('output')} + /> + + {props.metadata.type !== 'ppl_submit' && ( + + { + props.setFormData({ ...props.formData, correct: id === 'yes' }); + setFormErrors({ ...formErrors, expectedOutput: [] }); + }} + onBlur={() => setFormErrors({ ...formErrors, correct: [] })} + /> + + )} + {props.formData.correct === false && ( + + + props.setFormData({ ...props.formData, expectedOutput: e.target.value }) + } + onBlur={(e) => { + setFormErrors({ + ...formErrors, + expectedOutput: validator.expectedOutput( + e.target.value, + props.formData.correct === false + ), + }); + }} + isInvalid={hasError('expectedOutput')} + /> + + )} + + props.setFormData({ ...props.formData, comment: e.target.value })} + /> + + + + + + Cancel + + Send + + + + ); +}; + +const useSubmitFeedback = (data: FeedbackFormData, metadata: FeedbackMetaData, http: HttpStart) => { + const [loading, setLoading] = useState(false); + + return { + loading, + submitFeedback: () => { + setLoading(true); + return http + .post('/api/assistant/feedback', { body: JSON.stringify({ metadata, ...data }) }) + .finally(() => setLoading(false)); + }, + }; +}; + +const validatePPLQuery = async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => { + let responseMessage: [] | string[] = []; + const errorMessage = [' Invalid PPL Query, please re-check the ppl syntax']; + + if (feedBackType === 'ppl_submit') { + const pplService = getPPLService(); + await pplService + .fetch({ query: logsQuery, format: 'jdbc' }) + .then((res) => { + if (res === undefined) responseMessage = errorMessage; + }) + .catch((error: Error) => { + responseMessage = errorMessage; + }); + } + return responseMessage; +}; + +const validator = { + input: (text: string) => (text.trim().length === 0 ? ['Input is required'] : []), + output: (text: string) => (text.trim().length === 0 ? ['Output is required'] : []), + correct: (correct: boolean | undefined) => + correct === undefined ? ['Correctness is required'] : [], + expectedOutput: (text: string, required: boolean) => + required && text.trim().length === 0 ? ['expectedOutput is required'] : [], + validateQuery: async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => + await validatePPLQuery(logsQuery, feedBackType), +}; diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx new file mode 100644 index 0000000000..8a26e9eda7 --- /dev/null +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -0,0 +1,269 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiModal, +} from '@elastic/eui'; +import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; +import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; +import { RAW_QUERY } from '../../../../../common/constants/explorer'; +import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; +import { getOSDHttp } from '../../../../../common/utils'; +import { coreRefs } from '../../../../framework/core_refs'; +import { changeQuery } from '../../redux/slices/query_slice'; +import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; + +interface Props { + handleQueryChange: (query: string) => void; + handleTimeRangePickerRefresh: () => void; + tabId: string; +} +export const LLMInput: React.FC = (props) => { + const dispatch = useDispatch(); + const questionRef = useRef(null); + + const { data: indices, loading: indicesLoading } = useCatIndices(); + const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); + const [generating, setGenerating] = useState(false); + const [selectedIndex, setSelectedIndex] = useState([ + { label: 'opensearch_dashboards_sample_data_flights' }, + ]); + const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); + const [feedbackFormData, setFeedbackFormData] = useState({ + input: '', + output: '', + correct: undefined, + expectedOutput: '', + comment: '', + }); + const data = + indexPatterns && indices + ? [...indexPatterns, ...indices].filter( + (v1, index, array) => array.findIndex((v2) => v1.label === v2.label) === index + ) + : undefined; + const loading = indicesLoading || indexPatternsLoading; + + useEffect(() => { + if (questionRef.current) { + questionRef.current.value = 'what are the longest flights in the past day'; + } + }, []); + + // hide if not in a tab + if (props.tabId === '') return null; + + const request = async () => { + if (!selectedIndex.length) return; + try { + setGenerating(true); + const response = await getOSDHttp().post('/api/assistant/generate_ppl', { + body: JSON.stringify({ + question: questionRef.current?.value, + index: selectedIndex[0].label, + }), + }); + setFeedbackFormData({ + ...feedbackFormData, + input: questionRef.current?.value || '', + output: response, + }); + await props.handleQueryChange(response); + await dispatch( + changeQuery({ + tabId: props.tabId, + data: { + [RAW_QUERY]: response, + }, + }) + ); + await props.handleTimeRangePickerRefresh(); + } catch (error) { + setFeedbackFormData({ + ...feedbackFormData, + input: questionRef.current?.value || '', + }); + coreRefs.toasts?.addError(error, { title: 'Failed to generate PPL query' }); + } finally { + setGenerating(false); + } + }; + + return ( + <> + + + setSelectedIndex(index)} + /> + + + + + + + Predict + + + + {isFeedbackOpen && ( + setIsFeedbackOpen(false)}> + setIsFeedbackOpen(false)} + displayLabels={{ + correct: 'Did the results from the generated query answer your question?', + }} + /> + + )} + + ); +}; + +interface State { + data?: T; + loading: boolean; + error?: Error; +} + +type Action = + | { type: 'request' } + | { type: 'success'; payload: State['data'] } + | { type: 'failure'; error: NonNullable['error']> }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +export type GenericReducer = Reducer, Action>; +export const genericReducer: GenericReducer = (state, action) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: action.error }; + default: + return state; + } +}; + +export const useCatIndices = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + getOSDHttp() + .get(`${DSL_BASE}${DSL_CAT}`, { query: { format: 'json' }, signal: abortController.signal }) + .then((payload: CatIndicesResponse) => + dispatch({ type: 'success', payload: payload.map((meta) => ({ label: meta.index! })) }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; + +export const useGetIndexPatterns = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + let abort = false; + dispatch({ type: 'request' }); + + coreRefs + .savedObjectsClient!.find({ type: 'index-pattern', perPage: 10000 }) + .then((payload) => { + if (!abort) + dispatch({ + type: 'success', + payload: payload.savedObjects.map((meta) => ({ label: meta.attributes.title })), + }); + }) + .catch((error) => { + if (!abort) dispatch({ type: 'failure', error }); + }); + + return () => { + abort = true; + }; + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; + +export const SubmitPPLButton: React.FC<{ pplQuery: string }> = (props) => { + const [isSubmitOpen, setIsSubmitOpen] = useState(false); + const [submitFormData, setSubmitFormData] = useState({ + input: props.pplQuery, + output: '', + correct: true, + expectedOutput: '', + comment: '', + }); + + useEffect(() => { + setSubmitFormData({ + input: props.pplQuery, + output: '', + correct: true, + expectedOutput: '', + comment: '', + }); + }, [props.pplQuery]); + + return ( + <> + setIsSubmitOpen(true)}> + Submit PPL Query + + {isSubmitOpen && ( + setIsSubmitOpen(false)}> + setIsSubmitOpen(false)} + displayLabels={{ + formHeader: 'Submit PPL Query', + input: 'Your PPL Query', + inputPlaceholder: 'PPL Query', + output: 'Please write a Natural Language Question for the above Query', + outputPlaceholder: 'Natural Language Question', + }} + /> + + )} + + ); +}; diff --git a/public/components/event_analytics/home/home.tsx b/public/components/event_analytics/home/home.tsx index b567014131..f4a7aeb6d1 100644 --- a/public/components/event_analytics/home/home.tsx +++ b/public/components/event_analytics/home/home.tsx @@ -70,7 +70,9 @@ interface IHomeProps { const EventAnalyticsHome = (props: IHomeProps) => { const { setToast, http } = props; const history = useHistory(); - const [selectedDateRange, setSelectedDateRange] = useState(['now-15m', 'now']); + const dispatch = useDispatch(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedDateRange, setSelectedDateRange] = useState(['now-40y', 'now']); const [savedHistories, setSavedHistories] = useState([]); const [selectedHistories, setSelectedHistories] = useState([]); const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); diff --git a/public/components/event_analytics/redux/slices/query_slice.ts b/public/components/event_analytics/redux/slices/query_slice.ts index 38a9e41f81..90be708a1e 100644 --- a/public/components/event_analytics/redux/slices/query_slice.ts +++ b/public/components/event_analytics/redux/slices/query_slice.ts @@ -27,7 +27,7 @@ const initialQueryState = { [PATTERN_REGEX]: PPL_DEFAULT_PATTERN_REGEX_FILETER, [FILTERED_PATTERN]: '', [SELECTED_TIMESTAMP]: '', - [SELECTED_DATE_RANGE]: ['now-15m', 'now'], + [SELECTED_DATE_RANGE]: ['now-5y', 'now'], }; const appBaseQueryState = { diff --git a/public/dependencies/register_assistant.tsx b/public/dependencies/register_assistant.tsx index c84eb95a0b..e73b45bb1f 100644 --- a/public/dependencies/register_assistant.tsx +++ b/public/dependencies/register_assistant.tsx @@ -14,6 +14,8 @@ import { PPLVisualizationModal } from './components/ppl_visualization_model'; export const registerAsssitantDependencies = (setup?: AssistantSetup) => { if (!setup) return; + setup.assistantEnabled().then((enabled) => (coreRefs.assistantEnabled = enabled)); + setup.registerContentRenderer('ppl_visualization', (content) => { const params = content as Partial; const savedVisualization = createSavedVisualization(params); diff --git a/public/framework/core_refs.ts b/public/framework/core_refs.ts index 3504323e21..f45b22c0a3 100644 --- a/public/framework/core_refs.ts +++ b/public/framework/core_refs.ts @@ -23,6 +23,7 @@ class CoreRefs { public toasts?: IToasts; public chrome?: ChromeStart; public application?: ApplicationStart; + public assistantEnabled?: boolean; private constructor() { // ... } diff --git a/public/types.ts b/public/types.ts index e21789dd01..81ea36389d 100644 --- a/public/types.ts +++ b/public/types.ts @@ -26,6 +26,7 @@ type ActionExecutor = (params: Record) => void; export interface AssistantSetup { registerContentRenderer: (contentType: string, render: ContentRenderer) => void; registerActionExecutor: (actionType: string, execute: ActionExecutor) => void; + assistantEnabled: () => Promise; } export interface SetupDependencies { From 7eb7a170d5d5bd90d2e7e82778c6f72d948d1a90 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Tue, 31 Oct 2023 21:30:42 +0000 Subject: [PATCH 05/86] enable feedback button on log explorer Signed-off-by: Joshua Li --- .../components/event_analytics/explorer/llm/feedback_modal.tsx | 2 +- public/components/event_analytics/explorer/llm/input.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/llm/feedback_modal.tsx b/public/components/event_analytics/explorer/llm/feedback_modal.tsx index 3926c2d8f7..dbfe3b9267 100644 --- a/public/components/event_analytics/explorer/llm/feedback_modal.tsx +++ b/public/components/event_analytics/explorer/llm/feedback_modal.tsx @@ -81,7 +81,7 @@ interface FeedbackModalContentProps { export const FeedbackModalContent: React.FC = (props) => { const labels: NonNullable> = Object.assign( { - formHeader: 'LLM Feedback', + formHeader: 'Olly Skills Feedback', inputPlaceholder: 'Your input question', input: 'Input question', outputPlaceholder: 'The LLM response', diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 8a26e9eda7..dcac5f4f24 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -127,6 +127,9 @@ export const LLMInput: React.FC = (props) => { Predict
+ + setIsFeedbackOpen(true)}>Feedback + {isFeedbackOpen && ( setIsFeedbackOpen(false)}> From c6b7546c5bd32c63f107519cda86c63c7a08dd01 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 2 Nov 2023 21:41:49 +0000 Subject: [PATCH 06/86] update layout of llm input in log explorer Signed-off-by: Joshua Li --- public/components/common/search/search.tsx | 3 +- .../event_analytics/explorer/llm/input.tsx | 80 ++++++++++++------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index e06cb0ce40..33fcdf6a84 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -14,10 +14,9 @@ import { EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, - EuiLink, EuiPopover, EuiPopoverFooter, - EuiText, + EuiSpacer, EuiToolTip, } from '@elastic/eui'; import { isEqual } from 'lodash'; diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index dcac5f4f24..8a7e1e45e8 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -4,6 +4,7 @@ */ import { + EuiAccordion, EuiButton, EuiComboBox, EuiComboBoxOptionOption, @@ -11,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiModal, + EuiSpacer, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; @@ -61,7 +63,7 @@ export const LLMInput: React.FC = (props) => { }, []); // hide if not in a tab - if (props.tabId === '') return null; + if (props.tabId === '') return props.children; const request = async () => { if (!selectedIndex.length) return; @@ -101,36 +103,52 @@ export const LLMInput: React.FC = (props) => { return ( <> - - - setSelectedIndex(index)} - /> - - - - - - - Predict - - - - setIsFeedbackOpen(true)}>Feedback - - + setSelectedIndex(index)} + /> + + {props.children} + + + + + + + + + Go + + + + setIsFeedbackOpen(true)} + iconType="faceHappy" + iconSide="right" + > + Feedback + + + + {isFeedbackOpen && ( setIsFeedbackOpen(false)}> Date: Fri, 3 Nov 2023 21:34:14 +0000 Subject: [PATCH 07/86] update default question for query assist Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 8a7e1e45e8..660435d518 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -38,7 +38,7 @@ export const LLMInput: React.FC = (props) => { const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); const [generating, setGenerating] = useState(false); const [selectedIndex, setSelectedIndex] = useState([ - { label: 'opensearch_dashboards_sample_data_flights' }, + { label: 'sso_logs-*-*' }, ]); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); const [feedbackFormData, setFeedbackFormData] = useState({ @@ -58,7 +58,7 @@ export const LLMInput: React.FC = (props) => { useEffect(() => { if (questionRef.current) { - questionRef.current.value = 'what are the longest flights in the past day'; + questionRef.current.value = 'Are there any errors in my logs?'; } }, []); @@ -120,7 +120,7 @@ export const LLMInput: React.FC = (props) => { Date: Fri, 3 Nov 2023 21:35:12 +0000 Subject: [PATCH 08/86] add username and tenant to feedback Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/feedback_modal.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/feedback_modal.tsx b/public/components/event_analytics/explorer/llm/feedback_modal.tsx index dbfe3b9267..7695e417cd 100644 --- a/public/components/event_analytics/explorer/llm/feedback_modal.tsx +++ b/public/components/event_analytics/explorer/llm/feedback_modal.tsx @@ -261,13 +261,20 @@ export const FeedbackModalContent: React.FC = (props) const useSubmitFeedback = (data: FeedbackFormData, metadata: FeedbackMetaData, http: HttpStart) => { const [loading, setLoading] = useState(false); - return { loading, - submitFeedback: () => { + submitFeedback: async () => { setLoading(true); + const auth = await http + .get<{ data: { user_name: string; user_requested_tenant: string; roles: string[] } }>( + '/api/v1/configuration/account' + ) + .then((res) => ({ user: res.data.user_name, tenant: res.data.user_requested_tenant })); + return http - .post('/api/assistant/feedback', { body: JSON.stringify({ metadata, ...data }) }) + .post('/api/assistant/feedback', { + body: JSON.stringify({ metadata: { ...metadata, ...auth }, ...data }), + }) .finally(() => setLoading(false)); }, }; From 3ff0555b02ac303fb228fcbf7ba2f0bb36eccd61 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 4 Nov 2023 19:04:31 +0000 Subject: [PATCH 09/86] try to remove error toast Signed-off-by: Joshua Li --- public/services/data_fetchers/ppl/ppl_data_fetcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/services/data_fetchers/ppl/ppl_data_fetcher.ts b/public/services/data_fetchers/ppl/ppl_data_fetcher.ts index 68717b74a2..9aa0379451 100644 --- a/public/services/data_fetchers/ppl/ppl_data_fetcher.ts +++ b/public/services/data_fetchers/ppl/ppl_data_fetcher.ts @@ -49,9 +49,9 @@ export class PPLDataFetcher extends DataFetcherBase implements IDataFetcher { }); } } catch (error) { - this.notifications.toasts.addError(error, { + /* this.notifications.toasts.addError(error, { title: 'Unable to get default timestamp', - }); + }); */ } } From 60b36adc0d783fc3602948f6b188c4fd4a251d46 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 4 Nov 2023 19:04:56 +0000 Subject: [PATCH 10/86] use form for NLQ input field Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 660435d518..f908ce5fec 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -11,6 +11,7 @@ import { EuiFieldText, EuiFlexGroup, EuiFlexItem, + EuiForm, EuiModal, EuiSpacer, } from '@elastic/eui'; @@ -116,38 +117,49 @@ export const LLMInput: React.FC = (props) => { {props.children} - - - - - - - - Go - - - - setIsFeedbackOpen(true)} - iconType="faceHappy" - iconSide="right" - > - Feedback - - - + { + e.preventDefault(); + request(); + }} + > + + + + + + + + Go + + + + setIsFeedbackOpen(true)} + iconType="faceHappy" + iconSide="right" + > + Feedback + + + + {isFeedbackOpen && ( setIsFeedbackOpen(false)}> From 39690a53b9c854fb56114ec234fa9cc9cd631137 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 10 Nov 2023 11:05:54 -0800 Subject: [PATCH 11/86] small ui changes Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 66 ++++++-- public/components/common/search/search.tsx | 5 +- .../icons/query-assistant-logo.svg | 16 ++ .../event_analytics/explorer/llm/input.tsx | 158 ++++++++++-------- .../components/event_analytics/home/home.tsx | 2 +- 5 files changed, 162 insertions(+), 85 deletions(-) create mode 100644 public/components/datasources/icons/query-assistant-logo.svg diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index d72be00214..f53fb3a012 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -6,6 +6,7 @@ import { EuiButton, EuiCodeEditor, + EuiComboBox, EuiContextMenuPanel, EuiFieldText, EuiFlexGroup, @@ -14,6 +15,7 @@ import { EuiPanel, EuiPopover, } from '@elastic/eui'; +import { LLMInput, SubmitPPLButton } from '../../event_analytics/explorer/llm/input'; import React from 'react'; export function QueryArea({ @@ -21,11 +23,34 @@ export function QueryArea({ isLanguagePopoverOpen, closeLanguagePopover, languagePopOverItems, + tabId, + handleQueryChange, + handleTimeRangePickerRefresh, + tempQuery, }: any) { return ( - + + + + + + + + + {/* - index name here + setSelectedIndex(index)} + /> - - - @@ -70,6 +92,22 @@ export function QueryArea({ + */} + + { + handleQueryChange(query); + }} + value={tempQuery} + /> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 33fcdf6a84..81eb7cbcef 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -29,7 +29,6 @@ import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; -import { LLMInput, SubmitPPLButton } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; @@ -379,6 +378,10 @@ export const Search = (props: any) => { isLanguagePopoverOpen={isLanguagePopoverOpen} closeLanguagePopover={closeLanguagePopover} languagePopOverItems={languagePopOverItems} + tabId={tabId} + handleQueryChange={handleQueryChange} + handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} + tempQuery={tempQuery} /> )} diff --git a/public/components/datasources/icons/query-assistant-logo.svg b/public/components/datasources/icons/query-assistant-logo.svg new file mode 100644 index 0000000000..7bd56be25e --- /dev/null +++ b/public/components/datasources/icons/query-assistant-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index f908ce5fec..ecde80efe0 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -5,6 +5,7 @@ import { EuiAccordion, + EuiBadge, EuiButton, EuiComboBox, EuiComboBoxOptionOption, @@ -12,8 +13,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiForm, + EuiIcon, EuiModal, + EuiPanel, EuiSpacer, + EuiToken, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; @@ -25,6 +29,7 @@ import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; +import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; interface Props { handleQueryChange: (query: string) => void; @@ -70,6 +75,7 @@ export const LLMInput: React.FC = (props) => { if (!selectedIndex.length) return; try { setGenerating(true); + // const response = 'source = opensearch_dashboards_sample_data_logs'; const response = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ question: questionRef.current?.value, @@ -104,76 +110,90 @@ export const LLMInput: React.FC = (props) => { return ( <> - setSelectedIndex(index)} - /> - - {props.children} - - { - e.preventDefault(); - request(); - }} - > - - - - - - - - Go - - - - setIsFeedbackOpen(true)} - iconType="faceHappy" - iconSide="right" - > - Feedback - - - - - - {isFeedbackOpen && ( - setIsFeedbackOpen(false)}> - setIsFeedbackOpen(false)} - displayLabels={{ - correct: 'Did the results from the generated query answer your question?', + + + {props.children} + + setSelectedIndex(index)} + /> + + + + + + {/* */} + { + e.preventDefault(); + request(); }} - /> - - )} + > + + + + + + New! + + + + + + + Go + + + {/* + setIsFeedbackOpen(true)} + iconType="faceHappy" + iconSide="right" + > + Feedback + + */} + + + {/* */} + {isFeedbackOpen && ( + setIsFeedbackOpen(false)}> + setIsFeedbackOpen(false)} + displayLabels={{ + correct: 'Did the results from the generated query answer your question?', + }} + /> + + )} + + ); }; diff --git a/public/components/event_analytics/home/home.tsx b/public/components/event_analytics/home/home.tsx index f4a7aeb6d1..d19512d0fb 100644 --- a/public/components/event_analytics/home/home.tsx +++ b/public/components/event_analytics/home/home.tsx @@ -25,7 +25,7 @@ import { EuiTitle, } from '@elastic/eui'; import React, { ReactElement, useEffect, useRef, useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { HttpStart } from '../../../../../../src/core/public'; import { CUSTOM_PANELS_API_PREFIX } from '../../../../common/constants/custom_panels'; From 5ac1e9eef469e0b86a726a3dc766f00df8d6208c Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 10 Nov 2023 11:11:57 -0800 Subject: [PATCH 12/86] reintroduced refresh Signed-off-by: Paul Sebastian --- public/components/common/search/date_picker.tsx | 4 ++-- public/components/common/search/search.tsx | 12 ------------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 146cf725b4..20b2d55205 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -31,7 +31,7 @@ export function DatePicker(props: IDatePickerProps) { onTimeChange={handleTimeChange} onRefresh={handleTimeRangePickerRefresh} className="osdQueryBar__datePicker" - showUpdateButton={false} + // showUpdateButton={false} /> ) : ( <> @@ -47,7 +47,7 @@ export function DatePicker(props: IDatePickerProps) { onTimeChange={handleTimeChange} onRefresh={handleTimeRangePickerRefresh} className="osdQueryBar__datePicker" - showUpdateButton={false} + // showUpdateButton={false} isDisabled={true} /> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 81eb7cbcef..62cac12201 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -285,18 +285,6 @@ export const Search = (props: any) => { /> )} - - { - onQuerySearch(queryLang); - handleTimePickerChange(timePicker); - }} - > - Run - - {showSaveButton && !showSavePanelOptionsList && ( Date: Fri, 10 Nov 2023 13:38:48 -0800 Subject: [PATCH 13/86] disable timepicker Signed-off-by: Paul Sebastian --- public/components/common/query_utils/index.ts | 4 +++- public/components/common/search/query_area.tsx | 1 + public/components/common/search/search.tsx | 15 +++++++++++++-- .../event_analytics/explorer/llm/input.tsx | 6 +++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/public/components/common/query_utils/index.ts b/public/components/common/query_utils/index.ts index 9f4024bb31..40071f3713 100644 --- a/public/components/common/query_utils/index.ts +++ b/public/components/common/query_utils/index.ts @@ -199,9 +199,11 @@ export const preprocessQuery = ({ if (isEmpty(tokens)) return finalQuery; + // TODO: reintroduce timepicker finalQuery = `${tokens![1]}=${ tokens![2] - } | where ${timeField} >= '${start}' and ${timeField} <= '${end}'`; + }` + // | where ${timeField} >= '${start}' and ${timeField} <= '${end}'`; if (whereClause) { finalQuery += ` AND ${whereClause}`; diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index f53fb3a012..d970b312e0 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -104,6 +104,7 @@ export function QueryArea({ }} aria-label="Code Editor" onChange={(query) => { + console.log(query); handleQueryChange(query); }} value={tempQuery} diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 62cac12201..efa161afae 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -104,7 +104,6 @@ export const Search = (props: any) => { const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isQueryBarVisible, setIsQueryBarVisible] = useState(!coreRefs.assistantEnabled); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); - const [timePicker, setTimePicker] = useState(['now', 'now']); // TODO: make sure this default value won't interfere with anything const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; @@ -268,7 +267,7 @@ export const Search = (props: any) => { )} - + {/* {!isLiveTailOn && ( { }} /> )} + */} + + { + onQuerySearch(queryLang); + handleTimePickerChange(['now-5y', 'now']); + }} + > + Run + {showSaveButton && !showSavePanelOptionsList && ( diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index ecde80efe0..72757fb93d 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -44,7 +44,7 @@ export const LLMInput: React.FC = (props) => { const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); const [generating, setGenerating] = useState(false); const [selectedIndex, setSelectedIndex] = useState([ - { label: 'sso_logs-*-*' }, + { label: 'opensearch_dashboards_sample_data_logs' }, ]); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); const [feedbackFormData, setFeedbackFormData] = useState({ @@ -116,9 +116,9 @@ export const LLMInput: React.FC = (props) => { Date: Fri, 10 Nov 2023 16:10:47 -0800 Subject: [PATCH 14/86] fixed data grid render issue Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 1 + public/components/common/search/search.tsx | 8 +- .../explorer/events_views/data_grid.tsx | 124 +++++++++--------- .../event_analytics/explorer/llm/input.tsx | 2 +- 4 files changed, 67 insertions(+), 68 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index d970b312e0..4831970167 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -14,6 +14,7 @@ import { EuiIcon, EuiPanel, EuiPopover, + EuiSuperSelect, } from '@elastic/eui'; import { LLMInput, SubmitPPLButton } from '../../event_analytics/explorer/llm/input'; import React from 'react'; diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index efa161afae..104fba9344 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -62,6 +62,7 @@ export const Search = (props: any) => { query, tempQuery, handleQueryChange, + handleQuerySearch, handleTimePickerChange, dslService, startTime, @@ -289,8 +290,11 @@ export const Search = (props: any) => { iconType={'play'} fill={true} onClick={() => { - onQuerySearch(queryLang); - handleTimePickerChange(['now-5y', 'now']); + // onQuerySearch(queryLang); + // handleTimeRangePickerRefresh(); + // console.log(tempQuery); + handleQuerySearch(); + // handleTimePickerChange(['now-5y', 'now']); }} > Run diff --git a/public/components/event_analytics/explorer/events_views/data_grid.tsx b/public/components/event_analytics/explorer/events_views/data_grid.tsx index 56cac6abb5..6a8271257d 100644 --- a/public/components/event_analytics/explorer/events_views/data_grid.tsx +++ b/public/components/event_analytics/explorer/events_views/data_grid.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo, useState, useRef, Fragment, useCallback } from 'react'; +import React, { useMemo, useState, useRef, Fragment, useCallback, useEffect } from 'react'; import { EuiDataGrid, EuiDescriptionList, @@ -72,6 +72,12 @@ export function DataGrid(props: DataGridProps) { const [data, setData] = useState(rows); + useEffect(() => { + if (rows.length > 0) { + setData(rows); + } + }, [rows]); + // setSort and setPage are used to change the query and send a direct request to get data const setSort = (sort: EuiDataGridSorting['columns']) => { sortingFields.current = sort; @@ -103,7 +109,7 @@ export function DataGrid(props: DataGridProps) { }; // creates the header for each column listing what that column is - const dataGridColumns = useMemo(() => { + const dataGridColumns = () => { const columns: EuiDataGridColumn[] = []; selectedColumns.map(({ name, type }) => { if (name === 'timestamp') { @@ -119,10 +125,10 @@ export function DataGrid(props: DataGridProps) { } }); return columns; - }, [explorerFields, totalHits]); + }; // used for which columns are visible and their order - const dataGridColumnVisibility = useMemo(() => { + const dataGridColumnVisibility = () => { if (selectedColumns.length > 0) { const columns: string[] = []; selectedColumns.map(({ name }) => { @@ -137,10 +143,10 @@ export function DataGrid(props: DataGridProps) { } // default shown fields throw new Error('explorer data grid stored columns empty'); - }, [explorerFields, totalHits]); + }; // sets the very first column, which is the button used for the flyout of each row - const dataGridLeadingColumns = useMemo(() => { + const dataGridLeadingColumns = () => { return [ { id: 'inspectCollapseColumn', @@ -171,70 +177,58 @@ export function DataGrid(props: DataGridProps) { width: 40, }, ]; - }, [rows, http, explorerFields, pplService, rawQuery, timeStampField, totalHits]); + }; // renders what is shown in each cell, i.e. the content of each row - const dataGridCellRender = useCallback( - ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { - const trueIndex = rowIndex % pageFields.current[1]; - if (trueIndex < data.length) { - if (columnId === '_source') { - return ( - - {Object.keys(data[trueIndex]).map((key) => ( - - - {key} - - - {data[trueIndex][key]} - - - ))} - - ); - } - if (columnId === 'timestamp') { - return `${moment(data[trueIndex][columnId]).format(DATE_DISPLAY_FORMAT)}`; - } - return `${data[trueIndex][columnId]}`; + const dataGridCellRender = ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { + const trueIndex = rowIndex % pageFields.current[1]; + if (trueIndex < data.length) { + if (columnId === '_source') { + return ( + + {Object.keys(data[trueIndex]).map((key) => ( + + + {key} + + + {data[trueIndex][key]} + + + ))} + + ); } - return null; - }, - [data, rows, pageFields, explorerFields, totalHits] - ); + if (columnId === 'timestamp') { + return `${moment(data[trueIndex][columnId]).format(DATE_DISPLAY_FORMAT)}`; + } + return `${data[trueIndex][columnId]}`; + } + return null; + }; // ** Pagination config const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 100 }); // changing the number of items per page, reset index and modify page size - const onChangeItemsPerPage = useCallback( - (pageSize) => - setPagination(() => { - setPage([0, pageSize]); - return { pageIndex: 0, pageSize }; - }), - [setPagination, setPage, totalHits] - ); + const onChangeItemsPerPage = (pageSize) => + setPagination(() => { + setPage([0, pageSize]); + return { pageIndex: 0, pageSize }; + }); // changing the page index, keep page size constant - const onChangePage = useCallback( - (pageIndex) => { - setPagination(({ pageSize }) => { - setPage([pageIndex, pageSize]); - return { pageSize, pageIndex }; - }); - }, - [setPagination, setPage, totalHits] - ); + const onChangePage = (pageIndex) => { + setPagination(({ pageSize }) => { + setPage([pageIndex, pageSize]); + return { pageSize, pageIndex }; + }); + }; - const rowHeightsOptions = useMemo( - () => ({ - defaultHeight: { - // if source is listed as a column, add extra space - lineCount: selectedColumns.some((obj) => obj.name === '_source') ? 3 : 1, - }, - }), - [explorerFields, totalHits] - ); + const rowHeightsOptions = () => ({ + defaultHeight: { + // if source is listed as a column, add extra space + lineCount: selectedColumns.some((obj) => obj.name === '_source') ? 3 : 1, + }, + }); // TODO: memoize the expensive table below @@ -244,9 +238,9 @@ export function DataGrid(props: DataGridProps) {
diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 72757fb93d..a929f563b8 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -117,7 +117,7 @@ export const LLMInput: React.FC = (props) => { Index
} singleSelection={true} isLoading={loading} options={data} From 037a679c66a7b7bd86706a3b92e15325739596c9 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 10 Nov 2023 16:40:47 -0800 Subject: [PATCH 15/86] code editor wrap Signed-off-by: Paul Sebastian --- public/components/common/search/query_area.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 4831970167..8b0ac19ef0 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -98,7 +98,7 @@ export function QueryArea({ From efa1b945dae48a6a0c3d33979f7448517a72370b Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 10 Nov 2023 16:51:25 -0800 Subject: [PATCH 16/86] empty query row Signed-off-by: Paul Sebastian --- .../event_analytics/explorer/events_views/data_grid.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/public/components/event_analytics/explorer/events_views/data_grid.tsx b/public/components/event_analytics/explorer/events_views/data_grid.tsx index 6a8271257d..25b94eff56 100644 --- a/public/components/event_analytics/explorer/events_views/data_grid.tsx +++ b/public/components/event_analytics/explorer/events_views/data_grid.tsx @@ -73,9 +73,7 @@ export function DataGrid(props: DataGridProps) { const [data, setData] = useState(rows); useEffect(() => { - if (rows.length > 0) { - setData(rows); - } + setData(rows); }, [rows]); // setSort and setPage are used to change the query and send a direct request to get data From 14f789758a991b3b064254074c6dd08c38fbcf93 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 11 Nov 2023 00:45:40 +0000 Subject: [PATCH 17/86] add summarization UI in event analytics Signed-off-by: Joshua Li --- .../components/common/search/query_area.tsx | 34 ++++++++++++++----- .../event_analytics/explorer/explorer.tsx | 8 ++--- .../event_analytics/explorer/llm/input.tsx | 30 ++++++++++++---- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 8b0ac19ef0..292123e7d1 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -4,20 +4,18 @@ */ import { - EuiButton, + EuiAccordion, EuiCodeEditor, - EuiComboBox, EuiContextMenuPanel, - EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiIcon, + EuiMarkdownFormat, EuiPanel, EuiPopover, - EuiSuperSelect, + EuiSpacer, } from '@elastic/eui'; -import { LLMInput, SubmitPPLButton } from '../../event_analytics/explorer/llm/input'; -import React from 'react'; +import React, { useState } from 'react'; +import { LLMInput } from '../../event_analytics/explorer/llm/input'; export function QueryArea({ languagePopOverButton, @@ -29,6 +27,8 @@ export function QueryArea({ handleTimeRangePickerRefresh, tempQuery, }: any) { + const [summarizedText, setSummarizedText] = useState(''); + const [summaryLoading, setSummaryLoading] = useState(false); return ( @@ -36,6 +36,8 @@ export function QueryArea({ tabId={tabId} handleQueryChange={handleQueryChange} handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} + setSummarizedText={setSummarizedText} + setSummaryLoading={setSummaryLoading} > - + {/* @@ -113,6 +115,22 @@ export function QueryArea({ /> + + + {summarizedText.length > 0 && ( + <> + + {summarizedText} + + + )} + ); } diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 93f2e9a8f8..d2bb79e895 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -281,10 +281,10 @@ export const Explorer = ({ const getErrorHandler = (title: string) => { return (error: any) => { - const formattedError = formatError(error.name, error.message, error.body.message); - notifications.toasts.addError(formattedError, { - title, - }); + // const formattedError = formatError(error.name, error.message, error.body.message); + // notifications.toasts.addError(formattedError, { + // title, + // }); }; }; diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index a929f563b8..96ca24096e 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -4,7 +4,6 @@ */ import { - EuiAccordion, EuiBadge, EuiButton, EuiComboBox, @@ -16,24 +15,24 @@ import { EuiIcon, EuiModal, EuiPanel, - EuiSpacer, - EuiToken, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; -import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; +import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; +import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; -import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; interface Props { handleQueryChange: (query: string) => void; handleTimeRangePickerRefresh: () => void; + setSummarizedText: React.Dispatch>; + setSummaryLoading: React.Dispatch>; tabId: string; } export const LLMInput: React.FC = (props) => { @@ -97,14 +96,33 @@ export const LLMInput: React.FC = (props) => { }) ); await props.handleTimeRangePickerRefresh(); + setGenerating(false); + props.setSummaryLoading(true); + const queryResponse = await getOSDHttp() + .post(CONSOLE_PROXY, { + body: JSON.stringify({ query: response }), + query: { + path: '_plugins/_ppl', + method: 'POST', + }, + }) + .catch((error) => String(JSON.parse(error.body).error.details)); + const summarized = await getOSDHttp().post('/api/assistant/summarize', { + body: JSON.stringify({ + question: questionRef.current?.value, + text: JSON.stringify(queryResponse), + }), + }); + props.setSummarizedText(summarized); } catch (error) { setFeedbackFormData({ ...feedbackFormData, input: questionRef.current?.value || '', }); - coreRefs.toasts?.addError(error, { title: 'Failed to generate PPL query' }); + coreRefs.toasts?.addError(error.body, { title: 'Failed to generate PPL query' }); } finally { setGenerating(false); + props.setSummaryLoading(false); } }; From 7fba5e6e7b846d183d1b826c8f8cd8f92dd39876 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 11 Nov 2023 01:20:32 +0000 Subject: [PATCH 18/86] add error summary in event analytics Signed-off-by: Joshua Li --- public/components/common/search/query_area.tsx | 15 ++++++++++++--- .../event_analytics/explorer/llm/input.tsx | 10 +++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 292123e7d1..3b99183243 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -5,6 +5,7 @@ import { EuiAccordion, + EuiCallOut, EuiCodeEditor, EuiContextMenuPanel, EuiFlexGroup, @@ -29,6 +30,7 @@ export function QueryArea({ }: any) { const [summarizedText, setSummarizedText] = useState(''); const [summaryLoading, setSummaryLoading] = useState(false); + const [isPPLError, setIsPPLError] = useState(false); return ( @@ -38,6 +40,7 @@ export function QueryArea({ handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} setSummarizedText={setSummarizedText} setSummaryLoading={setSummaryLoading} + setIsPPLError={setIsPPLError} > {summarizedText.length > 0 && ( <> - - {summarizedText} - + {isPPLError ? ( + + {summarizedText} + + ) : ( + + {summarizedText} + + )} )} diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 96ca24096e..c0aa3ba4d0 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -33,6 +33,7 @@ interface Props { handleTimeRangePickerRefresh: () => void; setSummarizedText: React.Dispatch>; setSummaryLoading: React.Dispatch>; + setIsPPLError: React.Dispatch>; tabId: string; } export const LLMInput: React.FC = (props) => { @@ -106,7 +107,14 @@ export const LLMInput: React.FC = (props) => { method: 'POST', }, }) - .catch((error) => String(JSON.parse(error.body).error.details)); + .then((resp) => { + props.setIsPPLError(false); + return resp; + }) + .catch((error) => { + props.setIsPPLError(true); + return String(JSON.parse(error.body).error.details); + }); const summarized = await getOSDHttp().post('/api/assistant/summarize', { body: JSON.stringify({ question: questionRef.current?.value, From c33662efa89ffa79d0bd8acc830a50b1b646e07c Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 13 Nov 2023 18:08:50 +0000 Subject: [PATCH 19/86] handle summarization errors Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index c0aa3ba4d0..236829198b 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -69,14 +69,15 @@ export const LLMInput: React.FC = (props) => { }, []); // hide if not in a tab - if (props.tabId === '') return props.children; + if (props.tabId === '') return <>{props.children}; const request = async () => { if (!selectedIndex.length) return; + let response; try { setGenerating(true); // const response = 'source = opensearch_dashboards_sample_data_logs'; - const response = await getOSDHttp().post('/api/assistant/generate_ppl', { + response = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ question: questionRef.current?.value, index: selectedIndex[0].label, @@ -97,7 +98,17 @@ export const LLMInput: React.FC = (props) => { }) ); await props.handleTimeRangePickerRefresh(); + } catch (error) { + setFeedbackFormData({ + ...feedbackFormData, + input: questionRef.current?.value || '', + }); + coreRefs.toasts?.addError(error.body, { title: 'Failed to generate PPL query' }); + return; + } finally { setGenerating(false); + } + try { props.setSummaryLoading(true); const queryResponse = await getOSDHttp() .post(CONSOLE_PROXY, { @@ -123,13 +134,8 @@ export const LLMInput: React.FC = (props) => { }); props.setSummarizedText(summarized); } catch (error) { - setFeedbackFormData({ - ...feedbackFormData, - input: questionRef.current?.value || '', - }); - coreRefs.toasts?.addError(error.body, { title: 'Failed to generate PPL query' }); + coreRefs.toasts?.addError(error.body, { title: 'Failed to summarize results' }); } finally { - setGenerating(false); props.setSummaryLoading(false); } }; From aa599ce4770d07d61c478c0a0dc4675fadf6b1a3 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 13 Nov 2023 19:07:00 +0000 Subject: [PATCH 20/86] send PPL query for summarization Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 236829198b..67afd25581 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -73,11 +73,11 @@ export const LLMInput: React.FC = (props) => { const request = async () => { if (!selectedIndex.length) return; - let response; + let generatedPPL; try { setGenerating(true); // const response = 'source = opensearch_dashboards_sample_data_logs'; - response = await getOSDHttp().post('/api/assistant/generate_ppl', { + generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ question: questionRef.current?.value, index: selectedIndex[0].label, @@ -86,14 +86,14 @@ export const LLMInput: React.FC = (props) => { setFeedbackFormData({ ...feedbackFormData, input: questionRef.current?.value || '', - output: response, + output: generatedPPL, }); - await props.handleQueryChange(response); + await props.handleQueryChange(generatedPPL); await dispatch( changeQuery({ tabId: props.tabId, data: { - [RAW_QUERY]: response, + [RAW_QUERY]: generatedPPL, }, }) ); @@ -112,7 +112,7 @@ export const LLMInput: React.FC = (props) => { props.setSummaryLoading(true); const queryResponse = await getOSDHttp() .post(CONSOLE_PROXY, { - body: JSON.stringify({ query: response }), + body: JSON.stringify({ query: generatedPPL }), query: { path: '_plugins/_ppl', method: 'POST', @@ -129,7 +129,8 @@ export const LLMInput: React.FC = (props) => { const summarized = await getOSDHttp().post('/api/assistant/summarize', { body: JSON.stringify({ question: questionRef.current?.value, - text: JSON.stringify(queryResponse), + response: JSON.stringify(queryResponse), + query: generatedPPL, }), }); props.setSummarizedText(summarized); From 0ea7f027d3a235bab1887c99f700fe4879258ea6 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 13 Nov 2023 11:53:21 -0800 Subject: [PATCH 21/86] lang picker and lang flyout changes Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 39 +++++++++++++++++-- public/components/common/search/search.tsx | 2 + .../event_analytics/explorer/llm/input.tsx | 5 ++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 3b99183243..f7f0fe79c7 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -14,9 +14,14 @@ import { EuiPanel, EuiPopover, EuiSpacer, + EuiSuperSelect, + EuiText, + EuiIcon, } from '@elastic/eui'; import React, { useState } from 'react'; import { LLMInput } from '../../event_analytics/explorer/llm/input'; +import { uiSettingsService } from '../../../../common/utils'; +import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; export function QueryArea({ languagePopOverButton, @@ -27,10 +32,26 @@ export function QueryArea({ handleQueryChange, handleTimeRangePickerRefresh, tempQuery, + showFlyout, + handleQueryLanguageChange, }: any) { const [summarizedText, setSummarizedText] = useState(''); const [summaryLoading, setSummaryLoading] = useState(false); const [isPPLError, setIsPPLError] = useState(false); + + // TODO: REMOVE ALL BELOW + const options = [ + { value: 'PPL', inputDisplay: PPL }, + { value: 'DQL', inputDisplay: DQL }, + ]; + + const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); + + const onChange = (lang: string) => { + handleQueryLanguageChange(lang); + setQueryLang(lang); + }; + return ( @@ -43,7 +64,8 @@ export function QueryArea({ setIsPPLError={setIsPPLError} > - + {/* - + */} + + + showFlyout()} + color="#159D8D" + // onClickAriaLabel={'pplLinkShowFlyout'} + /> - {/* diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 104fba9344..35b187e083 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -385,6 +385,8 @@ export const Search = (props: any) => { handleQueryChange={handleQueryChange} handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} tempQuery={tempQuery} + showFlyout={showFlyout} + handleQueryLanguageChange={handleQueryLanguageChange} /> )} diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 67afd25581..1904a0e7b6 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -15,6 +15,7 @@ import { EuiIcon, EuiModal, EuiPanel, + EuiText, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; @@ -144,13 +145,13 @@ export const LLMInput: React.FC = (props) => { return ( <> - + {props.children} Index} + prepend={Index} singleSelection={true} isLoading={loading} options={data} From efce17730823847e4870e7c42fe001cf2b8a30b3 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 13 Nov 2023 22:29:58 +0000 Subject: [PATCH 22/86] update summarization api call Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 1904a0e7b6..9aa2aa4d95 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -29,6 +29,14 @@ import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; +interface SummarizationContext { + question: string; + query?: string; + response: string; + index: string; + isError: boolean; +} + interface Props { handleQueryChange: (query: string) => void; handleTimeRangePickerRefresh: () => void; @@ -74,10 +82,10 @@ export const LLMInput: React.FC = (props) => { const request = async () => { if (!selectedIndex.length) return; - let generatedPPL; + let generatedPPL: string = ''; + let generatePPLError: string | undefined; try { setGenerating(true); - // const response = 'source = opensearch_dashboards_sample_data_logs'; generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ question: questionRef.current?.value, @@ -104,35 +112,41 @@ export const LLMInput: React.FC = (props) => { ...feedbackFormData, input: questionRef.current?.value || '', }); - coreRefs.toasts?.addError(error.body, { title: 'Failed to generate PPL query' }); - return; + generatePPLError = String(error.body); } finally { setGenerating(false); } try { + const summarizationContext: SummarizationContext = { + question: questionRef.current?.value || 'unable to retrieve question', + index: selectedIndex[0].label, + isError: false, + response: '', + }; props.setSummaryLoading(true); - const queryResponse = await getOSDHttp() - .post(CONSOLE_PROXY, { - body: JSON.stringify({ query: generatedPPL }), - query: { - path: '_plugins/_ppl', - method: 'POST', - }, - }) - .then((resp) => { - props.setIsPPLError(false); - return resp; - }) - .catch((error) => { - props.setIsPPLError(true); - return String(JSON.parse(error.body).error.details); - }); + if (generatePPLError === undefined) { + const queryResponse = await getOSDHttp() + .post(CONSOLE_PROXY, { + body: JSON.stringify({ query: generatedPPL }), + query: { path: '_plugins/_ppl', method: 'POST' }, + }) + .then((resp) => { + props.setIsPPLError(false); + return resp; + }) + .catch((error) => { + props.setIsPPLError(true); + summarizationContext.isError = true; + return String(JSON.parse(error.body).error.details); + }); + summarizationContext.response = JSON.stringify(queryResponse); + summarizationContext.query = generatedPPL; + } else { + summarizationContext.isError = true; + summarizationContext.response = generatePPLError; + } const summarized = await getOSDHttp().post('/api/assistant/summarize', { - body: JSON.stringify({ - question: questionRef.current?.value, - response: JSON.stringify(queryResponse), - query: generatedPPL, - }), + body: JSON.stringify(summarizationContext), }); props.setSummarizedText(summarized); } catch (error) { From 310e293169e92925b80716e7d417bb7cac646cfc Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 13 Nov 2023 19:18:35 -0800 Subject: [PATCH 23/86] moved summary down to results Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 72 --------- .../event_analytics/explorer/explorer.tsx | 143 +++++++++++++----- .../event_analytics/explorer/llm/input.tsx | 50 ++++-- .../query_assistant_summarization_slice.ts | 36 +++++ public/framework/redux/reducers/index.ts | 2 + 5 files changed, 183 insertions(+), 120 deletions(-) create mode 100644 public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index f7f0fe79c7..fbd09d36b4 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -35,10 +35,6 @@ export function QueryArea({ showFlyout, handleQueryLanguageChange, }: any) { - const [summarizedText, setSummarizedText] = useState(''); - const [summaryLoading, setSummaryLoading] = useState(false); - const [isPPLError, setIsPPLError] = useState(false); - // TODO: REMOVE ALL BELOW const options = [ { value: 'PPL', inputDisplay: PPL }, @@ -59,9 +55,6 @@ export function QueryArea({ tabId={tabId} handleQueryChange={handleQueryChange} handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} - setSummarizedText={setSummarizedText} - setSummaryLoading={setSummaryLoading} - setIsPPLError={setIsPPLError} > @@ -89,49 +82,6 @@ export function QueryArea({ /> - {/* - - - - - - - - setSelectedIndex(index)} - /> - - - - - - - - - - - - - - Go - - - - */} - - - {summarizedText.length > 0 && ( - <> - {isPPLError ? ( - - {summarizedText} - - ) : ( - - {summarizedText} - - )} - - )} - ); } diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index d2bb79e895..77f16a339d 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -19,6 +19,10 @@ import { EuiSplitPanel, EuiPageSideBar, EuiPageBody, + EuiAccordion, + EuiCallOut, + EuiMarkdownFormat, + EuiIcon, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import _, { isEmpty, isEqual, reduce } from 'lodash'; @@ -99,6 +103,7 @@ import { selectQueryResult } from '../redux/slices/query_result_slice'; import { changeDateRange, changeQuery, selectQueries } from '../redux/slices/query_slice'; import { updateTabName } from '../redux/slices/query_tab_slice'; import { selectExplorerVisualization } from '../redux/slices/visualization_slice'; +import { selectQueryAssistantSummarization } from '../redux/slices/query_assistant_summarization_slice'; import { change as changeVisualizationConfig, change as changeVizConfig, @@ -124,6 +129,7 @@ import { DEFAULT_DATA_SOURCE_TYPE, QUERY_LANGUAGE, } from '../../../../common/constants/data_sources'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; export const Explorer = ({ pplService, @@ -178,6 +184,7 @@ export const Explorer = ({ const explorerVisualizations = useSelector(selectExplorerVisualization)[tabId]; const userVizConfigs = useSelector(selectVisualizationConfig)[tabId] || {}; const explorerSearchMeta = useSelector(selectSearchMetaData)[tabId] || {}; + const queryAssistantSummarization = useSelector(selectQueryAssistantSummarization)[tabId]; const [selectedContentTabId, setSelectedContentTab] = useState(TAB_EVENT_ID); const [selectedCustomPanelOptions, setSelectedCustomPanelOptions] = useState([]); const [selectedPanelName, setSelectedPanelName] = useState(''); @@ -480,48 +487,105 @@ export const Explorer = ({ {explorerData && !isEmpty(explorerData.jsonData) ? ( {(isDefaultDataSourceType || appLogEvents) && ( - - - {countDistribution?.data && !isLiveTailOnRef.current && ( + <> + + - {}} - /> - { - const intervalOptionsIndex = timeIntervalOptions.findIndex( - (item) => item.value === selectedIntrv - ); - const intrv = selectedIntrv.replace(/^auto_/, ''); - dispatch( - updateCountDistribution({ tabId, data: { selectedInterval: intrv } }) - ); - getCountVisualizations(intrv); - selectedIntervalRef.current = timeIntervalOptions[intervalOptionsIndex]; - getPatterns(intrv, getErrorHandler('Error fetching patterns')); - }} - stateInterval={ - countDistribution.selectedInterval || selectedIntervalRef.current?.value - } - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} - /> - - + + + Generated by Opensearch Assistant + + + + + + } - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} - /> + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + + + {queryAssistantSummarization?.summary} + + + ) : ( + + + {queryAssistantSummarization?.summary} + + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify + all information before using it in any environment or workload. + + + + )} + - )} - - + + + + + {countDistribution?.data && !isLiveTailOnRef.current && ( + + {}} + /> + { + const intervalOptionsIndex = timeIntervalOptions.findIndex( + (item) => item.value === selectedIntrv + ); + const intrv = selectedIntrv.replace(/^auto_/, ''); + dispatch( + updateCountDistribution({ tabId, data: { selectedInterval: intrv } }) + ); + getCountVisualizations(intrv); + selectedIntervalRef.current = timeIntervalOptions[intervalOptionsIndex]; + getPatterns(intrv, getErrorHandler('Error fetching patterns')); + }} + stateInterval={ + countDistribution.selectedInterval || selectedIntervalRef.current?.value + } + startTime={appLogEvents ? startTime : dateRange[0]} + endTime={appLogEvents ? endTime : dateRange[1]} + /> + + + + )} + + + )} {(isDefaultDataSourceType || appLogEvents) && ( @@ -608,6 +672,7 @@ export const Explorer = ({ query, isLiveTailOnRef.current, isQueryRunning, + queryAssistantSummarization, ]); const visualizations: IVisualizationContainerProps = useMemo(() => { diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 9aa2aa4d95..5c076fac9a 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -28,6 +28,7 @@ import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; +import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; interface SummarizationContext { question: string; @@ -40,9 +41,6 @@ interface SummarizationContext { interface Props { handleQueryChange: (query: string) => void; handleTimeRangePickerRefresh: () => void; - setSummarizedText: React.Dispatch>; - setSummaryLoading: React.Dispatch>; - setIsPPLError: React.Dispatch>; tabId: string; } export const LLMInput: React.FC = (props) => { @@ -123,7 +121,14 @@ export const LLMInput: React.FC = (props) => { isError: false, response: '', }; - props.setSummaryLoading(true); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: true, + }, + }) + ); if (generatePPLError === undefined) { const queryResponse = await getOSDHttp() .post(CONSOLE_PROXY, { @@ -131,11 +136,25 @@ export const LLMInput: React.FC = (props) => { query: { path: '_plugins/_ppl', method: 'POST' }, }) .then((resp) => { - props.setIsPPLError(false); + dispatch( + changeSummary({ + tabId: props.tabId, + data: { + isPPLError: false, + }, + }) + ); return resp; }) .catch((error) => { - props.setIsPPLError(true); + dispatch( + changeSummary({ + tabId: props.tabId, + data: { + isPPLError: true, + }, + }) + ); summarizationContext.isError = true; return String(JSON.parse(error.body).error.details); }); @@ -148,11 +167,25 @@ export const LLMInput: React.FC = (props) => { const summarized = await getOSDHttp().post('/api/assistant/summarize', { body: JSON.stringify(summarizationContext), }); - props.setSummarizedText(summarized); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summary: summarized, + }, + }) + ); } catch (error) { coreRefs.toasts?.addError(error.body, { title: 'Failed to summarize results' }); } finally { - props.setSummaryLoading(false); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: false, + }, + }) + ); } }; @@ -226,7 +259,6 @@ export const LLMInput: React.FC = (props) => { */} - {/* */} {isFeedbackOpen && ( setIsFeedbackOpen(false)}> { + state[payload.tabId] = { + ...state[payload.tabId], + ...payload.data, + }; + }, + reset: (state, { payload }) => { + state[payload.tabId] = {}; + }, + }, +}); + +export const { changeSummary, reset } = summarizationSlice.actions; + +export const selectQueryAssistantSummarization = createSelector( + (state) => state.queryAssistantSummarization, + (summarizationState) => summarizationState +); + +export const summarizationReducer = summarizationSlice.reducer; diff --git a/public/framework/redux/reducers/index.ts b/public/framework/redux/reducers/index.ts index 6fad432d47..50be36063e 100644 --- a/public/framework/redux/reducers/index.ts +++ b/public/framework/redux/reducers/index.ts @@ -16,6 +16,7 @@ import { patternsReducer } from '../../../components/event_analytics/redux/slice import { metricsReducers } from '../../../components/metrics/redux/slices/metrics_slice'; import { panelReducer } from '../../../components/custom_panels/redux/panel_slice'; import { searchMetaDataSliceReducer } from '../../../components/event_analytics/redux/slices/search_meta_data_slice'; +import { summarizationReducer } from '../../../components/event_analytics/redux/slices/query_assistant_summarization_slice'; const combinedReducer = combineReducers({ // explorer reducers @@ -30,6 +31,7 @@ const combinedReducer = combineReducers({ metrics: metricsReducers, customPanel: panelReducer, searchMetadata: searchMetaDataSliceReducer, + queryAssistantSummarization: summarizationReducer, }); export type RootState = ReturnType; From 73a6ea06a995d5779de7512d33256fce3da85da5 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 14 Nov 2023 11:02:26 -0800 Subject: [PATCH 24/86] auto select first data source Signed-off-by: Paul Sebastian --- .../explorer/datasources/datasources_selection.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index 3c6e34df1f..9364a3bab2 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -225,7 +225,11 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { dataSources={activeDataSources} dataSourceOptionList={memorizedDataSourceOptionList} setDataSourceOptionList={setDataSourceOptionList} - selectedSources={selectedSources} + selectedSources={ + selectedSources?.length > 0 + ? selectedSources + : memorizedDataSourceOptionList[0]?.options ?? [] + } onDataSourceSelect={handleSourceChange} onFetchDataSetError={handleDataSetFetchError} singleSelection={{ asPlainText: true }} From d6be9980be41723e60f4695a76c219396fb0e1bc Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 14 Nov 2023 11:49:52 -0800 Subject: [PATCH 25/86] actually set data source thru redux Signed-off-by: Paul Sebastian --- .../explorer/datasources/datasources_selection.tsx | 6 +----- .../event_analytics/redux/slices/search_meta_data_slice.ts | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index 9364a3bab2..3c6e34df1f 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -225,11 +225,7 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { dataSources={activeDataSources} dataSourceOptionList={memorizedDataSourceOptionList} setDataSourceOptionList={setDataSourceOptionList} - selectedSources={ - selectedSources?.length > 0 - ? selectedSources - : memorizedDataSourceOptionList[0]?.options ?? [] - } + selectedSources={selectedSources} onDataSourceSelect={handleSourceChange} onFetchDataSetError={handleDataSetFetchError} singleSelection={{ asPlainText: true }} diff --git a/public/components/event_analytics/redux/slices/search_meta_data_slice.ts b/public/components/event_analytics/redux/slices/search_meta_data_slice.ts index 449515440d..b96f4be1e3 100644 --- a/public/components/event_analytics/redux/slices/search_meta_data_slice.ts +++ b/public/components/event_analytics/redux/slices/search_meta_data_slice.ts @@ -10,7 +10,12 @@ import { initialTabId } from '../../../../framework/redux/store/shared_state'; const searchMetaInitialState = { lang: 'PPL', - datasources: [], + datasources: [{ + "label": "Default cluster", + "value": "Default cluster", + "type": "DEFAULT_INDEX_PATTERNS", + "name": "Default cluster" + }], queryId: '', isPolling: false, }; From 4e77592aab8f48506ae68881df2f4fed8cbd1f63 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 14 Nov 2023 11:53:44 -0800 Subject: [PATCH 26/86] use constants Signed-off-by: Paul Sebastian --- .../redux/slices/search_meta_data_slice.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/public/components/event_analytics/redux/slices/search_meta_data_slice.ts b/public/components/event_analytics/redux/slices/search_meta_data_slice.ts index b96f4be1e3..1f7ed6ef8f 100644 --- a/public/components/event_analytics/redux/slices/search_meta_data_slice.ts +++ b/public/components/event_analytics/redux/slices/search_meta_data_slice.ts @@ -7,14 +7,15 @@ import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; import { REDUX_EXPL_SLICE_SEARCH_META_DATA } from '../../../../../common/constants/explorer'; import { DirectQueryLoadingStatus, SelectedDataSource } from '../../../../../common/types/explorer'; import { initialTabId } from '../../../../framework/redux/store/shared_state'; +import { DEFAULT_DATA_SOURCE_NAME, DEFAULT_DATA_SOURCE_TYPE } from '../../../../../common/constants/data_sources'; const searchMetaInitialState = { lang: 'PPL', datasources: [{ - "label": "Default cluster", - "value": "Default cluster", - "type": "DEFAULT_INDEX_PATTERNS", - "name": "Default cluster" + "label": DEFAULT_DATA_SOURCE_NAME, + "value": DEFAULT_DATA_SOURCE_NAME, + "type": DEFAULT_DATA_SOURCE_TYPE, + "name": DEFAULT_DATA_SOURCE_NAME }], queryId: '', isPolling: false, From ea53f74bc2c4e5b73da3a0c80fa73c184dd5094a Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 14 Nov 2023 15:16:03 -0800 Subject: [PATCH 27/86] complete mimic of timepicker refresh button Signed-off-by: Paul Sebastian --- public/components/common/query_utils/index.ts | 4 +- .../components/common/search/date_picker.tsx | 4 +- public/components/common/search/search.tsx | 40 +++++++++++-------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/public/components/common/query_utils/index.ts b/public/components/common/query_utils/index.ts index 40071f3713..9f4024bb31 100644 --- a/public/components/common/query_utils/index.ts +++ b/public/components/common/query_utils/index.ts @@ -199,11 +199,9 @@ export const preprocessQuery = ({ if (isEmpty(tokens)) return finalQuery; - // TODO: reintroduce timepicker finalQuery = `${tokens![1]}=${ tokens![2] - }` - // | where ${timeField} >= '${start}' and ${timeField} <= '${end}'`; + } | where ${timeField} >= '${start}' and ${timeField} <= '${end}'`; if (whereClause) { finalQuery += ` AND ${whereClause}`; diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 20b2d55205..146cf725b4 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -31,7 +31,7 @@ export function DatePicker(props: IDatePickerProps) { onTimeChange={handleTimeChange} onRefresh={handleTimeRangePickerRefresh} className="osdQueryBar__datePicker" - // showUpdateButton={false} + showUpdateButton={false} /> ) : ( <> @@ -47,7 +47,7 @@ export function DatePicker(props: IDatePickerProps) { onTimeChange={handleTimeChange} onRefresh={handleTimeRangePickerRefresh} className="osdQueryBar__datePicker" - // showUpdateButton={false} + showUpdateButton={false} isDisabled={true} /> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 35b187e083..662ee5ef03 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -105,6 +105,8 @@ export const Search = (props: any) => { const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isQueryBarVisible, setIsQueryBarVisible] = useState(!coreRefs.assistantEnabled); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); + const [timeRange, setTimeRange] = useState(['', '']); + const [needsUpdate, setNeedsUpdate] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; @@ -268,7 +270,7 @@ export const Search = (props: any) => { )} - {/* + {!isLiveTailOn && ( { setIsOutputStale={setIsOutputStale} liveStreamChecked={props.liveStreamChecked} onLiveStreamChange={props.onLiveStreamChange} - handleTimePickerChange={(timeRange: string[]) => setTimePicker(timeRange)} + handleTimePickerChange={(tRange: string[]) => { + // modifies run button to look like the update button, if there is a time change + setNeedsUpdate(!(tRange[0] === startTime && tRange[1] === endTime)); + // keeps the time range change local, to be used when update pressed + setTimeRange(tRange); + }} handleTimeRangePickerRefresh={() => { onQuerySearch(queryLang); }} /> )} - */} + - { - // onQuerySearch(queryLang); - // handleTimeRangePickerRefresh(); - // console.log(tempQuery); - handleQuerySearch(); - // handleTimePickerChange(['now-5y', 'now']); - }} - > - Run - + + { + onQuerySearch(queryLang); + handleTimePickerChange(timeRange); + setNeedsUpdate(false); + }} + > + {needsUpdate ? 'Update' : 'Run'} + + {showSaveButton && !showSavePanelOptionsList && ( From 5203c2a5b1dd63382a71ee07cc42c10c06efabda Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 14 Nov 2023 15:26:02 -0800 Subject: [PATCH 28/86] modified query gen buttons Signed-off-by: Paul Sebastian --- public/components/common/search/query_area.tsx | 14 +++++++++++++- public/components/common/search/search.tsx | 13 ++++++++----- .../event_analytics/explorer/llm/input.tsx | 4 ++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index fbd09d36b4..09b84b4697 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -17,6 +17,7 @@ import { EuiSuperSelect, EuiText, EuiIcon, + EuiButton, } from '@elastic/eui'; import React, { useState } from 'react'; import { LLMInput } from '../../event_analytics/explorer/llm/input'; @@ -34,6 +35,7 @@ export function QueryArea({ tempQuery, showFlyout, handleQueryLanguageChange, + runChanges, }: any) { // TODO: REMOVE ALL BELOW const options = [ @@ -93,13 +95,23 @@ export function QueryArea({ }} aria-label="Code Editor" onChange={(query) => { - console.log(query); handleQueryChange(query); }} value={tempQuery} wrapEnabled={true} /> + + + Update + + ); diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 662ee5ef03..7445dc852e 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -222,6 +222,12 @@ export const Search = (props: any) => { } }, [pollingResult, pollingError]); + const runChanges = () => { + onQuerySearch(queryLang); + handleTimePickerChange(timeRange); + setNeedsUpdate(false); + }; + return (
@@ -298,11 +304,7 @@ export const Search = (props: any) => { color={needsUpdate ? 'success' : 'primary'} iconType={needsUpdate ? 'kqlFunction' : 'play'} fill={true} - onClick={() => { - onQuerySearch(queryLang); - handleTimePickerChange(timeRange); - setNeedsUpdate(false); - }} + onClick={runChanges} > {needsUpdate ? 'Update' : 'Run'} @@ -395,6 +397,7 @@ export const Search = (props: any) => { tempQuery={tempQuery} showFlyout={showFlyout} handleQueryLanguageChange={handleQueryLanguageChange} + runChanges={runChanges} /> )} diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 5c076fac9a..d9726d5010 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -243,9 +243,9 @@ export const LLMInput: React.FC = (props) => { iconType="returnKey" iconSide="right" fill - style={{ width: 100 }} + style={{ width: 170 }} > - Go + Generate query {/* From 4ab2c62e9e42d7e9d7a965c9c194e069cf148ec5 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 15 Nov 2023 12:11:51 -0800 Subject: [PATCH 29/86] changed positioning of lang and index selector and refactored Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 98 ++--------- public/components/common/search/search.tsx | 157 ++++++++++++------ .../event_analytics/explorer/llm/input.tsx | 157 ++++++++---------- 3 files changed, 191 insertions(+), 221 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 09b84b4697..2e05ad1e23 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -3,92 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - EuiAccordion, - EuiCallOut, - EuiCodeEditor, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiMarkdownFormat, - EuiPanel, - EuiPopover, - EuiSpacer, - EuiSuperSelect, - EuiText, - EuiIcon, - EuiButton, -} from '@elastic/eui'; -import React, { useState } from 'react'; +import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React from 'react'; import { LLMInput } from '../../event_analytics/explorer/llm/input'; -import { uiSettingsService } from '../../../../common/utils'; -import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; export function QueryArea({ - languagePopOverButton, - isLanguagePopoverOpen, - closeLanguagePopover, - languagePopOverItems, tabId, handleQueryChange, handleTimeRangePickerRefresh, + runQuery, tempQuery, - showFlyout, - handleQueryLanguageChange, - runChanges, + setNeedsUpdate, + selectedIndex, }: any) { - // TODO: REMOVE ALL BELOW - const options = [ - { value: 'PPL', inputDisplay: PPL }, - { value: 'DQL', inputDisplay: DQL }, - ]; - - const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); - - const onChange = (lang: string) => { - handleQueryLanguageChange(lang); - setQueryLang(lang); - }; - return ( - - - - {/* - - */} - - - showFlyout()} - color="#159D8D" - // onClickAriaLabel={'pplLinkShowFlyout'} - /> - - { handleQueryChange(query); + // query is considered updated when the last run query is not the same as whats in the editor + // setUpdatedQuery(runQuery !== query); + setNeedsUpdate(runQuery !== query); }} value={tempQuery} wrapEnabled={true} /> - - - Update - + + diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 7445dc852e..524702bf55 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -10,13 +10,18 @@ import { EuiBadge, EuiButton, EuiButtonEmpty, + EuiComboBox, + EuiComboBoxOptionOption, EuiContextMenuItem, EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiPopover, EuiPopoverFooter, - EuiSpacer, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, EuiToolTip, } from '@elastic/eui'; import { isEqual } from 'lodash'; @@ -38,6 +43,7 @@ import { DatePicker } from './date_picker'; import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { QueryArea } from './query_area'; import './search.scss'; +import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; export interface IQueryBarProps { query: string; tempQuery: string; @@ -105,7 +111,7 @@ export const Search = (props: any) => { const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isQueryBarVisible, setIsQueryBarVisible] = useState(!coreRefs.assistantEnabled); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); - const [timeRange, setTimeRange] = useState(['', '']); + const [timeRange, setTimeRange] = useState(['now-5y', 'now']); // default time range const [needsUpdate, setNeedsUpdate] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; @@ -187,6 +193,11 @@ export const Search = (props: any) => { setLanguagePopoverOpen(false); }; + const languageOptions: EuiSuperSelectOption[] = [ + { value: QUERY_LANGUAGE.PPL, inputDisplay: PPL }, + { value: QUERY_LANGUAGE.DQL, inputDisplay: DQL }, + ]; + const languagePopOverItems = [ { setNeedsUpdate(false); }; + // STATE FOR LANG PICKER AND INDEX PICKER + const [selectedIndex, setSelectedIndex] = useState([ + { label: 'opensearch_dashboards_sample_data_logs' }, + ]); + const { data: indices, loading: indicesLoading } = useCatIndices(); + const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); + const data = + indexPatterns && indices + ? [...indexPatterns, ...indices].filter( + (v1, index, array) => array.findIndex((v2) => v1.label === v2.label) === index + ) + : undefined; + const loading = indicesLoading || indexPatternsLoading; + return (
- - {appLogEvents && ( - - - - Base Query - - - - )} - {appLogEvents && ( - - { - onQuerySearch(queryLang); - }} - dslService={dslService} - getSuggestions={getSuggestions} - onItemSelect={onItemSelect} - tabId={tabId} - /> - showFlyout()} - onClickAriaLabel={'pplLinkShowFlyout'} + + {appLogEvents ? ( + <> + + + + Base Query + + + + - PPL - - + { + onQuerySearch(queryLang); + }} + dslService={dslService} + getSuggestions={getSuggestions} + onItemSelect={onItemSelect} + tabId={tabId} + /> + showFlyout()} + onClickAriaLabel={'pplLinkShowFlyout'} + > + PPL + + + + ) : ( + <> + + { + handleQueryLanguageChange(lang); + setQueryLang(lang); + }} + /> + + + showFlyout()} + color="#159D8D" + // onClickAriaLabel={'pplLinkShowFlyout'} + /> + + + Index} + singleSelection={true} + isLoading={loading} + options={data} + selectedOptions={selectedIndex} + onChange={(index) => setSelectedIndex(index)} + /> + + )} @@ -387,17 +449,12 @@ export const Search = (props: any) => { {!appLogEvents && ( )} diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index d9726d5010..b15da4a44d 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -42,17 +42,16 @@ interface Props { handleQueryChange: (query: string) => void; handleTimeRangePickerRefresh: () => void; tabId: string; + setNeedsUpdate: any; + selectedIndex: EuiComboBoxOptionOption[]; } export const LLMInput: React.FC = (props) => { + const [barSelected, setBarSelected] = useState(false); + const dispatch = useDispatch(); const questionRef = useRef(null); - const { data: indices, loading: indicesLoading } = useCatIndices(); - const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); const [generating, setGenerating] = useState(false); - const [selectedIndex, setSelectedIndex] = useState([ - { label: 'opensearch_dashboards_sample_data_logs' }, - ]); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); const [feedbackFormData, setFeedbackFormData] = useState({ input: '', @@ -61,13 +60,6 @@ export const LLMInput: React.FC = (props) => { expectedOutput: '', comment: '', }); - const data = - indexPatterns && indices - ? [...indexPatterns, ...indices].filter( - (v1, index, array) => array.findIndex((v2) => v1.label === v2.label) === index - ) - : undefined; - const loading = indicesLoading || indexPatternsLoading; useEffect(() => { if (questionRef.current) { @@ -79,7 +71,7 @@ export const LLMInput: React.FC = (props) => { if (props.tabId === '') return <>{props.children}; const request = async () => { - if (!selectedIndex.length) return; + if (!props.selectedIndex.length) return; let generatedPPL: string = ''; let generatePPLError: string | undefined; try { @@ -87,7 +79,7 @@ export const LLMInput: React.FC = (props) => { generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ question: questionRef.current?.value, - index: selectedIndex[0].label, + index: props.selectedIndex[0].label, }), }); setFeedbackFormData({ @@ -117,7 +109,7 @@ export const LLMInput: React.FC = (props) => { try { const summarizationContext: SummarizationContext = { question: questionRef.current?.value || 'unable to retrieve question', - index: selectedIndex[0].label, + index: props.selectedIndex[0].label, isError: false, response: '', }; @@ -191,64 +183,50 @@ export const LLMInput: React.FC = (props) => { return ( <> - - - {props.children} - - Index} - singleSelection={true} - isLoading={loading} - options={data} - selectedOptions={selectedIndex} - onChange={(index) => setSelectedIndex(index)} - /> - - - - - - {/* */} - { - e.preventDefault(); - request(); - }} - > - - - - - - New! - - - - - - - Generate query - - - {/* + + { + e.preventDefault(); + request(); + }} + > + + + + + + New! + + + { + setBarSelected(true); + props.setNeedsUpdate(false); + }} + onBlur={() => setBarSelected(false)} + /> + + + + Generate query + + + {/* setIsFeedbackOpen(true)} iconType="faceHappy" @@ -257,23 +235,22 @@ export const LLMInput: React.FC = (props) => { Feedback */} - - - {isFeedbackOpen && ( - setIsFeedbackOpen(false)}> - setIsFeedbackOpen(false)} - displayLabels={{ - correct: 'Did the results from the generated query answer your question?', - }} - /> - - )} - - + + + {isFeedbackOpen && ( + setIsFeedbackOpen(false)}> + setIsFeedbackOpen(false)} + displayLabels={{ + correct: 'Did the results from the generated query answer your question?', + }} + /> + + )} + ); }; From f48aa3fb643a253ecb637f78bcda694c03a7e433 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 15 Nov 2023 14:29:50 -0800 Subject: [PATCH 30/86] ui set for query assistant bar Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 3 + public/components/common/search/search.tsx | 5 +- .../event_analytics/explorer/explorer.tsx | 2 + .../event_analytics/explorer/llm/input.tsx | 102 ++++++++++++------ 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 2e05ad1e23..ddb78d97d5 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -14,6 +14,7 @@ export function QueryArea({ runQuery, tempQuery, setNeedsUpdate, + setFillRun, selectedIndex, }: any) { return ( @@ -35,6 +36,8 @@ export function QueryArea({ // setUpdatedQuery(runQuery !== query); setNeedsUpdate(runQuery !== query); }} + onFocus={() => setFillRun(true)} + onBlur={() => setFillRun(false)} value={tempQuery} wrapEnabled={true} /> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 524702bf55..d2aba044e3 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -113,6 +113,7 @@ export const Search = (props: any) => { const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); const [timeRange, setTimeRange] = useState(['now-5y', 'now']); // default time range const [needsUpdate, setNeedsUpdate] = useState(false); + const [fillRun, setFillRun] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; @@ -365,7 +366,7 @@ export const Search = (props: any) => { {needsUpdate ? 'Update' : 'Run'} @@ -455,6 +456,8 @@ export const Search = (props: any) => { runQuery={query} tempQuery={tempQuery} setNeedsUpdate={setNeedsUpdate} + setFillRun={setFillRun} + selectedIndex={selectedIndex} /> )} diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 77f16a339d..64c4c17d70 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -535,6 +535,8 @@ export const Explorer = ({ The OpenSearch Assistant may produce inaccurate information. Verify all information before using it in any environment or workload. + Share feedback via Email or{' '} + Slack diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index b15da4a44d..41c988491b 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiForm, EuiIcon, + EuiLink, EuiModal, EuiPanel, EuiText, @@ -183,7 +184,7 @@ export const LLMInput: React.FC = (props) => { return ( <> - + = (props) => { request(); }} > - + - - - - New! - - - { - setBarSelected(true); - props.setNeedsUpdate(false); - }} - onBlur={() => setBarSelected(false)} - /> - - - - Generate query - - - {/* + + + + + + Query Assistant + + + New! + + + { + setBarSelected(true); + props.setNeedsUpdate(false); + }} + onBlur={() => setBarSelected(false)} + /> + + {/* setIsFeedbackOpen(true)} iconType="faceHappy" @@ -235,6 +228,45 @@ export const LLMInput: React.FC = (props) => { Feedback */} + + + + + + + Generate query + + + + + Generate and run + + + + + + Share feedback via Email or Slack + + + + + {isFeedbackOpen && ( From 7d7bafc37d63fabc405124275fa5ea99cb065046 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 15 Nov 2023 15:34:58 -0800 Subject: [PATCH 31/86] moved summary above events Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 87 ++++++++++++++++--- .../event_analytics/explorer/explorer.tsx | 61 ------------- 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index d2aba044e3..6d2a8e592e 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -7,9 +7,11 @@ import './search.scss'; import '@algolia/autocomplete-theme-classic'; import { + EuiAccordion, EuiBadge, EuiButton, EuiButtonEmpty, + EuiCallOut, EuiComboBox, EuiComboBoxOptionOption, EuiContextMenuItem, @@ -17,8 +19,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLink, + EuiMarkdownFormat, + EuiPanel, EuiPopover, EuiPopoverFooter, + EuiSpacer, EuiSuperSelect, EuiSuperSelectOption, EuiText, @@ -26,7 +32,7 @@ import { } from '@elastic/eui'; import { isEqual } from 'lodash'; import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { APP_ANALYTICS_TAB_ID_REGEX } from '../../../../common/constants/explorer'; import { PPL_SPAN_REGEX } from '../../../../common/constants/shared'; import { uiSettingsService } from '../../../../common/utils'; @@ -36,6 +42,7 @@ import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; +import { selectQueryAssistantSummarization } from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; import { PPLReferenceFlyout } from '../helpers'; import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { Autocomplete } from './autocomplete'; @@ -44,6 +51,7 @@ import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { QueryArea } from './query_area'; import './search.scss'; import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; export interface IQueryBarProps { query: string; tempQuery: string; @@ -104,6 +112,7 @@ export const Search = (props: any) => { setIsQueryRunning, } = props; + const queryAssistantSummarization = useSelector(selectQueryAssistantSummarization)[tabId]; const dispatch = useDispatch(); const appLogEvents = tabId.match(APP_ANALYTICS_TAB_ID_REGEX); const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); @@ -448,18 +457,70 @@ export const Search = (props: any) => { {!appLogEvents && ( - - - + <> + + + + + + + + + Generated by Opensearch Assistant + + + + + + + } + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + + + {queryAssistantSummarization?.summary} + + + ) : ( + + + {queryAssistantSummarization?.summary} + + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify all + information before using it in any environment or workload. Share feedback + via Email or Slack + + + + )} + + + + )} {flyout} diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 64c4c17d70..2c79e4de73 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -103,7 +103,6 @@ import { selectQueryResult } from '../redux/slices/query_result_slice'; import { changeDateRange, changeQuery, selectQueries } from '../redux/slices/query_slice'; import { updateTabName } from '../redux/slices/query_tab_slice'; import { selectExplorerVisualization } from '../redux/slices/visualization_slice'; -import { selectQueryAssistantSummarization } from '../redux/slices/query_assistant_summarization_slice'; import { change as changeVisualizationConfig, change as changeVizConfig, @@ -129,7 +128,6 @@ import { DEFAULT_DATA_SOURCE_TYPE, QUERY_LANGUAGE, } from '../../../../common/constants/data_sources'; -import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; export const Explorer = ({ pplService, @@ -184,7 +182,6 @@ export const Explorer = ({ const explorerVisualizations = useSelector(selectExplorerVisualization)[tabId]; const userVizConfigs = useSelector(selectVisualizationConfig)[tabId] || {}; const explorerSearchMeta = useSelector(selectSearchMetaData)[tabId] || {}; - const queryAssistantSummarization = useSelector(selectQueryAssistantSummarization)[tabId]; const [selectedContentTabId, setSelectedContentTab] = useState(TAB_EVENT_ID); const [selectedCustomPanelOptions, setSelectedCustomPanelOptions] = useState([]); const [selectedPanelName, setSelectedPanelName] = useState(''); @@ -488,63 +485,6 @@ export const Explorer = ({ {(isDefaultDataSourceType || appLogEvents) && ( <> - - - - - - - Generated by Opensearch Assistant - - - - - - - } - > - {queryAssistantSummarization?.summary?.length > 0 && ( - <> - - {queryAssistantSummarization?.isPPLError ? ( - - - {queryAssistantSummarization?.summary} - - - ) : ( - - - {queryAssistantSummarization?.summary} - - - )} - - - - The OpenSearch Assistant may produce inaccurate information. Verify - all information before using it in any environment or workload. - Share feedback via Email or{' '} - Slack - - - - )} - - - - {countDistribution?.data && !isLiveTailOnRef.current && ( @@ -674,7 +614,6 @@ export const Explorer = ({ query, isLiveTailOnRef.current, isQueryRunning, - queryAssistantSummarization, ]); const visualizations: IVisualizationContainerProps = useMemo(() => { From 0bbdfecae45c73316e563beaf7dc4ffc786a6353 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 15 Nov 2023 15:55:25 -0800 Subject: [PATCH 32/86] working generate buttons Signed-off-by: Paul Sebastian --- .../event_analytics/explorer/llm/input.tsx | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 41c988491b..38fe29a815 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -53,6 +53,7 @@ export const LLMInput: React.FC = (props) => { const questionRef = useRef(null); const [generating, setGenerating] = useState(false); + const [generatingRun, setGeneratingRun] = useState(false); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); const [feedbackFormData, setFeedbackFormData] = useState({ input: '', @@ -71,33 +72,54 @@ export const LLMInput: React.FC = (props) => { // hide if not in a tab if (props.tabId === '') return <>{props.children}; + // generic method for generating ppl from natural language const request = async () => { + let generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { + body: JSON.stringify({ + question: questionRef.current?.value, + index: props.selectedIndex[0].label, + }), + }); + setFeedbackFormData({ + ...feedbackFormData, + input: questionRef.current?.value || '', + output: generatedPPL, + }); + await props.handleQueryChange(generatedPPL); + console.log('generatedPPL', generatedPPL); + await dispatch( + changeQuery({ + tabId: props.tabId, + query: { + [RAW_QUERY]: generatedPPL, + }, + }) + ); + return generatedPPL; + }; + // used by generate query button + const generate = async () => { if (!props.selectedIndex.length) return; - let generatedPPL: string = ''; - let generatePPLError: string | undefined; try { setGenerating(true); - generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { - body: JSON.stringify({ - question: questionRef.current?.value, - index: props.selectedIndex[0].label, - }), - }); + console.log('generated query is', await request()); + } catch (error) { setFeedbackFormData({ ...feedbackFormData, input: questionRef.current?.value || '', - output: generatedPPL, }); - await props.handleQueryChange(generatedPPL); - await dispatch( - changeQuery({ - tabId: props.tabId, - data: { - [RAW_QUERY]: generatedPPL, - }, - }) - ); - await props.handleTimeRangePickerRefresh(); + } finally { + setGenerating(false); + } + }; + // used by generate and run button + const runAndSummarize = async () => { + if (!props.selectedIndex.length) return; + let generatedPPL: string = ''; + let generatePPLError: string | undefined; + try { + setGeneratingRun(true); + generatedPPL = await request(); } catch (error) { setFeedbackFormData({ ...feedbackFormData, @@ -105,9 +127,10 @@ export const LLMInput: React.FC = (props) => { }); generatePPLError = String(error.body); } finally { - setGenerating(false); + setGeneratingRun(false); } try { + await props.handleTimeRangePickerRefresh(); const summarizationContext: SummarizationContext = { question: questionRef.current?.value || 'unable to retrieve question', index: props.selectedIndex[0].label, @@ -235,7 +258,8 @@ export const LLMInput: React.FC = (props) => { = (props) => { Date: Wed, 15 Nov 2023 18:01:32 -0800 Subject: [PATCH 33/86] change chat logo Signed-off-by: Paul Sebastian --- .../datasources/icons/query-assistant-logo.svg | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/public/components/datasources/icons/query-assistant-logo.svg b/public/components/datasources/icons/query-assistant-logo.svg index 7bd56be25e..d21737d822 100644 --- a/public/components/datasources/icons/query-assistant-logo.svg +++ b/public/components/datasources/icons/query-assistant-logo.svg @@ -1,14 +1,16 @@ - - - - - + + + + + + + - + - + From f832b5ec479b4bf0bf49af40a5baef6abedaa5b1 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 16 Nov 2023 03:00:41 +0000 Subject: [PATCH 34/86] update summarization api and UI to show suggestions as badges Signed-off-by: Joshua Li --- .../components/common/search/query_area.tsx | 4 ++ public/components/common/search/search.tsx | 60 +++++++++++++++---- .../event_analytics/explorer/llm/input.tsx | 39 ++++++------ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index ddb78d97d5..5ce60070f4 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -16,6 +16,8 @@ export function QueryArea({ setNeedsUpdate, setFillRun, selectedIndex, + nlqInput, + setNlqInput, }: any) { return ( @@ -49,6 +51,8 @@ export function QueryArea({ handleTimeRangePickerRefresh={handleTimeRangePickerRefresh} setNeedsUpdate={setNeedsUpdate} selectedIndex={selectedIndex} + nlqInput={nlqInput} + setNlqInput={setNlqInput} /> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 6d2a8e592e..a930e1c666 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -3,8 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './search.scss'; - import '@algolia/autocomplete-theme-classic'; import { EuiAccordion, @@ -33,6 +31,7 @@ import { import { isEqual } from 'lodash'; import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { APP_ANALYTICS_TAB_ID_REGEX } from '../../../../common/constants/explorer'; import { PPL_SPAN_REGEX } from '../../../../common/constants/shared'; import { uiSettingsService } from '../../../../common/utils'; @@ -40,18 +39,18 @@ import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; +import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; -import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { selectQueryAssistantSummarization } from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; +import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { Autocomplete } from './autocomplete'; import { DatePicker } from './date_picker'; -import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { QueryArea } from './query_area'; import './search.scss'; -import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; -import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; + export interface IQueryBarProps { query: string; tempQuery: string; @@ -125,6 +124,7 @@ export const Search = (props: any) => { const [fillRun, setFillRun] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; + const [nlqInput, setNlqInput] = useState('Are there any errors in my logs?'); const { data: pollingResult, @@ -203,7 +203,7 @@ export const Search = (props: any) => { setLanguagePopoverOpen(false); }; - const languageOptions: EuiSuperSelectOption[] = [ + const languageOptions: Array> = [ { value: QUERY_LANGUAGE.PPL, inputDisplay: PPL }, { value: QUERY_LANGUAGE.DQL, inputDisplay: DQL }, ]; @@ -468,6 +468,8 @@ export const Search = (props: any) => { setNeedsUpdate={setNeedsUpdate} setFillRun={setFillRun} selectedIndex={selectedIndex} + nlqInput={nlqInput} + setNlqInput={setNlqInput} /> @@ -495,15 +497,47 @@ export const Search = (props: any) => { <> {queryAssistantSummarization?.isPPLError ? ( - - - {queryAssistantSummarization?.summary} - - + <> + + + {queryAssistantSummarization.summary} + + + + + + Suggestions: + + {queryAssistantSummarization.suggestedQuestions.map((question) => ( + + setNlqInput(question)} + onClickAriaLabel="Set input to the suggested question" + > + {question} + + + ))} + + + PPL Documentation + + + + ) : ( - {queryAssistantSummarization?.summary} + {queryAssistantSummarization.summary} )} diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 38fe29a815..8f35758fb5 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -6,7 +6,6 @@ import { EuiBadge, EuiButton, - EuiComboBox, EuiComboBoxOptionOption, EuiFieldText, EuiFlexGroup, @@ -27,9 +26,9 @@ import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constant import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; +import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; -import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; interface SummarizationContext { question: string; @@ -44,13 +43,14 @@ interface Props { handleTimeRangePickerRefresh: () => void; tabId: string; setNeedsUpdate: any; - selectedIndex: EuiComboBoxOptionOption[]; + selectedIndex: Array>; + nlqInput: string; + setNlqInput: React.Dispatch>; } export const LLMInput: React.FC = (props) => { const [barSelected, setBarSelected] = useState(false); const dispatch = useDispatch(); - const questionRef = useRef(null); const [generating, setGenerating] = useState(false); const [generatingRun, setGeneratingRun] = useState(false); @@ -63,26 +63,20 @@ export const LLMInput: React.FC = (props) => { comment: '', }); - useEffect(() => { - if (questionRef.current) { - questionRef.current.value = 'Are there any errors in my logs?'; - } - }, []); - // hide if not in a tab if (props.tabId === '') return <>{props.children}; // generic method for generating ppl from natural language const request = async () => { - let generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { + const generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { body: JSON.stringify({ - question: questionRef.current?.value, + question: props.nlqInput, index: props.selectedIndex[0].label, }), }); setFeedbackFormData({ ...feedbackFormData, - input: questionRef.current?.value || '', + input: props.nlqInput, output: generatedPPL, }); await props.handleQueryChange(generatedPPL); @@ -106,7 +100,7 @@ export const LLMInput: React.FC = (props) => { } catch (error) { setFeedbackFormData({ ...feedbackFormData, - input: questionRef.current?.value || '', + input: props.nlqInput, }); } finally { setGenerating(false); @@ -123,7 +117,7 @@ export const LLMInput: React.FC = (props) => { } catch (error) { setFeedbackFormData({ ...feedbackFormData, - input: questionRef.current?.value || '', + input: props.nlqInput, }); generatePPLError = String(error.body); } finally { @@ -132,7 +126,7 @@ export const LLMInput: React.FC = (props) => { try { await props.handleTimeRangePickerRefresh(); const summarizationContext: SummarizationContext = { - question: questionRef.current?.value || 'unable to retrieve question', + question: props.nlqInput, index: props.selectedIndex[0].label, isError: false, response: '', @@ -180,19 +174,23 @@ export const LLMInput: React.FC = (props) => { summarizationContext.isError = true; summarizationContext.response = generatePPLError; } - const summarized = await getOSDHttp().post('/api/assistant/summarize', { + const summary = await getOSDHttp().post<{ + summary: string; + suggestedQuestions: string[]; + }>('/api/assistant/summarize', { body: JSON.stringify(summarizationContext), }); await dispatch( changeSummary({ tabId: props.tabId, data: { - summary: summarized, + summary: summary.summary, + suggestedQuestions: summary.suggestedQuestions, }, }) ); } catch (error) { - coreRefs.toasts?.addError(error.body, { title: 'Failed to summarize results' }); + coreRefs.toasts?.addError(error.body || error, { title: 'Failed to summarize results' }); } finally { await dispatch( changeSummary({ @@ -233,8 +231,9 @@ export const LLMInput: React.FC = (props) => { placeholder="Ask a question" // prepend={['Question']} disabled={generating} + value={props.nlqInput} + onChange={(e) => props.setNlqInput(e.target.value)} fullWidth - inputRef={questionRef} onFocus={() => { setBarSelected(true); props.setNeedsUpdate(false); From d60a83c28b14a28e8b022f8717fb8288607209c8 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 09:50:55 -0800 Subject: [PATCH 35/86] url params fill olly question Signed-off-by: Paul Sebastian --- common/constants/data_sources.ts | 2 ++ common/constants/explorer.ts | 1 + .../components/common/search/query_area.tsx | 2 +- public/components/common/search/search.tsx | 7 +++++++ .../datasources/datasources_selection.tsx | 20 +++++++++++++++++++ .../event_analytics/explorer/explorer.tsx | 4 ++-- .../event_analytics/explorer/llm/input.tsx | 16 +++++++++++++-- .../redux/slices/query_slice.ts | 6 ++++-- 8 files changed, 51 insertions(+), 7 deletions(-) diff --git a/common/constants/data_sources.ts b/common/constants/data_sources.ts index 931f1f7290..7918516628 100644 --- a/common/constants/data_sources.ts +++ b/common/constants/data_sources.ts @@ -5,6 +5,8 @@ export const DATA_SOURCE_NAME_URL_PARAM_KEY = 'datasourceName'; export const DATA_SOURCE_TYPE_URL_PARAM_KEY = 'datasourceType'; +export const OLLY_QUESTION_URL_PARAM_KEY = 'olly_q'; +export const INDEX_URL_PARAM_KEY = 'indexPattern'; export const DEFAULT_DATA_SOURCE_TYPE = 'DEFAULT_INDEX_PATTERNS'; export const DEFAULT_DATA_SOURCE_NAME = 'Default cluster'; export const DEFAULT_DATA_SOURCE_OBSERVABILITY_DISPLAY_NAME = 'OpenSearch'; diff --git a/common/constants/explorer.ts b/common/constants/explorer.ts index c0942c0a12..b6c2051259 100644 --- a/common/constants/explorer.ts +++ b/common/constants/explorer.ts @@ -18,6 +18,7 @@ export const RAW_QUERY = 'rawQuery'; export const FINAL_QUERY = 'finalQuery'; export const SELECTED_DATE_RANGE = 'selectedDateRange'; export const INDEX = 'index'; +export const OLLY_QUERY_ASSISTANT = 'ollyQueryAssistant'; export const SELECTED_PATTERN_FIELD = 'selectedPatternField'; export const PATTERN_REGEX = 'patternRegex'; export const FILTERED_PATTERN = 'filteredPattern'; diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 5ce60070f4..801ffdd831 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -26,7 +26,7 @@ export function QueryArea({ { setIsQueryRunning, } = props; + const queryRedux = useSelector(selectQueries)[tabId]; const queryAssistantSummarization = useSelector(selectQueryAssistantSummarization)[tabId]; const dispatch = useDispatch(); const appLogEvents = tabId.match(APP_ANALYTICS_TAB_ID_REGEX); @@ -243,6 +246,10 @@ export const Search = (props: any) => { } }, [pollingResult, pollingError]); + useEffect(() => { + if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); + }, [queryRedux.index]); + const runChanges = () => { onQuerySearch(queryLang); handleTimePickerChange(timeRange); diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index 3c6e34df1f..d815c4d319 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -20,6 +20,7 @@ import { reset as resetCountDistribution } from '../../redux/slices/count_distri import { reset as resetFields } from '../../redux/slices/field_slice'; import { reset as resetPatterns } from '../../redux/slices/patterns_slice'; import { reset as resetQueryResults } from '../../redux/slices/query_result_slice'; +import { changeData } from '../../redux/slices/query_slice'; import { reset as resetVisualization } from '../../redux/slices/visualization_slice'; import { reset as resetVisConfig } from '../../redux/slices/viualization_config_slice'; import { reset as resetQuery } from '../../redux/slices/query_slice'; @@ -34,6 +35,8 @@ import { DEFAULT_DATA_SOURCE_OBSERVABILITY_DISPLAY_NAME, DATA_SOURCE_TYPES, QUERY_LANGUAGE, + OLLY_QUESTION_URL_PARAM_KEY, + INDEX_URL_PARAM_KEY, } from '../../../../../common/constants/data_sources'; import { SQLService } from '../../../../services/requests/sql'; import { get as getObjValue } from '../../../../../common/utils/shared'; @@ -42,6 +45,7 @@ import { getAsyncSessionId, } from '../../../../../common/utils/query_session_utils'; import { DIRECT_DUMMY_QUERY } from '../../../../../common/constants/shared'; +import { INDEX, OLLY_QUERY_ASSISTANT } from '../../../../../common/constants/explorer'; const getDataSourceState = (selectedSourceState: SelectedDataSource[]) => { if (selectedSourceState.length === 0) return []; @@ -69,6 +73,8 @@ const removeDataSourceFromURLParams = (currURL: string) => { // Remove the data source redirection parameters hashParams.delete(DATA_SOURCE_NAME_URL_PARAM_KEY); hashParams.delete(DATA_SOURCE_TYPE_URL_PARAM_KEY); + hashParams.delete(OLLY_QUESTION_URL_PARAM_KEY); + hashParams.delete(INDEX_URL_PARAM_KEY); // Reconstruct the hash currentURL.hash = hashParams.toString() ? `${hashBase}?${hashParams.toString()}` : hashBase; @@ -178,6 +184,9 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { useEffect(() => { const datasourceName = routerContext?.searchParams.get(DATA_SOURCE_NAME_URL_PARAM_KEY); const datasourceType = routerContext?.searchParams.get(DATA_SOURCE_TYPE_URL_PARAM_KEY); + const idxPattern = routerContext?.searchParams.get(INDEX_URL_PARAM_KEY); + const ollyQuestion = routerContext?.searchParams.get(OLLY_QUESTION_URL_PARAM_KEY) || ''; + const decodedOllyQ = decodeURIComponent(ollyQuestion); if (datasourceName && datasourceType) { // remove datasourceName and datasourceType from URL for a clean search state removeDataSourceFromURLParams(window.location.href); @@ -190,6 +199,17 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { }) ); }); + if (idxPattern && decodedOllyQ) { + dispatch( + changeData({ + tabId: tabId, + data: { + [INDEX]: idxPattern, + [OLLY_QUERY_ASSISTANT]: decodedOllyQ, + }, + }) + ); + } } }, []); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 2c79e4de73..23b1913ffd 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -100,7 +100,7 @@ import { } from '../redux/slices/count_distribution_slice'; import { selectFields, updateFields } from '../redux/slices/field_slice'; import { selectQueryResult } from '../redux/slices/query_result_slice'; -import { changeDateRange, changeQuery, selectQueries } from '../redux/slices/query_slice'; +import { changeData, changeQuery, selectQueries } from '../redux/slices/query_slice'; import { updateTabName } from '../redux/slices/query_tab_slice'; import { selectExplorerVisualization } from '../redux/slices/visualization_slice'; import { @@ -417,7 +417,7 @@ export const Explorer = ({ setEndTime(timeRange[1]); } await dispatch( - changeDateRange({ + changeData({ tabId: requestParams.tabId, data: { [RAW_QUERY]: queryRef.current![RAW_QUERY], [SELECTED_DATE_RANGE]: timeRange }, }) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 8f35758fb5..358812ee07 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; @@ -27,7 +27,7 @@ import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; -import { changeQuery } from '../../redux/slices/query_slice'; +import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; interface SummarizationContext { @@ -48,6 +48,8 @@ interface Props { setNlqInput: React.Dispatch>; } export const LLMInput: React.FC = (props) => { + const query = useSelector(selectQueries)[props.tabId]; + const [barSelected, setBarSelected] = useState(false); const dispatch = useDispatch(); @@ -63,6 +65,16 @@ export const LLMInput: React.FC = (props) => { comment: '', }); + useEffect(() => { + if (questionRef.current) { + console.log(query.ollyQueryAssistant); + questionRef.current.value = + query.ollyQueryAssistant.length > 0 + ? query.ollyQueryAssistant + : 'Are there any errors in my logs?'; + } + }, [query.ollyQueryAssistant]); + // hide if not in a tab if (props.tabId === '') return <>{props.children}; diff --git a/public/components/event_analytics/redux/slices/query_slice.ts b/public/components/event_analytics/redux/slices/query_slice.ts index 90be708a1e..f3d307b4f1 100644 --- a/public/components/event_analytics/redux/slices/query_slice.ts +++ b/public/components/event_analytics/redux/slices/query_slice.ts @@ -9,6 +9,7 @@ import { FILTERED_PATTERN, FINAL_QUERY, INDEX, + OLLY_QUERY_ASSISTANT, PATTERN_REGEX, PPL_DEFAULT_PATTERN_REGEX_FILETER, RAW_QUERY, @@ -28,6 +29,7 @@ const initialQueryState = { [FILTERED_PATTERN]: '', [SELECTED_TIMESTAMP]: '', [SELECTED_DATE_RANGE]: ['now-5y', 'now'], + [OLLY_QUERY_ASSISTANT]: '', }; const appBaseQueryState = { @@ -57,7 +59,7 @@ export const queriesSlice = createSlice({ ...payload.query, }; }, - changeDateRange: (state, { payload }) => { + changeData: (state, { payload }) => { state[payload.tabId] = { ...state[payload.tabId], ...payload.data, @@ -80,7 +82,7 @@ export const queriesSlice = createSlice({ extraReducers: (builder) => {}, }); -export const { changeQuery, changeDateRange, remove, init, reset } = queriesSlice.actions; +export const { changeQuery, changeData, remove, init, reset } = queriesSlice.actions; export const selectQueries = createSelector( (state) => state.queries, From c490725b90d8400ea9dc82ac2e9731fcab401404 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 10:31:25 -0800 Subject: [PATCH 36/86] switched buttons around and added mail link Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 10 +++-- .../event_analytics/explorer/llm/input.tsx | 42 +++++++------------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 6d4d154a9d..d575431495 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -45,7 +45,6 @@ import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { selectQueries } from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { selectQueryAssistantSummarization } from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; -import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { Autocomplete } from './autocomplete'; @@ -248,7 +247,8 @@ export const Search = (props: any) => { useEffect(() => { if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); - }, [queryRedux.index]); + if (queryRedux.ollyQueryAssistant.length > 0) setNlqInput(query.ollyQueryAssistant); + }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { onQuerySearch(queryLang); @@ -553,7 +553,11 @@ export const Search = (props: any) => { The OpenSearch Assistant may produce inaccurate information. Verify all information before using it in any environment or workload. Share feedback - via Email or Slack + via{' '} + + Email + {' '} + or Slack diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 358812ee07..b3f9556d0e 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; @@ -27,7 +27,7 @@ import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; -import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; +import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; interface SummarizationContext { @@ -48,8 +48,6 @@ interface Props { setNlqInput: React.Dispatch>; } export const LLMInput: React.FC = (props) => { - const query = useSelector(selectQueries)[props.tabId]; - const [barSelected, setBarSelected] = useState(false); const dispatch = useDispatch(); @@ -65,16 +63,6 @@ export const LLMInput: React.FC = (props) => { comment: '', }); - useEffect(() => { - if (questionRef.current) { - console.log(query.ollyQueryAssistant); - questionRef.current.value = - query.ollyQueryAssistant.length > 0 - ? query.ollyQueryAssistant - : 'Are there any errors in my logs?'; - } - }, [query.ollyQueryAssistant]); - // hide if not in a tab if (props.tabId === '') return <>{props.children}; @@ -268,36 +256,38 @@ export const LLMInput: React.FC = (props) => { - Generate query + Generate and run - Generate and run + Generate query - Share feedback via Email or Slack + Share feedback via{' '} + + Email + {' '} + or Slack From 08e3d5ac443982e785574e5ae2c939fec26d278e Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 10:40:44 -0800 Subject: [PATCH 37/86] fixed url parsing Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index d575431495..13952846d7 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -247,7 +247,7 @@ export const Search = (props: any) => { useEffect(() => { if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); - if (queryRedux.ollyQueryAssistant.length > 0) setNlqInput(query.ollyQueryAssistant); + if (queryRedux.ollyQueryAssistant.length > 0) setNlqInput(queryRedux.ollyQueryAssistant); }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { From 8478d90792fb6b6632da6662edbe7ad771bc5f0d Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 11:13:21 -0800 Subject: [PATCH 38/86] toast error messages for generate query button Signed-off-by: Paul Sebastian --- public/components/event_analytics/explorer/llm/input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index b3f9556d0e..536c2b2fd5 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -102,6 +102,7 @@ export const LLMInput: React.FC = (props) => { ...feedbackFormData, input: props.nlqInput, }); + coreRefs.toasts?.addError(error.body || error, { title: 'Failed to generate results' }); } finally { setGenerating(false); } From b3e4f8f25cc07edda3f95325bec63a5332b04144 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 14:01:51 -0800 Subject: [PATCH 39/86] url redirect will run question, and functional working version of suggestion questions up Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 2 +- .../event_analytics/explorer/llm/input.tsx | 85 ++++++++++++++++--- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 13952846d7..918fbafebe 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -126,7 +126,7 @@ export const Search = (props: any) => { const [fillRun, setFillRun] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; - const [nlqInput, setNlqInput] = useState('Are there any errors in my logs?'); + const [nlqInput, setNlqInput] = useState(''); const { data: pollingResult, diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 536c2b2fd5..f8edcd193e 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -16,16 +16,20 @@ import { EuiModal, EuiPanel, EuiText, + EuiListGroup, + EuiListGroupItem, + EuiInputPopover, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; +import { selectQueries } from '../../redux/slices/query_slice'; import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; @@ -48,10 +52,37 @@ interface Props { setNlqInput: React.Dispatch>; } export const LLMInput: React.FC = (props) => { + const queryRedux = useSelector(selectQueries)[props.tabId]; + + // HARDCODED QUESTION SUGGESTIONS: + const hardcodedSuggestions = { + opensearch_datashboards_sample_data_ecommerce: [ + 'How many unique customers placed orders this week?', + 'Count the number of orders grouped by manufacturer and category', + 'find customers with first names like Eddie', + ], + opensearch_dashboards_sample_data_logs: [ + 'Are there any errors in my logs?', + 'How many requests were there grouped by response code last week?', + "What's the average request size by week?", + ], + opensearch_dashboards_sample_data_flights: [ + 'how many flights were there this week grouped by destination country?', + 'what were the longest flight delays this week?', + 'what carriers have the furthest flights?', + ], + 'sso_logs-*.*': [ + 'show me the most recent 10 logs', + 'how many requests were there grouped by status code', + 'how many request failures were there by week?', + ], + }; + const [barSelected, setBarSelected] = useState(false); const dispatch = useDispatch(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [generating, setGenerating] = useState(false); const [generatingRun, setGeneratingRun] = useState(false); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); @@ -63,6 +94,12 @@ export const LLMInput: React.FC = (props) => { comment: '', }); + useEffect(() => { + if (queryRedux.ollyQueryAssistant.length > 0) { + runAndSummarize(); + } + }, [queryRedux.ollyQueryAssistant]); + // hide if not in a tab if (props.tabId === '') return <>{props.children}; @@ -228,19 +265,41 @@ export const LLMInput: React.FC = (props) => { New! - props.setNlqInput(e.target.value)} - fullWidth - onFocus={() => { - setBarSelected(true); - props.setNeedsUpdate(false); + props.setNlqInput(e.target.value)} + fullWidth + onFocus={() => { + setBarSelected(true); + props.setNeedsUpdate(false); + if (props.nlqInput.length === 0) setIsPopoverOpen(true); + }} + onBlur={() => setBarSelected(false)} + /> + } + fullWidth={true} + isOpen={isPopoverOpen} + closePopover={() => { + setIsPopoverOpen(false); }} - onBlur={() => setBarSelected(false)} - /> + > + + {hardcodedSuggestions[props.selectedIndex[0].label].map((question) => ( + { + props.setNlqInput(question); + setIsPopoverOpen(false); + }} + label={question} + /> + ))} + + {/* Date: Thu, 16 Nov 2023 14:05:40 -0800 Subject: [PATCH 40/86] stop crashes when index has no suggested questions Signed-off-by: Paul Sebastian --- public/components/event_analytics/explorer/llm/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index f8edcd193e..7e0933ab43 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -289,7 +289,7 @@ export const LLMInput: React.FC = (props) => { }} > - {hardcodedSuggestions[props.selectedIndex[0].label].map((question) => ( + {hardcodedSuggestions[props.selectedIndex[0].label]?.map((question) => ( { props.setNlqInput(question); From e83f2b50e46bf92087b34f80b3b86d2da80be248 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 16 Nov 2023 22:48:05 +0000 Subject: [PATCH 41/86] bump to 2.11.0 for easier build Note this branch is based on `main` 3.0.0.0 version, downgrading temporarily for build Signed-off-by: Joshua Li --- opensearch_dashboards.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 4a80c99087..f785bb84d9 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,7 +1,7 @@ { "id": "observabilityDashboards", - "version": "3.0.0.0", - "opensearchDashboardsVersion": "opensearchDashboards", + "version": "2.11.0.0", + "opensearchDashboardsVersion": "2.11.0", "server": true, "ui": true, "requiredPlugins": [ diff --git a/package.json b/package.json index d9ac42f9ce..b07010b1d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "observability-dashboards", - "version": "3.0.0.0", + "version": "2.11.0.0", "main": "index.ts", "license": "Apache-2.0", "scripts": { @@ -82,4 +82,4 @@ "node_modules/*", "target/*" ] -} \ No newline at end of file +} From b0410a104cf41ed73d34cd3553cf46f66d7e9417 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 14:53:42 -0800 Subject: [PATCH 42/86] only include hardcoded indexes Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 918fbafebe..38ce2ca8cb 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -269,6 +269,14 @@ export const Search = (props: any) => { ) : undefined; const loading = indicesLoading || indexPatternsLoading; + // HARDCODED INDEXES BELOW + const onlyInclude = [ + 'opensearch_datashboards_sample_data_ecommerce', + 'opensearch_dashboards_sample_data_logs', + 'opensearch_dashboards_sample_data_flights', + 'sso_logs-*.*', + ]; + const filteredData = data?.filter((obj) => onlyInclude.some((index) => index === obj.label)); return (
@@ -347,7 +355,7 @@ export const Search = (props: any) => { prepend={Index} singleSelection={true} isLoading={loading} - options={data} + options={filteredData} selectedOptions={selectedIndex} onChange={(index) => setSelectedIndex(index)} /> From 58778dbf76e542a7101a125b74a8a9f425677e1d Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 15:02:54 -0800 Subject: [PATCH 43/86] empty index no more crashing Signed-off-by: Paul Sebastian --- public/components/event_analytics/explorer/llm/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 7e0933ab43..9dd44a3e5a 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -289,7 +289,7 @@ export const LLMInput: React.FC = (props) => { }} > - {hardcodedSuggestions[props.selectedIndex[0].label]?.map((question) => ( + {hardcodedSuggestions[props.selectedIndex[0]?.label]?.map((question) => ( { props.setNlqInput(question); From 8a6ad6e378229cc3f3efb00c0bd880b1096ad36a Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 16 Nov 2023 16:56:10 -0800 Subject: [PATCH 44/86] reset results and summary after run or generate buttons pressed Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 8 +++++++- .../components/event_analytics/explorer/llm/input.tsx | 10 +++++++++- .../slices/query_assistant_summarization_slice.ts | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 38ce2ca8cb..7fa1c008b9 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -42,9 +42,13 @@ import { SQLService } from '../../../services/requests/sql'; import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; +import { reset } from '../../event_analytics/redux/slices/query_result_slice'; import { selectQueries } from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; -import { selectQueryAssistantSummarization } from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; +import { + selectQueryAssistantSummarization, + resetSummary, +} from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; import { PPLReferenceFlyout } from '../helpers'; import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { Autocomplete } from './autocomplete'; @@ -251,6 +255,8 @@ export const Search = (props: any) => { }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); onQuerySearch(queryLang); handleTimePickerChange(timeRange); setNeedsUpdate(false); diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 9dd44a3e5a..f7f12b169f 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -30,7 +30,11 @@ import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; import { selectQueries } from '../../redux/slices/query_slice'; -import { changeSummary } from '../../redux/slices/query_assistant_summarization_slice'; +import { + changeSummary, + resetSummary, +} from '../../redux/slices/query_assistant_summarization_slice'; +import { reset } from '../../redux/slices/query_result_slice'; import { changeQuery } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; @@ -130,6 +134,8 @@ export const LLMInput: React.FC = (props) => { }; // used by generate query button const generate = async () => { + dispatch(reset({ tabId: props.tabId })); + dispatch(resetSummary({ tabId: props.tabId })); if (!props.selectedIndex.length) return; try { setGenerating(true); @@ -146,6 +152,8 @@ export const LLMInput: React.FC = (props) => { }; // used by generate and run button const runAndSummarize = async () => { + dispatch(reset({ tabId: props.tabId })); + dispatch(resetSummary({ tabId: props.tabId })); if (!props.selectedIndex.length) return; let generatedPPL: string = ''; let generatePPLError: string | undefined; diff --git a/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts b/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts index fcd3f27e14..663623ed2d 100644 --- a/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts +++ b/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts @@ -20,13 +20,13 @@ export const summarizationSlice = createSlice({ ...payload.data, }; }, - reset: (state, { payload }) => { + resetSummary: (state, { payload }) => { state[payload.tabId] = {}; }, }, }); -export const { changeSummary, reset } = summarizationSlice.actions; +export const { changeSummary, resetSummary } = summarizationSlice.actions; export const selectQueryAssistantSummarization = createSelector( (state) => state.queryAssistantSummarization, From 7e2c019d5ecd1daaa9c7dbf816349fcb1e5053b8 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 17 Nov 2023 12:29:43 -0800 Subject: [PATCH 45/86] url redirection auto run happens after props set, and moved buttons around Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 23 ++++++-- .../event_analytics/explorer/llm/input.tsx | 53 ++++++++++--------- 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 7fa1c008b9..a0fa553082 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -32,7 +32,11 @@ import { isEqual } from 'lodash'; import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; -import { APP_ANALYTICS_TAB_ID_REGEX } from '../../../../common/constants/explorer'; +import { + APP_ANALYTICS_TAB_ID_REGEX, + INDEX, + OLLY_QUERY_ASSISTANT, +} from '../../../../common/constants/explorer'; import { PPL_SPAN_REGEX } from '../../../../common/constants/shared'; import { uiSettingsService } from '../../../../common/utils'; import { useFetchEvents } from '../../../components/event_analytics/hooks'; @@ -43,7 +47,7 @@ import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { reset } from '../../event_analytics/redux/slices/query_result_slice'; -import { selectQueries } from '../../event_analytics/redux/slices/query_slice'; +import { changeData, selectQueries } from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { selectQueryAssistantSummarization, @@ -250,8 +254,21 @@ export const Search = (props: any) => { }, [pollingResult, pollingError]); useEffect(() => { + // set index and olly query assistant question if changed elsewhere if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); - if (queryRedux.ollyQueryAssistant.length > 0) setNlqInput(queryRedux.ollyQueryAssistant); + if (queryRedux.ollyQueryAssistant.length > 0) { + setNlqInput(queryRedux.ollyQueryAssistant); + // remove index and olly query assistant + dispatch( + changeData({ + tabId: props.tabId, + data: { + [INDEX]: '', + [OLLY_QUERY_ASSISTANT]: '', + }, + }) + ); + } }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index f7f12b169f..744b7f4b05 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -90,6 +90,8 @@ export const LLMInput: React.FC = (props) => { const [generating, setGenerating] = useState(false); const [generatingRun, setGeneratingRun] = useState(false); const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); + // below is only used for url redirection + const [autoRun, setAutoRun] = useState(false); const [feedbackFormData, setFeedbackFormData] = useState({ input: '', output: '', @@ -99,8 +101,11 @@ export const LLMInput: React.FC = (props) => { }); useEffect(() => { - if (queryRedux.ollyQueryAssistant.length > 0) { + if (autoRun) { + setAutoRun(false); runAndSummarize(); + } else if (queryRedux.ollyQueryAssistant.length > 0) { + setAutoRun(true); } }, [queryRedux.ollyQueryAssistant]); @@ -322,19 +327,16 @@ export const LLMInput: React.FC = (props) => { - - - Generate and run - + + + + Share feedback via{' '} + + Email + {' '} + or Slack + + = (props) => { Generate query - - - - Share feedback via{' '} - - Email - {' '} - or Slack - - + + + Generate and run + From 16f5cddbd99971463b475ae75b4115f8e33cf9bb Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Fri, 17 Nov 2023 20:48:15 +0000 Subject: [PATCH 46/86] disable focus trap on suggestions Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 744b7f4b05..688e7217d4 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -295,6 +295,7 @@ export const LLMInput: React.FC = (props) => { onBlur={() => setBarSelected(false)} /> } + disableFocusTrap fullWidth={true} isOpen={isPopoverOpen} closePopover={() => { From 449a3d8e608dcb3da90a1fbade6ab9df1b3ac324 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Fri, 17 Nov 2023 13:30:35 -0800 Subject: [PATCH 47/86] fixed run button issue not updating to most recent query Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 17 +++++++++++++---- .../event_analytics/explorer/explorer.tsx | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index a0fa553082..a6b62d81b0 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -30,12 +30,13 @@ import { } from '@elastic/eui'; import { isEqual } from 'lodash'; import React, { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { batch, useDispatch, useSelector } from 'react-redux'; import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; import { APP_ANALYTICS_TAB_ID_REGEX, INDEX, OLLY_QUERY_ASSISTANT, + RAW_QUERY, } from '../../../../common/constants/explorer'; import { PPL_SPAN_REGEX } from '../../../../common/constants/shared'; import { uiSettingsService } from '../../../../common/utils'; @@ -47,7 +48,11 @@ import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; import { useCatIndices, useGetIndexPatterns } from '../../event_analytics/explorer/llm/input'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; import { reset } from '../../event_analytics/redux/slices/query_result_slice'; -import { changeData, selectQueries } from '../../event_analytics/redux/slices/query_slice'; +import { + changeData, + changeQuery, + selectQueries, +} from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { selectQueryAssistantSummarization, @@ -272,8 +277,12 @@ export const Search = (props: any) => { }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { - dispatch(reset({ tabId })); - dispatch(resetSummary({ tabId })); + console.log(tempQuery); + batch(() => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); + dispatch(changeQuery({ tabId, query: { [RAW_QUERY]: tempQuery } })); + }); onQuerySearch(queryLang); handleTimePickerChange(timeRange); setNeedsUpdate(false); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 23b1913ffd..a1a049b8db 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -419,7 +419,7 @@ export const Explorer = ({ await dispatch( changeData({ tabId: requestParams.tabId, - data: { [RAW_QUERY]: queryRef.current![RAW_QUERY], [SELECTED_DATE_RANGE]: timeRange }, + data: { [SELECTED_DATE_RANGE]: timeRange }, }) ); }; From acad90284c4ca3bec9ec7fc543710a7d26803e85 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 18 Nov 2023 00:56:48 +0000 Subject: [PATCH 48/86] reuse explorerData for summarization Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 132 +++++++++--------- .../event_analytics/hooks/use_fetch_events.ts | 63 ++++++--- .../redux/reducers/fetch_reducers.ts | 4 + .../event_analytics/redux/reducers/index.ts | 2 +- .../query_assistant_summarization_slice.ts | 24 +++- .../redux/slices/query_result_slice.ts | 12 +- 6 files changed, 144 insertions(+), 93 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 688e7217d4..f35b16e90c 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -12,30 +12,31 @@ import { EuiFlexItem, EuiForm, EuiIcon, + EuiInputPopover, EuiLink, + EuiListGroup, + EuiListGroupItem, EuiModal, EuiPanel, EuiText, - EuiListGroup, - EuiListGroupItem, - EuiInputPopover, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; -import React, { Reducer, useEffect, useReducer, useRef, useState } from 'react'; +import React, { Reducer, useEffect, useReducer, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; -import { CONSOLE_PROXY, DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; +import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; -import { selectQueries } from '../../redux/slices/query_slice'; import { changeSummary, resetSummary, + selectQueryAssistantSummarization, + setResponseForSummaryStatus, } from '../../redux/slices/query_assistant_summarization_slice'; -import { reset } from '../../redux/slices/query_result_slice'; -import { changeQuery } from '../../redux/slices/query_slice'; +import { reset, selectQueryResult } from '../../redux/slices/query_result_slice'; +import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; interface SummarizationContext { @@ -57,6 +58,27 @@ interface Props { } export const LLMInput: React.FC = (props) => { const queryRedux = useSelector(selectQueries)[props.tabId]; + const explorerData = useSelector(selectQueryResult)[props.tabId]; + const summaryData = useSelector(selectQueryAssistantSummarization)[props.tabId]; + + useEffect(() => { + if ( + summaryData.responseForSummaryStatus === 'success' || + summaryData.responseForSummaryStatus === 'failure' + ) { + void (async () => { + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: false, + }, + }) + ); + generateSummary(); + })(); + } + }, [summaryData.responseForSummaryStatus]); // HARDCODED QUESTION SUGGESTIONS: const hardcodedSuggestions = { @@ -138,7 +160,7 @@ export const LLMInput: React.FC = (props) => { return generatedPPL; }; // used by generate query button - const generate = async () => { + const generatePPL = async () => { dispatch(reset({ tabId: props.tabId })); dispatch(resetSummary({ tabId: props.tabId })); if (!props.selectedIndex.length) return; @@ -155,76 +177,27 @@ export const LLMInput: React.FC = (props) => { setGenerating(false); } }; - // used by generate and run button - const runAndSummarize = async () => { - dispatch(reset({ tabId: props.tabId })); - dispatch(resetSummary({ tabId: props.tabId })); - if (!props.selectedIndex.length) return; - let generatedPPL: string = ''; - let generatePPLError: string | undefined; - try { - setGeneratingRun(true); - generatedPPL = await request(); - } catch (error) { - setFeedbackFormData({ - ...feedbackFormData, - input: props.nlqInput, - }); - generatePPLError = String(error.body); - } finally { - setGeneratingRun(false); - } + const generateSummary = async (context?: Partial) => { try { - await props.handleTimeRangePickerRefresh(); + const isError = summaryData.responseForSummaryStatus === 'failure'; const summarizationContext: SummarizationContext = { question: props.nlqInput, index: props.selectedIndex[0].label, - isError: false, - response: '', + isError, + response: isError + ? String(JSON.parse(explorerData.error.body.message).error.details) + : JSON.stringify(explorerData), + ...context, }; await dispatch( changeSummary({ tabId: props.tabId, data: { summaryLoading: true, + isPPLError: isError, }, }) ); - if (generatePPLError === undefined) { - const queryResponse = await getOSDHttp() - .post(CONSOLE_PROXY, { - body: JSON.stringify({ query: generatedPPL }), - query: { path: '_plugins/_ppl', method: 'POST' }, - }) - .then((resp) => { - dispatch( - changeSummary({ - tabId: props.tabId, - data: { - isPPLError: false, - }, - }) - ); - return resp; - }) - .catch((error) => { - dispatch( - changeSummary({ - tabId: props.tabId, - data: { - isPPLError: true, - }, - }) - ); - summarizationContext.isError = true; - return String(JSON.parse(error.body).error.details); - }); - summarizationContext.response = JSON.stringify(queryResponse); - summarizationContext.query = generatedPPL; - } else { - summarizationContext.isError = true; - summarizationContext.response = generatePPLError; - } const summary = await getOSDHttp().post<{ summary: string; suggestedQuestions: string[]; @@ -251,6 +224,31 @@ export const LLMInput: React.FC = (props) => { }, }) ); + dispatch( + setResponseForSummaryStatus({ + tabId: props.tabId, + responseForSummaryStatus: 'false', + }) + ); + } + }; + // used by generate and run button + const runAndSummarize = async () => { + dispatch(reset({ tabId: props.tabId })); + dispatch(resetSummary({ tabId: props.tabId })); + if (!props.selectedIndex.length) return; + try { + setGeneratingRun(true); + await request(); + await props.handleTimeRangePickerRefresh(); + } catch (error) { + setFeedbackFormData({ + ...feedbackFormData, + input: props.nlqInput, + }); + generateSummary({ isError: false, response: String(error.body) }); + } finally { + setGeneratingRun(false); } }; @@ -342,7 +340,7 @@ export const LLMInput: React.FC = (props) => { { + async (res: any) => { if (!isEmpty(res.jsonData)) { - return dispatchOnGettingHis(res, searchQuery); + await dispatchOnGettingHis(res, searchQuery); } else if (!isEmpty(res.data?.resp)) { - return dispatchOnGettingHis(JSON.parse(res.data?.resp), searchQuery); + await dispatchOnGettingHis(JSON.parse(res.data?.resp), searchQuery); + } else { + // when no hits and needs to get available fields to override default timestamp + dispatchOnNoHis(res); } - // when no hits and needs to get available fields to override default timestamp - dispatchOnNoHis(res); + dispatch( + setResponseForSummaryStatus({ + tabId: requestParams.tabId, + responseForSummaryStatus: 'success', + }) + ); }, - errorHandler + (error) => { + errorHandler?.(error); + batch(() => { + dispatch( + queryResultReset({ + tabId: requestParams.tabId, + }) + ); + dispatch( + fetchFailure({ + tabId: requestParams.tabId, + error, + }) + ); + dispatch( + setResponseForSummaryStatus({ + tabId: requestParams.tabId, + responseForSummaryStatus: 'failure', + }) + ); + }); + } ); }; diff --git a/public/components/event_analytics/redux/reducers/fetch_reducers.ts b/public/components/event_analytics/redux/reducers/fetch_reducers.ts index d2fb7a8245..7aca9df48f 100644 --- a/public/components/event_analytics/redux/reducers/fetch_reducers.ts +++ b/public/components/event_analytics/redux/reducers/fetch_reducers.ts @@ -6,3 +6,7 @@ export const fetchSuccess = (state, { payload }) => { state[payload.tabId] = payload.data; }; + +export const fetchFailure = (state, { payload }) => { + state[payload.tabId] = { error: payload.error }; +}; diff --git a/public/components/event_analytics/redux/reducers/index.ts b/public/components/event_analytics/redux/reducers/index.ts index 9bdf291214..925f5ecec8 100644 --- a/public/components/event_analytics/redux/reducers/index.ts +++ b/public/components/event_analytics/redux/reducers/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { fetchSuccess } from './fetch_reducers'; \ No newline at end of file +export { fetchSuccess, fetchFailure } from './fetch_reducers'; diff --git a/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts b/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts index 663623ed2d..e25ebab022 100644 --- a/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts +++ b/public/components/event_analytics/redux/slices/query_assistant_summarization_slice.ts @@ -3,17 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createSlice, createSelector } from '@reduxjs/toolkit'; +import { createSelector, createSlice } from '@reduxjs/toolkit'; import { initialTabId } from '../../../../framework/redux/store/shared_state'; const initialState = { - [initialTabId]: {}, + [initialTabId]: { + responseForSummaryStatus: 'false' as 'false' | 'success' | 'failure', + }, }; export const summarizationSlice = createSlice({ - name: "queryAssistantSummarization", + name: 'queryAssistantSummarization', initialState, reducers: { + setResponseForSummaryStatus: (state, { payload }) => { + state[payload.tabId] = { + ...state[payload.tabId], + responseForSummaryStatus: payload.responseForSummaryStatus, + }; + }, changeSummary: (state, { payload }) => { state[payload.tabId] = { ...state[payload.tabId], @@ -21,12 +29,18 @@ export const summarizationSlice = createSlice({ }; }, resetSummary: (state, { payload }) => { - state[payload.tabId] = {}; + state[payload.tabId] = { + responseForSummaryStatus: initialState[initialTabId].responseForSummaryStatus, + }; }, }, }); -export const { changeSummary, resetSummary } = summarizationSlice.actions; +export const { + setResponseForSummaryStatus, + changeSummary, + resetSummary, +} = summarizationSlice.actions; export const selectQueryAssistantSummarization = createSelector( (state) => state.queryAssistantSummarization, diff --git a/public/components/event_analytics/redux/slices/query_result_slice.ts b/public/components/event_analytics/redux/slices/query_result_slice.ts index d4d212d90a..b689cf7cf8 100644 --- a/public/components/event_analytics/redux/slices/query_result_slice.ts +++ b/public/components/event_analytics/redux/slices/query_result_slice.ts @@ -3,10 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createSlice, createSelector } from '@reduxjs/toolkit'; -import { fetchSuccess as fetchSuccessReducer } from '../reducers'; -import { initialTabId } from '../../../../framework/redux/store/shared_state'; +import { createSelector, createSlice } from '@reduxjs/toolkit'; import { REDUX_EXPL_SLICE_QUERY_RESULT } from '../../../../../common/constants/explorer'; +import { initialTabId } from '../../../../framework/redux/store/shared_state'; +import { + fetchFailure as fetchFailureReducer, + fetchSuccess as fetchSuccessReducer, +} from '../reducers'; const initialState = { [initialTabId]: {}, @@ -17,6 +20,7 @@ export const queryResultSlice = createSlice({ initialState, reducers: { fetchSuccess: fetchSuccessReducer, + fetchFailure: fetchFailureReducer, reset: (state, { payload }) => { state[payload.tabId] = {}; }, @@ -29,7 +33,7 @@ export const queryResultSlice = createSlice({ }, }); -export const { fetchSuccess, remove, reset, init } = queryResultSlice.actions; +export const { fetchSuccess, fetchFailure, remove, reset, init } = queryResultSlice.actions; export const selectQueryResult = createSelector( (state) => state.queryResults, From 361d13e9cf5d02c51c72138edcc987947986f510 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 18 Nov 2023 01:04:02 +0000 Subject: [PATCH 49/86] only summarize when calling "Generate and run" Signed-off-by: Joshua Li --- .../event_analytics/explorer/explorer.tsx | 18 ++++++++--- .../event_analytics/explorer/llm/input.tsx | 2 +- .../event_analytics/hooks/use_fetch_events.ts | 32 +++++++++++-------- .../data_fetchers/ppl/ppl_data_fetcher.ts | 3 +- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index a1a049b8db..3eb2df9bf4 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -292,7 +292,11 @@ export const Explorer = ({ }; }; - const fetchData = async (startingTime?: string, endingTime?: string) => { + const fetchData = async ( + startingTime?: string, + endingTime?: string, + setSummaryStatus?: boolean + ) => { const curQuery: IQuery = queryRef.current!; new PPLDataFetcher( { ...curQuery }, @@ -312,6 +316,7 @@ export const Explorer = ({ queryManager, getDefaultVisConfig, getAvailableFields, + setSummaryStatus, }, { appBaseQuery, @@ -434,8 +439,11 @@ export const Explorer = ({ ); }; - const handleTimeRangePickerRefresh = async (availability?: boolean) => { - handleQuerySearch(availability); + const handleTimeRangePickerRefresh = async ( + availability?: boolean, + setSummaryStatus?: boolean + ) => { + handleQuerySearch(availability, setSummaryStatus); if (availability !== true && query.rawQuery.match(PATTERNS_REGEX)) { let currQuery = query.rawQuery; const currPattern = currQuery.match(PATTERNS_EXTRACTOR_REGEX)!.groups!.pattern; @@ -691,7 +699,7 @@ export const Explorer = ({ ); }; - const handleQuerySearch = async (availability?: boolean) => { + const handleQuerySearch = async (availability?: boolean, setSummaryStatus: boolean) => { // clear previous selected timestamp when index pattern changes const searchedQuery = tempQueryRef.current; if (isIndexPatternChanged(searchedQuery, query[RAW_QUERY])) { @@ -708,7 +716,7 @@ export const Explorer = ({ if (availability !== true) { await updateQueryInStore(searchedQuery); } - await fetchData(); + await fetchData(undefined, undefined, setSummaryStatus); }; const handleQueryChange = async (newQuery: string) => setTempQuery(newQuery); diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index f35b16e90c..9190cdde1f 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -240,7 +240,7 @@ export const LLMInput: React.FC = (props) => { try { setGeneratingRun(true); await request(); - await props.handleTimeRangePickerRefresh(); + await props.handleTimeRangePickerRefresh(undefined, true); } catch (error) { setFeedbackFormData({ ...feedbackFormData, diff --git a/public/components/event_analytics/hooks/use_fetch_events.ts b/public/components/event_analytics/hooks/use_fetch_events.ts index daca6bcc68..c7dc4cc2af 100644 --- a/public/components/event_analytics/hooks/use_fetch_events.ts +++ b/public/components/event_analytics/hooks/use_fetch_events.ts @@ -193,7 +193,11 @@ export const useFetchEvents = ({ pplService, requestParams }: IFetchEventsParams ); }; - const getEvents = (query: string = '', errorHandler?: (error: any) => void) => { + const getEvents = ( + query: string = '', + errorHandler?: (error: any) => void, + setSummaryStatus?: boolean + ) => { if (isEmpty(query)) return; const cur = queriesRef.current; const searchQuery = isEmpty(query) ? cur![requestParams.tabId][FINAL_QUERY] : query; @@ -209,12 +213,13 @@ export const useFetchEvents = ({ pplService, requestParams }: IFetchEventsParams // when no hits and needs to get available fields to override default timestamp dispatchOnNoHis(res); } - dispatch( - setResponseForSummaryStatus({ - tabId: requestParams.tabId, - responseForSummaryStatus: 'success', - }) - ); + if (setSummaryStatus) + dispatch( + setResponseForSummaryStatus({ + tabId: requestParams.tabId, + responseForSummaryStatus: 'success', + }) + ); }, (error) => { errorHandler?.(error); @@ -230,12 +235,13 @@ export const useFetchEvents = ({ pplService, requestParams }: IFetchEventsParams error, }) ); - dispatch( - setResponseForSummaryStatus({ - tabId: requestParams.tabId, - responseForSummaryStatus: 'failure', - }) - ); + if (setSummaryStatus) + dispatch( + setResponseForSummaryStatus({ + tabId: requestParams.tabId, + responseForSummaryStatus: 'failure', + }) + ); }); } ); diff --git a/public/services/data_fetchers/ppl/ppl_data_fetcher.ts b/public/services/data_fetchers/ppl/ppl_data_fetcher.ts index 9aa0379451..108326d3e2 100644 --- a/public/services/data_fetchers/ppl/ppl_data_fetcher.ts +++ b/public/services/data_fetchers/ppl/ppl_data_fetcher.ts @@ -80,6 +80,7 @@ export class PPLDataFetcher extends DataFetcherBase implements IDataFetcher { getErrorHandler, getPatterns, getAvailableFields, + setSummaryStatus, } = this.searchContext; const { dispatch, changeQuery } = this.storeContext; @@ -113,7 +114,7 @@ export class PPLDataFetcher extends DataFetcherBase implements IDataFetcher { if (isLiveTailOn) { getLiveTail(finalQuery, getErrorHandler('Error fetching events')); } else { - getEvents(finalQuery, getErrorHandler('Error fetching events')); + getEvents(finalQuery, getErrorHandler('Error fetching events'), setSummaryStatus); } // still need all fields when query contains stats if (finalQuery.match(PPL_STATS_REGEX)) getAvailableFields(`search source=${this.queryIndex}`); From 6fddaafa1a1016b8c22f7402c560fbff2010eeb8 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 20 Nov 2023 11:38:15 -0800 Subject: [PATCH 50/86] update links and redirections Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 9 ++++++--- public/components/event_analytics/explorer/llm/input.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index a6b62d81b0..956431e5ac 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -594,10 +594,13 @@ export const Search = (props: any) => { The OpenSearch Assistant may produce inaccurate information. Verify all information before using it in any environment or workload. Share feedback via{' '} - - Email + + Forum {' '} - or Slack + or{' '} + + Slack + diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 9190cdde1f..fe6dfe1bd7 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -330,10 +330,13 @@ export const LLMInput: React.FC = (props) => { Share feedback via{' '} - - Email + + Forum {' '} - or Slack + or{' '} + + Slack + From 64f734cf3630796f66a6b5741ba8228691875fd4 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 20 Nov 2023 13:33:19 -0800 Subject: [PATCH 51/86] changed some hardcoded indices Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 11 +++++------ .../components/event_analytics/explorer/llm/input.tsx | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 956431e5ac..5b6a16afec 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -302,13 +302,12 @@ export const Search = (props: any) => { : undefined; const loading = indicesLoading || indexPatternsLoading; // HARDCODED INDEXES BELOW - const onlyInclude = [ - 'opensearch_datashboards_sample_data_ecommerce', - 'opensearch_dashboards_sample_data_logs', - 'opensearch_dashboards_sample_data_flights', - 'sso_logs-*.*', + const filteredData = [ + { label: 'opensearch_dashboards_sample_data_ecommerce' }, + { label: 'opensearch_dashboards_sample_data_logs' }, + { label: 'opensearch_dashboards_sample_data_flights' }, + { label: 'sso_logs-*.*' }, ]; - const filteredData = data?.filter((obj) => onlyInclude.some((index) => index === obj.label)); return (
diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index fe6dfe1bd7..253e298343 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -82,7 +82,7 @@ export const LLMInput: React.FC = (props) => { // HARDCODED QUESTION SUGGESTIONS: const hardcodedSuggestions = { - opensearch_datashboards_sample_data_ecommerce: [ + opensearch_dashboards_sample_data_ecommerce: [ 'How many unique customers placed orders this week?', 'Count the number of orders grouped by manufacturer and category', 'find customers with first names like Eddie', From cfdd9cbc0fb7ab62d460c087fd7fd8d0f698fffc Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 20 Nov 2023 23:28:56 +0000 Subject: [PATCH 52/86] do not pass extra data to summarization api Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 253e298343..53efaef482 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -186,7 +186,12 @@ export const LLMInput: React.FC = (props) => { isError, response: isError ? String(JSON.parse(explorerData.error.body.message).error.details) - : JSON.stringify(explorerData), + : JSON.stringify({ + datarows: explorerData.datarows, + schema: explorerData.schema, + size: explorerData.size, + total: explorerData.total, + }), ...context, }; await dispatch( From b6d846ea5994bd18bb05e9896b9a93dea26afec7 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 20 Nov 2023 16:40:08 -0800 Subject: [PATCH 53/86] hide ai insights if no summary is there or loading Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 167 +++++++++++---------- 1 file changed, 85 insertions(+), 82 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 5b6a16afec..a8850c15ef 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -518,95 +518,98 @@ export const Search = (props: any) => { setNlqInput={setNlqInput} /> - - - - - - Generated by Opensearch Assistant - - - - - - - } - > - {queryAssistantSummarization?.summary?.length > 0 && ( - <> - - {queryAssistantSummarization?.isPPLError ? ( - <> - - - {queryAssistantSummarization.summary} - - - - - - Suggestions: - - {queryAssistantSummarization.suggestedQuestions.map((question) => ( + {(queryAssistantSummarization?.summary?.length > 0 || + queryAssistantSummarization?.summaryLoading) && ( + + + + + + Generated by Opensearch Assistant + + + + + + + } + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + <> + + + {queryAssistantSummarization.summary} + + + + + + Suggestions: + + {queryAssistantSummarization.suggestedQuestions.map((question) => ( + + setNlqInput(question)} + onClickAriaLabel="Set input to the suggested question" + > + {question} + + + ))} setNlqInput(question)} - onClickAriaLabel="Set input to the suggested question" + onClick={showFlyout} + onClickAriaLabel="Show PPL documentation" > - {question} + PPL Documentation - ))} - - - PPL Documentation - - - - - ) : ( - - - {queryAssistantSummarization.summary} - - - )} - - - - The OpenSearch Assistant may produce inaccurate information. Verify all - information before using it in any environment or workload. Share feedback - via{' '} - - Forum - {' '} - or{' '} - - Slack - - - - - )} - - - + + + ) : ( + + + {queryAssistantSummarization.summary} + + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify all + information before using it in any environment or workload. Share + feedback via{' '} + + Forum + {' '} + or{' '} + + Slack + + + + + )} + + + + )} )} From 8c1fde42c6a5305d9c5c414059eeadf41b7514a9 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Mon, 20 Nov 2023 17:14:05 -0800 Subject: [PATCH 54/86] set the order_date to be default timestamp Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 1 - .../event_analytics/explorer/sidebar/field.tsx | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index a8850c15ef..861d154980 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -277,7 +277,6 @@ export const Search = (props: any) => { }, [queryRedux.index, queryRedux.ollyQueryAssistant]); const runChanges = () => { - console.log(tempQuery); batch(() => { dispatch(reset({ tabId })); dispatch(resetSummary({ tabId })); diff --git a/public/components/event_analytics/explorer/sidebar/field.tsx b/public/components/event_analytics/explorer/sidebar/field.tsx index edb4d5baf6..bae0e3d4a0 100644 --- a/public/components/event_analytics/explorer/sidebar/field.tsx +++ b/public/components/event_analytics/explorer/sidebar/field.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@osd/i18n'; import { isEqual, toUpper, upperFirst } from 'lodash'; import { @@ -83,6 +83,13 @@ export const Field = (props: IFieldProps) => { onToggleField(fields); }; + // hardcoded for demo purposes - remove afterwards + useEffect(() => { + if (field.name === 'order_date') { + handleOverrideTimestamp(field); + } + }, []); + return ( From d3268f1363854459d25c9f73bc097633cfcbd54e Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Nov 2023 19:48:04 +0000 Subject: [PATCH 55/86] fixing app analytics page crash Signed-off-by: Eric --- public/components/common/search/search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 861d154980..67eb4b79d1 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -260,6 +260,7 @@ export const Search = (props: any) => { useEffect(() => { // set index and olly query assistant question if changed elsewhere + if (!queryRedux.ollyQueryAssistant) return; if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); if (queryRedux.ollyQueryAssistant.length > 0) { setNlqInput(queryRedux.ollyQueryAssistant); From 1b2390ba5bf342811c0cfde39f500eb1cdf50f9e Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Nov 2023 20:19:36 +0000 Subject: [PATCH 56/86] re-enable datepicker for app analytics Signed-off-by: Eric --- public/components/common/search/date_picker.tsx | 10 ++++++++-- public/components/common/search/search.tsx | 3 +++ .../components/event_analytics/explorer/explorer.tsx | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 146cf725b4..1dcd1ad43a 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -10,7 +10,13 @@ import { coreRefs } from '../../../framework/core_refs'; import { IDatePickerProps } from './search'; export function DatePicker(props: IDatePickerProps) { - const { startTime, endTime, handleTimePickerChange, handleTimeRangePickerRefresh } = props; + const { + startTime, + endTime, + handleTimePickerChange, + handleTimeRangePickerRefresh, + isAppAnalytics, + } = props; const fixedStartTime = 'now-40y'; const fixedEndTime = 'now'; @@ -22,7 +28,7 @@ export function DatePicker(props: IDatePickerProps) { } }; - return coreRefs.assistantEnabled ? ( + return coreRefs.assistantEnabled || isAppAnalytics ? ( void; handleTimePickerChange: (timeRange: string[]) => any; handleTimeRangePickerRefresh: () => any; + isAppAnalytics: boolean; } export const Search = (props: any) => { @@ -123,6 +124,7 @@ export const Search = (props: any) => { curVisId, setSubType, setIsQueryRunning, + isAppAnalytics, } = props; const queryRedux = useSelector(selectQueries)[tabId]; @@ -413,6 +415,7 @@ export const Search = (props: any) => { handleTimeRangePickerRefresh={() => { onQuerySearch(queryLang); }} + isAppAnalytics={isAppAnalytics} /> )} diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 3eb2df9bf4..9a56ea88d7 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -978,6 +978,7 @@ export const Explorer = ({ setSubType={setSubType} http={http} setIsQueryRunning={setIsQueryRunning} + isAppAnalytics={appLogEvents} /> {explorerSearchMeta.isPolling ? ( From c10a64f1533a90626dc57968684f443fb18b04ca Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Nov 2023 21:36:05 +0000 Subject: [PATCH 57/86] remove live button for olly Signed-off-by: Eric --- public/components/common/search/search.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 648635723e..0a68b51141 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -431,23 +431,6 @@ export const Search = (props: any) => { - {showSaveButton && !showSavePanelOptionsList && ( - - - - - - )} - {isLiveTailOn && ( - - - - )} {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( <> From c18d85fc286a6794dd2c36b8a31b37a9857c6ea2 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Tue, 21 Nov 2023 14:57:29 -0800 Subject: [PATCH 58/86] fixing startTime and endTime states in search.tsx Signed-off-by: Sean Li --- public/components/common/search/search.tsx | 6 +++-- .../event_analytics/explorer/explorer.tsx | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 0a68b51141..4066c05634 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -76,8 +76,8 @@ export interface IQueryBarProps { export interface IDatePickerProps { startTime: string; endTime: string; - setStartTime: () => void; - setEndTime: () => void; + setStartTime: (start: string) => void; + setEndTime: (end: string) => void; setTimeRange: () => void; setIsOutputStale: () => void; handleTimePickerChange: (timeRange: string[]) => any; @@ -411,6 +411,8 @@ export const Search = (props: any) => { setNeedsUpdate(!(tRange[0] === startTime && tRange[1] === endTime)); // keeps the time range change local, to be used when update pressed setTimeRange(tRange); + setStartTime(tRange[0]); + setEndTime(tRange[1]); }} handleTimeRangePickerRefresh={() => { onQuerySearch(queryLang); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 9a56ea88d7..8447f93ce2 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -145,10 +145,6 @@ export const Explorer = ({ appId = '', appBaseQuery = '', addVisualizationToPanel, - startTime, - endTime, - setStartTime, - setEndTime, callback, callbackInApp, queryManager = new QueryManager(), @@ -238,6 +234,10 @@ export const Explorer = ({ liveTailNameRef.current = liveTailName; tempQueryRef.current = tempQuery; + const dateRange = getDateRange(undefined, undefined, query); + const [startTime, setStartTime] = useState(dateRange[0]); + const [endTime, setEndTime] = useState(dateRange[1]); + const findAutoInterval = (start: string = '', end: string = '') => { const momentStart = dateMath.parse(start)!; const momentEnd = dateMath.parse(end, { roundUp: true })!; @@ -485,7 +485,6 @@ export const Explorer = ({ return 0; }, [countDistribution?.data]); - const dateRange = getDateRange(startTime, endTime, query); const mainContent = useMemo(() => { return (
@@ -519,8 +518,8 @@ export const Explorer = ({ stateInterval={ countDistribution.selectedInterval || selectedIntervalRef.current?.value } - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} + startTime={startTime} + endTime={endTime} /> )} @@ -595,8 +594,8 @@ export const Explorer = ({ : explorerData.datarows.length } requestParams={requestParams} - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} + startTime={startTime} + endTime={endTime} /> )} @@ -947,8 +946,10 @@ export const Explorer = ({ handleQueryChange={handleQueryChange} handleQuerySearch={handleQuerySearch} dslService={dslService} - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} + startTime={startTime} + endTime={endTime} + setStartTime={setStartTime} + setEndTime={setEndTime} handleTimePickerChange={(timeRange: string[]) => handleTimePickerChange(timeRange) } From d7c53c461b71879e180ed8833169f6065a72218e Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Nov 2023 23:20:05 +0000 Subject: [PATCH 59/86] add support for catching timestamp in redirection url Signed-off-by: Eric --- .../explorer/datasources/datasources_selection.tsx | 11 +++++++++-- .../components/event_analytics/explorer/explorer.tsx | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index d815c4d319..d1e53d850b 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -45,7 +45,11 @@ import { getAsyncSessionId, } from '../../../../../common/utils/query_session_utils'; import { DIRECT_DUMMY_QUERY } from '../../../../../common/constants/shared'; -import { INDEX, OLLY_QUERY_ASSISTANT } from '../../../../../common/constants/explorer'; +import { + INDEX, + OLLY_QUERY_ASSISTANT, + SELECTED_TIMESTAMP, +} from '../../../../../common/constants/explorer'; const getDataSourceState = (selectedSourceState: SelectedDataSource[]) => { if (selectedSourceState.length === 0) return []; @@ -75,6 +79,7 @@ const removeDataSourceFromURLParams = (currURL: string) => { hashParams.delete(DATA_SOURCE_TYPE_URL_PARAM_KEY); hashParams.delete(OLLY_QUESTION_URL_PARAM_KEY); hashParams.delete(INDEX_URL_PARAM_KEY); + hashParams.delete('timestamp'); // Reconstruct the hash currentURL.hash = hashParams.toString() ? `${hashBase}?${hashParams.toString()}` : hashBase; @@ -187,6 +192,7 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { const idxPattern = routerContext?.searchParams.get(INDEX_URL_PARAM_KEY); const ollyQuestion = routerContext?.searchParams.get(OLLY_QUESTION_URL_PARAM_KEY) || ''; const decodedOllyQ = decodeURIComponent(ollyQuestion); + const parsedTimeStamp = routerContext?.searchParams.get('timestamp') || ''; if (datasourceName && datasourceType) { // remove datasourceName and datasourceType from URL for a clean search state removeDataSourceFromURLParams(window.location.href); @@ -202,10 +208,11 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { if (idxPattern && decodedOllyQ) { dispatch( changeData({ - tabId: tabId, + tabId, data: { [INDEX]: idxPattern, [OLLY_QUERY_ASSISTANT]: decodedOllyQ, + [SELECTED_TIMESTAMP]: parsedTimeStamp, }, }) ); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 8447f93ce2..91a6665f0f 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -701,7 +701,10 @@ export const Explorer = ({ const handleQuerySearch = async (availability?: boolean, setSummaryStatus: boolean) => { // clear previous selected timestamp when index pattern changes const searchedQuery = tempQueryRef.current; - if (isIndexPatternChanged(searchedQuery, query[RAW_QUERY])) { + if ( + isIndexPatternChanged(searchedQuery, query[RAW_QUERY]) && + query[SELECTED_TIMESTAMP] !== '' + ) { await dispatch( changeQuery({ tabId, From 678ed90cd852dc2d95198af0315eb0dc6d449eb4 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Wed, 22 Nov 2023 23:49:23 +0000 Subject: [PATCH 60/86] send PPL query for summarization Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 53efaef482..1e8bf14d49 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -184,6 +184,7 @@ export const LLMInput: React.FC = (props) => { question: props.nlqInput, index: props.selectedIndex[0].label, isError, + query: queryRedux.rawQuery, response: isError ? String(JSON.parse(explorerData.error.body.message).error.details) : JSON.stringify({ @@ -251,6 +252,7 @@ export const LLMInput: React.FC = (props) => { ...feedbackFormData, input: props.nlqInput, }); + // do not show as error callout if query couldn't be generated, just explain why generateSummary({ isError: false, response: String(error.body) }); } finally { setGeneratingRun(false); From 0193c49429030bc99be8ec1db29c1b1f3c706312 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 27 Nov 2023 18:25:05 +0000 Subject: [PATCH 61/86] truncate summarize API call to prevent going over maxPayloadBytes Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 1e8bf14d49..7913239b97 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -192,7 +192,7 @@ export const LLMInput: React.FC = (props) => { schema: explorerData.schema, size: explorerData.size, total: explorerData.total, - }), + }).slice(0, 7000), ...context, }; await dispatch( From 1ab96ba1ab4555d09098626ce8942d63e34a6c4a Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Tue, 28 Nov 2023 01:00:12 +0000 Subject: [PATCH 62/86] add error message when response code is 429 Signed-off-by: Joshua Li --- .../event_analytics/explorer/llm/input.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 7913239b97..7fbf8587b5 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -21,6 +21,7 @@ import { EuiText, } from '@elastic/eui'; import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; import React, { Reducer, useEffect, useReducer, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; @@ -159,6 +160,17 @@ export const LLMInput: React.FC = (props) => { ); return generatedPPL; }; + const formatError = (error: ResponseError): Error => { + if (error.body) { + if (error.body.statusCode === 429) + return { + ...error.body, + message: 'Request is throttled. Try again later or contact your administrator', + } as Error; + return error.body as Error; + } + return error; + }; // used by generate query button const generatePPL = async () => { dispatch(reset({ tabId: props.tabId })); @@ -172,7 +184,7 @@ export const LLMInput: React.FC = (props) => { ...feedbackFormData, input: props.nlqInput, }); - coreRefs.toasts?.addError(error.body || error, { title: 'Failed to generate results' }); + coreRefs.toasts?.addError(formatError(error), { title: 'Failed to generate results' }); } finally { setGenerating(false); } @@ -220,7 +232,7 @@ export const LLMInput: React.FC = (props) => { }) ); } catch (error) { - coreRefs.toasts?.addError(error.body || error, { title: 'Failed to summarize results' }); + coreRefs.toasts?.addError(formatError(error), { title: 'Failed to summarize results' }); } finally { await dispatch( changeSummary({ From 0c5a45bfabc68cdd6a2d1686e2ad364dbe0de18b Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 28 Nov 2023 11:16:07 -0800 Subject: [PATCH 63/86] set default text in editor upon index start and change Signed-off-by: Paul Sebastian --- public/components/common/search/query_area.tsx | 5 +++++ public/components/common/search/search.tsx | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 801ffdd831..97ed5aa0b0 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -5,6 +5,7 @@ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React from 'react'; +import { useEffect } from 'react'; import { LLMInput } from '../../event_analytics/explorer/llm/input'; export function QueryArea({ @@ -19,6 +20,10 @@ export function QueryArea({ nlqInput, setNlqInput, }: any) { + useEffect(() => { + handleQueryChange(`source = ${selectedIndex[0].label}`); + }, []); + return ( diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 4066c05634..7fc3008468 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -390,7 +390,16 @@ export const Search = (props: any) => { isLoading={loading} options={filteredData} selectedOptions={selectedIndex} - onChange={(index) => setSelectedIndex(index)} + onChange={(index) => { + // clear previous state + batch(() => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); + }); + // change the query in the editor to be just source= + handleQueryChange(`source = ${index[0].label}`); + setSelectedIndex(index); + }} /> From 6ccb122adbd8c7a51b95024075d24bcfef8e7a8b Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 28 Nov 2023 13:23:31 -0800 Subject: [PATCH 64/86] populate sidebar fields depending on selected index Signed-off-by: Paul Sebastian --- .../components/common/search/query_area.tsx | 16 ++++++++++++++-- public/components/common/search/search.tsx | 19 +++++++++++++++++-- .../explorer/sidebar/sidebar.tsx | 2 -- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 97ed5aa0b0..7330960d75 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -7,6 +7,9 @@ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui import React from 'react'; import { useEffect } from 'react'; import { LLMInput } from '../../event_analytics/explorer/llm/input'; +import { useFetchEvents } from '../../event_analytics/hooks/use_fetch_events'; +import { coreRefs } from '../../../framework/core_refs'; +import PPLService from '../../../services/requests/ppl'; export function QueryArea({ tabId, @@ -20,9 +23,18 @@ export function QueryArea({ nlqInput, setNlqInput, }: any) { + const requestParams = { tabId }; + const { getAvailableFields } = useFetchEvents({ + pplService: new PPLService(coreRefs.http), + requestParams, + }); + + // use effect that sets the editor text and populates sidebar field for a particular index upon initialization useEffect(() => { - handleQueryChange(`source = ${selectedIndex[0].label}`); - }, []); + const indexQuery = `source = ${selectedIndex[0].label}`; + handleQueryChange(indexQuery); + getAvailableFields(indexQuery); + }); return ( diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 7fc3008468..cd75ac6d08 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -64,6 +64,7 @@ import { Autocomplete } from './autocomplete'; import { DatePicker } from './date_picker'; import { QueryArea } from './query_area'; import './search.scss'; +import PPLService from '../../../services/requests/ppl'; export interface IQueryBarProps { query: string; @@ -158,6 +159,10 @@ export const Search = (props: any) => { pplService: new SQLService(coreRefs.http), requestParams, }); + const { getAvailableFields } = useFetchEvents({ + pplService: new PPLService(coreRefs.http), + requestParams, + }); const closeFlyout = () => { setIsFlyoutVisible(false); @@ -263,7 +268,14 @@ export const Search = (props: any) => { useEffect(() => { // set index and olly query assistant question if changed elsewhere if (!queryRedux.ollyQueryAssistant) return; - if (queryRedux.index.length > 0) setSelectedIndex([{ label: queryRedux.index }]); + if (queryRedux.index.length > 0) { + const reduxIndex = [{ label: queryRedux.index }]; + setSelectedIndex(reduxIndex); + // sets the editor text and populates sidebar field for a particular index upon initialization + const indexQuery = `source = ${reduxIndex[0].label}`; + handleQueryChange(indexQuery); + getAvailableFields(indexQuery); + } if (queryRedux.ollyQueryAssistant.length > 0) { setNlqInput(queryRedux.ollyQueryAssistant); // remove index and olly query assistant @@ -397,7 +409,10 @@ export const Search = (props: any) => { dispatch(resetSummary({ tabId })); }); // change the query in the editor to be just source= - handleQueryChange(`source = ${index[0].label}`); + const indexQuery = `source = ${index[0].label}`; + handleQueryChange(indexQuery); + // get the fields into the sidebar + getAvailableFields(indexQuery); setSelectedIndex(index); }} /> diff --git a/public/components/event_analytics/explorer/sidebar/sidebar.tsx b/public/components/event_analytics/explorer/sidebar/sidebar.tsx index f489d3bb50..be7413250e 100644 --- a/public/components/event_analytics/explorer/sidebar/sidebar.tsx +++ b/public/components/event_analytics/explorer/sidebar/sidebar.tsx @@ -32,8 +32,6 @@ interface ISidebarProps { isFieldToggleButtonDisabled: boolean; handleOverridePattern: (pattern: IField) => void; handleOverrideTimestamp: (timestamp: IField) => void; - storedExplorerFields: IExplorerFields; - setStoredExplorerFields: (explorer: IExplorerFields) => void; tabId: string; } From 536c8657ec3083c39030a22adf2f435b51ec1b27 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 28 Nov 2023 18:38:38 -0800 Subject: [PATCH 65/86] pass in pplService and use explicit dependency array Signed-off-by: Paul Sebastian --- public/components/common/search/query_area.tsx | 5 +++-- public/components/common/search/search.tsx | 4 +++- public/components/event_analytics/explorer/explorer.tsx | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 7330960d75..2ace0a4d10 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -22,10 +22,11 @@ export function QueryArea({ selectedIndex, nlqInput, setNlqInput, + pplService, }: any) { const requestParams = { tabId }; const { getAvailableFields } = useFetchEvents({ - pplService: new PPLService(coreRefs.http), + pplService, requestParams, }); @@ -34,7 +35,7 @@ export function QueryArea({ const indexQuery = `source = ${selectedIndex[0].label}`; handleQueryChange(indexQuery); getAvailableFields(indexQuery); - }); + }, []); return ( diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index cd75ac6d08..f7d88bca8b 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -126,6 +126,7 @@ export const Search = (props: any) => { setSubType, setIsQueryRunning, isAppAnalytics, + pplService, } = props; const queryRedux = useSelector(selectQueries)[tabId]; @@ -160,7 +161,7 @@ export const Search = (props: any) => { requestParams, }); const { getAvailableFields } = useFetchEvents({ - pplService: new PPLService(coreRefs.http), + pplService: pplService, requestParams, }); @@ -528,6 +529,7 @@ export const Search = (props: any) => { selectedIndex={selectedIndex} nlqInput={nlqInput} setNlqInput={setNlqInput} + pplService={pplService} /> {(queryAssistantSummarization?.summary?.length > 0 || diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 91a6665f0f..b3cb4ef314 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -983,6 +983,7 @@ export const Explorer = ({ http={http} setIsQueryRunning={setIsQueryRunning} isAppAnalytics={appLogEvents} + pplService={pplService} /> {explorerSearchMeta.isPolling ? ( From 448f48e6ed10882b9ed491d476d55be0bdaec77d Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Wed, 29 Nov 2023 22:47:15 +0000 Subject: [PATCH 66/86] send the correct error message when summarizing response Signed-off-by: Joshua Li --- public/components/event_analytics/explorer/llm/input.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 7fbf8587b5..9ab920b56b 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -264,8 +264,7 @@ export const LLMInput: React.FC = (props) => { ...feedbackFormData, input: props.nlqInput, }); - // do not show as error callout if query couldn't be generated, just explain why - generateSummary({ isError: false, response: String(error.body) }); + generateSummary({ isError: true, response: JSON.stringify(error.body) }); } finally { setGeneratingRun(false); } From 19ad0580f10168f23d0f6b141ed9cebe6655cf57 Mon Sep 17 00:00:00 2001 From: Sean Li Date: Wed, 29 Nov 2023 16:47:39 -0800 Subject: [PATCH 67/86] reducing sidebar requests and fixing hit counter + patterns bug Signed-off-by: Sean Li --- public/components/common/search/query_area.tsx | 2 +- .../event_analytics/hooks/use_fetch_patterns.ts | 8 +++++++- .../hooks/use_fetch_visualizations.ts | 13 +++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 2ace0a4d10..2a0da1bea2 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -35,7 +35,7 @@ export function QueryArea({ const indexQuery = `source = ${selectedIndex[0].label}`; handleQueryChange(indexQuery); getAvailableFields(indexQuery); - }, []); + }, [selectedIndex[0]]); return ( diff --git a/public/components/event_analytics/hooks/use_fetch_patterns.ts b/public/components/event_analytics/hooks/use_fetch_patterns.ts index b50aa24676..7a23aa3179 100644 --- a/public/components/event_analytics/hooks/use_fetch_patterns.ts +++ b/public/components/event_analytics/hooks/use_fetch_patterns.ts @@ -125,7 +125,13 @@ export const useFetchPatterns = ({ pplService, requestParams }: IFetchPatternsPa })); dispatchOnPatterns({ patternTableData: formatToTableData }); }) - .catch(errorHandler); + .catch((error) => { + dispatch( + resetPatterns({ + tabId: requestParams.tabId, + }) + ); + }); }; const setDefaultPatternsField = async ( diff --git a/public/components/event_analytics/hooks/use_fetch_visualizations.ts b/public/components/event_analytics/hooks/use_fetch_visualizations.ts index 8ef331d64c..0dcd94cf87 100644 --- a/public/components/event_analytics/hooks/use_fetch_visualizations.ts +++ b/public/components/event_analytics/hooks/use_fetch_visualizations.ts @@ -11,7 +11,10 @@ import { SELECTED_FIELDS, SELECTED_TIMESTAMP, } from '../../../../common/constants/explorer'; -import { render as renderCountDis } from '../redux/slices/count_distribution_slice'; +import { + render as renderCountDis, + reset as resetCountDis, +} from '../redux/slices/count_distribution_slice'; import { selectQueries } from '../redux/slices/query_slice'; import { render as renderExplorerVis } from '../redux/slices/visualization_slice'; import { updateFields, sortFields } from '../redux/slices/field_slice'; @@ -79,7 +82,13 @@ export const useFetchVisualizations = ({ }) ); }, - (error: Error) => {} + (error: Error) => { + dispatch( + resetCountDis({ + tabId: requestParams.tabId, + }) + ); + } ); }; From 4ce20d1cbe3fe7ba1568df9b8e6a2022c7cea97d Mon Sep 17 00:00:00 2001 From: Sean Li Date: Tue, 5 Dec 2023 14:03:37 -0800 Subject: [PATCH 68/86] removing summary when no data is returned Signed-off-by: Sean Li --- .../event_analytics/explorer/llm/input.tsx | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/llm/input.tsx index 9ab920b56b..91165d9f8d 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/llm/input.tsx @@ -191,46 +191,48 @@ export const LLMInput: React.FC = (props) => { }; const generateSummary = async (context?: Partial) => { try { - const isError = summaryData.responseForSummaryStatus === 'failure'; - const summarizationContext: SummarizationContext = { - question: props.nlqInput, - index: props.selectedIndex[0].label, - isError, - query: queryRedux.rawQuery, - response: isError - ? String(JSON.parse(explorerData.error.body.message).error.details) - : JSON.stringify({ - datarows: explorerData.datarows, - schema: explorerData.schema, - size: explorerData.size, - total: explorerData.total, - }).slice(0, 7000), - ...context, - }; - await dispatch( - changeSummary({ - tabId: props.tabId, - data: { - summaryLoading: true, - isPPLError: isError, - }, - }) - ); - const summary = await getOSDHttp().post<{ - summary: string; - suggestedQuestions: string[]; - }>('/api/assistant/summarize', { - body: JSON.stringify(summarizationContext), - }); - await dispatch( - changeSummary({ - tabId: props.tabId, - data: { - summary: summary.summary, - suggestedQuestions: summary.suggestedQuestions, - }, - }) - ); + if (explorerData.total > 0) { + const isError = summaryData.responseForSummaryStatus === 'failure'; + const summarizationContext: SummarizationContext = { + question: props.nlqInput, + index: props.selectedIndex[0].label, + isError, + query: queryRedux.rawQuery, + response: isError + ? String(JSON.parse(explorerData.error.body.message).error.details) + : JSON.stringify({ + datarows: explorerData.datarows, + schema: explorerData.schema, + size: explorerData.size, + total: explorerData.total, + }).slice(0, 7000), + ...context, + }; + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: true, + isPPLError: isError, + }, + }) + ); + const summary = await getOSDHttp().post<{ + summary: string; + suggestedQuestions: string[]; + }>('/api/assistant/summarize', { + body: JSON.stringify(summarizationContext), + }); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summary: summary.summary, + suggestedQuestions: summary.suggestedQuestions, + }, + }) + ); + } } catch (error) { coreRefs.toasts?.addError(formatError(error), { title: 'Failed to summarize results' }); } finally { From 3f6bf5e9ce0c07097e4f69bf9fee399e6e15f0e5 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Thu, 7 Dec 2023 11:26:55 -0800 Subject: [PATCH 69/86] initial state and empty state change Signed-off-by: Paul Sebastian --- .../event_analytics/explorer/explorer.tsx | 28 +++- .../event_analytics/explorer/no_results.tsx | 129 +++++++++++++----- 2 files changed, 117 insertions(+), 40 deletions(-) diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index b3cb4ef314..45c6977491 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -263,11 +263,16 @@ export const Explorer = ({ { text: 'Auto', value: 'auto_' + minInterval }, ...TIME_INTERVAL_OPTIONS, ]); - selectedIntervalRef.current = { text: 'Auto', value: 'auto_' + minInterval }; + selectedIntervalRef.current = { + text: 'Auto', + value: 'auto_' + minInterval, + }; dispatch( updateCountDistribution({ tabId, - data: { selectedInterval: selectedIntervalRef.current.value.replace(/^auto_/, '') }, + data: { + selectedInterval: selectedIntervalRef.current.value.replace(/^auto_/, ''), + }, }) ); }; @@ -465,7 +470,12 @@ export const Explorer = ({ const handleOverrideTimestamp = async (timestamp: IField) => { setIsOverridingTimestamp(true); - await dispatch(changeQuery({ tabId, query: { [SELECTED_TIMESTAMP]: timestamp?.name || '' } })); + await dispatch( + changeQuery({ + tabId, + query: { [SELECTED_TIMESTAMP]: timestamp?.name || '' }, + }) + ); setIsOverridingTimestamp(false); handleQuerySearch(); }; @@ -509,7 +519,10 @@ export const Explorer = ({ ); const intrv = selectedIntrv.replace(/^auto_/, ''); dispatch( - updateCountDistribution({ tabId, data: { selectedInterval: intrv } }) + updateCountDistribution({ + tabId, + data: { selectedInterval: intrv }, + }) ); getCountVisualizations(intrv); selectedIntervalRef.current = timeIntervalOptions[intervalOptionsIndex]; @@ -607,7 +620,7 @@ export const Explorer = ({ ) : ( - + )}
); @@ -694,7 +707,10 @@ export const Explorer = ({ const updateQueryInStore = async (updateQuery: string) => { await dispatch( - changeQuery({ tabId, query: { [RAW_QUERY]: updateQuery.replaceAll(PPL_NEWLINE_REGEX, '') } }) + changeQuery({ + tabId, + query: { [RAW_QUERY]: updateQuery.replaceAll(PPL_NEWLINE_REGEX, '') }, + }) ); }; diff --git a/public/components/event_analytics/explorer/no_results.tsx b/public/components/event_analytics/explorer/no_results.tsx index d6c96af865..d13622c031 100644 --- a/public/components/event_analytics/explorer/no_results.tsx +++ b/public/components/event_analytics/explorer/no_results.tsx @@ -5,43 +5,104 @@ import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPage, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiSpacer, + EuiText, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { coreRefs } from '../../../framework/core_refs'; +import { useSelector } from 'react-redux'; +import { selectQueries } from '../redux/slices/query_slice'; + +export const NoResults = ({ tabId }: any) => { + // get the queries isLoaded, if it exists AND is true = show no res + const queryInfo = useSelector(selectQueries)[tabId]; -export const NoResults = () => { return ( - - - - } - color="warning" - iconType="help" - data-test-subj="observabilityNoResultsCallout" - /> - - - - -

- -

-

- -

-
-
-
+ {coreRefs.assistantEnabled ? ( + <> + {/* check to see if the rawQuery is empty or not */} + {queryInfo?.rawQuery ? ( + + + + } + color="warning" + iconType="help" + data-test-subj="observabilityNoResultsCallout" + /> + + + No results} + body={ +

+ Try selecting a different data source, expanding your time range or modifying + the query & filters. You may also use the Query Assistant to fine-tune your + query using simple conversational prompts. +

+ } + /> +
+
+ ) : ( + Get started} + body={ +

+ Run a query to view results, or use the Query Assistant to automatically generate + complex queries using simple conversational prompts. +

+ } + /> + )} + + ) : ( + + + + } + color="warning" + iconType="help" + data-test-subj="observabilityNoResultsCallout" + /> + + + + +

+ +

+

+ +

+
+
+
+ )}
); }; From 1e2ce0637e001348737fab41ae7394b988b78852 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Mon, 18 Dec 2023 23:52:37 +0000 Subject: [PATCH 70/86] refactor query assist input remove unused feedback components rename llm to query_assist fix types Signed-off-by: Joshua Li --- public/.DS_Store | Bin 6148 -> 0 bytes .../components/common/search/query_area.tsx | 9 +- public/components/common/search/search.tsx | 7 +- .../event_analytics/explorer/explorer.tsx | 2 +- .../explorer/llm/feedback_modal.tsx | 310 ------------------ .../explorer/query_assist/hooks.ts | 89 +++++ .../explorer/{llm => query_assist}/input.tsx | 254 +++----------- 7 files changed, 142 insertions(+), 529 deletions(-) delete mode 100644 public/.DS_Store delete mode 100644 public/components/event_analytics/explorer/llm/feedback_modal.tsx create mode 100644 public/components/event_analytics/explorer/query_assist/hooks.ts rename public/components/event_analytics/explorer/{llm => query_assist}/input.tsx (58%) diff --git a/public/.DS_Store b/public/.DS_Store deleted file mode 100644 index dbd07c1a1c1d289c1b490405a326e8a55ae29325..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ8r^25S>XVP-t9I?iIMf%5qNN3m_#C4KXMr)UI+aj>emhq98+&f+l(+&Ai>& zd29I<9*>A<`}MjKX+&fWH%Fk~B9M^^Pys6Nqkw%M3f!hP6`=yd49* i9b;qdc*nm7hJ9eJk%`7>a;(5S$@6*vQa;1$mR diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index 2a0da1bea2..f30b5519fb 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -4,12 +4,9 @@ */ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import React from 'react'; -import { useEffect } from 'react'; -import { LLMInput } from '../../event_analytics/explorer/llm/input'; +import React, { useEffect } from 'react'; +import { QueryAssistInput } from '../../event_analytics/explorer/query_assist/input'; import { useFetchEvents } from '../../event_analytics/hooks/use_fetch_events'; -import { coreRefs } from '../../../framework/core_refs'; -import PPLService from '../../../services/requests/ppl'; export function QueryArea({ tabId, @@ -63,7 +60,7 @@ export function QueryArea({ />
- { requestParams, }); const { getAvailableFields } = useFetchEvents({ - pplService: pplService, + pplService, requestParams, }); diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 45c6977491..8a2a6d5aed 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -714,7 +714,7 @@ export const Explorer = ({ ); }; - const handleQuerySearch = async (availability?: boolean, setSummaryStatus: boolean) => { + const handleQuerySearch = async (availability?: boolean, setSummaryStatus?: boolean) => { // clear previous selected timestamp when index pattern changes const searchedQuery = tempQueryRef.current; if ( diff --git a/public/components/event_analytics/explorer/llm/feedback_modal.tsx b/public/components/event_analytics/explorer/llm/feedback_modal.tsx deleted file mode 100644 index 7695e417cd..0000000000 --- a/public/components/event_analytics/explorer/llm/feedback_modal.tsx +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiButton, - EuiButtonEmpty, - EuiForm, - EuiFormRow, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiRadioGroup, - EuiTextArea, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { HttpStart } from '../../../../../../../src/core/public'; -import { getPPLService } from '../../../../../common/utils'; -import { coreRefs } from '../../../../framework/core_refs'; - -export interface LabelData { - formHeader: string; - inputPlaceholder: string; - outputPlaceholder: string; -} - -export interface FeedbackFormData { - input: string; - output: string; - correct: boolean | undefined; - expectedOutput: string; - comment: string; -} - -interface FeedbackMetaData { - type: 'event_analytics' | 'chat' | 'ppl_submit'; - chatId?: string; - sessionId?: string; - error?: boolean; - selectedIndex?: string; -} - -interface FeedbackModelProps { - input?: string; - output?: string; - metadata: FeedbackMetaData; - onClose: () => void; -} - -export const FeedbackModal: React.FC = (props) => { - const [formData, setFormData] = useState({ - input: props.input ?? '', - output: props.output ?? '', - correct: undefined, - expectedOutput: '', - comment: '', - }); - return ( - - - - ); -}; - -interface FeedbackModalContentProps { - formData: FeedbackFormData; - setFormData: React.Dispatch>; - metadata: FeedbackMetaData; - displayLabels?: Partial> & Partial; - onClose: () => void; -} - -export const FeedbackModalContent: React.FC = (props) => { - const labels: NonNullable> = Object.assign( - { - formHeader: 'Olly Skills Feedback', - inputPlaceholder: 'Your input question', - input: 'Input question', - outputPlaceholder: 'The LLM response', - output: 'Output', - correct: 'Does the output match your expectations?', - expectedOutput: 'Expected output', - comment: 'Comment', - }, - props.displayLabels - ); - const { loading, submitFeedback } = useSubmitFeedback( - props.formData, - props.metadata, - coreRefs.http! - ); - const [formErrors, setFormErrors] = useState< - Partial<{ [x in keyof FeedbackFormData]: string[] }> - >({ - input: [], - output: [], - expectedOutput: [], - }); - - const hasError = (key?: keyof FeedbackFormData) => { - if (!key) return Object.values(formErrors).some((e) => !!e.length); - return !!formErrors[key]?.length; - }; - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - const errors = { - input: validator - .input(props.formData.input) - .concat(await validator.validateQuery(props.formData.input, props.metadata.type)), - output: validator.output(props.formData.output), - correct: validator.correct(props.formData.correct), - expectedOutput: validator.expectedOutput( - props.formData.expectedOutput, - props.formData.correct === false - ), - }; - if (Object.values(errors).some((e) => !!e.length)) { - setFormErrors(errors); - return; - } - - try { - await submitFeedback(); - props.setFormData({ - input: '', - output: '', - correct: undefined, - expectedOutput: '', - comment: '', - }); - coreRefs.toasts?.addSuccess('Thanks for your feedback!'); - props.onClose(); - } catch (e) { - coreRefs.toasts?.addError(e, { title: 'Failed to submit feedback' }); - } - }; - - return ( - <> - - {labels.formHeader} - - - - - - props.setFormData({ ...props.formData, input: e.target.value })} - onBlur={(e) => { - setFormErrors({ ...formErrors, input: validator.input(e.target.value) }); - }} - isInvalid={hasError('input')} - /> - - - props.setFormData({ ...props.formData, output: e.target.value })} - onBlur={(e) => { - setFormErrors({ ...formErrors, output: validator.output(e.target.value) }); - }} - isInvalid={hasError('output')} - /> - - {props.metadata.type !== 'ppl_submit' && ( - - { - props.setFormData({ ...props.formData, correct: id === 'yes' }); - setFormErrors({ ...formErrors, expectedOutput: [] }); - }} - onBlur={() => setFormErrors({ ...formErrors, correct: [] })} - /> - - )} - {props.formData.correct === false && ( - - - props.setFormData({ ...props.formData, expectedOutput: e.target.value }) - } - onBlur={(e) => { - setFormErrors({ - ...formErrors, - expectedOutput: validator.expectedOutput( - e.target.value, - props.formData.correct === false - ), - }); - }} - isInvalid={hasError('expectedOutput')} - /> - - )} - - props.setFormData({ ...props.formData, comment: e.target.value })} - /> - - - - - - Cancel - - Send - - - - ); -}; - -const useSubmitFeedback = (data: FeedbackFormData, metadata: FeedbackMetaData, http: HttpStart) => { - const [loading, setLoading] = useState(false); - return { - loading, - submitFeedback: async () => { - setLoading(true); - const auth = await http - .get<{ data: { user_name: string; user_requested_tenant: string; roles: string[] } }>( - '/api/v1/configuration/account' - ) - .then((res) => ({ user: res.data.user_name, tenant: res.data.user_requested_tenant })); - - return http - .post('/api/assistant/feedback', { - body: JSON.stringify({ metadata: { ...metadata, ...auth }, ...data }), - }) - .finally(() => setLoading(false)); - }, - }; -}; - -const validatePPLQuery = async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => { - let responseMessage: [] | string[] = []; - const errorMessage = [' Invalid PPL Query, please re-check the ppl syntax']; - - if (feedBackType === 'ppl_submit') { - const pplService = getPPLService(); - await pplService - .fetch({ query: logsQuery, format: 'jdbc' }) - .then((res) => { - if (res === undefined) responseMessage = errorMessage; - }) - .catch((error: Error) => { - responseMessage = errorMessage; - }); - } - return responseMessage; -}; - -const validator = { - input: (text: string) => (text.trim().length === 0 ? ['Input is required'] : []), - output: (text: string) => (text.trim().length === 0 ? ['Output is required'] : []), - correct: (correct: boolean | undefined) => - correct === undefined ? ['Correctness is required'] : [], - expectedOutput: (text: string, required: boolean) => - required && text.trim().length === 0 ? ['expectedOutput is required'] : [], - validateQuery: async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => - await validatePPLQuery(logsQuery, feedBackType), -}; diff --git a/public/components/event_analytics/explorer/query_assist/hooks.ts b/public/components/event_analytics/explorer/query_assist/hooks.ts new file mode 100644 index 0000000000..1e57e7bfeb --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/hooks.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; +import { Reducer, useReducer, useState, useEffect } from 'react'; +import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; +import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; +import { getOSDHttp } from '../../../../../common/utils'; +import { coreRefs } from '../../../../framework/core_refs'; + +interface State { + data?: T; + loading: boolean; + error?: Error; +} + +type Action = + | { type: 'request' } + | { type: 'success'; payload: State['data'] } + | { type: 'failure'; error: NonNullable['error']> }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +type GenericReducer = Reducer, Action>; +const genericReducer: GenericReducer = (state, action) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: action.error }; + default: + return state; + } +}; + +export const useCatIndices = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + getOSDHttp() + .get(`${DSL_BASE}${DSL_CAT}`, { query: { format: 'json' }, signal: abortController.signal }) + .then((payload: CatIndicesResponse) => + dispatch({ type: 'success', payload: payload.map((meta) => ({ label: meta.index! })) }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; + +export const useGetIndexPatterns = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + let abort = false; + dispatch({ type: 'request' }); + + coreRefs + .savedObjectsClient!.find({ type: 'index-pattern', perPage: 10000 }) + .then((payload) => { + if (!abort) + dispatch({ + type: 'success', + payload: payload.savedObjects.map((meta) => ({ label: meta.attributes.title })), + }); + }) + .catch((error) => { + if (!abort) dispatch({ type: 'failure', error }); + }); + + return () => { + abort = true; + }; + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; diff --git a/public/components/event_analytics/explorer/llm/input.tsx b/public/components/event_analytics/explorer/query_assist/input.tsx similarity index 58% rename from public/components/event_analytics/explorer/llm/input.tsx rename to public/components/event_analytics/explorer/query_assist/input.tsx index 91165d9f8d..ad1813826c 100644 --- a/public/components/event_analytics/explorer/llm/input.tsx +++ b/public/components/event_analytics/explorer/query_assist/input.tsx @@ -16,17 +16,13 @@ import { EuiLink, EuiListGroup, EuiListGroupItem, - EuiModal, EuiPanel, EuiText, } from '@elastic/eui'; -import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; -import React, { Reducer, useEffect, useReducer, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; import { RAW_QUERY } from '../../../../../common/constants/explorer'; -import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; import { getOSDHttp } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; @@ -38,7 +34,6 @@ import { } from '../../redux/slices/query_assistant_summarization_slice'; import { reset, selectQueryResult } from '../../redux/slices/query_result_slice'; import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; -import { FeedbackFormData, FeedbackModalContent } from './feedback_modal'; interface SummarizationContext { question: string; @@ -50,16 +45,43 @@ interface SummarizationContext { interface Props { handleQueryChange: (query: string) => void; - handleTimeRangePickerRefresh: () => void; + handleTimeRangePickerRefresh: (availability?: boolean, setSummaryStatus?: boolean) => void; tabId: string; setNeedsUpdate: any; selectedIndex: Array>; nlqInput: string; setNlqInput: React.Dispatch>; } -export const LLMInput: React.FC = (props) => { + +const HARDCODED_SUGGESTIONS: Record = { + opensearch_dashboards_sample_data_ecommerce: [ + 'How many unique customers placed orders this week?', + 'Count the number of orders grouped by manufacturer and category', + 'find customers with first names like Eddie', + ], + opensearch_dashboards_sample_data_logs: [ + 'Are there any errors in my logs?', + 'How many requests were there grouped by response code last week?', + "What's the average request size by week?", + ], + opensearch_dashboards_sample_data_flights: [ + 'how many flights were there this week grouped by destination country?', + 'what were the longest flight delays this week?', + 'what carriers have the furthest flights?', + ], + 'sso_logs-*.*': [ + 'show me the most recent 10 logs', + 'how many requests were there grouped by status code', + 'how many request failures were there by week?', + ], +}; + +export const QueryAssistInput: React.FC = (props) => { + // @ts-ignore const queryRedux = useSelector(selectQueries)[props.tabId]; + // @ts-ignore const explorerData = useSelector(selectQueryResult)[props.tabId]; + // @ts-ignore const summaryData = useSelector(selectQueryAssistantSummarization)[props.tabId]; useEffect(() => { @@ -81,47 +103,15 @@ export const LLMInput: React.FC = (props) => { } }, [summaryData.responseForSummaryStatus]); - // HARDCODED QUESTION SUGGESTIONS: - const hardcodedSuggestions = { - opensearch_dashboards_sample_data_ecommerce: [ - 'How many unique customers placed orders this week?', - 'Count the number of orders grouped by manufacturer and category', - 'find customers with first names like Eddie', - ], - opensearch_dashboards_sample_data_logs: [ - 'Are there any errors in my logs?', - 'How many requests were there grouped by response code last week?', - "What's the average request size by week?", - ], - opensearch_dashboards_sample_data_flights: [ - 'how many flights were there this week grouped by destination country?', - 'what were the longest flight delays this week?', - 'what carriers have the furthest flights?', - ], - 'sso_logs-*.*': [ - 'show me the most recent 10 logs', - 'how many requests were there grouped by status code', - 'how many request failures were there by week?', - ], - }; - const [barSelected, setBarSelected] = useState(false); const dispatch = useDispatch(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [generating, setGenerating] = useState(false); - const [generatingRun, setGeneratingRun] = useState(false); - const [isFeedbackOpen, setIsFeedbackOpen] = useState(false); + const [generatingOrRunning, setGeneratingOrRunning] = useState(false); // below is only used for url redirection const [autoRun, setAutoRun] = useState(false); - const [feedbackFormData, setFeedbackFormData] = useState({ - input: '', - output: '', - correct: undefined, - expectedOutput: '', - comment: '', - }); useEffect(() => { if (autoRun) { @@ -143,13 +133,7 @@ export const LLMInput: React.FC = (props) => { index: props.selectedIndex[0].label, }), }); - setFeedbackFormData({ - ...feedbackFormData, - input: props.nlqInput, - output: generatedPPL, - }); await props.handleQueryChange(generatedPPL); - console.log('generatedPPL', generatedPPL); await dispatch( changeQuery({ tabId: props.tabId, @@ -178,13 +162,11 @@ export const LLMInput: React.FC = (props) => { if (!props.selectedIndex.length) return; try { setGenerating(true); - console.log('generated query is', await request()); + await request(); } catch (error) { - setFeedbackFormData({ - ...feedbackFormData, - input: props.nlqInput, + coreRefs.toasts?.addError(formatError(error as ResponseError), { + title: 'Failed to generate results', }); - coreRefs.toasts?.addError(formatError(error), { title: 'Failed to generate results' }); } finally { setGenerating(false); } @@ -234,7 +216,9 @@ export const LLMInput: React.FC = (props) => { ); } } catch (error) { - coreRefs.toasts?.addError(formatError(error), { title: 'Failed to summarize results' }); + coreRefs.toasts?.addError(formatError(error as ResponseError), { + title: 'Failed to summarize results', + }); } finally { await dispatch( changeSummary({ @@ -258,17 +242,13 @@ export const LLMInput: React.FC = (props) => { dispatch(resetSummary({ tabId: props.tabId })); if (!props.selectedIndex.length) return; try { - setGeneratingRun(true); + setGeneratingOrRunning(true); await request(); await props.handleTimeRangePickerRefresh(undefined, true); } catch (error) { - setFeedbackFormData({ - ...feedbackFormData, - input: props.nlqInput, - }); - generateSummary({ isError: true, response: JSON.stringify(error.body) }); + generateSummary({ isError: true, response: JSON.stringify((error as ResponseError).body) }); } finally { - setGeneratingRun(false); + setGeneratingOrRunning(false); } }; @@ -300,7 +280,6 @@ export const LLMInput: React.FC = (props) => { input={ props.setNlqInput(e.target.value)} @@ -321,7 +300,7 @@ export const LLMInput: React.FC = (props) => { }} > - {hardcodedSuggestions[props.selectedIndex[0]?.label]?.map((question) => ( + {HARDCODED_SUGGESTIONS[props.selectedIndex[0]?.label]?.map((question) => ( { props.setNlqInput(question); @@ -333,15 +312,6 @@ export const LLMInput: React.FC = (props) => { - {/* - setIsFeedbackOpen(true)} - iconType="faceHappy" - iconSide="right" - > - Feedback - - */}
@@ -364,7 +334,7 @@ export const LLMInput: React.FC = (props) => { = (props) => { = (props) => { - {isFeedbackOpen && ( - setIsFeedbackOpen(false)}> - setIsFeedbackOpen(false)} - displayLabels={{ - correct: 'Did the results from the generated query answer your question?', - }} - /> - - )} ); }; - -interface State { - data?: T; - loading: boolean; - error?: Error; -} - -type Action = - | { type: 'request' } - | { type: 'success'; payload: State['data'] } - | { type: 'failure'; error: NonNullable['error']> }; - -// TODO use instantiation expressions when typescript is upgraded to >= 4.7 -export type GenericReducer = Reducer, Action>; -export const genericReducer: GenericReducer = (state, action) => { - switch (action.type) { - case 'request': - return { data: state.data, loading: true }; - case 'success': - return { loading: false, data: action.payload }; - case 'failure': - return { loading: false, error: action.error }; - default: - return state; - } -}; - -export const useCatIndices = () => { - const reducer: GenericReducer = genericReducer; - const [state, dispatch] = useReducer(reducer, { loading: false }); - const [refresh, setRefresh] = useState({}); - - useEffect(() => { - const abortController = new AbortController(); - dispatch({ type: 'request' }); - getOSDHttp() - .get(`${DSL_BASE}${DSL_CAT}`, { query: { format: 'json' }, signal: abortController.signal }) - .then((payload: CatIndicesResponse) => - dispatch({ type: 'success', payload: payload.map((meta) => ({ label: meta.index! })) }) - ) - .catch((error) => dispatch({ type: 'failure', error })); - - return () => abortController.abort(); - }, [refresh]); - - return { ...state, refresh: () => setRefresh({}) }; -}; - -export const useGetIndexPatterns = () => { - const reducer: GenericReducer = genericReducer; - const [state, dispatch] = useReducer(reducer, { loading: false }); - const [refresh, setRefresh] = useState({}); - - useEffect(() => { - let abort = false; - dispatch({ type: 'request' }); - - coreRefs - .savedObjectsClient!.find({ type: 'index-pattern', perPage: 10000 }) - .then((payload) => { - if (!abort) - dispatch({ - type: 'success', - payload: payload.savedObjects.map((meta) => ({ label: meta.attributes.title })), - }); - }) - .catch((error) => { - if (!abort) dispatch({ type: 'failure', error }); - }); - - return () => { - abort = true; - }; - }, [refresh]); - - return { ...state, refresh: () => setRefresh({}) }; -}; - -export const SubmitPPLButton: React.FC<{ pplQuery: string }> = (props) => { - const [isSubmitOpen, setIsSubmitOpen] = useState(false); - const [submitFormData, setSubmitFormData] = useState({ - input: props.pplQuery, - output: '', - correct: true, - expectedOutput: '', - comment: '', - }); - - useEffect(() => { - setSubmitFormData({ - input: props.pplQuery, - output: '', - correct: true, - expectedOutput: '', - comment: '', - }); - }, [props.pplQuery]); - - return ( - <> - setIsSubmitOpen(true)}> - Submit PPL Query - - {isSubmitOpen && ( - setIsSubmitOpen(false)}> - setIsSubmitOpen(false)} - displayLabels={{ - formHeader: 'Submit PPL Query', - input: 'Your PPL Query', - inputPlaceholder: 'PPL Query', - output: 'Please write a Natural Language Question for the above Query', - outputPlaceholder: 'Natural Language Question', - }} - /> - - )} - - ); -}; From b5303654e37bf17a56019f4872274eb5719384b3 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 21 Dec 2023 21:39:29 +0000 Subject: [PATCH 71/86] replace langchain API with ml-commons agents for query assist Signed-off-by: Joshua Li --- common/constants/query_assist.ts | 12 ++ .../explorer/query_assist/input.tsx | 87 +++++----- .../query_assist/generate_field_context.ts | 71 ++++++++ server/routes/index.ts | 2 + server/routes/query_assist/routes.ts | 163 ++++++++++++++++++ 5 files changed, 291 insertions(+), 44 deletions(-) create mode 100644 common/constants/query_assist.ts create mode 100644 server/common/helpers/query_assist/generate_field_context.ts create mode 100644 server/routes/query_assist/routes.ts diff --git a/common/constants/query_assist.ts b/common/constants/query_assist.ts new file mode 100644 index 0000000000..04b38c1299 --- /dev/null +++ b/common/constants/query_assist.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +const QUERY_ASSIST_API_PREFIX = '/api/observability/query_assist'; +export const QUERY_ASSIST_API = { + GENERATE_PPL: `${QUERY_ASSIST_API_PREFIX}/generate_ppl`, + SUMMARIZE: `${QUERY_ASSIST_API_PREFIX}/summarize`, +}; + +export const ML_COMMONS_API_PREFIX = '/_plugins/_ml'; diff --git a/public/components/event_analytics/explorer/query_assist/input.tsx b/public/components/event_analytics/explorer/query_assist/input.tsx index ad1813826c..0eab5893cc 100644 --- a/public/components/event_analytics/explorer/query_assist/input.tsx +++ b/public/components/event_analytics/explorer/query_assist/input.tsx @@ -34,6 +34,7 @@ import { } from '../../redux/slices/query_assistant_summarization_slice'; import { reset, selectQueryResult } from '../../redux/slices/query_result_slice'; import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; +import { QUERY_ASSIST_API } from '../../../../../common/constants/query_assist'; interface SummarizationContext { question: string; @@ -98,7 +99,7 @@ export const QueryAssistInput: React.FC = (props) => { }, }) ); - generateSummary(); + if (explorerData.total > 0) generateSummary(); })(); } }, [summaryData.responseForSummaryStatus]); @@ -127,7 +128,7 @@ export const QueryAssistInput: React.FC = (props) => { // generic method for generating ppl from natural language const request = async () => { - const generatedPPL = await getOSDHttp().post('/api/assistant/generate_ppl', { + const generatedPPL = await getOSDHttp().post(QUERY_ASSIST_API.GENERATE_PPL, { body: JSON.stringify({ question: props.nlqInput, index: props.selectedIndex[0].label, @@ -173,48 +174,46 @@ export const QueryAssistInput: React.FC = (props) => { }; const generateSummary = async (context?: Partial) => { try { - if (explorerData.total > 0) { - const isError = summaryData.responseForSummaryStatus === 'failure'; - const summarizationContext: SummarizationContext = { - question: props.nlqInput, - index: props.selectedIndex[0].label, - isError, - query: queryRedux.rawQuery, - response: isError - ? String(JSON.parse(explorerData.error.body.message).error.details) - : JSON.stringify({ - datarows: explorerData.datarows, - schema: explorerData.schema, - size: explorerData.size, - total: explorerData.total, - }).slice(0, 7000), - ...context, - }; - await dispatch( - changeSummary({ - tabId: props.tabId, - data: { - summaryLoading: true, - isPPLError: isError, - }, - }) - ); - const summary = await getOSDHttp().post<{ - summary: string; - suggestedQuestions: string[]; - }>('/api/assistant/summarize', { - body: JSON.stringify(summarizationContext), - }); - await dispatch( - changeSummary({ - tabId: props.tabId, - data: { - summary: summary.summary, - suggestedQuestions: summary.suggestedQuestions, - }, - }) - ); - } + const isError = summaryData.responseForSummaryStatus === 'failure'; + const summarizationContext: SummarizationContext = { + question: props.nlqInput, + index: props.selectedIndex[0].label, + isError, + query: queryRedux.rawQuery, + response: isError + ? String(JSON.parse(explorerData.error.body.message).error.details) + : JSON.stringify({ + datarows: explorerData.datarows, + schema: explorerData.schema, + size: explorerData.size, + total: explorerData.total, + }).slice(0, 7000), + ...context, + }; + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: true, + isPPLError: isError, + }, + }) + ); + const summary = await getOSDHttp().post<{ + summary: string; + suggestedQuestions: string[]; + }>(QUERY_ASSIST_API.SUMMARIZE, { + body: JSON.stringify(summarizationContext), + }); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summary: summary.summary, + suggestedQuestions: summary.suggestedQuestions, + }, + }) + ); } catch (error) { coreRefs.toasts?.addError(formatError(error as ResponseError), { title: 'Failed to summarize results', diff --git a/server/common/helpers/query_assist/generate_field_context.ts b/server/common/helpers/query_assist/generate_field_context.ts new file mode 100644 index 0000000000..ff5d491236 --- /dev/null +++ b/server/common/helpers/query_assist/generate_field_context.ts @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch'; +import { + IndicesGetMappingResponse, + MappingProperty, + SearchResponse, +} from '@opensearch-project/opensearch/api/types'; +import { get } from 'lodash'; + +/** + * @template T = unknown - mapping Context + * @template U = unknown - search Context + * @param mappings - mapping from get mappings request + * @param hits - search response that contains a sample document + * @returns a string that describes fields, types, and sample values + */ +export const generateFieldContext = ( + mappings: ApiResponse, + hits: ApiResponse, U> +) => { + const flattenedFields = flattenMappings(mappings); + const source = hits.body.hits.hits[0]?._source; + + return Object.entries(flattenedFields) + .filter(([, type]) => type !== 'alias') // PPL doesn't support 'alias' type + .map(([field, type]) => { + return `- ${field}: ${type} (${extractValue(source, field, type)})`; + }) + .join('\n'); +}; + +const extractValue = (source: unknown | undefined, field: string, type: string) => { + const value = get(source, field); + if (value === undefined) return null; + if (['text', 'keyword'].includes(type)) return `"${value}"`; + return value; +}; + +/** + * Flatten mappings response to an object of fields and types. + * + * @template T = unknown - Context + * @param mappings - mapping from get mappings request + * @returns an object of fields and types + */ +const flattenMappings = (mappings: ApiResponse) => { + const fields: Record = {}; + Object.values(mappings.body).forEach((body) => + parseProperties(body.mappings.properties, undefined, fields) + ); + return fields; +}; + +const parseProperties = ( + properties: Record | undefined, + prefixes: string[] = [], + fields: Record +) => { + Object.entries(properties || {}).forEach(([key, value]) => { + if (value.properties) { + parseProperties(value.properties, [...prefixes, key], fields); + } else { + fields[[...prefixes, key].join('.')] = value.type!; + } + }); + return fields; +}; diff --git a/server/routes/index.ts b/server/routes/index.ts index 2d02adca09..e1f5adc69d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -23,6 +23,7 @@ import { registerMetricsRoute } from './metrics/metrics_rounter'; import { registerIntegrationsRoute } from './integrations/integrations_router'; import { registerDataConnectionsRoute } from './data_connections/data_connections_router'; import { registerDatasourcesRoute } from './datasources/datasources_router'; +import { registerQueryAssistRoutes } from './query_assist/routes'; export function setupRoutes({ router, client }: { router: IRouter; client: ILegacyClusterClient }) { PanelsRouter(router); @@ -46,4 +47,5 @@ export function setupRoutes({ router, client }: { router: IRouter; client: ILega registerIntegrationsRoute(router); registerDataConnectionsRoute(router); registerDatasourcesRoute(router); + registerQueryAssistRoutes(router); } diff --git a/server/routes/query_assist/routes.ts b/server/routes/query_assist/routes.ts new file mode 100644 index 0000000000..6c5c0b37a3 --- /dev/null +++ b/server/routes/query_assist/routes.ts @@ -0,0 +1,163 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch'; +import { schema } from '@osd/config-schema'; +import { + IOpenSearchDashboardsResponse, + IRouter, + ResponseError, +} from '../../../../../src/core/server'; +import { ML_COMMONS_API_PREFIX, QUERY_ASSIST_API } from '../../../common/constants/query_assist'; +import { generateFieldContext } from '../../common/helpers/query_assist/generate_field_context'; + +const pplAgentId = '284wjowBQRcDZmIs1OF8'; +const summarySuccessAgentId = 'o84kjowBQRcDZmIsweF6'; +const summaryErrorAgentId = 'pM4kjowBQRcDZmIsweGb'; + +const AGENT_REQUEST_OPTIONS = { + /** + * It is time-consuming for LLM to generate final answer + * Give it a large timeout window + */ + requestTimeout: 5 * 60 * 1000, + /** + * Do not retry + */ + maxRetries: 0, +}; + +type AgentResponse = ApiResponse<{ + inference_results: Array<{ + output: Array<{ name: string; result?: string }>; + }>; +}>; + +export function registerQueryAssistRoutes(router: IRouter) { + router.post( + { + path: QUERY_ASSIST_API.GENERATE_PPL, + validate: { + body: schema.object({ + index: schema.string(), + question: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const client = context.core.opensearch.client.asCurrentUser; + try { + const pplRequest = (await client.transport.request( + { + method: 'POST', + path: `${ML_COMMONS_API_PREFIX}/agents/${pplAgentId}/_execute`, + body: { + parameters: { + index: request.body.index, + question: request.body.question, + }, + }, + }, + AGENT_REQUEST_OPTIONS + )) as AgentResponse; + if (!pplRequest.body.inference_results[0].output[0].result) + throw new Error('Generated PPL query not found.'); + const result = JSON.parse(pplRequest.body.inference_results[0].output[0].result) as { + ppl: string; + executionResult: string; + }; + const ppl = result.ppl + .replace(/[\r\n]/g, ' ') + .trim() + .replace(/ISNOTNULL/g, 'isnotnull') // https://github.com/opensearch-project/sql/issues/2431 + .replace(/`/g, '') // https://github.com/opensearch-project/dashboards-observability/issues/509, https://github.com/opensearch-project/dashboards-observability/issues/557 + .replace(/\bSPAN\(/g, 'span('); // https://github.com/opensearch-project/dashboards-observability/issues/759 + return response.ok({ body: ppl }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: QUERY_ASSIST_API.SUMMARIZE, + validate: { + body: schema.object({ + index: schema.string(), + question: schema.string(), + query: schema.maybe(schema.string()), + response: schema.string(), + isError: schema.boolean(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const client = context.core.opensearch.client.asCurrentUser; + const { index, question, query, response: _response, isError } = request.body; + const queryResponse = JSON.stringify(_response); + let summaryRequest: AgentResponse; + try { + if (!isError) { + summaryRequest = (await client.transport.request( + { + method: 'POST', + path: `${ML_COMMONS_API_PREFIX}/agents/${summarySuccessAgentId}/_execute`, + body: { + parameters: { index, question, query, response: queryResponse }, + }, + }, + AGENT_REQUEST_OPTIONS + )) as AgentResponse; + } else { + const [mappings, sampleDoc] = await Promise.all([ + client.indices.getMapping({ index }), + client.search({ index, size: 1 }), + ]); + const fields = generateFieldContext(mappings, sampleDoc); + summaryRequest = (await client.transport.request( + { + method: 'POST', + path: `${ML_COMMONS_API_PREFIX}/agents/${summaryErrorAgentId}/_execute`, + body: { + parameters: { index, question, query, response: queryResponse, fields }, + }, + }, + AGENT_REQUEST_OPTIONS + )) as AgentResponse; + } + const summary = summaryRequest.body.inference_results[0].output[0].result; + if (!summary) throw new Error('Generated summary not found.'); + const suggestedQuestions = Array.from( + (summaryRequest.body.inference_results[0].output[1]?.result || '').matchAll( + /((.|[\r\n])+?)<\/question>/g + ) + ).map((m) => (m as unknown[])[1]); + return response.ok({ + body: { + summary, + suggestedQuestions, + }, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); +} From 6b17329d865e5eae0cefd3940b74b3abdf5a44c9 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 21 Dec 2023 22:10:53 +0000 Subject: [PATCH 72/86] read agent id from config Signed-off-by: Joshua Li --- server/index.ts | 21 +++++++++++++- server/plugin.ts | 12 ++++++-- server/routes/index.ts | 41 +++++++++++++++++----------- server/routes/query_assist/routes.ts | 22 ++++++++++----- 4 files changed, 69 insertions(+), 27 deletions(-) diff --git a/server/index.ts b/server/index.ts index fb4e47bd3a..e48705625d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PluginInitializerContext } from '../../../src/core/server'; +import { TypeOf, schema } from '@osd/config-schema'; +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../src/core/server'; import { ObservabilityPlugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { @@ -11,3 +12,21 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { ObservabilityPluginSetup, ObservabilityPluginStart } from './types'; + +const observabilityConfig = { + schema: schema.object({ + observability: schema.object({ + query_assist: schema.object({ + ppl_agent_id: schema.maybe(schema.string()), + response_summary_agent_id: schema.maybe(schema.string()), + error_summary_agent_id: schema.maybe(schema.string()), + }), + }), + }), +}; + +export type ObservabilityConfig = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: observabilityConfig.schema, +}; diff --git a/server/plugin.ts b/server/plugin.ts index 59e3e41247..1d12a0712b 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { first } from 'rxjs/operators'; +import { ObservabilityConfig } from '.'; import { CoreSetup, CoreStart, @@ -25,13 +27,17 @@ export class ObservabilityPlugin implements Plugin { private readonly logger: Logger; - constructor(initializerContext: PluginInitializerContext) { + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup) { + public async setup(core: CoreSetup) { this.logger.debug('Observability: Setup'); const router = core.http.createRouter(); + const config = await this.initializerContext.config + .create() + .pipe(first()) + .toPromise(); const openSearchObservabilityClient: ILegacyClusterClient = core.opensearch.legacy.createClient( 'opensearch_observability', { @@ -111,7 +117,7 @@ export class ObservabilityPlugin core.savedObjects.registerType(integrationInstanceType); // Register server side APIs - setupRoutes({ router, client: openSearchObservabilityClient }); + setupRoutes({ router, client: openSearchObservabilityClient, config }); core.savedObjects.registerType(visualizationSavedObject); core.savedObjects.registerType(searchSavedObject); diff --git a/server/routes/index.ts b/server/routes/index.ts index e1f5adc69d..6bc08028d2 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -3,29 +3,38 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IRouter, ILegacyClusterClient } from '../../../../src/core/server'; -import { registerPplRoute } from './ppl'; -import { PPLFacet } from '../services/facets/ppl_facet'; -import { registerDslRoute } from './dsl'; +import { ObservabilityConfig } from '..'; +import { ILegacyClusterClient, IRouter } from '../../../../src/core/server'; import { DSLFacet } from '../services/facets/dsl_facet'; +import { PPLFacet } from '../services/facets/ppl_facet'; import SavedObjectFacet from '../services/facets/saved_objects'; -import { PanelsRouter } from './custom_panels/panels_router'; -import { VisualizationsRouter } from './custom_panels/visualizations_router'; -import { registerTraceAnalyticsDslRouter } from './trace_analytics_dsl_router'; -import { registerParaRoute } from './notebooks/paraRouter'; -import { registerNoteRoute } from './notebooks/noteRouter'; -import { registerVizRoute } from './notebooks/vizRouter'; import { QueryService } from '../services/queryService'; -import { registerSqlRoute } from './notebooks/sqlRouter'; -import { registerEventAnalyticsRouter } from './event_analytics/event_analytics_router'; import { registerAppAnalyticsRouter } from './application_analytics/app_analytics_router'; -import { registerMetricsRoute } from './metrics/metrics_rounter'; -import { registerIntegrationsRoute } from './integrations/integrations_router'; +import { PanelsRouter } from './custom_panels/panels_router'; +import { VisualizationsRouter } from './custom_panels/visualizations_router'; import { registerDataConnectionsRoute } from './data_connections/data_connections_router'; import { registerDatasourcesRoute } from './datasources/datasources_router'; +import { registerDslRoute } from './dsl'; +import { registerEventAnalyticsRouter } from './event_analytics/event_analytics_router'; +import { registerIntegrationsRoute } from './integrations/integrations_router'; +import { registerMetricsRoute } from './metrics/metrics_rounter'; +import { registerNoteRoute } from './notebooks/noteRouter'; +import { registerParaRoute } from './notebooks/paraRouter'; +import { registerSqlRoute } from './notebooks/sqlRouter'; +import { registerVizRoute } from './notebooks/vizRouter'; +import { registerPplRoute } from './ppl'; import { registerQueryAssistRoutes } from './query_assist/routes'; +import { registerTraceAnalyticsDslRouter } from './trace_analytics_dsl_router'; -export function setupRoutes({ router, client }: { router: IRouter; client: ILegacyClusterClient }) { +export function setupRoutes({ + router, + client, + config, +}: { + router: IRouter; + client: ILegacyClusterClient; + config: ObservabilityConfig; +}) { PanelsRouter(router); VisualizationsRouter(router); registerPplRoute({ router, facet: new PPLFacet(client) }); @@ -47,5 +56,5 @@ export function setupRoutes({ router, client }: { router: IRouter; client: ILega registerIntegrationsRoute(router); registerDataConnectionsRoute(router); registerDatasourcesRoute(router); - registerQueryAssistRoutes(router); + registerQueryAssistRoutes(router, config); } diff --git a/server/routes/query_assist/routes.ts b/server/routes/query_assist/routes.ts index 6c5c0b37a3..9ae8c4203f 100644 --- a/server/routes/query_assist/routes.ts +++ b/server/routes/query_assist/routes.ts @@ -5,6 +5,7 @@ import { ApiResponse } from '@opensearch-project/opensearch'; import { schema } from '@osd/config-schema'; +import { ObservabilityConfig } from '../..'; import { IOpenSearchDashboardsResponse, IRouter, @@ -13,10 +14,6 @@ import { import { ML_COMMONS_API_PREFIX, QUERY_ASSIST_API } from '../../../common/constants/query_assist'; import { generateFieldContext } from '../../common/helpers/query_assist/generate_field_context'; -const pplAgentId = '284wjowBQRcDZmIs1OF8'; -const summarySuccessAgentId = 'o84kjowBQRcDZmIsweF6'; -const summaryErrorAgentId = 'pM4kjowBQRcDZmIsweGb'; - const AGENT_REQUEST_OPTIONS = { /** * It is time-consuming for LLM to generate final answer @@ -35,7 +32,13 @@ type AgentResponse = ApiResponse<{ }>; }>; -export function registerQueryAssistRoutes(router: IRouter) { +export function registerQueryAssistRoutes(router: IRouter, config: ObservabilityConfig) { + const { + ppl_agent_id: pplAgentId, + response_summary_agent_id: responseSummaryAgentId, + error_summary_agent_id: ErrorSummaryAgentId, + } = config.observability.query_assist; + router.post( { path: QUERY_ASSIST_API.GENERATE_PPL, @@ -51,6 +54,8 @@ export function registerQueryAssistRoutes(router: IRouter) { request, response ): Promise> => { + if (!pplAgentId) return response.custom({ statusCode: 400, body: 'PPL agent not found.' }); + const client = context.core.opensearch.client.asCurrentUser; try { const pplRequest = (await client.transport.request( @@ -106,6 +111,9 @@ export function registerQueryAssistRoutes(router: IRouter) { request, response ): Promise> => { + if (!responseSummaryAgentId || !ErrorSummaryAgentId) + return response.custom({ statusCode: 400, body: 'Summary agent not found.' }); + const client = context.core.opensearch.client.asCurrentUser; const { index, question, query, response: _response, isError } = request.body; const queryResponse = JSON.stringify(_response); @@ -115,7 +123,7 @@ export function registerQueryAssistRoutes(router: IRouter) { summaryRequest = (await client.transport.request( { method: 'POST', - path: `${ML_COMMONS_API_PREFIX}/agents/${summarySuccessAgentId}/_execute`, + path: `${ML_COMMONS_API_PREFIX}/agents/${responseSummaryAgentId}/_execute`, body: { parameters: { index, question, query, response: queryResponse }, }, @@ -131,7 +139,7 @@ export function registerQueryAssistRoutes(router: IRouter) { summaryRequest = (await client.transport.request( { method: 'POST', - path: `${ML_COMMONS_API_PREFIX}/agents/${summaryErrorAgentId}/_execute`, + path: `${ML_COMMONS_API_PREFIX}/agents/${ErrorSummaryAgentId}/_execute`, body: { parameters: { index, question, query, response: queryResponse, fields }, }, From 247283b9df1423771edab9f84e148453966b35b9 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Thu, 21 Dec 2023 23:14:45 +0000 Subject: [PATCH 73/86] fix config errors Signed-off-by: Joshua Li --- opensearch_dashboards.json | 4 ++-- package.json | 4 ++-- server/routes/query_assist/routes.ts | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 33bd7a7c5c..efaf215872 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,7 +1,7 @@ { "id": "observabilityDashboards", - "version": "2.11.0.0", - "opensearchDashboardsVersion": "2.11.0", + "version": "3.0.0.0", + "opensearchDashboardsVersion": "3.0.0", "server": true, "ui": true, "requiredPlugins": [ diff --git a/package.json b/package.json index cf6a4dd72d..3929fcd37d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "observability-dashboards", - "version": "2.11.0.0", + "version": "3.0.0.0", "main": "index.ts", "license": "Apache-2.0", "scripts": { @@ -82,4 +82,4 @@ "node_modules/*", "target/*" ] -} +} \ No newline at end of file diff --git a/server/routes/query_assist/routes.ts b/server/routes/query_assist/routes.ts index 9ae8c4203f..edf030742e 100644 --- a/server/routes/query_assist/routes.ts +++ b/server/routes/query_assist/routes.ts @@ -37,7 +37,7 @@ export function registerQueryAssistRoutes(router: IRouter, config: Observability ppl_agent_id: pplAgentId, response_summary_agent_id: responseSummaryAgentId, error_summary_agent_id: ErrorSummaryAgentId, - } = config.observability.query_assist; + } = config.query_assist; router.post( { @@ -54,7 +54,12 @@ export function registerQueryAssistRoutes(router: IRouter, config: Observability request, response ): Promise> => { - if (!pplAgentId) return response.custom({ statusCode: 400, body: 'PPL agent not found.' }); + if (!pplAgentId) + return response.custom({ + statusCode: 400, + body: + 'PPL agent not found in opensearch_dashboards.yml. Expected observability.query_assist.ppl_agent_id', + }); const client = context.core.opensearch.client.asCurrentUser; try { @@ -112,7 +117,11 @@ export function registerQueryAssistRoutes(router: IRouter, config: Observability response ): Promise> => { if (!responseSummaryAgentId || !ErrorSummaryAgentId) - return response.custom({ statusCode: 400, body: 'Summary agent not found.' }); + return response.custom({ + statusCode: 400, + body: + 'Summary agent not found in opensearch_dashboards.yml. Expected observability.query_assist.response_summary_agent_id and observability.query_assist.error_summary_agent_id', + }); const client = context.core.opensearch.client.asCurrentUser; const { index, question, query, response: _response, isError } = request.body; From c4436d30632ec523972556b57c4ddb0f2336f208 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Fri, 22 Dec 2023 00:30:55 +0000 Subject: [PATCH 74/86] add unit tests for util functions Signed-off-by: Joshua Li --- .../query_assist/__tests__/hooks.test.ts | 50 ++++++++++ .../explorer/query_assist/hooks.ts | 2 +- .../__tests__/generate_field_context.test.ts | 95 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts create mode 100644 server/common/helpers/query_assist/__tests__/generate_field_context.test.ts diff --git a/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts new file mode 100644 index 0000000000..aa04d960d8 --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { genericReducer } from '../hooks'; + +describe('genericReducer', () => { + it('should return original state', () => { + expect( + genericReducer( + { data: { foo: 'bar' }, loading: false }, + // mock not supported type + { type: ('not-supported-type' as unknown) as 'request' } + ) + ).toEqual({ + data: { foo: 'bar' }, + loading: false, + }); + }); + + it('should return state follow request action', () => { + expect(genericReducer({ data: { foo: 'bar' }, loading: false }, { type: 'request' })).toEqual({ + data: { foo: 'bar' }, + loading: true, + }); + }); + + it('should return state follow success action', () => { + expect( + genericReducer( + { data: { foo: 'bar' }, loading: false }, + { type: 'success', payload: { foo: 'baz' } } + ) + ).toEqual({ + data: { foo: 'baz' }, + loading: false, + }); + }); + + it('should return state follow failure action', () => { + const error = new Error(); + expect( + genericReducer({ data: { foo: 'bar' }, loading: false }, { type: 'failure', error }) + ).toEqual({ + error, + loading: false, + }); + }); +}); diff --git a/public/components/event_analytics/explorer/query_assist/hooks.ts b/public/components/event_analytics/explorer/query_assist/hooks.ts index 1e57e7bfeb..281f659bac 100644 --- a/public/components/event_analytics/explorer/query_assist/hooks.ts +++ b/public/components/event_analytics/explorer/query_assist/hooks.ts @@ -24,7 +24,7 @@ type Action = // TODO use instantiation expressions when typescript is upgraded to >= 4.7 type GenericReducer = Reducer, Action>; -const genericReducer: GenericReducer = (state, action) => { +export const genericReducer: GenericReducer = (state, action) => { switch (action.type) { case 'request': return { data: state.data, loading: true }; diff --git a/server/common/helpers/query_assist/__tests__/generate_field_context.test.ts b/server/common/helpers/query_assist/__tests__/generate_field_context.test.ts new file mode 100644 index 0000000000..cabb19ecfb --- /dev/null +++ b/server/common/helpers/query_assist/__tests__/generate_field_context.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch/.'; +import { IndicesGetMappingResponse } from '@opensearch-project/opensearch/api/types'; +import { SearchResponse } from 'elasticsearch'; +import { generateFieldContext } from '../generate_field_context'; + +describe('Generate field context', () => { + it('handles empty mappings', () => { + const fields = generateFieldContext( + ({ + body: { employee_nested: { mappings: {} } }, + } as unknown) as ApiResponse, + ({ + body: { + took: 0, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'gte' }, max_score: 1, hits: [] }, + }, + } as unknown) as ApiResponse> + ); + expect(fields).toEqual(''); + }); + + it('generates field context', () => { + const fields = generateFieldContext( + ({ + body: { + employee_nested: { + mappings: { + properties: { + comments: { + properties: { + date: { type: 'date' }, + likes: { type: 'long' }, + message: { + type: 'text', + fields: { keyword: { type: 'keyword', ignore_above: 256 } }, + }, + }, + }, + id: { type: 'long' }, + name: { type: 'keyword' }, + projects: { + properties: { + address: { + properties: { city: { type: 'keyword' }, state: { type: 'keyword' } }, + }, + name: { type: 'keyword' }, + started_year: { type: 'long' }, + }, + }, + title: { type: 'keyword' }, + }, + }, + }, + }, + } as unknown) as ApiResponse, + ({ + body: { + took: 0, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + total: { value: 10000, relation: 'gte' }, + max_score: 1, + hits: [ + { + _index: 'employee_nested', + _id: '-cIErYkBQjxNwHvKnmIS', + _score: 1, + _source: { + id: 4, + name: 'Susan Smith', + projects: [], + comments: [ + { date: '2018-06-23', message: 'I love New york', likes: 56 }, + { date: '2017-10-25', message: 'Today is good weather', likes: 22 }, + ], + }, + }, + ], + }, + }, + } as unknown) as ApiResponse> + ); + expect(fields).toEqual( + '- comments.date: date (null)\n- comments.likes: long (null)\n- comments.message: text (null)\n- id: long (4)\n- name: keyword ("Susan Smith")\n- projects.address.city: keyword (null)\n- projects.address.state: keyword (null)\n- projects.name: keyword (null)\n- projects.started_year: long (null)\n- title: keyword (null)' + ); + }); +}); From 070620dfdc88b6753396dbb4674d40ff1ac27ddb Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Fri, 22 Dec 2023 18:43:48 +0000 Subject: [PATCH 75/86] add `observability.query_assist.enabled` config and hide query assist UI if not set Signed-off-by: Joshua Li --- .../components/common/search/date_picker.tsx | 10 +-- .../components/common/search/query_area.tsx | 25 ++++---- public/components/common/search/search.tsx | 64 +++++++++---------- .../event_analytics/explorer/no_results.tsx | 2 +- .../redux/slices/query_slice.ts | 2 +- public/dependencies/register_assistant.tsx | 1 - public/framework/core_refs.ts | 2 +- public/index.ts | 14 ++-- public/plugin.ts | 57 ++++++++++------- server/index.ts | 4 ++ 10 files changed, 93 insertions(+), 88 deletions(-) diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 1dcd1ad43a..fc6110c97d 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -20,15 +20,9 @@ export function DatePicker(props: IDatePickerProps) { const fixedStartTime = 'now-40y'; const fixedEndTime = 'now'; - const handleTimeChange = (e: any) => { - if (coreRefs.assistantEnabled) { - handleTimePickerChange([e.start, e.end]); - } else { - handleTimePickerChange(['now-40y', 'now']); - } - }; + const handleTimeChange = (e: any) => handleTimePickerChange([e.start, e.end]); - return coreRefs.assistantEnabled || isAppAnalytics ? ( + return !coreRefs.queryAssistEnabled || isAppAnalytics ? ( - - - + {coreRefs.queryAssistEnabled && ( + + + + )} ); diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index adb4c9e7cd..426fdb36a0 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -136,9 +136,8 @@ export const Search = (props: any) => { const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); const [isLanguagePopoverOpen, setLanguagePopoverOpen] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [isQueryBarVisible, setIsQueryBarVisible] = useState(!coreRefs.assistantEnabled); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); - const [timeRange, setTimeRange] = useState(['now-5y', 'now']); // default time range + const [timeRange, setTimeRange] = useState(['now-15m', 'now']); // default time range const [needsUpdate, setNeedsUpdate] = useState(false); const [fillRun, setFillRun] = useState(false); const sqlService = new SQLService(coreRefs.http); @@ -309,20 +308,13 @@ export const Search = (props: any) => { ]); const { data: indices, loading: indicesLoading } = useCatIndices(); const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); - const data = + const indicesAndIndexPatterns = indexPatterns && indices ? [...indexPatterns, ...indices].filter( (v1, index, array) => array.findIndex((v2) => v1.label === v2.label) === index ) : undefined; const loading = indicesLoading || indexPatternsLoading; - // HARDCODED INDEXES BELOW - const filteredData = [ - { label: 'opensearch_dashboards_sample_data_ecommerce' }, - { label: 'opensearch_dashboards_sample_data_logs' }, - { label: 'opensearch_dashboards_sample_data_flights' }, - { label: 'sso_logs-*.*' }, - ]; return (
@@ -362,30 +354,34 @@ export const Search = (props: any) => { // onClickAriaLabel={'pplLinkShowFlyout'} /> - - Index} - singleSelection={true} - isLoading={loading} - options={filteredData} - selectedOptions={selectedIndex} - onChange={(index) => { - // clear previous state - batch(() => { - dispatch(reset({ tabId })); - dispatch(resetSummary({ tabId })); - }); - // change the query in the editor to be just source= - const indexQuery = `source = ${index[0].label}`; - handleQueryChange(indexQuery); - // get the fields into the sidebar - getAvailableFields(indexQuery); - setSelectedIndex(index); - }} - /> - + {coreRefs.queryAssistEnabled ? ( + + Index} + singleSelection={true} + isLoading={loading} + options={indicesAndIndexPatterns} + selectedOptions={selectedIndex} + onChange={(index) => { + // clear previous state + batch(() => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); + }); + // change the query in the editor to be just source= + const indexQuery = `source = ${index[0].label}`; + handleQueryChange(indexQuery); + // get the fields into the sidebar + getAvailableFields(indexQuery); + setSelectedIndex(index); + }} + /> + + ) : ( + + )} )} diff --git a/public/components/event_analytics/explorer/no_results.tsx b/public/components/event_analytics/explorer/no_results.tsx index d13622c031..489a72ace2 100644 --- a/public/components/event_analytics/explorer/no_results.tsx +++ b/public/components/event_analytics/explorer/no_results.tsx @@ -24,7 +24,7 @@ export const NoResults = ({ tabId }: any) => { return ( - {coreRefs.assistantEnabled ? ( + {coreRefs.queryAssistEnabled ? ( <> {/* check to see if the rawQuery is empty or not */} {queryInfo?.rawQuery ? ( diff --git a/public/components/event_analytics/redux/slices/query_slice.ts b/public/components/event_analytics/redux/slices/query_slice.ts index f3d307b4f1..bdbbaea607 100644 --- a/public/components/event_analytics/redux/slices/query_slice.ts +++ b/public/components/event_analytics/redux/slices/query_slice.ts @@ -28,7 +28,7 @@ const initialQueryState = { [PATTERN_REGEX]: PPL_DEFAULT_PATTERN_REGEX_FILETER, [FILTERED_PATTERN]: '', [SELECTED_TIMESTAMP]: '', - [SELECTED_DATE_RANGE]: ['now-5y', 'now'], + [SELECTED_DATE_RANGE]: ['now-15m', 'now'], [OLLY_QUERY_ASSISTANT]: '', }; diff --git a/public/dependencies/register_assistant.tsx b/public/dependencies/register_assistant.tsx index 5b50c63078..ebacf4d6bc 100644 --- a/public/dependencies/register_assistant.tsx +++ b/public/dependencies/register_assistant.tsx @@ -14,7 +14,6 @@ import { PPLVisualizationModal } from './components/ppl_visualization_model'; export const registerAsssitantDependencies = (setup?: AssistantSetup) => { if (!setup) return; - coreRefs.assistantEnabled = setup.chatEnabled(); setup.registerContentRenderer('ppl_visualization', (content) => { const params = content as Partial; diff --git a/public/framework/core_refs.ts b/public/framework/core_refs.ts index 4ee99df83d..2ffef84885 100644 --- a/public/framework/core_refs.ts +++ b/public/framework/core_refs.ts @@ -24,7 +24,7 @@ class CoreRefs { public toasts?: IToasts; public chrome?: ChromeStart; public application?: ApplicationStart; - public assistantEnabled?: boolean; + public queryAssistEnabled?: boolean; public dashboard?: DashboardStart; public dashboardProviders?: unknown; private constructor() { diff --git a/public/index.ts b/public/index.ts index a43ed0e205..919af71b25 100644 --- a/public/index.ts +++ b/public/index.ts @@ -3,15 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './variables.scss'; +import { PluginInitializerContext } from '../../../src/core/public'; +import './components/notebooks/index.scss'; import './components/trace_analytics/index.scss'; -import './components/notebooks/index.scss' - -import { PluginInitializer, PluginInitializerContext } from '../../../src/core/public'; -import { - ObservabilityPlugin -} from './plugin'; +import { ObservabilityPlugin } from './plugin'; +import './variables.scss'; export { ObservabilityPlugin as Plugin }; -export const plugin = (initializerContext: PluginInitializerContext) => new ObservabilityPlugin(initializerContext); \ No newline at end of file +export const plugin = (initializerContext: PluginInitializerContext) => + new ObservabilityPlugin(initializerContext); diff --git a/public/plugin.ts b/public/plugin.ts index db3673380f..339b8bb833 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './index.scss'; import { i18n } from '@osd/i18n'; import { AppCategory, @@ -12,37 +11,38 @@ import { CoreStart, DEFAULT_APP_CATEGORIES, Plugin, + PluginInitializerContext, + SavedObject, } from '../../../src/core/public'; import { CREATE_TAB_PARAM, CREATE_TAB_PARAM_KEY, TAB_CHART_ID } from '../common/constants/explorer'; - import { + DATACONNECTIONS_BASE, observabilityApplicationsID, observabilityApplicationsPluginOrder, observabilityApplicationsTitle, - observabilityTracesTitle, + observabilityDataConnectionsID, + observabilityDataConnectionsPluginOrder, + observabilityDataConnectionsTitle, + observabilityIntegrationsID, + observabilityIntegrationsPluginOrder, + observabilityIntegrationsTitle, + observabilityLogsID, + observabilityLogsPluginOrder, + observabilityLogsTitle, observabilityMetricsID, observabilityMetricsPluginOrder, observabilityMetricsTitle, observabilityNotebookID, observabilityNotebookPluginOrder, observabilityNotebookTitle, - observabilityTracesID, - observabilityTracesPluginOrder, observabilityPanelsID, - observabilityPanelsTitle, observabilityPanelsPluginOrder, - observabilityLogsID, - observabilityLogsTitle, - observabilityLogsPluginOrder, - observabilityIntegrationsID, - observabilityIntegrationsTitle, - observabilityIntegrationsPluginOrder, + observabilityPanelsTitle, observabilityPluginOrder, - DATACONNECTIONS_BASE, + observabilityTracesID, + observabilityTracesPluginOrder, + observabilityTracesTitle, S3_DATASOURCE_TYPE, - observabilityDataConnectionsID, - observabilityDataConnectionsPluginOrder, - observabilityDataConnectionsTitle, } from '../common/constants/shared'; import { QueryManager } from '../common/query_manager'; import { VISUALIZATION_SAVED_OBJECT } from '../common/types/observability_saved_object_attributes'; @@ -52,10 +52,11 @@ import { setPPLService, uiSettingsService, } from '../common/utils'; +import { Search } from './components/common/search/search'; +import { DirectSearch } from './components/common/search/sql_search'; import { convertLegacyNotebooksUrl } from './components/notebooks/components/helpers/legacy_route_helpers'; import { convertLegacyTraceAnalyticsUrl } from './components/trace_analytics/components/common/legacy_route_helpers'; -import { SavedObject } from '../../../src/core/public'; -import { coreRefs } from './framework/core_refs'; +import { registerAsssitantDependencies } from './dependencies/register_assistant'; import { OBSERVABILITY_EMBEDDABLE, OBSERVABILITY_EMBEDDABLE_DESCRIPTION, @@ -64,6 +65,9 @@ import { OBSERVABILITY_EMBEDDABLE_ID, } from './embeddable/observability_embeddable'; import { ObservabilityEmbeddableFactoryDefinition } from './embeddable/observability_embeddable_factory'; +import { coreRefs } from './framework/core_refs'; +import { S3DataSource } from './framework/datasources/s3_datasource'; +import { DataSourcePluggable } from './framework/datasource_pluggables/datasource_pluggable'; import './index.scss'; import DSLService from './services/requests/dsl'; import PPLService from './services/requests/ppl'; @@ -75,15 +79,21 @@ import { ObservabilityStart, SetupDependencies, } from './types'; -import { S3DataSource } from './framework/datasources/s3_datasource'; -import { DataSourcePluggable } from './framework/datasource_pluggables/datasource_pluggable'; -import { DirectSearch } from './components/common/search/sql_search'; -import { Search } from './components/common/search/search'; -import { registerAsssitantDependencies } from './dependencies/register_assistant'; + +interface PublicConfig { + query_assist: { + enabled: boolean; + }; +} export class ObservabilityPlugin implements Plugin { + private config: PublicConfig; + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } + public setup( core: CoreSetup, setupDeps: SetupDependencies @@ -325,6 +335,7 @@ export class ObservabilityPlugin coreRefs.dataSources = startDeps.data.dataSources; coreRefs.application = core.application; coreRefs.dashboard = startDeps.dashboard; + coreRefs.queryAssistEnabled = this.config.query_assist.enabled; const { dataSourceService, dataSourceFactory } = startDeps.data.dataSources; diff --git a/server/index.ts b/server/index.ts index c6fa6e3635..5281e7b8ed 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ export { ObservabilityPluginSetup, ObservabilityPluginStart } from './types'; const observabilityConfig = { schema: schema.object({ query_assist: schema.object({ + enabled: schema.boolean({ defaultValue: false }), ppl_agent_id: schema.maybe(schema.string()), response_summary_agent_id: schema.maybe(schema.string()), error_summary_agent_id: schema.maybe(schema.string()), @@ -27,4 +28,7 @@ export type ObservabilityConfig = TypeOf; export const config: PluginConfigDescriptor = { schema: observabilityConfig.schema, + exposeToBrowser: { + query_assist: true, + }, }; From b343245c5933b3046b4375eb409e04138ce9da74 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Fri, 22 Dec 2023 22:53:08 +0000 Subject: [PATCH 76/86] add more unit tests Signed-off-by: Joshua Li --- .../query_assist/__tests__/hooks.test.ts | 79 ++++++++++++- .../query_assist/__tests__/input.test.tsx | 107 ++++++++++++++++++ .../explorer/query_assist/input.tsx | 2 + 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx diff --git a/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts index aa04d960d8..45e910d027 100644 --- a/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts +++ b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts @@ -3,7 +3,84 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { genericReducer } from '../hooks'; +import { renderHook } from '@testing-library/react-hooks'; +import { SavedObjectsFindResponsePublic } from '../../../../../../../../src/core/public'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import * as coreServices from '../../../../../../common/utils/core_services'; +import { coreRefs } from '../../../../../framework/core_refs'; +import { genericReducer, useCatIndices, useGetIndexPatterns } from '../hooks'; + +const coreStartMock = coreMock.createStart(); + +describe('useCatIndices', () => { + const httpMock = coreStartMock.http; + + beforeEach(() => { + jest.spyOn(coreServices, 'getOSDHttp').mockReturnValue(httpMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return indices', async () => { + httpMock.get.mockResolvedValueOnce([{ index: 'test1' }, { index: 'test2' }]); + + const { result, waitForNextUpdate } = renderHook(() => useCatIndices()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual([{ label: 'test1' }, { label: 'test2' }]); + }); + + it('should handle errors', async () => { + httpMock.get.mockRejectedValueOnce('API failed'); + + const { result, waitForNextUpdate } = renderHook(() => useCatIndices()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toEqual('API failed'); + }); +}); + +describe('useGetIndexPatterns', () => { + const savedObjectsClientMock = coreStartMock.savedObjects.client as jest.Mocked< + typeof coreStartMock.savedObjects.client + >; + + beforeAll(() => { + coreRefs.savedObjectsClient = savedObjectsClientMock; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return index patterns', async () => { + savedObjectsClientMock.find.mockResolvedValueOnce({ + savedObjects: [{ attributes: { title: 'test1' } }, { attributes: { title: 'test2' } }], + } as SavedObjectsFindResponsePublic); + + const { result, waitForNextUpdate } = renderHook(() => useGetIndexPatterns()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual([{ label: 'test1' }, { label: 'test2' }]); + }); + + it('should handle errors', async () => { + savedObjectsClientMock.find.mockRejectedValueOnce('API failed'); + + const { result, waitForNextUpdate } = renderHook(() => useGetIndexPatterns()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toEqual('API failed'); + }); +}); describe('genericReducer', () => { it('should return original state', () => { diff --git a/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx b/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx new file mode 100644 index 0000000000..f6a9dd02de --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { Provider } from 'react-redux'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { QUERY_ASSIST_API } from '../../../../../../common/constants/query_assist'; +import * as coreServices from '../../../../../../common/utils/core_services'; +import { coreRefs } from '../../../../../framework/core_refs'; +import { rootReducer } from '../../../../../framework/redux/reducers'; +import { initialTabId } from '../../../../../framework/redux/store/shared_state'; +import { QueryAssistInput } from '../input'; + +const renderQueryAssistInput = ( + overrideProps: Partial> = {} +) => { + const preloadedState = {}; + const store = configureStore({ reducer: rootReducer, preloadedState }); + const props: ComponentProps = Object.assign( + { + handleQueryChange: jest.fn(), + handleTimeRangePickerRefresh: jest.fn(), + tabId: initialTabId, + setNeedsUpdate: jest.fn(), + selectedIndex: [{ label: 'selected-test-index' }], + nlqInput: 'test-input', + setNlqInput: jest.fn(), + }, + overrideProps + ); + const component = render( + + + + ); + return { component, props, store }; +}; + +describe(' spec', () => { + const coreStartMock = coreMock.createStart(); + coreRefs.toasts = coreStartMock.notifications.toasts; + const httpMock = coreStartMock.http; + + beforeEach(() => { + jest.spyOn(coreServices, 'getOSDHttp').mockReturnValue(httpMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call generate ppl based on nlq input value', async () => { + httpMock.post.mockResolvedValueOnce('source = index'); + + const { component, props } = renderQueryAssistInput(); + + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-and-run-button')); + }); + + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.GENERATE_PPL, { + body: '{"question":"test-input","index":"selected-test-index"}', + }); + expect(props.handleQueryChange).toBeCalledWith('source = index'); + }); + + it('should display toast for generate errors', async () => { + httpMock.post.mockRejectedValueOnce({ body: { statusCode: 429 } }); + + const { component } = renderQueryAssistInput(); + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-button')); + }); + + expect(coreRefs.toasts!.addError).toBeCalledWith( + { + message: 'Request is throttled. Try again later or contact your administrator', + statusCode: 429, + }, + { title: 'Failed to generate results' } + ); + }); + + it('should call summarize for generate and run errors', async () => { + httpMock.post.mockRejectedValueOnce({ body: { statusCode: 429 } }).mockResolvedValueOnce({ + summary: 'too many requests', + suggestedQuestions: ['1', '2'], + }); + + const { component } = renderQueryAssistInput(); + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-and-run-button')); + }); + + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.GENERATE_PPL, { + body: '{"question":"test-input","index":"selected-test-index"}', + }); + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.SUMMARIZE, { + body: + '{"question":"test-input","index":"selected-test-index","isError":true,"query":"","response":"{\\"statusCode\\":429}"}', + }); + }); +}); diff --git a/public/components/event_analytics/explorer/query_assist/input.tsx b/public/components/event_analytics/explorer/query_assist/input.tsx index 0eab5893cc..58008819d7 100644 --- a/public/components/event_analytics/explorer/query_assist/input.tsx +++ b/public/components/event_analytics/explorer/query_assist/input.tsx @@ -336,6 +336,7 @@ export const QueryAssistInput: React.FC = (props) => { isDisabled={generating || generatingOrRunning} iconSide="right" fill={false} + data-test-subj="query-assist-generate-button" style={{ width: 160 }} > Generate query @@ -350,6 +351,7 @@ export const QueryAssistInput: React.FC = (props) => { iconSide="right" type="submit" fill={barSelected} + data-test-subj="query-assist-generate-and-run-button" style={{ width: 175 }} > Generate and run From 592e09a5905d23e008886889c730d641a74f6040 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Tue, 9 Jan 2024 19:32:20 -0800 Subject: [PATCH 77/86] small linting complaints Signed-off-by: Paul Sebastian --- public/components/common/search/query_area.tsx | 10 ++++++---- .../explorer/datasources/datasources_selection.tsx | 3 +-- .../explorer/events_views/data_grid.scss | 2 +- server/plugin.ts | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index bd6fc9ea15..ffa486a366 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -4,7 +4,7 @@ */ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { coreRefs } from '../../../framework/core_refs'; import { QueryAssistInput } from '../../event_analytics/explorer/query_assist/input'; import { useFetchEvents } from '../../event_analytics/hooks/use_fetch_events'; @@ -29,11 +29,13 @@ export function QueryArea({ }); // use effect that sets the editor text and populates sidebar field for a particular index upon initialization + const memoizedGetAvailableFields = useMemo(() => getAvailableFields, []); + const memoizedHandleQueryChange = useMemo(() => handleQueryChange, []); useEffect(() => { const indexQuery = `source = ${selectedIndex[0].label}`; - handleQueryChange(indexQuery); - getAvailableFields(indexQuery); - }, [selectedIndex[0]]); + memoizedHandleQueryChange(indexQuery); + memoizedGetAvailableFields(indexQuery); + }, [selectedIndex, memoizedGetAvailableFields, memoizedHandleQueryChange]); return ( diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index d1e53d850b..122e7cc7f8 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -20,10 +20,9 @@ import { reset as resetCountDistribution } from '../../redux/slices/count_distri import { reset as resetFields } from '../../redux/slices/field_slice'; import { reset as resetPatterns } from '../../redux/slices/patterns_slice'; import { reset as resetQueryResults } from '../../redux/slices/query_result_slice'; -import { changeData } from '../../redux/slices/query_slice'; +import { changeData, reset as resetQuery } from '../../redux/slices/query_slice'; import { reset as resetVisualization } from '../../redux/slices/visualization_slice'; import { reset as resetVisConfig } from '../../redux/slices/viualization_config_slice'; -import { reset as resetQuery } from '../../redux/slices/query_slice'; import { DirectQueryRequest, SelectedDataSource } from '../../../../../common/types/explorer'; import { ObservabilityDefaultDataSource } from '../../../../framework/datasources/obs_opensearch_datasource'; import { diff --git a/public/components/event_analytics/explorer/events_views/data_grid.scss b/public/components/event_analytics/explorer/events_views/data_grid.scss index af387c2cd5..207c76ff46 100644 --- a/public/components/event_analytics/explorer/events_views/data_grid.scss +++ b/public/components/event_analytics/explorer/events_views/data_grid.scss @@ -61,7 +61,7 @@ dt { background-color: transparentize(shade($euiColorPrimary, 20%), 0.9); color: $euiTextColor; - padding: ($euiSizeXS / 2) $euiSizeXS; + padding: calc($euiSizeXS / 2) $euiSizeXS; margin-right: $euiSizeXS; word-break: normal; border-radius: $euiBorderRadius; diff --git a/server/plugin.ts b/server/plugin.ts index d67f5aaec7..7d9f40788b 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -134,7 +134,7 @@ export class ObservabilityPlugin }, })); - assistantDashboards?.registerMessageParser(PPLParsers); + // assistantDashboards?.registerMessageParser(PPLParsers); return {}; } From 4b5f78758e2a165952db775a459cac590486596a Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 10 Jan 2024 11:21:17 -0800 Subject: [PATCH 78/86] fix issue with query assist enabled blocking timerange past 15 mins Signed-off-by: Paul Sebastian --- .../components/common/search/date_picker.tsx | 35 ++++++++++--------- public/components/common/search/search.tsx | 7 ++-- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index fc6110c97d..7a2032b333 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -4,7 +4,7 @@ */ import { EuiSuperDatePicker, EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import React, { useEffect } from 'react'; import { uiSettingsService } from '../../../../common/utils'; import { coreRefs } from '../../../framework/core_refs'; import { IDatePickerProps } from './search'; @@ -21,34 +21,35 @@ export function DatePicker(props: IDatePickerProps) { const fixedEndTime = 'now'; const handleTimeChange = (e: any) => handleTimePickerChange([e.start, e.end]); + const allowTimeChanging = !coreRefs.queryAssistEnabled || isAppAnalytics; - return !coreRefs.queryAssistEnabled || isAppAnalytics ? ( - - ) : ( + // set the time range to be 40 years rather than the standard 15 minutes if using query assistant + useEffect(() => { + if (!allowTimeChanging) { + handleTimePickerChange([fixedStartTime, fixedEndTime]); + } + }, []); + + return ( <> diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 426fdb36a0..54b0a42647 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -396,8 +396,11 @@ export const Search = (props: any) => { liveStreamChecked={props.liveStreamChecked} onLiveStreamChange={props.onLiveStreamChange} handleTimePickerChange={(tRange: string[]) => { - // modifies run button to look like the update button, if there is a time change - setNeedsUpdate(!(tRange[0] === startTime && tRange[1] === endTime)); + // modifies run button to look like the update button, if there is a time change, disables timepicker setting update if timepicker is disabled + setNeedsUpdate( + (!coreRefs.queryAssistEnabled || isAppAnalytics) && // keeps statement false if not using query assistant ui + !(tRange[0] === startTime && tRange[1] === endTime) // checks to see if the time given is different from prev + ); // keeps the time range change local, to be used when update pressed setTimeRange(tRange); setStartTime(tRange[0]); From 5e4e7f5fd5203d2190f6f1cef15e842b81d60be6 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 10 Jan 2024 11:42:52 -0800 Subject: [PATCH 79/86] refactor query assist summarization Signed-off-by: Paul Sebastian --- .../search/query_assist_summarization.tsx | 111 ++++++++++++++++++ public/components/common/search/search.tsx | 94 +-------------- 2 files changed, 117 insertions(+), 88 deletions(-) create mode 100644 public/components/common/search/query_assist_summarization.tsx diff --git a/public/components/common/search/query_assist_summarization.tsx b/public/components/common/search/query_assist_summarization.tsx new file mode 100644 index 0000000000..bb7d92e051 --- /dev/null +++ b/public/components/common/search/query_assist_summarization.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiBadge, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiMarkdownFormat, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; +import React from 'react'; + +export function QueryAssistSummarization({ + queryAssistantSummarization, + setNlqInput, + showFlyout, +}: any) { + return ( + + + + + Generated by Opensearch Assistant + + + + + + + } + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + <> + + {queryAssistantSummarization.summary} + + + + + Suggestions: + + {queryAssistantSummarization.suggestedQuestions.map((question) => ( + + setNlqInput(question)} + onClickAriaLabel="Set input to the suggested question" + > + {question} + + + ))} + + + PPL Documentation + + + + + ) : ( + + {queryAssistantSummarization.summary} + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify all information + before using it in any environment or workload. Share feedback via{' '} + + Forum + {' '} + or{' '} + + Slack + + + + + )} + + + ); +} diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 54b0a42647..5f64d2bfb1 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -43,7 +43,6 @@ import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; -import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; import { useCatIndices, useGetIndexPatterns, @@ -65,6 +64,7 @@ import { LiveTailButton } from '../live_tail/live_tail_button'; import { DatePicker } from './date_picker'; import { QueryArea } from './query_area'; import './search.scss'; +import { QueryAssistSummarization } from './query_assist_summarization'; export interface IQueryBarProps { query: string; @@ -502,93 +502,11 @@ export const Search = (props: any) => { {(queryAssistantSummarization?.summary?.length > 0 || queryAssistantSummarization?.summaryLoading) && ( - - - - - Generated by Opensearch Assistant - - - - - - - } - > - {queryAssistantSummarization?.summary?.length > 0 && ( - <> - - {queryAssistantSummarization?.isPPLError ? ( - <> - - - {queryAssistantSummarization.summary} - - - - - - Suggestions: - - {queryAssistantSummarization.suggestedQuestions.map((question) => ( - - setNlqInput(question)} - onClickAriaLabel="Set input to the suggested question" - > - {question} - - - ))} - - - PPL Documentation - - - - - ) : ( - - - {queryAssistantSummarization.summary} - - - )} - - - - The OpenSearch Assistant may produce inaccurate information. Verify all - information before using it in any environment or workload. Share - feedback via{' '} - - Forum - {' '} - or{' '} - - Slack - - - - - )} - - + )} From b6e6993d707227421f05a329330d148573bd20f3 Mon Sep 17 00:00:00 2001 From: Paul Sebastian Date: Wed, 10 Jan 2024 13:02:00 -0800 Subject: [PATCH 80/86] reintroduce old autocorrect feature for non-query-assistant users, fix app analytics not showing query bar, other smaller fixes to match look and feel of old querying Signed-off-by: Paul Sebastian --- public/components/common/search/search.tsx | 68 +++++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 5f64d2bfb1..278919533d 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -13,6 +13,7 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiContextMenuItem, + EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -60,11 +61,12 @@ import { } from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; -import { LiveTailButton } from '../live_tail/live_tail_button'; +import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { DatePicker } from './date_picker'; import { QueryArea } from './query_area'; import './search.scss'; import { QueryAssistSummarization } from './query_assist_summarization'; +import { Autocomplete } from './autocomplete'; export interface IQueryBarProps { query: string; @@ -144,6 +146,8 @@ export const Search = (props: any) => { const { application } = coreRefs; const [nlqInput, setNlqInput] = useState(''); + const showQueryArea = !appLogEvents && coreRefs.queryAssistEnabled; + const { data: pollingResult, loading: pollingLoading, @@ -324,7 +328,7 @@ export const Search = (props: any) => { {appLogEvents && ( - + Base Query @@ -354,7 +358,7 @@ export const Search = (props: any) => { // onClickAriaLabel={'pplLinkShowFlyout'} /> - {coreRefs.queryAssistEnabled ? ( + {coreRefs.queryAssistEnabled && ( { }} /> - ) : ( - )} )} + {!showQueryArea && ( + + { + onQuerySearch(queryLang); + }} + dslService={dslService} + getSuggestions={getSuggestions} + onItemSelect={onItemSelect} + tabId={tabId} + /> + showFlyout()} + onClickAriaLabel={'pplLinkShowFlyout'} + > + PPL + + + )} {!isLiveTailOn && ( @@ -398,7 +433,7 @@ export const Search = (props: any) => { handleTimePickerChange={(tRange: string[]) => { // modifies run button to look like the update button, if there is a time change, disables timepicker setting update if timepicker is disabled setNeedsUpdate( - (!coreRefs.queryAssistEnabled || isAppAnalytics) && // keeps statement false if not using query assistant ui + !showQueryArea && // keeps statement false if using query assistant ui, timepicker shouldn't change run button !(tRange[0] === startTime && tRange[1] === endTime) // checks to see if the time given is different from prev ); // keeps the time range change local, to be used when update pressed @@ -418,13 +453,30 @@ export const Search = (props: any) => { {needsUpdate ? 'Update' : 'Run'} + {!showQueryArea && showSaveButton && !showSavePanelOptionsList && ( + + + + + + )} + {!showQueryArea && isLiveTailOn && ( + + + + )} {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( <> @@ -482,7 +534,7 @@ export const Search = (props: any) => { )} - {!appLogEvents && ( + {showQueryArea && ( <> Date: Wed, 10 Jan 2024 13:08:26 -0800 Subject: [PATCH 81/86] Various fixes and changes (#1350) Signed-off-by: Paul Sebastian * fix issue with query assist enabled blocking timerange past 15 mins Signed-off-by: Paul Sebastian * refactor query assist summarization Signed-off-by: Paul Sebastian * reintroduce old autocorrect feature for non-query-assistant users, fix app analytics not showing query bar, other smaller fixes to match look and feel of old querying Signed-off-by: Paul Sebastian --- .babelrc | 18 - .cypress/.eslintrc.js | 16 + .../integrations_test/integrations.spec.js | 64 +- .eslintrc.js | 28 +- .github/draft-release-notes-config.yml | 17 +- .github/workflows/enforce-labels.yml | 13 + .../ftr-e2e-dashboards-observability-test.yml | 163 + .github/workflows/lint.yml | 39 +- babel.config.js | 26 + common/constants/explorer.ts | 2 +- common/constants/notebooks.ts | 1 - package.json | 9 +- .../__tests__/query_utils.test.tsx | 38 +- public/components/common/query_utils/index.ts | 4 +- .../components/common/search/date_picker.tsx | 35 +- .../components/common/search/query_area.tsx | 10 +- .../search/query_assist_summarization.tsx | 111 + public/components/common/search/search.tsx | 167 +- .../custom_panels/helpers/utils.tsx | 44 +- .../visualization_container.tsx | 18 +- .../datasources/datasources_selection.tsx | 3 +- .../explorer/events_views/data_grid.scss | 2 +- .../create_integration_helpers.test.ts | 135 +- .../components/setup_integration.tsx | 19 +- .../metrics/redux/slices/metrics_slice.ts | 48 +- public/components/metrics/sidebar/sidebar.tsx | 8 +- .../components/metrics/view/metrics_grid.tsx | 16 +- .../__snapshots__/notebook.test.tsx.snap | 425 ++- .../components/__tests__/note_table.test.tsx | 317 +- .../components/__tests__/notebook.test.tsx | 552 ++- .../helpers/__tests__/default_parser.test.tsx | 16 +- .../__tests__/sampleZeppelinNotebooks.tsx | 359 -- .../__tests__/zeppelin_parser.test.tsx | 44 - .../components/helpers/zeppelin_parser.tsx | 149 - .../notebooks/components/note_table.tsx | 7 +- .../notebooks/components/notebook.tsx | 57 +- .../__snapshots__/para_output.test.tsx.snap | 34 +- .../__tests__/para_input.test.tsx | 2 +- .../__tests__/para_output.test.tsx | 67 +- .../__tests__/paragraphs.test.tsx | 10 +- .../paragraph_components/para_output.tsx | 122 +- .../paragraph_components/para_query_grid.tsx | 7 +- .../docs/dev/Zeppelin_backend_adaptor.md | 129 - .../docs/dev/images/zeppelin_architecture.png | Bin 193552 -> 0 bytes .../Introduction Notebook-Zeppelin.json | 1 - .../zeppelin/Log Analysis-Zeppelin.json | 1 - ...rch_Dashboards_Embeddable_Documentation.md | 55 - .../docs/poc/Zeppelin_OpenSearch_Storage.md | 67 - .../poc/docs/Zeppelin_OpenSearch_Storage.md | 73 - .../notebooks/docs/poc/zeppelin-patch | 3057 ----------------- .../notebookrepo/opensearch/pom.xml | 66 - .../notebook/repo/OpenSearchNotebookRepo.java | 219 -- public/services/requests/ppl.ts | 2 +- server/adaptors/notebooks/index.ts | 9 +- server/adaptors/notebooks/zeppelin_backend.ts | 411 --- server/plugin.ts | 2 +- test/jest.config.js | 3 - .../notebooks_constants.ts | 95 +- yarn.lock | 157 +- 59 files changed, 2216 insertions(+), 5353 deletions(-) delete mode 100644 .babelrc create mode 100644 .cypress/.eslintrc.js create mode 100644 .github/workflows/enforce-labels.yml create mode 100644 .github/workflows/ftr-e2e-dashboards-observability-test.yml create mode 100644 babel.config.js create mode 100644 public/components/common/search/query_assist_summarization.tsx delete mode 100644 public/components/notebooks/components/helpers/__tests__/sampleZeppelinNotebooks.tsx delete mode 100644 public/components/notebooks/components/helpers/__tests__/zeppelin_parser.test.tsx delete mode 100644 public/components/notebooks/components/helpers/zeppelin_parser.tsx delete mode 100644 public/components/notebooks/docs/dev/Zeppelin_backend_adaptor.md delete mode 100644 public/components/notebooks/docs/dev/images/zeppelin_architecture.png delete mode 100644 public/components/notebooks/docs/example_notebooks/zeppelin/Introduction Notebook-Zeppelin.json delete mode 100644 public/components/notebooks/docs/example_notebooks/zeppelin/Log Analysis-Zeppelin.json delete mode 100644 public/components/notebooks/docs/poc/OpenSearch_Dashboards_Embeddable_Documentation.md delete mode 100644 public/components/notebooks/docs/poc/Zeppelin_OpenSearch_Storage.md delete mode 100644 public/components/notebooks/docs/poc/docs/Zeppelin_OpenSearch_Storage.md delete mode 100644 public/components/notebooks/docs/poc/zeppelin-patch delete mode 100644 public/components/notebooks/docs/poc/zeppelin/zeppelin-plugins/notebookrepo/opensearch/pom.xml delete mode 100644 public/components/notebooks/docs/poc/zeppelin/zeppelin-plugins/notebookrepo/opensearch/src/main/java/org/apache/zeppelin/notebook/repo/OpenSearchNotebookRepo.java delete mode 100644 server/adaptors/notebooks/zeppelin_backend.ts rename public/components/notebooks/components/helpers/__tests__/sampleDefaultNotebooks.tsx => test/notebooks_constants.ts (76%) diff --git a/.babelrc b/.babelrc deleted file mode 100644 index e21b3f2f2b..0000000000 --- a/.babelrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { "node": "10" } - } - ], - "@babel/preset-react", - "@babel/preset-typescript" - ], - "plugins": [ - "@babel/plugin-transform-modules-commonjs", - ["@babel/plugin-transform-runtime", { "regenerator": true }], - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread" - ] -} \ No newline at end of file diff --git a/.cypress/.eslintrc.js b/.cypress/.eslintrc.js new file mode 100644 index 0000000000..d38ee9d66c --- /dev/null +++ b/.cypress/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + root: true, + extends: ['plugin:cypress/recommended'], + env: { + 'cypress/globals': true, + }, + plugins: ['cypress'], + rules: { + // Add cypress specific rules here + 'cypress/no-assigning-return-values': 'error', + 'cypress/no-unnecessary-waiting': 'error', + 'cypress/assertion-before-screenshot': 'warn', + 'cypress/no-force': 'warn', + 'cypress/no-async-tests': 'error', + }, +}; diff --git a/.cypress/integration/integrations_test/integrations.spec.js b/.cypress/integration/integrations_test/integrations.spec.js index 4b446f26fa..f1281a795a 100644 --- a/.cypress/integration/integrations_test/integrations.spec.js +++ b/.cypress/integration/integrations_test/integrations.spec.js @@ -2,15 +2,14 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-disable jest/expect-expect */ /// -import { - TEST_INTEGRATION_INSTANCE, TEST_SAMPLE_INSTANCE, -} from '../../utils/constants'; +import { TEST_INTEGRATION_INSTANCE, TEST_SAMPLE_INSTANCE } from '../../utils/constants'; -let testInstanceSuffix = (Math.random() + 1).toString(36).substring(7); -let testInstance = `${TEST_INTEGRATION_INSTANCE}_${testInstanceSuffix}`; +const testInstanceSuffix = (Math.random() + 1).toString(36).substring(7); +const testInstance = `${TEST_INTEGRATION_INSTANCE}_${testInstanceSuffix}`; const moveToIntegrationsHome = () => { cy.visit(`${Cypress.env('opensearchDashboards')}/app/integrations#/available`); @@ -28,10 +27,9 @@ const createSamples = () => { moveToAvailableNginxIntegration(); cy.get('[data-test-subj="try-it-button"]').click(); cy.get('.euiToastHeader__title').should('contain', 'successfully'); -} - +}; -describe('Basic sanity test for integrations plugin', () => { +describe('Integrations plugin', () => { it('Navigates to integrations plugin and expects the correct header', () => { moveToIntegrationsHome(); cy.get('[data-test-subj="integrations-header"]').should('exist'); @@ -40,66 +38,66 @@ describe('Basic sanity test for integrations plugin', () => { it('Navigates to integrations plugin and tests that clicking the nginx cards navigates to the nginx page', () => { moveToIntegrationsHome(); cy.get('[data-test-subj="integration_card_nginx"]').click(); - cy.url().should('include', '/available/nginx') - }) + cy.url().should('include', '/available/nginx'); + }); it('Navigates to nginx page and asserts the page to be as expected', () => { moveToAvailableNginxIntegration(); - cy.get('[data-test-subj="nginx-overview"]').should('exist') - cy.get('[data-test-subj="nginx-details"]').should('exist') - cy.get('[data-test-subj="nginx-screenshots"]').should('exist') - cy.get('[data-test-subj="nginx-assets"]').should('exist') + cy.get('[data-test-subj="nginx-overview"]').should('exist'); + cy.get('[data-test-subj="nginx-details"]').should('exist'); + cy.get('[data-test-subj="nginx-screenshots"]').should('exist'); + cy.get('[data-test-subj="nginx-assets"]').should('exist'); cy.get('[data-test-subj="fields"]').click(); - cy.get('[data-test-subj="nginx-fields"]').should('exist') - }) + cy.get('[data-test-subj="nginx-fields"]').should('exist'); + }); it('Uses the search of assets and fields tables', () => { moveToAvailableNginxIntegration(); cy.get('input[type="search"]').eq(0).focus().type('ss4o{enter}'); - cy.get('.euiTableRow').should('have.length', 1);//Filters correctly to the index pattern + cy.get('.euiTableRow').should('have.length', 1); //Filters correctly to the index pattern cy.get('[data-test-subj="fields"]').click(); - cy.get('input[type="search"]').eq(0).focus().clear().type('severity.observe') - cy.get('.euiTableRow').should('have.length', 2);//Filters correctly to the field name - }) + cy.get('input[type="search"]').eq(0).focus().clear().type('severity.observe'); + cy.get('.euiTableRow').should('have.length', 2); //Filters correctly to the field name + }); it('Uses the filter of assets table', () => { moveToAvailableNginxIntegration(); cy.get('.euiFilterGroup').trigger('mouseover').click(); cy.get('.euiFilterSelectItem').contains('visualization').click(); - cy.get('.euiTableRow').should('have.length', 6);//Filters correctly to visualization types - }) + cy.get('.euiTableRow').should('have.length', 6); //Filters correctly to visualization types + }); }); -describe('Tests the add nginx integration instance flow', () => { +describe('Add nginx integration instance flow', () => { it('Navigates to nginx page and triggers the adds the instance flow', () => { createSamples(); moveToAvailableNginxIntegration(); cy.get('[data-test-subj="add-integration-button"]').click(); cy.get('[data-test-subj="new-instance-name"]').should('have.value', 'nginx Integration'); - cy.get('[data-test-subj="create-instance-button"]').should('be.disabled') + cy.get('[data-test-subj="create-instance-button"]').should('be.disabled'); // Modifies the name of the integration cy.get('[data-test-subj="new-instance-name"]').clear().type(testInstance); // Validates the created sample index cy.get('[data-test-subj="data-source-name"]').type('ss4o_logs-nginx-sample-sample{enter}'); cy.get('[data-test-subj="create-instance-button"]').click(); cy.get('[data-test-subj="eventHomePageTitle"]').should('contain', 'nginx'); - }) + }); it('Navigates to installed integrations page and verifies that nginx-test exists', () => { moveToAddedIntegrations(); cy.contains(testInstance).should('exist'); cy.get('input[type="search"]').eq(0).focus().type(`${testInstance}{enter}`); - cy.get('.euiTableRow').should('have.length', 1);//Filters correctly to the test integration instance + cy.get('.euiTableRow').should('have.length', 1); //Filters correctly to the test integration instance cy.get(`[data-test-subj="${testInstance}IntegrationLink"]`).click(); - }) + }); it('Navigates to added integrations page and verifies that nginx-test exists and linked asset works as expected', () => { moveToAddedIntegrations(); cy.contains(TEST_INTEGRATION_INSTANCE).should('exist'); cy.get(`[data-test-subj="${testInstance}IntegrationLink"]`).click(); cy.get(`[data-test-subj="IntegrationAssetLink"]`).click(); - cy.url().should('include', '/dashboards#/') - }) + cy.url().should('include', '/dashboards#/'); + }); it('Navigates to installed nginx-test instance page and deletes it', () => { moveToAddedIntegrations(); @@ -115,17 +113,15 @@ describe('Tests the add nginx integration instance flow', () => { cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('not.be.disabled'); cy.get('button[data-test-subj="popoverModal__deleteButton"]').click(); cy.get('.euiToastHeader__title').should('contain', 'successfully'); - }) + }); }); -describe('Tests the add nginx integration instance flow', () => { +describe('Nginx try it flow', () => { it('Navigates to nginx page and triggers the try it flow', () => { moveToAvailableNginxIntegration(); cy.get('[data-test-subj="try-it-button"]').click(); cy.get('.euiToastHeader__title').should('contain', 'successfully'); moveToAddedIntegrations(); cy.contains(TEST_SAMPLE_INSTANCE).should('exist'); - }) + }); }); - - diff --git a/.eslintrc.js b/.eslintrc.js index 6ba689e332..12f093d2ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,18 +14,19 @@ module.exports = { '@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended', 'plugin:react-hooks/recommended', - "eslint:recommended", - "plugin:cypress/recommended", - "plugin:import/recommended", - "prettier" - ], - env: { - 'cypress/globals': true, - }, - plugins: [ - 'cypress', + 'plugin:jest/recommended', + 'plugin:prettier/recommended', ], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], '@osd/eslint/no-restricted-paths': [ 'error', { @@ -38,17 +39,12 @@ module.exports = { ], }, ], - // Add cypress specific rules here - 'cypress/no-assigning-return-values': 'error', - 'cypress/no-unnecessary-waiting': 'error', - 'cypress/assertion-before-screenshot': 'warn', - 'cypress/no-force': 'warn', - 'cypress/no-async-tests': 'error', }, overrides: [ { files: ['**/*.{js,ts,tsx}'], rules: { + '@typescript-eslint/no-explicit-any': 'warn', 'no-console': 0, '@osd/eslint/require-license-header': [ 'error', diff --git a/.github/draft-release-notes-config.yml b/.github/draft-release-notes-config.yml index 371f1b065e..34b199cbf7 100644 --- a/.github/draft-release-notes-config.yml +++ b/.github/draft-release-notes-config.yml @@ -16,30 +16,23 @@ replacers: categories: - title: 'Breaking Changes' labels: - - 'Breaking Changes' + - 'breaking' - title: 'Features' labels: - 'feature' - - title: 'Enhancements' - labels: - 'enhancement' - title: 'Bug Fixes' labels: - 'bug' - title: 'Infrastructure' labels: - - 'infra' - - 'test' - - 'dependencies' - - 'github actions' + - 'infrastructure' + - 'testing' + - 'integ-test-failure' + - 'repository' - title: 'Documentation' labels: - 'documentation' - title: 'Maintenance' labels: - - "version compatibility" - "maintenance" - - title: 'Refactoring' - labels: - - 'refactor' - - 'code quality' diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml new file mode 100644 index 0000000000..71d923f66d --- /dev/null +++ b/.github/workflows/enforce-labels.yml @@ -0,0 +1,13 @@ +name: Enforce PR labels + +on: + pull_request: + types: [labeled, unlabeled, opened, edited, synchronize] +jobs: + enforce-label: + runs-on: ubuntu-latest + steps: + - uses: yogevbd/enforce-label-action@2.1.0 + with: + REQUIRED_LABELS_ANY: "breaking,feature,enhancement,bug,infrastructure,dependencies,documentation,maintenance,skip-changelog" + REQUIRED_LABELS_ANY_DESCRIPTION: "A release label is required: ['breaking', 'bug', 'dependencies', 'documentation', 'enhancement', 'feature', 'infrastructure', 'maintenance', 'skip-changelog']" diff --git a/.github/workflows/ftr-e2e-dashboards-observability-test.yml b/.github/workflows/ftr-e2e-dashboards-observability-test.yml new file mode 100644 index 0000000000..2e8a133d01 --- /dev/null +++ b/.github/workflows/ftr-e2e-dashboards-observability-test.yml @@ -0,0 +1,163 @@ +name: FTR E2E Dashboards observability Test + +on: [pull_request, push] + +env: + PLUGIN_NAME: dashboards-observability + OPENSEARCH_DASHBOARDS_VERSION: 'main' + OPENSEARCH_VERSION: '3.0.0' + OPENSEARCH_PLUGIN_VERSION: '3.0.0.0' + +jobs: + tests: + name: Run FTR E2E Dashboards Observability Tests + env: + # Prevents extra Cypress installation progress messages + CI: 1 + # Avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + WORKING_DIR: ${{ matrix.working_directory }}. + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + java: [11] + runs-on: ${{ matrix.os }} + + steps: + - name: Set up Java 11 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '11' + + - name: Download observability artifact + uses: suisei-cn/actions-download-file@v1.4.0 + with: + url: https://aws.oss.sonatype.org/service/local/artifact/maven/redirect?r=snapshots&g=org.opensearch.plugin&a=opensearch-observability&v=${{ env.OPENSEARCH_PLUGIN_VERSION }}-SNAPSHOT&p=zip + target: plugin-artifacts/ + filename: observability.zip + + - name: Download SQL artifact + uses: suisei-cn/actions-download-file@v1.4.0 + with: + url: https://aws.oss.sonatype.org/service/local/artifact/maven/redirect?r=snapshots&g=org.opensearch.plugin&a=opensearch-sql-plugin&v=${{ env.OPENSEARCH_PLUGIN_VERSION }}-SNAPSHOT&p=zip + target: plugin-artifacts/ + filename: sql.zip + + - name: Download OpenSearch + uses: peternied/download-file@v2 + with: + url: https://artifacts.opensearch.org/snapshots/core/opensearch/${{ env.OPENSEARCH_VERSION }}-SNAPSHOT/opensearch-min-${{ env.OPENSEARCH_VERSION }}-SNAPSHOT-linux-x64-latest.tar.gz + + - name: Extract OpenSearch + run: | + tar -xzf opensearch-*.tar.gz + rm -f opensearch-*.tar.gz + shell: bash + + - name: Install observability plugin + run: | + /bin/bash -c "yes | ./opensearch-${{ env.OPENSEARCH_VERSION }}-SNAPSHOT/bin/opensearch-plugin install file:$(pwd)/plugin-artifacts/observability.zip" + shell: bash + + - name: Install SQL plugin + run: | + /bin/bash -c "yes | ./opensearch-${{ env.OPENSEARCH_VERSION }}-SNAPSHOT/bin/opensearch-plugin install file:$(pwd)/plugin-artifacts/sql.zip" + shell: bash + + - name: Run OpenSearch + run: /bin/bash -c "./opensearch-${{ env.OPENSEARCH_VERSION }}-SNAPSHOT/bin/opensearch &" + shell: bash + + - name: Checkout OpenSearch Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/Opensearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + + - name: Checkout dashboards observability + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/dashboards-observability + + - name: Get node and yarn versions + working-directory: ${{ env.WORKING_DIR }} + id: versions_step + run: | + echo "::set-output name=node_version::$(cat ./OpenSearch-Dashboards/.nvmrc | cut -d"." -f1)" + echo "::set-output name=yarn_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.yarn).match(/[.0-9]+/)[0]")" + + - name: Setup node + uses: actions/setup-node@v1 + with: + node-version: ${{ steps.versions_step.outputs.node_version }} + registry-url: 'https://registry.npmjs.org' + + - name: Install correct yarn version for OpenSearch Dashboards + run: | + npm uninstall -g yarn + echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" + npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} + + - name: Bootstrap the plugin + run: | + cd OpenSearch-Dashboards/plugins/dashboards-observability + yarn osd bootstrap + + - name: Run OpenSearch Dashboards server + run: | + cd OpenSearch-Dashboards + nohup yarn start --no-base-path --no-watch | tee dashboard.log & + + - name : Check If OpenSearch Dashboards Is Ready + if: ${{ runner.os == 'Linux' }} + run: | + cd ./OpenSearch-Dashboards + if timeout 600 grep -q "bundles compiled successfully after" <(tail -n0 -f dashboard.log); then + echo "OpenSearch Dashboards compiled successfully." + else + echo "Timeout for 600 seconds reached. OpenSearch Dashboards did not finish compiling." + exit 1 + fi + + - name: Checkout Dashboards Functioanl Test Repo + uses: actions/checkout@v2 + with: + path: opensearch-dashboards-functional-test + repository: opensearch-project/opensearch-dashboards-functional-test + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + fetch-depth: 0 + + - name: Install Cypress + run: | + npm install cypress --save-dev + shell: bash + working-directory: opensearch-dashboards-functional-test + + - name: Get Cypress version + id: cypress_version + run: | + echo "::set-output name=cypress_version::$(cat ./package.json | jq '.dependencies.cypress' | tr -d '"')" + working-directory: opensearch-dashboards-functional-test + + - name: Run Cypress tests + run: | + yarn cypress:run-without-security --browser chromium --spec 'cypress/integration/plugins/observability-dashboards/*.js' + working-directory: opensearch-dashboards-functional-test + + - name: Capture failure screenshots + uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-screenshots-${{ matrix.os }} + path: opensearch-dashboards-functional-test/cypress/screenshots + + - name: Capture failure test video + uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-videos-${{ matrix.os }} + path: opensearch-dashboards-functional-test/cypress/videos diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d66abe5366..f26802c597 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,10 +1,10 @@ name: Lint -on: [push, pull_request] +on: [pull_request] env: PLUGIN_NAME: dashboards-observability - OPENSEARCH_DASHBOARDS_VERSION: 'main' + OPENSEARCH_DASHBOARDS_VERSION: "main" jobs: build: @@ -22,7 +22,8 @@ jobs: - name: Checkout dashboards observability uses: actions/checkout@v2 with: - path: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} + path: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} + fetch-depth: 0 - name: Get node and yarn versions working-directory: ${{ env.WORKING_DIR }} @@ -35,7 +36,7 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ steps.versions_step.outputs.node_version }} - registry-url: 'https://registry.npmjs.org' + registry-url: "https://registry.npmjs.org" - name: Install correct yarn version for OpenSearch Dashboards run: | @@ -44,18 +45,28 @@ jobs: npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} - name: Bootstrap the plugin - working-directory: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} - run: - yarn osd bootstrap + working-directory: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} + run: yarn osd bootstrap - - name: lint code base + - name: Get list of changed files + id: files + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + git fetch origin $BASE_SHA + git diff --name-only $BASE_SHA...$HEAD_SHA > changed_files.txt + CHANGED_FILES=$(cat changed_files.txt | grep -E '\.(js|ts|tsx)$' || true) + echo "::set-output name=changed::${CHANGED_FILES}" working-directory: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} + + - name: Lint Changed Files run: | - git fetch origin main - CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB origin/main | grep -E "\.(js|ts|tsx)$") - if [ -n "$CHANGED_FILES" ]; then + CHANGED_FILES="${{ steps.files.outputs.changed }}" + if [[ -n "$CHANGED_FILES" ]]; then echo "Linting changed files..." - yarn lint $CHANGED_FILES + IFS=$'\n' read -r -a FILES_TO_LINT <<< "$CHANGED_FILES" + yarn lint "${FILES_TO_LINT[@]}" else - echo "No JavaScript/TypeScript files changed." - fi \ No newline at end of file + echo "No matched files to lint." + fi + working-directory: OpenSearch-Dashboards/plugins/${{ env.PLUGIN_NAME }} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..3805f7cf5d --- /dev/null +++ b/babel.config.js @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// babelrc doesn't respect NODE_PATH anymore but using require does. +// Alternative to install them locally in node_modules +module.exports = function (api) { + // ensure env is test so that this config won't impact build or dev server + if (api.env('test')) { + return { + presets: [ + require('@babel/preset-env'), + require('@babel/preset-react'), + require('@babel/preset-typescript'), + ], + plugins: [ + [require('@babel/plugin-transform-runtime'), { regenerator: true }], + require('@babel/plugin-transform-class-properties'), + require('@babel/plugin-transform-object-rest-spread'), + [require('@babel/plugin-transform-modules-commonjs'), { allowTopLevelThis: true }], + ], + }; + } + return {}; +}; diff --git a/common/constants/explorer.ts b/common/constants/explorer.ts index b6c2051259..d94957c3e8 100644 --- a/common/constants/explorer.ts +++ b/common/constants/explorer.ts @@ -9,7 +9,7 @@ import { VIS_CHART_TYPES } from './shared'; // URLs export const EVENT_ANALYTICS_DOCUMENTATION_URL = - 'https://opensearch.org/docs/latest/observability-plugin/event-analytics/'; + 'https://opensearch.org/docs/latest/observing-your-data/event-analytics/'; export const OPEN_TELEMETRY_LOG_CORRELATION_LINK = 'https://opentelemetry.io/docs/reference/specification/logs/overview/#log-correlation'; export const LOG_EXPLORER_BASE_PATH = 'observability-logs#/explorer/'; diff --git a/common/constants/notebooks.ts b/common/constants/notebooks.ts index 2ece3c29e7..c5eb07348c 100644 --- a/common/constants/notebooks.ts +++ b/common/constants/notebooks.ts @@ -4,7 +4,6 @@ */ export const NOTEBOOKS_API_PREFIX = '/api/observability/notebooks'; -export const NOTEBOOKS_SELECTED_BACKEND: 'ZEPPELIN' | 'DEFAULT' = 'DEFAULT'; export const NOTEBOOKS_FETCH_SIZE = 1000; export const CREATE_NOTE_MESSAGE = 'Enter a name to describe the purpose of this notebook.'; export const NOTEBOOKS_DOCUMENTATION_URL = diff --git a/package.json b/package.json index 3929fcd37d..079c09def7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "performance-now": "^2.1.0", "plotly.js-dist": "^2.2.0", "postinstall": "^0.7.4", - "react-graph-vis": "^1.0.5", + "react-graph-vis": "^1.0.7", "react-paginate": "^8.1.3", "react-plotly.js": "^2.5.1", "redux-persist": "^6.0.0", @@ -50,16 +50,15 @@ "@types/enzyme-adapter-react-16": "^1.0.6", "@types/mime": "^3.0.1", "@types/react-plotly.js": "^2.5.0", - "@types/react-test-renderer": "^16.9.1", + "@types/react-test-renderer": "^18.0.0", "@types/sanitize-filename": "^1.6.3", "antlr4ts-cli": "^0.5.0-alpha.4", - "cypress": "^12.8.1", + "cypress": "^13.6.0", "cypress-watch-and-reload": "^1.10.6", "eslint": "^6.8.0", "husky": "^8.0.3", "jest-dom": "^4.0.0", - "lint-staged": "^13.1.0", - "ts-jest": "^29.1.0" + "lint-staged": "^13.1.0" }, "resolutions": { "react-syntax-highlighter": "^15.4.3", diff --git a/public/components/common/query_utils/__tests__/query_utils.test.tsx b/public/components/common/query_utils/__tests__/query_utils.test.tsx index 2db7db97d3..4119da5604 100644 --- a/public/components/common/query_utils/__tests__/query_utils.test.tsx +++ b/public/components/common/query_utils/__tests__/query_utils.test.tsx @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; import { + convertDateTime, findMinInterval, parsePromQLIntoKeywords, preprocessMetricQuery, @@ -61,6 +61,42 @@ describe('Query Utils', () => { expect(minInterval).toEqual(span); }); }); + describe('convertDateTime', () => { + it('converts from absolute timestamp', () => { + const time = '2020-07-21T18:37:44.710Z'; + const converted = convertDateTime(time); + expect(converted).toEqual('2020-07-21 18:37:44.710000'); + }); + it('formats to PPL standard format when default formatting', () => { + const time = '2020-07-21T18:37:44.710Z'; + const converted = convertDateTime(time, true, true); + expect(converted).toEqual('2020-07-21 18:37:44.710000'); + }); + it('formats to specified format when provided', () => { + const time = '2020-07-21T18:37:44.710Z'; + const converted = convertDateTime(time, true, 'YYYY-MMM-DD'); + expect(converted).toMatch(/2020-jul-21/i); + }); + describe('with moment reference notations', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-02-02 12:01:00')); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('converts named-reference, rounded', () => { + const time = 'now-1d/d'; + const converted = convertDateTime(time, true); + expect(converted).toEqual('2020-02-01 00:00:00.000000'); + }); + it.skip('converts named-reference, rounded as end of interval', () => { + const time = 'now/d'; + const converted = convertDateTime(time); + expect(converted).toEqual('2020-02-02 23:59:59.999999'); + }); + }); + }); describe('Metric Query processors', () => { const defaultQueryMetaData = { catalogSourceName: 'my_catalog', diff --git a/public/components/common/query_utils/index.ts b/public/components/common/query_utils/index.ts index 489afad130..eccd6b9ddd 100644 --- a/public/components/common/query_utils/index.ts +++ b/public/components/common/query_utils/index.ts @@ -74,7 +74,9 @@ export const convertDateTime = ( const epochTime = myDate.getTime() / 1000.0; return Math.round(epochTime); } - if (formatted) return returnTime!.utc().format(PPL_DATE_FORMAT); + if (formatted === true) return returnTime?.utc()?.format(PPL_DATE_FORMAT); + if (formatted) return returnTime?.utc()?.format(formatted); + return returnTime; }; diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index fc6110c97d..7a2032b333 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -4,7 +4,7 @@ */ import { EuiSuperDatePicker, EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import React, { useEffect } from 'react'; import { uiSettingsService } from '../../../../common/utils'; import { coreRefs } from '../../../framework/core_refs'; import { IDatePickerProps } from './search'; @@ -21,34 +21,35 @@ export function DatePicker(props: IDatePickerProps) { const fixedEndTime = 'now'; const handleTimeChange = (e: any) => handleTimePickerChange([e.start, e.end]); + const allowTimeChanging = !coreRefs.queryAssistEnabled || isAppAnalytics; - return !coreRefs.queryAssistEnabled || isAppAnalytics ? ( - - ) : ( + // set the time range to be 40 years rather than the standard 15 minutes if using query assistant + useEffect(() => { + if (!allowTimeChanging) { + handleTimePickerChange([fixedStartTime, fixedEndTime]); + } + }, []); + + return ( <> diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx index bd6fc9ea15..ffa486a366 100644 --- a/public/components/common/search/query_area.tsx +++ b/public/components/common/search/query_area.tsx @@ -4,7 +4,7 @@ */ import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { coreRefs } from '../../../framework/core_refs'; import { QueryAssistInput } from '../../event_analytics/explorer/query_assist/input'; import { useFetchEvents } from '../../event_analytics/hooks/use_fetch_events'; @@ -29,11 +29,13 @@ export function QueryArea({ }); // use effect that sets the editor text and populates sidebar field for a particular index upon initialization + const memoizedGetAvailableFields = useMemo(() => getAvailableFields, []); + const memoizedHandleQueryChange = useMemo(() => handleQueryChange, []); useEffect(() => { const indexQuery = `source = ${selectedIndex[0].label}`; - handleQueryChange(indexQuery); - getAvailableFields(indexQuery); - }, [selectedIndex[0]]); + memoizedHandleQueryChange(indexQuery); + memoizedGetAvailableFields(indexQuery); + }, [selectedIndex, memoizedGetAvailableFields, memoizedHandleQueryChange]); return ( diff --git a/public/components/common/search/query_assist_summarization.tsx b/public/components/common/search/query_assist_summarization.tsx new file mode 100644 index 0000000000..bb7d92e051 --- /dev/null +++ b/public/components/common/search/query_assist_summarization.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiBadge, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiMarkdownFormat, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; +import React from 'react'; + +export function QueryAssistSummarization({ + queryAssistantSummarization, + setNlqInput, + showFlyout, +}: any) { + return ( + + + + + Generated by Opensearch Assistant + + + + + + + } + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + <> + + {queryAssistantSummarization.summary} + + + + + Suggestions: + + {queryAssistantSummarization.suggestedQuestions.map((question) => ( + + setNlqInput(question)} + onClickAriaLabel="Set input to the suggested question" + > + {question} + + + ))} + + + PPL Documentation + + + + + ) : ( + + {queryAssistantSummarization.summary} + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify all information + before using it in any environment or workload. Share feedback via{' '} + + Forum + {' '} + or{' '} + + Slack + + + + + )} + + + ); +} diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 426fdb36a0..278919533d 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -13,6 +13,7 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiContextMenuItem, + EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -43,7 +44,6 @@ import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; -import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; import { useCatIndices, useGetIndexPatterns, @@ -61,10 +61,12 @@ import { } from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; -import { LiveTailButton } from '../live_tail/live_tail_button'; +import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; import { DatePicker } from './date_picker'; import { QueryArea } from './query_area'; import './search.scss'; +import { QueryAssistSummarization } from './query_assist_summarization'; +import { Autocomplete } from './autocomplete'; export interface IQueryBarProps { query: string; @@ -144,6 +146,8 @@ export const Search = (props: any) => { const { application } = coreRefs; const [nlqInput, setNlqInput] = useState(''); + const showQueryArea = !appLogEvents && coreRefs.queryAssistEnabled; + const { data: pollingResult, loading: pollingLoading, @@ -324,7 +328,7 @@ export const Search = (props: any) => { {appLogEvents && ( - + Base Query @@ -354,7 +358,7 @@ export const Search = (props: any) => { // onClickAriaLabel={'pplLinkShowFlyout'} /> - {coreRefs.queryAssistEnabled ? ( + {coreRefs.queryAssistEnabled && ( { }} /> - ) : ( - )} )} + {!showQueryArea && ( + + { + onQuerySearch(queryLang); + }} + dslService={dslService} + getSuggestions={getSuggestions} + onItemSelect={onItemSelect} + tabId={tabId} + /> + showFlyout()} + onClickAriaLabel={'pplLinkShowFlyout'} + > + PPL + + + )} {!isLiveTailOn && ( @@ -396,8 +431,11 @@ export const Search = (props: any) => { liveStreamChecked={props.liveStreamChecked} onLiveStreamChange={props.onLiveStreamChange} handleTimePickerChange={(tRange: string[]) => { - // modifies run button to look like the update button, if there is a time change - setNeedsUpdate(!(tRange[0] === startTime && tRange[1] === endTime)); + // modifies run button to look like the update button, if there is a time change, disables timepicker setting update if timepicker is disabled + setNeedsUpdate( + !showQueryArea && // keeps statement false if using query assistant ui, timepicker shouldn't change run button + !(tRange[0] === startTime && tRange[1] === endTime) // checks to see if the time given is different from prev + ); // keeps the time range change local, to be used when update pressed setTimeRange(tRange); setStartTime(tRange[0]); @@ -415,13 +453,30 @@ export const Search = (props: any) => { {needsUpdate ? 'Update' : 'Run'} + {!showQueryArea && showSaveButton && !showSavePanelOptionsList && ( + + + + + + )} + {!showQueryArea && isLiveTailOn && ( + + + + )} {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( <> @@ -479,7 +534,7 @@ export const Search = (props: any) => { )} - {!appLogEvents && ( + {showQueryArea && ( <> { {(queryAssistantSummarization?.summary?.length > 0 || queryAssistantSummarization?.summaryLoading) && ( - - - - - Generated by Opensearch Assistant - - - - - - - } - > - {queryAssistantSummarization?.summary?.length > 0 && ( - <> - - {queryAssistantSummarization?.isPPLError ? ( - <> - - - {queryAssistantSummarization.summary} - - - - - - Suggestions: - - {queryAssistantSummarization.suggestedQuestions.map((question) => ( - - setNlqInput(question)} - onClickAriaLabel="Set input to the suggested question" - > - {question} - - - ))} - - - PPL Documentation - - - - - ) : ( - - - {queryAssistantSummarization.summary} - - - )} - - - - The OpenSearch Assistant may produce inaccurate information. Verify all - information before using it in any environment or workload. Share - feedback via{' '} - - Forum - {' '} - or{' '} - - Slack - - - - - )} - - + )} diff --git a/public/components/custom_panels/helpers/utils.tsx b/public/components/custom_panels/helpers/utils.tsx index 9abc0d8d7b..e06c795df7 100644 --- a/public/components/custom_panels/helpers/utils.tsx +++ b/public/components/custom_panels/helpers/utils.tsx @@ -259,25 +259,34 @@ const dynamicLayoutFromQueryData = (queryData) => { }; }; -const createCatalogVisualizationMetaData = ( - catalogSource: string, - visualizationQuery: string, - visualizationType: string, - visualizationTimeField: string, - queryData: object -) => { +const createCatalogVisualizationMetaData = ({ + catalogSource, + query, + type, + subType, + timeField, + queryData, +}: { + catalogSource: string; + query: string; + type: string; + subType: string; + timeField: string; + queryData: object; +}) => { return { name: catalogSource, description: '', - query: visualizationQuery, - type: visualizationType, + query, + type, + subType, selected_date_range: { start: 'now/y', end: 'now', text: '', }, selected_timestamp: { - name: visualizationTimeField, + name: timeField, type: 'timestamp', }, selected_fields: { @@ -332,6 +341,7 @@ export const renderCatalogVisualization = async ({ const visualizationType = 'line'; const visualizationTimeField = '@timestamp'; + const visualizationSubType = visualization.subType; const visualizationQuery = updateCatalogVisualizationQuery({ ...visualization.queryMetaData, @@ -357,15 +367,15 @@ export const renderCatalogVisualization = async ({ ); setVisualizationData(queryData); - const visualizationMetaData = createCatalogVisualizationMetaData( + const visualizationMetaData = createCatalogVisualizationMetaData({ catalogSource, - visualizationQuery, - visualizationType, - visualizationTimeField, - queryData - ); + query: visualizationQuery, + type: visualizationType, + subType: visualization.subType, + timeField: visualizationTimeField, + queryData, + }); - console.log('renderCatalogVisualization', { visualizationMetaData }); setVisualizationMetaData(visualizationMetaData); } catch (error) { setIsError({ error }); diff --git a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx index 3d52f9eddd..6cc87f8e02 100644 --- a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx +++ b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx @@ -37,7 +37,10 @@ import './visualization_container.scss'; import { VizContainerError } from '../../../../../common/types/custom_panels'; import { metricQuerySelector } from '../../../metrics/redux/slices/metrics_slice'; import { coreRefs } from '../../../../framework/core_refs'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../../common/constants/shared'; +import { + PROMQL_METRIC_SUBTYPE, + observabilityMetricsID, +} from '../../../../../common/constants/shared'; /* * Visualization container - This module is a placeholder to add visualizations in react-grid-layout @@ -78,6 +81,7 @@ interface Props { removeVisualization?: (visualizationId: string) => void; catalogVisualization?: boolean; inlineEditor?: JSX.Element; + actionMenuType?: string; } export const VisualizationContainer = ({ @@ -98,6 +102,7 @@ export const VisualizationContainer = ({ removeVisualization, catalogVisualization, inlineEditor, + actionMenuType, }: Props) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [visualizationTitle, setVisualizationTitle] = useState(''); @@ -175,7 +180,11 @@ export const VisualizationContainer = ({ disabled={editMode} onClick={() => { closeActionsMenu(); - onEditClick(savedVisualizationId); + if (visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE) { + window.location.assign(`${observabilityMetricsID}#/${savedVisualizationId}`); + } else { + onEditClick(savedVisualizationId); + } }} > Edit @@ -217,7 +226,10 @@ export const VisualizationContainer = ({ , ]; - if (visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE) { + if ( + visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE && + actionMenuType === 'metricsGrid' + ) { popoverPanel = [showPPLQueryPanel]; } else if (usedInNotebooks) { popoverPanel = [popoverPanel[0]]; diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index d1e53d850b..122e7cc7f8 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -20,10 +20,9 @@ import { reset as resetCountDistribution } from '../../redux/slices/count_distri import { reset as resetFields } from '../../redux/slices/field_slice'; import { reset as resetPatterns } from '../../redux/slices/patterns_slice'; import { reset as resetQueryResults } from '../../redux/slices/query_result_slice'; -import { changeData } from '../../redux/slices/query_slice'; +import { changeData, reset as resetQuery } from '../../redux/slices/query_slice'; import { reset as resetVisualization } from '../../redux/slices/visualization_slice'; import { reset as resetVisConfig } from '../../redux/slices/viualization_config_slice'; -import { reset as resetQuery } from '../../redux/slices/query_slice'; import { DirectQueryRequest, SelectedDataSource } from '../../../../../common/types/explorer'; import { ObservabilityDefaultDataSource } from '../../../../framework/datasources/obs_opensearch_datasource'; import { diff --git a/public/components/event_analytics/explorer/events_views/data_grid.scss b/public/components/event_analytics/explorer/events_views/data_grid.scss index af387c2cd5..207c76ff46 100644 --- a/public/components/event_analytics/explorer/events_views/data_grid.scss +++ b/public/components/event_analytics/explorer/events_views/data_grid.scss @@ -61,7 +61,7 @@ dt { background-color: transparentize(shade($euiColorPrimary, 20%), 0.9); color: $euiTextColor; - padding: ($euiSizeXS / 2) $euiSizeXS; + padding: calc($euiSizeXS / 2) $euiSizeXS; margin-right: $euiSizeXS; word-break: normal; border-radius: $euiBorderRadius; diff --git a/public/components/integrations/components/__tests__/create_integration_helpers.test.ts b/public/components/integrations/components/__tests__/create_integration_helpers.test.ts index 71ccc99bf6..2bf7c76663 100644 --- a/public/components/integrations/components/__tests__/create_integration_helpers.test.ts +++ b/public/components/integrations/components/__tests__/create_integration_helpers.test.ts @@ -3,17 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { HttpResponse, HttpSetup } from '../../../../../../../src/core/public'; +import { coreStartMock } from '../../../../../test/__mocks__/coreMocks'; import { checkDataSourceName, - doTypeValidation, + doExistingDataSourceValidation, doNestedPropertyValidation, doPropertyValidation, + doTypeValidation, fetchDataSourceMappings, fetchIntegrationMappings, - doExistingDataSourceValidation, } from '../create_integration_helpers'; -import * as create_integration_helpers from '../create_integration_helpers'; -import { HttpSetup } from '../../../../../../../src/core/public'; describe('doTypeValidation', () => { it('should return true if required type is not specified', () => { @@ -261,73 +261,88 @@ describe('fetchIntegrationMappings', () => { describe('doExistingDataSourceValidation', () => { it('Catches and returns checkDataSourceName errors', async () => { - const mockHttp = {} as Partial; - jest - .spyOn(create_integration_helpers, 'checkDataSourceName') - .mockReturnValue({ ok: false, errors: ['mock'] }); - - const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); - - await expect(result).resolves.toHaveProperty('ok', false); + coreStartMock.http.post = jest.fn(() => + Promise.resolve(({ + logs: { mappings: { properties: { test: true } } }, + } as unknown) as HttpResponse) + ); + coreStartMock.http.get = jest.fn(() => + Promise.resolve(({ + data: { mappings: { logs: { template: { mappings: { properties: { test: true } } } } } }, + statusCode: 200, + } as unknown) as HttpResponse) + ); + + const result = await doExistingDataSourceValidation('ss4o_metrics-test-test', 'target', 'logs'); + + expect(result).toHaveProperty('ok', false); }); it('Catches data stream fetch errors', async () => { - const mockHttp = {} as Partial; - jest.spyOn(create_integration_helpers, 'checkDataSourceName').mockReturnValue({ ok: true }); - jest.spyOn(create_integration_helpers, 'fetchDataSourceMappings').mockResolvedValue(null); - jest - .spyOn(create_integration_helpers, 'fetchIntegrationMappings') - .mockResolvedValue({ test: { template: { mappings: {} } } }); - - const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); - - await expect(result).resolves.toHaveProperty('ok', false); + coreStartMock.http.post = jest.fn(() => + Promise.resolve(({ + mock: 'resp', + } as unknown) as HttpResponse) + ); + + coreStartMock.http.get = jest.fn(() => + Promise.resolve(({ + data: { mappings: { logs: { template: { mappings: { properties: { test: true } } } } } }, + statusCode: 200, + } as unknown) as HttpResponse) + ); + + const result = await doExistingDataSourceValidation('ss4o_logs-test-test', 'target', 'logs'); + expect(result).toHaveProperty('ok', false); }); it('Catches integration fetch errors', async () => { - const mockHttp = {} as Partial; - jest.spyOn(create_integration_helpers, 'checkDataSourceName').mockReturnValue({ ok: true }); - jest - .spyOn(create_integration_helpers, 'fetchDataSourceMappings') - .mockResolvedValue({ test: { properties: {} } }); - jest.spyOn(create_integration_helpers, 'fetchIntegrationMappings').mockResolvedValue(null); - - const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); - - await expect(result).resolves.toHaveProperty('ok', false); + coreStartMock.http.post = jest.fn(() => + Promise.resolve(({ + logs: { mappings: { properties: { test: true } } }, + } as unknown) as HttpResponse) + ); + coreStartMock.http.get = jest.fn(() => + Promise.resolve(({ + mock: 'resp', + } as unknown) as HttpResponse) + ); + + const result = await doExistingDataSourceValidation('ss4o_logs-test-test', 'target', 'logs'); + expect(result).toHaveProperty('ok', false); }); it('Catches type validation issues', async () => { - const mockHttp = {} as Partial; - jest.spyOn(create_integration_helpers, 'checkDataSourceName').mockReturnValue({ ok: true }); - jest - .spyOn(create_integration_helpers, 'fetchDataSourceMappings') - .mockResolvedValue({ test: { properties: {} } }); - jest - .spyOn(create_integration_helpers, 'fetchIntegrationMappings') - .mockResolvedValue({ test: { template: { mappings: {} } } }); - jest - .spyOn(create_integration_helpers, 'doPropertyValidation') - .mockReturnValue({ ok: false, errors: ['mock'] }); - - const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); - - await expect(result).resolves.toHaveProperty('ok', false); + coreStartMock.http.post = jest.fn(() => + Promise.resolve(({ + logs: { mappings: { properties: { test: true } } }, + } as unknown) as HttpResponse) + ); + coreStartMock.http.get = jest.fn(() => + Promise.resolve(({ + data: { mappings: { template: { mappings: { properties: { test: true } } } } }, + statusCode: 200, + } as unknown) as HttpResponse) + ); + + const result = await doExistingDataSourceValidation('ss4o_logs-test-test', 'target', 'logs'); + expect(result).toHaveProperty('ok', false); }); it('Returns no errors if everything passes', async () => { - const mockHttp = {} as Partial; - jest.spyOn(create_integration_helpers, 'checkDataSourceName').mockReturnValue({ ok: true }); - jest - .spyOn(create_integration_helpers, 'fetchDataSourceMappings') - .mockResolvedValue({ test: { properties: {} } }); - jest - .spyOn(create_integration_helpers, 'fetchIntegrationMappings') - .mockResolvedValue({ test: { template: { mappings: {} } } }); - jest.spyOn(create_integration_helpers, 'doPropertyValidation').mockReturnValue({ ok: true }); - - const result = doExistingDataSourceValidation('target', 'name', 'type', mockHttp as HttpSetup); - - await expect(result).resolves.toHaveProperty('ok', true); + coreStartMock.http.post = jest.fn(() => + Promise.resolve(({ + logs: { mappings: { properties: { test: true } } }, + } as unknown) as HttpResponse) + ); + coreStartMock.http.get = jest.fn(() => + Promise.resolve(({ + data: { mappings: { logs: { template: { mappings: { properties: { test: true } } } } } }, + statusCode: 200, + } as unknown) as HttpResponse) + ); + + const result = await doExistingDataSourceValidation('ss4o_logs-test-test', 'target', 'logs'); + expect(result).toHaveProperty('ok', true); }); }); diff --git a/public/components/integrations/components/setup_integration.tsx b/public/components/integrations/components/setup_integration.tsx index 8b7efea453..97fc85e02d 100644 --- a/public/components/integrations/components/setup_integration.tsx +++ b/public/components/integrations/components/setup_integration.tsx @@ -26,11 +26,14 @@ import { EuiTitle, } from '@elastic/eui'; import React, { useState, useEffect } from 'react'; -import { Color } from 'common/constants/integrations'; +import { Color } from '../../../../common/constants/integrations'; import { coreRefs } from '../../../framework/core_refs'; import { IntegrationTemplate, addIntegrationRequest } from './create_integration_helpers'; -import { CONSOLE_PROXY, INTEGRATIONS_BASE } from '../../../../common/constants/shared'; -import { DATACONNECTIONS_BASE } from '../../../../common/constants/shared'; +import { + CONSOLE_PROXY, + INTEGRATIONS_BASE, + DATACONNECTIONS_BASE, +} from '../../../../common/constants/shared'; export interface IntegrationSetupInputs { displayName: string; @@ -119,7 +122,7 @@ const suggestDataSources = async (type: string): Promise (dispatch, getState) => { + const { metrics } = getState().metrics; + const modifiableMetricsMap = { ...metrics }; -const now = () => new Date().getMilliseconds(); + const mergedMetrics = mergeWith(modifiableMetricsMap, newMetricMap, mergeMetricCustomizer); + dispatch(setMetrics(mergedMetrics)); +}; export const loadMetrics = () => async (dispatch) => { const pplService = getPPLService(); @@ -81,15 +94,15 @@ export const loadMetrics = () => async (dispatch) => { setDataSourceIcons(coloredIconsFrom([OBSERVABILITY_CUSTOM_METRIC, ...remoteDataSources])) ); - const remoteDataRequests = fetchRemoteMetrics(remoteDataSources); + const remoteDataRequests = await fetchRemoteMetrics(remoteDataSources); const metricsResultSet = await Promise.all([customDataRequest, ...remoteDataRequests]); const metricsResult = metricsResultSet.flat(); const metricsMapById = keyBy(metricsResult.flat(), 'id'); - await dispatch(mergeMetrics(metricsMapById)); + dispatch(mergeMetrics(metricsMapById)); const sortedIds = sortBy(metricsResult, 'catalog', 'id').map((m) => m.id); - await dispatch(setSortedIds(sortedIds)); + dispatch(setSortedIds(sortedIds)); }; const fetchCustomMetrics = async () => { @@ -150,17 +163,9 @@ export const metricSlice = createSlice({ name: REDUX_SLICE_METRICS, initialState, reducers: { - mergeMetrics: (state, { payload }) => { - const { metrics } = state; - const modifiableMetricsMap = { ...metrics }; - - const mergedMetrics = mergeWith(modifiableMetricsMap, payload, mergeMetricCustomizer); - state.metrics = mergedMetrics; + setMetrics: (state, { payload }) => { + state.metrics = payload; }, - - // setMetrics: (state, { payload }) => { - // state.metrics = payload; - // }, setMetric: (state, { payload }) => { state.metrics[payload.id] = payload; }, @@ -214,8 +219,7 @@ export const metricSlice = createSlice({ export const { deSelectMetric, clearSelectedMetrics, - - mergeMetrics, + selectMetric, moveMetric, setSearch, setDateSpan, @@ -227,7 +231,7 @@ export const { /** private actions */ -const { selectMetric, setMetric, setSortedIds } = metricSlice.actions; +const { setMetrics, setMetric, setSortedIds } = metricSlice.actions; const getAvailableAttributes = (id, metricIndex) => async (dispatch, getState) => { const { toasts } = coreRefs; @@ -300,16 +304,12 @@ export const availableMetricsSelector = (state) => { export const selectedMetricsSelector = (state) => pick(state.metrics.metrics, state.metrics.selectedIds) ?? {}; -export const selectedMetricByIdSelector = (id) => (state) => state.metrics.metrics[id]; - export const selectedMetricsIdsSelector = (state) => state.metrics.selectedIds ?? []; export const searchSelector = (state) => state.metrics.search; export const metricIconsSelector = (state) => state.metrics.dataSourceIcons; -export const metricsLayoutSelector = (state) => state.metrics.metricsLayout; - export const dateSpanFilterSelector = (state) => state.metrics.dateSpanFilter; export const refreshSelector = (state) => state.metrics.refresh; diff --git a/public/components/metrics/sidebar/sidebar.tsx b/public/components/metrics/sidebar/sidebar.tsx index 6afb40c1fb..ac93037fc5 100644 --- a/public/components/metrics/sidebar/sidebar.tsx +++ b/public/components/metrics/sidebar/sidebar.tsx @@ -43,10 +43,12 @@ export const Sidebar = ({ useEffect(() => { if (additionalMetric) { - dispatch(clearSelectedMetrics()); - dispatch(addSelectedMetric(additionalMetric)); + (async function () { + await dispatch(clearSelectedMetrics()); + await dispatch(addSelectedMetric(additionalMetric)); + })(); } - }, [additionalMetric]); + }, [additionalMetric?.id]); const selectedMetricsList = useMemo(() => { return selectedMetricsIds.map((id) => selectedMetrics[id]).filter((m) => m); // filter away null entries diff --git a/public/components/metrics/view/metrics_grid.tsx b/public/components/metrics/view/metrics_grid.tsx index 2c41224552..161431918d 100644 --- a/public/components/metrics/view/metrics_grid.tsx +++ b/public/components/metrics/view/metrics_grid.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useMemo } from 'react'; -import { EuiDragDropContext, EuiDraggable, EuiDroppable } from '@elastic/eui'; +import { EuiContextMenuItem, EuiDragDropContext, EuiDraggable, EuiDroppable } from '@elastic/eui'; import { useObservable } from 'react-use'; import { connect } from 'react-redux'; import { CoreStart } from '../../../../../../src/core/public'; @@ -53,6 +53,19 @@ const visualizationFromMetric = (metric, dateSpanFilter): SavedVisualizationType }, }); +const promQLActionMenu = [ + { + closeActionsMenu(); + showModal('catalogModal'); + }} + > + View query + , +]; + const navigateToEventExplorerVisualization = (savedVisualizationId: string) => { window.location.assign(`${observabilityLogsID}#/explorer/${savedVisualizationId}`); }; @@ -80,6 +93,7 @@ export const InnerGridVisualization = ({ id, idx, dateSpanFilter, metric, refres inlineEditor={ metric.subType === PROMQL_METRIC_SUBTYPE && } + actionMenuType="metricsGrid" /> ); diff --git a/public/components/notebooks/components/__tests__/__snapshots__/notebook.test.tsx.snap b/public/components/notebooks/components/__tests__/__snapshots__/notebook.test.tsx.snap index 90933ace6d..b2bc5ba057 100644 --- a/public/components/notebooks/components/__tests__/__snapshots__/notebook.test.tsx.snap +++ b/public/components/notebooks/components/__tests__/__snapshots__/notebook.test.tsx.snap @@ -1,6 +1,344 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` spec renders the component 1`] = ` +exports[` spec Renders the empty component 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+

+ sample-notebook-1 +

+
+
+
+
+
+

+ Created +
+ + 12/14/2023 06:49 PM +

+
+
+
+
+
+
+
+
+
+

+ No paragraphs +

+
+ Add a paragraph to compose your document or story. Notebooks now support two types of input: +
+
+
+
+
+
+
+
+
+ +
+
+ + Code block + +
+

+ Write contents directly using markdown, SQL or PPL. +

+
+
+ +
+
+
+
+
+ +
+
+ + Visualization + +
+

+ Import OpenSearch Dashboards or Observability visualizations to the notes. +

+
+
+ +
+
+
+
+
+
+
+
+
+
+`; + +exports[` spec Renders the visualization component 1`] = `
@@ -313,7 +651,7 @@ exports[` spec renders the component 1`] = `
`; -exports[` spec renders the empty component 1`] = ` +exports[` spec test reporting action button 1`] = `
@@ -354,14 +692,19 @@ exports[` spec renders the empty component 1`] = ` > + > + + @@ -374,7 +717,48 @@ exports[` spec renders the empty component 1`] = `
+ > +
+
+
+ +
+
+
+
@@ -394,14 +778,19 @@ exports[` spec renders the empty component 1`] = ` > + > + + @@ -418,7 +807,9 @@ exports[` spec renders the empty component 1`] = ` />

+ > + sample-notebook-1 +

@@ -436,7 +827,7 @@ exports[` spec renders the empty component 1`] = ` Created
- Invalid date + 12/14/2023 06:49 PM

@@ -487,14 +878,18 @@ exports[` spec renders the empty component 1`] = ` > + > + +
spec renders the empty component 1`] = ` > + > + +
({ describe(' spec', () => { configure({ adapter: new Adapter() }); + const props = { + loading: false, + fetchNotebooks: jest.fn(), + addSampleNotebooks: jest.fn(), + createNotebook: jest.fn(), + renameNotebook: jest.fn(), + cloneNotebook: jest.fn(), + deleteNotebook: jest.fn(), + parentBreadcrumb: { href: 'parent-href', text: 'parent-text' }, + setBreadcrumbs: jest.fn(), + setToast: jest.fn(), + }; + + const renderNoteTable = (overrides = {}) => { + const utils = render(); + // Additional setup or assertions if needed + return utils; + }; + + afterEach(() => { + cleanup(); // Cleanup the rendered component after each test + }); + it('renders the empty component', () => { - const fetchNotebooks = jest.fn(); - const addSampleNotebooks = jest.fn(); - const createNotebook = jest.fn(); - const renameNotebook = jest.fn(); - const cloneNotebook = jest.fn(); - const deleteNotebook = jest.fn(); - const setBreadcrumbs = jest.fn(); - const setToast = jest.fn(); - const utils = render( - - ); + const utils = renderNoteTable({ notebooks: [] }); expect(utils.container.firstChild).toMatchSnapshot(); }); it('renders the component', () => { - const fetchNotebooks = jest.fn(); - const addSampleNotebooks = jest.fn(); - const createNotebook = jest.fn(); - const renameNotebook = jest.fn(); - const cloneNotebook = jest.fn(); - const deleteNotebook = jest.fn(); - const setBreadcrumbs = jest.fn(); - const setToast = jest.fn(); const notebooks = Array.from({ length: 5 }, (v, k) => ({ path: `path-${k}`, id: `id-${k}`, dateCreated: '2023-01-01 12:00:00', dateModified: '2023-01-02 12:00:00', })); - const utils = render( - - ); + const utils = renderNoteTable({ notebooks }); expect(utils.container.firstChild).toMatchSnapshot(); - utils.getByText('Actions').click(); - utils.getByText('Add samples').click(); - utils.getAllByLabelText('Select this row')[0].click(); - utils.getByText('Actions').click(); - utils.getByText('Delete').click(); - utils.getByText('Cancel').click(); - utils.getAllByLabelText('Select this row')[0].click(); - utils.getByText('Actions').click(); - utils.getByText('Rename').click(); + fireEvent.click(utils.getByText('Actions')); + fireEvent.click(utils.getByText('Add samples')); + fireEvent.click(utils.getAllByLabelText('Select this row')[0]); + fireEvent.click(utils.getByText('Actions')); + fireEvent.click(utils.getByText('Delete')); + fireEvent.click(utils.getByText('Cancel')); + fireEvent.click(utils.getAllByLabelText('Select this row')[0]); + fireEvent.click(utils.getByText('Actions')); + fireEvent.click(utils.getByText('Rename')); }); - it('create notebook', async () => { - const fetchNotebooks = jest.fn(); - const addSampleNotebooks = jest.fn(); - const createNotebook = jest.fn(); - const renameNotebook = jest.fn(); - const cloneNotebook = jest.fn(); - const deleteNotebook = jest.fn(); - const setBreadcrumbs = jest.fn(); - const setToast = jest.fn(); + it('create notebook modal', async () => { const notebooks = Array.from({ length: 5 }, (v, k) => ({ path: `path-${k}`, id: `id-${k}`, dateCreated: 'date-created', dateModified: 'date-modified', })); - const utils = render( - - ); - utils.getByText('Create notebook').click(); + const utils = renderNoteTable({ notebooks }); + fireEvent.click(utils.getByText('Create notebook')); await waitFor(() => { expect(global.window.location.href).toContain('/create'); }); }); + + it('filters notebooks based on search input', () => { + const { getByPlaceholderText, getAllByText, queryByText } = renderNoteTable({ + notebooks: [ + { + path: 'path-1', + id: 'id-1', + dateCreated: 'date-created', + dateModified: 'date-modified', + }, + ], + }); + + const searchInput = getByPlaceholderText('Search notebook name'); + fireEvent.change(searchInput, { target: { value: 'path-1' } }); + + // Assert that only the matching notebook is displayed + expect(getAllByText('path-1')).toHaveLength(1); + expect(queryByText('path-0')).toBeNull(); + expect(queryByText('path-2')).toBeNull(); + }); + + it('displays empty state message and create notebook button', () => { + const { getAllByText, getAllByTestId } = renderNoteTable({ notebooks: [] }); + + expect(getAllByText('No notebooks')).toHaveLength(1); + + // Create notebook using the modal + fireEvent.click(getAllByText('Create notebook')[0]); + fireEvent.click(getAllByTestId('custom-input-modal-input')[0]); + fireEvent.input(getAllByTestId('custom-input-modal-input')[0], { + target: { value: 'test-notebook' }, + }); + fireEvent.click(getAllByText('Create')[0]); + expect(props.createNotebook).toHaveBeenCalledTimes(1); + }); + + it('renames a notebook', () => { + const notebooks = [ + { + path: 'path-1', + id: 'id-1', + dateCreated: 'date-created', + dateModified: 'date-modified', + }, + ]; + const { getByText, getByLabelText, getAllByText, getByTestId } = renderNoteTable({ notebooks }); + + // Select a notebook + fireEvent.click(getByLabelText('Select this row')); + + // Open Actions dropdown and click Rename + fireEvent.click(getByText('Actions')); + fireEvent.click(getByText('Rename')); + + // Ensure the modal is open (you may need to adjust based on your modal implementation) + expect(getAllByText('Rename notebook')).toHaveLength(1); + + // Mock user input and submit + fireEvent.input(getByTestId('custom-input-modal-input'), { + target: { value: 'test-notebook-newname' }, + }); + fireEvent.click(getByTestId('custom-input-modal-confirm-button')); + + // Assert that the renameNotebook function is called + expect(props.renameNotebook).toHaveBeenCalledTimes(1); + expect(props.renameNotebook).toHaveBeenCalledWith('test-notebook-newname', 'id-1'); + }); + + it('clones a notebook', () => { + const notebooks = [ + { + path: 'path-1', + id: 'id-1', + dateCreated: 'date-created', + dateModified: 'date-modified', + }, + ]; + const { getByText, getByLabelText, getAllByText, getByTestId } = renderNoteTable({ notebooks }); + + // Select a notebook + fireEvent.click(getByLabelText('Select this row')); + + // Open Actions dropdown and click Duplicate + fireEvent.click(getByText('Actions')); + fireEvent.click(getByText('Duplicate')); + + // Ensure the modal is open (you may need to adjust based on your modal implementation) + expect(getAllByText('Duplicate notebook')).toHaveLength(1); + + // Mock user input and submit + fireEvent.input(getByTestId('custom-input-modal-input'), { + target: { value: 'new-copy' }, + }); + fireEvent.click(getByTestId('custom-input-modal-confirm-button')); + + // Assert that the cloneNotebook function is called + expect(props.cloneNotebook).toHaveBeenCalledTimes(1); + expect(props.cloneNotebook).toHaveBeenCalledWith('new-copy', 'id-1'); + }); + + it('deletes a notebook', () => { + const notebooks = [ + { + path: 'path-1', + id: 'id-1', + dateCreated: 'date-created', + dateModified: 'date-modified', + }, + ]; + const { getByText, getByLabelText, getAllByText, getByTestId } = renderNoteTable({ notebooks }); + + // Select a notebook + fireEvent.click(getByLabelText('Select this row')); + + // Open Actions dropdown and click Delete + fireEvent.click(getByText('Actions')); + fireEvent.click(getByText('Delete')); + + // Ensure the modal is open (you may need to adjust based on your modal implementation) + expect(getAllByText('Delete 1 notebook')).toHaveLength(1); + + // Mock user confirmation and submit + fireEvent.input(getByTestId('delete-notebook-modal-input'), { + target: { value: 'delete' }, + }); + fireEvent.click(getByTestId('delete-notebook-modal-delete-button')); + + // Assert that the deleteNotebook function is called + expect(props.deleteNotebook).toHaveBeenCalledTimes(1); + expect(props.deleteNotebook).toHaveBeenCalledWith(['id-1'], expect.any(String)); + }); + + it('adds sample notebooks', async () => { + const { getByText, getAllByText, getByTestId } = renderNoteTable({ notebooks: [] }); + + // Open Actions dropdown and click Add samples + fireEvent.click(getByText('Actions')); + fireEvent.click(getAllByText('Add samples')[0]); + + // Ensure the modal is open (you may need to adjust based on your modal implementation) + expect(getAllByText('Add sample notebooks')).toHaveLength(1); + + // Mock user confirmation and submit + fireEvent.click(getByTestId('confirmModalConfirmButton')); + + // Assert that the addSampleNotebooks function is called + expect(props.addSampleNotebooks).toHaveBeenCalledTimes(1); + }); + + it('closes the action panel', async () => { + const { getByText, queryByTestId } = renderNoteTable({ notebooks: [] }); + expect(queryByTestId('rename-notebook-btn')).not.toBeInTheDocument(); + + // Open Actions dropdown + fireEvent.click(getByText('Actions')); + + // Ensure the action panel is open + expect(queryByTestId('rename-notebook-btn')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(getByText('Actions')); + }); + + // Ensure the action panel is closed + expect(queryByTestId('rename-notebook-btn')).not.toBeInTheDocument(); + }); + + it('closes the delete modal', () => { + const notebooks = [ + { + path: 'path-1', + id: 'id-1', + dateCreated: 'date-created', + dateModified: 'date-modified', + }, + ]; + const { getByText, getByLabelText, queryByText } = renderNoteTable({ notebooks }); + + // Select a notebook + fireEvent.click(getByLabelText('Select this row')); + + // Open Actions dropdown and click Delete + fireEvent.click(getByText('Actions')); + fireEvent.click(getByText('Delete')); + + // Ensure the modal is open + expect(getByText('Delete 1 notebook')).toBeInTheDocument(); + + // Close the delete modal + fireEvent.click(getByText('Cancel')); + + // Ensure the delete modal is closed + expect(queryByText('Delete 1 notebook')).toBeNull(); + }); }); diff --git a/public/components/notebooks/components/__tests__/notebook.test.tsx b/public/components/notebooks/components/__tests__/notebook.test.tsx index f31508852e..eb33888af0 100644 --- a/public/components/notebooks/components/__tests__/notebook.test.tsx +++ b/public/components/notebooks/components/__tests__/notebook.test.tsx @@ -3,17 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render, waitFor } from '@testing-library/react'; -import { configure, mount, shallow } from 'enzyme'; +import '@testing-library/jest-dom'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; -import PPLService from '../../../../services/requests/ppl'; import React from 'react'; import { HttpResponse } from '../../../../../../../src/core/public'; -import httpClientMock from '../../../../../test/__mocks__/httpClientMock'; -import { sampleNotebook1 } from '../helpers/__tests__/sampleDefaultNotebooks'; -import { Notebook } from '../notebook'; -import { SavedObjectsActions } from '../../../../services/saved_objects/saved_object_client/saved_objects_actions'; +import { getOSDHttp } from '../../../../../common/utils'; +import { + addCodeBlockResponse, + clearOutputNotebook, + codeBlockNotebook, + codePlaceholderText, + emptyNotebook, + notebookPutResponse, + runCodeBlockResponse, + sampleNotebook1, +} from '../../../../../test/notebooks_constants'; import { sampleSavedVisualization } from '../../../../../test/panels_constants'; +import PPLService from '../../../../services/requests/ppl'; +import { SavedObjectsActions } from '../../../../services/saved_objects/saved_object_client/saved_objects_actions'; +import { Notebook } from '../notebook'; jest.mock('../../../../../../../src/plugins/embeddable/public', () => ({ ViewMode: { @@ -36,23 +46,27 @@ global.fetch = jest.fn(() => describe(' spec', () => { configure({ adapter: new Adapter() }); + const httpClient = getOSDHttp(); + const pplService = new PPLService(httpClient); + const setBreadcrumbs = jest.fn(); + const renameNotebook = jest.fn(); + const cloneNotebook = jest.fn(); + const deleteNotebook = jest.fn(); + const setToast = jest.fn(); + const location = jest.fn() as any; + location.search = ''; + const history = jest.fn() as any; + history.replace = jest.fn(); + history.push = jest.fn(); - it('renders the empty component', async () => { - const pplService = new PPLService(httpClientMock); - const setBreadcrumbs = jest.fn(); - const renameNotebook = jest.fn(); - const cloneNotebook = jest.fn(); - const deleteNotebook = jest.fn(); - const setToast = jest.fn(); - const location = jest.fn(); - const history = jest.fn() as any; - history.replace = jest.fn(); + it('Renders the empty component', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); const utils = render( spec', () => { history={history} /> ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); expect(utils.container.firstChild).toMatchSnapshot(); - utils.getByText('Add code block').click(); - utils.getByText('Add visualization').click(); }); - it('renders the component', async () => { - const pplService = new PPLService(httpClientMock); - const setBreadcrumbs = jest.fn(); - const renameNotebook = jest.fn(); - const cloneNotebook = jest.fn(); - const deleteNotebook = jest.fn(); - const setToast = jest.fn(); - const location = jest.fn(); - const history = jest.fn() as any; - history.replace = jest.fn(); + it('test reporting action button', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + expect(utils.container.firstChild).toMatchSnapshot(); + + act(() => { + fireEvent.click(utils.getByText('Reporting actions')); + }); + + expect(utils.queryByTestId('download-notebook-pdf')).toBeInTheDocument(); + + act(() => { + fireEvent.click(utils.getByText('Reporting actions')); + }); + + await waitFor(() => { + expect(utils.queryByTestId('download-notebook-pdf')).toBeNull(); + }); + }); + + it('Adds a code block', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); + let postFlag = 1; + httpClient.post = jest.fn(() => { + if (postFlag === 1) { + postFlag += 1; + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + } else return Promise.resolve((runCodeBlockResponse as unknown) as HttpResponse); + }); + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + utils.getByText('Add code block').click(); + }); + + await waitFor(() => { + expect(utils.getByPlaceholderText(codePlaceholderText)).toBeInTheDocument(); + }); + }); + + it('toggles show input in code block', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); + let postFlag = 1; + httpClient.post = jest.fn(() => { + if (postFlag === 1) { + postFlag += 1; + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + } else return Promise.resolve((runCodeBlockResponse as unknown) as HttpResponse); + }); + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + utils.getByText('Add code block').click(); + }); + + await waitFor(() => { + expect(utils.getByPlaceholderText(codePlaceholderText)).toBeInTheDocument(); + }); + + act(() => { + utils.getByLabelText('Toggle show input').click(); + }); + + await waitFor(() => { + expect(utils.queryByPlaceholderText(codePlaceholderText)).toBeNull(); + }); + }); + + it('runs a code block and checks the output', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); + let postFlag = 1; + httpClient.post = jest.fn(() => { + if (postFlag === 1) { + postFlag += 1; + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + } else return Promise.resolve((runCodeBlockResponse as unknown) as HttpResponse); + }); + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + utils.getByText('Add code block').click(); + }); + + await waitFor(() => { + expect(utils.getByPlaceholderText(codePlaceholderText)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.input(utils.getByPlaceholderText(codePlaceholderText), { + target: { value: '%md \\n hello' }, + }); + fireEvent.click(utils.getByText('Run')); + }); + + await waitFor(() => { + expect(utils.queryByText('Run')).toBeNull(); + expect(utils.getByText('hello')).toBeInTheDocument(); + }); + }); + + it('toggles between input/output only views', async () => { + httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + utils.getByText('Add code block').click(); + }); + + await waitFor(() => { + expect(utils.getByPlaceholderText(codePlaceholderText)).toBeInTheDocument(); + }); + + act(() => { + utils.getByLabelText('Toggle show input').click(); + }); + + await waitFor(() => { + expect(utils.queryByPlaceholderText(codePlaceholderText)).toBeNull(); + }); + + act(() => { + utils.getByLabelText('Toggle show input').click(); + }); + + act(() => { + fireEvent.click(utils.getByTestId('input_only')); + }); + + await waitFor(() => { + expect(utils.queryByText('Refresh')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByTestId('output_only')); + }); + + await waitFor(() => { + expect(utils.queryByText('Refresh')).toBeNull(); + expect(utils.getByText('hello')).toBeInTheDocument(); + }); + }); + + it('Renders a notebook and checks paragraph actions', async () => { + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + httpClient.put = jest.fn(() => + Promise.resolve((clearOutputNotebook as unknown) as HttpResponse) + ); + httpClient.delete = jest.fn(() => + Promise.resolve(({ paragraphs: [] } as unknown) as HttpResponse) + ); + + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Paragraph actions')); + }); + + act(() => { + fireEvent.click(utils.getByText('Clear all outputs')); + }); + + await waitFor(() => { + expect( + utils.queryByText( + 'Are you sure you want to clear all outputs? The action cannot be undone.' + ) + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(utils.queryByText('hello')).toBeNull(); + }); + + act(() => { + fireEvent.click(utils.getByText('Paragraph actions')); + }); + + act(() => { + fireEvent.click(utils.getByText('Delete all paragraphs')); + }); + + await waitFor(() => { + expect( + utils.queryByText( + 'Are you sure you want to delete all paragraphs? The action cannot be undone.' + ) + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(utils.queryByText('No paragraphs')).toBeInTheDocument(); + }); + }); + + it('Checks notebook rename action', async () => { + const renameNotebookMock = jest.fn(() => + Promise.resolve((notebookPutResponse as unknown) as HttpResponse) + ); + const cloneNotebookMock = jest.fn(() => Promise.resolve('dummy-string')); + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + + httpClient.put = jest.fn(() => { + return Promise.resolve((notebookPutResponse as unknown) as HttpResponse); + }); + + httpClient.post = jest.fn(() => { + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + }); + + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Notebook actions')); + }); + + act(() => { + fireEvent.click(utils.getByText('Rename notebook')); + }); + + await waitFor(() => { + expect(utils.queryByTestId('custom-input-modal-input')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.input(utils.getByTestId('custom-input-modal-input'), { + target: { value: 'test-notebook-newname' }, + }); + fireEvent.click(utils.getByTestId('custom-input-modal-confirm-button')); + }); + + await waitFor(() => { + expect(renameNotebookMock).toHaveBeenCalledTimes(1); + }); + }); + + it('Checks notebook clone action', async () => { + const renameNotebookMock = jest.fn(() => + Promise.resolve((notebookPutResponse as unknown) as HttpResponse) + ); + const cloneNotebookMock = jest.fn(() => Promise.resolve('dummy-string')); + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + + httpClient.put = jest.fn(() => { + return Promise.resolve((notebookPutResponse as unknown) as HttpResponse); + }); + + httpClient.post = jest.fn(() => { + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + }); + + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Notebook actions')); + }); + + act(() => { + fireEvent.click(utils.getByText('Duplicate notebook')); + }); + + await waitFor(() => { + expect(utils.queryByTestId('custom-input-modal-input')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByTestId('custom-input-modal-confirm-button')); + }); + + expect(cloneNotebookMock).toHaveBeenCalledTimes(1); + }); + + it('Checks notebook delete action', async () => { + const renameNotebookMock = jest.fn(() => + Promise.resolve((notebookPutResponse as unknown) as HttpResponse) + ); + const cloneNotebookMock = jest.fn(() => Promise.resolve('dummy-string')); + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + + httpClient.put = jest.fn(() => { + return Promise.resolve((notebookPutResponse as unknown) as HttpResponse); + }); + + httpClient.post = jest.fn(() => { + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + }); + + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Notebook actions')); + }); + + act(() => { + fireEvent.click(utils.getByText('Delete notebook')); + }); + + await waitFor(() => { + expect(utils.queryByTestId('delete-notebook-modal-input')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.input(utils.getByTestId('delete-notebook-modal-input'), { + target: { value: 'delete' }, + }); + }); + + act(() => { + fireEvent.click(utils.getByTestId('delete-notebook-modal-delete-button')); + }); + + expect(deleteNotebook).toHaveBeenCalledTimes(1); + }); + it('Renders the visualization component', async () => { SavedObjectsActions.getBulk = jest.fn().mockResolvedValue({ observabilityObjectList: [{ savedVisualization: sampleSavedVisualization }], }); - httpClientMock.get = jest.fn(() => + httpClient.get = jest.fn(() => Promise.resolve(({ ...sampleNotebook1, path: sampleNotebook1.name, @@ -108,7 +596,7 @@ describe(' spec', () => { pplService={pplService} openedNoteId={sampleNotebook1.id} DashboardContainerByValueRenderer={jest.fn()} - http={httpClientMock} + http={httpClient} parentBreadcrumb={{ href: 'parent-href', text: 'parent-text' }} setBreadcrumbs={setBreadcrumbs} renameNotebook={renameNotebook} diff --git a/public/components/notebooks/components/helpers/__tests__/default_parser.test.tsx b/public/components/notebooks/components/helpers/__tests__/default_parser.test.tsx index 3c03a3d5b9..2339b95026 100644 --- a/public/components/notebooks/components/helpers/__tests__/default_parser.test.tsx +++ b/public/components/notebooks/components/helpers/__tests__/default_parser.test.tsx @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { defaultParagraphParser } from '../default_parser'; import { sampleNotebook1, sampleNotebook2, @@ -12,18 +11,18 @@ import { sampleNotebook5, sampleParsedParagraghs1, sampleParsedParagraghs2, -} from './sampleDefaultNotebooks'; +} from '../../../../../../test/notebooks_constants'; +import { defaultParagraphParser } from '../default_parser'; // Perfect schema describe('Testing default backend parser function with perfect schema', () => { - test('defaultParagraphParserTest1', (done) => { + it('defaultParagraphParserTest1', () => { const parsedParagraphs1 = defaultParagraphParser(sampleNotebook1.paragraphs); const parsedParagraphs2 = defaultParagraphParser(sampleNotebook2.paragraphs); const parsedParagraphs3 = defaultParagraphParser([]); expect(parsedParagraphs1).toEqual(sampleParsedParagraghs1); expect(parsedParagraphs2).toEqual(sampleParsedParagraghs2); expect(parsedParagraphs3).toEqual([]); - done(); }); it('returns parsed paragraphs', () => { @@ -82,16 +81,15 @@ describe('Testing default backend parser function with perfect schema', () => { // Issue in schema describe('Testing default backend parser function with wrong schema', () => { - test('defaultParagraphParserTest2', (done) => { + it('defaultParagraphParserTest2', () => { expect(() => { - const parsedParagraphs1 = defaultParagraphParser(sampleNotebook3.paragraphs); + const _parsedParagraphs1 = defaultParagraphParser(sampleNotebook3.paragraphs); }).toThrow(Error); expect(() => { - const parsedParagraphs2 = defaultParagraphParser(sampleNotebook4.paragraphs); + const _parsedParagraphs2 = defaultParagraphParser(sampleNotebook4.paragraphs); }).toThrow(Error); expect(() => { - const parsedParagraphs3 = defaultParagraphParser(sampleNotebook5.paragraphs); + const _parsedParagraphs3 = defaultParagraphParser(sampleNotebook5.paragraphs); }).toThrow(Error); - done(); }); }); diff --git a/public/components/notebooks/components/helpers/__tests__/sampleZeppelinNotebooks.tsx b/public/components/notebooks/components/helpers/__tests__/sampleZeppelinNotebooks.tsx deleted file mode 100644 index db07da1c51..0000000000 --- a/public/components/notebooks/components/helpers/__tests__/sampleZeppelinNotebooks.tsx +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -// Sample notebook with all input and output -export const sampleNotebook1 = { - paragraphs: [ - { - text: - "%md \n\n### Hi Everyone\n* Here's a demo on **OpenSearch Dashboards Notebooks**\n* You may use the top left buttons to play around with notebooks and Paragraphs", - user: 'anonymous', - dateUpdated: '2020-08-20 21:15:04.590', - config: {}, - settings: { params: {}, forms: {} }, - results: { - code: 'SUCCESS', - msg: [ - { - type: 'HTML', - data: - '
\n

Hi Everyone

\n
    \n
  • Here’s a demo on OpenSearch Dashboards Notebooks
  • \n
  • You may use the top left buttons to play around with notebooks and Paragraphs
  • \n
\n\n
', - }, - ], - }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958104590_901298942', - id: 'paragraph_1596519508360_932236116', - dateCreated: '2020-08-20 21:15:04.590', - status: 'READY', - }, - { - title: 'VISUALIZATION', - text: - '%sh #vizobject:{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - user: 'anonymous', - dateUpdated: '2020-08-20 21:25:28.588', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958728587_1310320520', - id: 'paragraph_1597958728587_1310320520', - dateCreated: '2020-08-20 21:25:28.587', - status: 'READY', - }, - ], - name: 'Embed Vizualization', - id: '2FJH8PW8K', - defaultInterpreterGroup: 'spark', - version: '0.9.0-preview2', - noteParams: {}, - noteForms: {}, - angularObjects: {}, - config: { isZeppelinNotebookCronEnable: false }, - info: {}, -}; - -// Parsed Output of sample notebook1 -export const sampleParsedParagraghs1 = [ - { - uniqueId: 'paragraph_1596519508360_932236116', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: false, - vizObjectInput: '', - id: 1, - inp: - "%md \n\n### Hi Everyone\n* Here's a demo on **OpenSearch Dashboards Notebooks**\n* You may use the top left buttons to play around with notebooks and Paragraphs", - lang: 'text/x-', - editorLanguage: '', - typeOut: ['HTML'], - out: [ - '
\n

Hi Everyone

\n
    \n
  • Here’s a demo on OpenSearch Dashboards Notebooks
  • \n
  • You may use the top left buttons to play around with notebooks and Paragraphs
  • \n
\n\n
', - ], - }, - { - uniqueId: 'paragraph_1597958728587_1310320520', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: true, - vizObjectInput: - '{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - id: 2, - inp: - '%sh #vizobject:{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - lang: 'text/x-', - editorLanguage: '', - typeOut: [], - out: [], - }, -]; - -// Sample notebook with all input and cleared outputs -export const sampleNotebook2 = { - paragraphs: [ - { - text: - "%md \n\n### Hi Everyone\n* Here's a demo on **OpenSearch Dashboards Notebooks**\n* You may use the top left buttons to play around with notebooks and Paragraphs", - user: 'anonymous', - dateUpdated: '2020-08-20 21:15:04.590', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958104590_901298942', - id: 'paragraph_1596519508360_932236116', - dateCreated: '2020-08-20 21:15:04.590', - status: 'READY', - }, - { - title: 'Paragraph inserted', - text: '%md\n\n## Greetings!\n* Yay! you may import and export me ', - user: 'anonymous', - dateUpdated: '2020-08-20 21:15:04.590', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958104590_1715920734', - id: 'paragraph_1596742076640_674206137', - dateCreated: '2020-08-20 21:15:04.590', - status: 'READY', - }, - { - title: 'Paragraph inserted', - text: - "%md\n\n### Let's use Visualization API with dashboard container to embed Visualizations in notebooks\n2. **Unpin** the container to *edit the size* or *delete it*\n3. **Refresh** the container after *date is changed*", - user: 'anonymous', - dateUpdated: '2020-08-20 21:15:04.590', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958104590_931410594', - id: 'paragraph_1596524302932_2112910756', - dateCreated: '2020-08-20 21:15:04.590', - status: 'READY', - }, - { - title: 'VISUALIZATION', - text: - '%sh #vizobject:{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - user: 'anonymous', - dateUpdated: '2020-08-20 21:25:28.588', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958728587_1310320520', - id: 'paragraph_1597958728587_1310320520', - dateCreated: '2020-08-20 21:25:28.587', - status: 'READY', - }, - ], - name: 'Embed Vizualization', - id: '2FJH8PW8K', - defaultInterpreterGroup: 'spark', - version: '0.9.0-preview2', - noteParams: {}, - noteForms: {}, - angularObjects: {}, - config: { isZeppelinNotebookCronEnable: false }, - info: {}, -}; - -// Parsed Output of sample notebook2 -export const sampleParsedParagraghs2 = [ - { - uniqueId: 'paragraph_1596519508360_932236116', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: false, - vizObjectInput: '', - id: 1, - inp: - "%md \n\n### Hi Everyone\n* Here's a demo on **OpenSearch Dashboards Notebooks**\n* You may use the top left buttons to play around with notebooks and Paragraphs", - lang: 'text/x-', - editorLanguage: '', - typeOut: [], - out: [], - }, - { - uniqueId: 'paragraph_1596742076640_674206137', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: false, - vizObjectInput: '', - id: 2, - inp: '%md\n\n## Greetings!\n* Yay! you may import and export me ', - lang: 'text/x-md', - editorLanguage: 'md', - typeOut: [], - out: [], - }, - { - uniqueId: 'paragraph_1596524302932_2112910756', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: false, - vizObjectInput: '', - id: 3, - inp: - "%md\n\n### Let's use Visualization API with dashboard container to embed Visualizations in notebooks\n2. **Unpin** the container to *edit the size* or *delete it*\n3. **Refresh** the container after *date is changed*", - lang: 'text/x-md', - editorLanguage: 'md', - typeOut: [], - out: [], - }, - { - uniqueId: 'paragraph_1597958728587_1310320520', - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: true, - vizObjectInput: - '{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - id: 4, - inp: - '%sh #vizobject:{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - lang: 'text/x-', - editorLanguage: '', - typeOut: [], - out: [], - }, -]; - -// Sample notebook with no paragraph Id -export const sampleNotebook3 = { - paragraphs: [ - { - text: - "%md \n\n### Hi Everyone\n* Here's a demo on **OpenSearch Dashboards Notebooks**\n* You may use the top left buttons to play around with notebooks and Paragraphs", - user: 'anonymous', - dateUpdated: '2020-08-20 21:15:04.590', - config: {}, - settings: { params: {}, forms: {} }, - results: { - code: 'SUCCESS', - msg: [ - { - type: 'HTML', - data: - '
\n

Hi Everyone

\n
    \n
  • Here’s a demo on OpenSearch Dashboards Notebooks
  • \n
  • You may use the top left buttons to play around with notebooks and Paragraphs
  • \n
\n\n
', - }, - ], - }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958104590_901298942', - dateCreated: '2020-08-20 21:15:04.590', - status: 'READY', - }, - ], - name: 'Embed Vizualization', - id: '2FJH8PW8K', - defaultInterpreterGroup: 'spark', - version: '0.9.0-preview2', - noteParams: {}, - noteForms: {}, - angularObjects: {}, - config: { isZeppelinNotebookCronEnable: false }, - info: {}, -}; - -// Sample notebook with no VISUALIZAITON title -export const sampleNotebook4 = { - paragraphs: [ - { - text: - '%sh #vizobject:{"viewMode":"view","panels":{"1":{"gridData":{"x":15,"y":0,"w":20,"h":20,"i":"1"},"type":"visualization","explicitInput":{"id":"1","savedObjectId":"06cf9c40-9ee8-11e7-8711-e7a007dcef99"}}},"isFullScreenMode":false,"filters":[],"useMargins":false,"id":"iab4eaba1-e32b-11ea-aac8-99f209533253","timeRange":{"to":"2020-08-20T21:25:28.538Z","from":"2020-07-21T21:25:28.538Z"},"title":"embed_viz_iab4eaba1-e32b-11ea-aac8-99f209533253","query":{"query":"","language":"lucene"},"refreshConfig":{"pause":true,"value":15}}', - user: 'anonymous', - dateUpdated: '2020-08-20 21:25:28.588', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958728587_1310320520', - id: 'paragraph_1597958728587_1310320520', - dateCreated: '2020-08-20 21:25:28.587', - status: 'READY', - }, - ], - name: 'Embed Vizualization', - id: '2FJH8PW8K', - defaultInterpreterGroup: 'spark', - version: '0.9.0-preview2', - noteParams: {}, - noteForms: {}, - angularObjects: {}, - config: { isZeppelinNotebookCronEnable: false }, - info: {}, -}; - -// Sample notebook with no input and output -export const sampleNotebook5 = { - paragraphs: [ - { - user: 'anonymous', - dateUpdated: '2020-08-20 21:25:28.588', - config: {}, - settings: { params: {}, forms: {} }, - apps: [], - runtimeInfos: {}, - progressUpdateIntervalMs: 500, - jobName: 'paragraph_1597958728587_1310320520', - id: 'paragraph_1597958728587_1310320520', - dateCreated: '2020-08-20 21:25:28.587', - status: 'READY', - }, - ], - name: 'Embed Vizualization', - id: '2FJH8PW8K', - defaultInterpreterGroup: 'spark', - version: '0.9.0-preview2', - noteParams: {}, - noteForms: {}, - angularObjects: {}, - config: { isZeppelinNotebookCronEnable: false }, - info: {}, -}; diff --git a/public/components/notebooks/components/helpers/__tests__/zeppelin_parser.test.tsx b/public/components/notebooks/components/helpers/__tests__/zeppelin_parser.test.tsx deleted file mode 100644 index e6c5b351e1..0000000000 --- a/public/components/notebooks/components/helpers/__tests__/zeppelin_parser.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { zeppelinParagraphParser } from '../zeppelin_parser'; -import { - sampleNotebook1, - sampleNotebook2, - sampleNotebook3, - sampleNotebook4, - sampleNotebook5, - sampleParsedParagraghs1, - sampleParsedParagraghs2, -} from './sampleZeppelinNotebooks'; - -// Perfect schema -describe('Testing Zeppelin backend parser function with perfect schema', () => { - test('zeppelinParagraphParserTest1', (done) => { - const parsedParagraphs1 = zeppelinParagraphParser(sampleNotebook1.paragraphs); - const parsedParagraphs2 = zeppelinParagraphParser(sampleNotebook2.paragraphs); - const parsedParagraphs3 = zeppelinParagraphParser([]); - expect(parsedParagraphs1).toEqual(sampleParsedParagraghs1); - expect(parsedParagraphs2).toEqual(sampleParsedParagraghs2); - expect(parsedParagraphs3).toEqual([]); - done(); - }); -}); - -// Issue in schema -describe('Testing default backend parser function with wrong schema', () => { - test('zeppelinParagraphParserTest2', (done) => { - expect(() => { - const parsedParagraphs1 = zeppelinParagraphParser(sampleNotebook3.paragraphs); - }).toThrow(Error); - expect(() => { - const parsedParagraphs2 = zeppelinParagraphParser(sampleNotebook4.paragraphs); - }).toThrow(Error); - expect(() => { - const parsedParagraphs3 = zeppelinParagraphParser(sampleNotebook5.paragraphs); - }).toThrow(Error); - done(); - }); -}); diff --git a/public/components/notebooks/components/helpers/zeppelin_parser.tsx b/public/components/notebooks/components/helpers/zeppelin_parser.tsx deleted file mode 100644 index f2293c3a72..0000000000 --- a/public/components/notebooks/components/helpers/zeppelin_parser.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* This file contains parsing functions - * These functions have to be changed based on backend configuration - * If backend changes the incoming paragraph structures may change, so parsing adapts to it - */ - -import { ParaType } from '../../../common'; - -const visualizationPrefix = '%sh #vizobject:'; -const observabilityVisualizationPrefix = '%sh #observabilityviz:'; - -const langSupport = { - '%sh': 'shell', - '%md': 'md', - '%python': 'python', - '%opensearchsql': 'sql', - '%elasticsearch': 'json', -}; - -// Get the coding language from a Zeppelin paragraph input -// Param: textHeader-> header on a Zeppelin paragraph example "%md" -const parseCodeLanguage = (textHeader: string) => { - const codeLanguage = langSupport[textHeader]; - return codeLanguage || ''; -}; - -// Get the type of output message from a Zeppelin paragraph -// Param: Zeppelin Paragraph -const parseMessage = (paraObject: any) => { - try { - let mtype = []; - let mdata = []; - paraObject.results.msg.map((msg: { type: string; data: string }) => { - mtype.push(msg.type); - mdata.push(msg.data); - }); - return { - outputType: mtype, - outputData: mdata, - }; - } catch (error) { - return { - outputType: [], - outputData: [], - }; - } -}; - -// Get the type of output message from a Zeppelin paragraph -// Param: Zeppelin Paragraph -const parseText = (paraObject: any) => { - if ('text' in paraObject) { - return paraObject.text; - } else { - throw new Error('Input text parse issue'); - } -}; - -// Get the visualization from a Zeppelin Paragraph input -// All Visualizations in Zeppelin are stored as shell comment -> "%sh #vizobject:" -// TODO: This is a workaround need to look for better solutions -// Param: Zeppelin Paragraph -const parseVisualization = (paraObject: any) => { - let vizContent = ''; - if ( - paraObject.hasOwnProperty('text') && - paraObject.text.substring(0, 15) === visualizationPrefix - ) { - if (paraObject.title !== 'VISUALIZATION') { - throw new Error('Visualization parse issue'); - } - vizContent = paraObject.text.substring(15); - return { - isViz: true, - VizObject: vizContent, - }; - } - - if ( - paraObject.hasOwnProperty('text') && - paraObject.text.substring(0, 22) === observabilityVisualizationPrefix - ) { - if (paraObject.title !== 'OBSERVABILITY_VISUALIZATION') { - throw new Error('Visualization parse issue'); - } - vizContent = paraObject.text.substring(22); - return { - isViz: true, - VizObject: vizContent, - }; - } - - return { - isViz: false, - VizObject: vizContent, - }; -}; - -// This parser is used to get paragraph id -// Param: Zeppelin Paragraph -const parseId = (paraObject: any) => { - if ('id' in paraObject) { - return paraObject.id; - } else { - throw new Error('Id not found in paragraph'); - } -}; - -// This parser helps to convert Zeppelin paragraphs to a common ParaType format -// This parsing makes any backend notebook compatible with notebooks plugin -export const zeppelinParagraphParser = (zeppelinBackendParagraphs: any) => { - let parsedPara: Array = []; - try { - zeppelinBackendParagraphs.map((paraObject: ParaType, index: number) => { - const paragraphId = parseId(paraObject); - const vizParams = parseVisualization(paraObject); - const inputParam = parseText(paraObject); - const codeLanguage = parseCodeLanguage(inputParam.split('\n')[0].split('.')[0]); - const message = parseMessage(paraObject); - - let tempPara = { - uniqueId: paragraphId, - isRunning: false, - inQueue: false, - ishovered: false, - isSelected: false, - isInputHidden: false, - isOutputHidden: false, - showAddPara: false, - isVizualisation: vizParams.isViz, - vizObjectInput: vizParams.VizObject, - id: index + 1, - inp: inputParam, - lang: 'text/x-' + codeLanguage, - editorLanguage: codeLanguage, - typeOut: message.outputType, - out: message.outputData, - }; - parsedPara.push(tempPara); - }); - return parsedPara; - } catch (error) { - throw new Error('Parsing Paragraph Issue ' + error); - } -}; diff --git a/public/components/notebooks/components/note_table.tsx b/public/components/notebooks/components/note_table.tsx index 9c63e00082..a25a1d7a68 100644 --- a/public/components/notebooks/components/note_table.tsx +++ b/public/components/notebooks/components/note_table.tsx @@ -36,14 +36,13 @@ import { CREATE_NOTE_MESSAGE, NOTEBOOKS_DOCUMENTATION_URL, } from '../../../../common/constants/notebooks'; -import { UI_DATE_FORMAT } from '../../../../common/constants/shared'; +import { UI_DATE_FORMAT, pageStyles } from '../../../../common/constants/shared'; import { DeleteNotebookModal, getCustomModal, getSampleNotebooksModal, } from './helpers/modal_containers'; import { NotebookType } from './main'; -import { pageStyles } from '../../../../common/constants/shared'; interface NoteTableProps { loading: boolean; @@ -222,6 +221,7 @@ export function NoteTable({ setIsActionsPopoverOpen(false); renameNote(); }} + data-test-subj="rename-notebook-btn" > Rename , @@ -232,6 +232,7 @@ export function NoteTable({ setIsActionsPopoverOpen(false); cloneNote(); }} + data-test-subj="duplicate-notebook-btn" > Duplicate , @@ -242,6 +243,7 @@ export function NoteTable({ setIsActionsPopoverOpen(false); deleteNote(); }} + data-test-subj="delete-notebook-btn" > Delete , @@ -251,6 +253,7 @@ export function NoteTable({ setIsActionsPopoverOpen(false); addSampleNotebooksModal(); }} + data-test-subj="add-samples-btn" > Add samples , diff --git a/public/components/notebooks/components/notebook.tsx b/public/components/notebooks/components/notebook.tsx index d86d8486fe..9db0114126 100644 --- a/public/components/notebooks/components/notebook.tsx +++ b/public/components/notebooks/components/notebook.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiButtonGroup, - EuiButtonGroupOption, + EuiButtonGroupOptionProps, EuiCard, EuiContextMenu, EuiContextMenuPanelDescriptor, @@ -27,16 +27,15 @@ import moment from 'moment'; import queryString from 'query-string'; import React, { Component } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import PPLService from '../../../services/requests/ppl'; import { ChromeBreadcrumb, CoreStart } from '../../../../../../src/core/public'; import { DashboardStart } from '../../../../../../src/plugins/dashboard/public'; import { CREATE_NOTE_MESSAGE, NOTEBOOKS_API_PREFIX, - NOTEBOOKS_SELECTED_BACKEND, } from '../../../../common/constants/notebooks'; import { UI_DATE_FORMAT } from '../../../../common/constants/shared'; import { ParaType } from '../../../../common/types/notebooks'; +import PPLService from '../../../services/requests/ppl'; import { GenerateReportLoadingModal } from './helpers/custom_modals/reporting_loading_modal'; import { defaultParagraphParser } from './helpers/default_parser'; import { DeleteNotebookModal, getCustomModal, getDeleteModal } from './helpers/modal_containers'; @@ -45,7 +44,6 @@ import { contextMenuViewReports, generateInContextReport, } from './helpers/reporting_context_menu_helper'; -import { zeppelinParagraphParser } from './helpers/zeppelin_parser'; import { Paragraphs } from './paragraph_components/paragraphs'; const panelStyles: CSS.Properties = { float: 'left', @@ -140,12 +138,7 @@ export class Notebook extends Component { try { let parsedPara; // @ts-ignore - if (NOTEBOOKS_SELECTED_BACKEND === 'ZEPPELIN') { - parsedPara = zeppelinParagraphParser(paragraphs); - this.setState({ vizPrefix: '%sh #vizobject:' }); - } else { - parsedPara = defaultParagraphParser(paragraphs); - } + parsedPara = defaultParagraphParser(paragraphs); parsedPara.forEach((para: ParaType) => { para.isInputExpanded = this.state.selectedViewId === 'input_only'; para.paraRef = React.createRef(); @@ -200,7 +193,7 @@ export class Notebook extends Component { paragraphId: para.uniqueId, }, }) - .then((res) => { + .then((_res) => { const paragraphs = [...this.state.paragraphs]; paragraphs.splice(index, 1); const parsedPara = [...this.state.parsedPara]; @@ -212,6 +205,7 @@ export class Notebook extends Component { 'Error deleting paragraph, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); } }; @@ -253,6 +247,7 @@ export class Notebook extends Component { 'Error deleting paragraph, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }, 'Delete all paragraphs', @@ -361,6 +356,7 @@ export class Notebook extends Component { 'Error deleting visualization, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }; @@ -395,6 +391,7 @@ export class Notebook extends Component { 'Error adding paragraph, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }; @@ -428,13 +425,14 @@ export class Notebook extends Component { .post(`${NOTEBOOKS_API_PREFIX}/set_paragraphs/`, { body: JSON.stringify(moveParaObj), }) - .then((res) => this.setState({ paragraphs, parsedPara })) - .then((res) => this.scrollToPara(targetIndex)) + .then((_res) => this.setState({ paragraphs, parsedPara })) + .then((_res) => this.scrollToPara(targetIndex)) .catch((err) => { this.props.setToast( 'Error moving paragraphs, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }; @@ -467,6 +465,7 @@ export class Notebook extends Component { 'Error clearing paragraphs, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }; @@ -537,9 +536,9 @@ export class Notebook extends Component { } }; - runForAllParagraphs = (reducer: (para: ParaType, index: number) => Promise) => { + runForAllParagraphs = (reducer: (para: ParaType, _index: number) => Promise) => { return this.state.parsedPara - .map((para: ParaType, index: number) => () => reducer(para, index)) + .map((para: ParaType, _index: number) => () => reducer(para, _index)) .reduce((chain, func) => chain.then(func), Promise.resolve()); }; @@ -595,6 +594,7 @@ export class Notebook extends Component { 'Error fetching notebooks, please make sure you have the correct permission.', 'danger' ); + console.error(err); }); }; @@ -611,6 +611,7 @@ export class Notebook extends Component { }) .catch((err) => { this.props.setToast('Error getting query output', 'danger'); + console.error(err); }); }; @@ -662,6 +663,7 @@ export class Notebook extends Component { }) .catch((error) => { this.props.setToast('Error checking Reporting Plugin Installation status.', 'danger'); + console.error(error); }); } @@ -696,7 +698,7 @@ export class Notebook extends Component {

); - const viewOptions: EuiButtonGroupOption[] = [ + const viewOptions: EuiButtonGroupOptionProps[] = [ { id: 'view_both', label: 'View both', @@ -750,7 +752,7 @@ export class Notebook extends Component { disabled: this.state.parsedPara.length === 0, onClick: () => { this.setState({ isParaActionsPopoverOpen: false }); - this.runForAllParagraphs((para: ParaType, index: number) => { + this.runForAllParagraphs((para: ParaType, _index: number) => { return para.paraRef.current?.runParagraph(); }); if (this.state.selectedViewId === 'input_only') { @@ -854,7 +856,7 @@ export class Notebook extends Component { items: [ { name: 'Download PDF', - icon: , + icon: , onClick: () => { this.setState({ isReportingActionsPopoverOpen: false }); generateInContextReport('pdf', this.props, this.toggleReportingLoadingModal); @@ -897,7 +899,11 @@ export class Notebook extends Component { id="reportingActionsButton" iconType="arrowDown" iconSide="right" - onClick={() => this.setState({ isReportingActionsPopoverOpen: true })} + onClick={() => + this.setState({ + isReportingActionsPopoverOpen: !this.state.isReportingActionsPopoverOpen, + }) + } > Reporting actions @@ -929,6 +935,7 @@ export class Notebook extends Component { onChange={(id) => { this.updateView(id); }} + legend="notebook view buttons" /> )} @@ -942,7 +949,11 @@ export class Notebook extends Component { data-test-subj="notebook-paragraph-actions-button" iconType="arrowDown" iconSide="right" - onClick={() => this.setState({ isParaActionsPopoverOpen: true })} + onClick={() => + this.setState({ + isParaActionsPopoverOpen: !this.state.isParaActionsPopoverOpen, + }) + } > Paragraph actions @@ -962,7 +973,11 @@ export class Notebook extends Component { data-test-subj="notebook-notebook-actions-button" iconType="arrowDown" iconSide="right" - onClick={() => this.setState({ isNoteActionsPopoverOpen: true })} + onClick={() => + this.setState({ + isNoteActionsPopoverOpen: !this.state.isNoteActionsPopoverOpen, + }) + } > Notebook actions diff --git a/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/para_output.test.tsx.snap b/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/para_output.test.tsx.snap index a149d56beb..029f9e027c 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/para_output.test.tsx.snap +++ b/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/para_output.test.tsx.snap @@ -1,5 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` spec renders dashboards visualization outputs 1`] = ` +
+ 2020-Jul-21 18:37:44 - 2020-Aug-20 18:37:44 +
+`; + exports[` spec renders markdown outputs 1`] = `
spec renders markdown outputs 1`] = `
`; +exports[` spec renders observability visualization outputs 1`] = ` +
+ 2020-Jul-21 18:37:44 - 2020-Aug-20 18:37:44 +
+`; + exports[` spec renders other types of outputs 1`] = `
spec renders query outputs 1`] = `
`; -exports[` spec renders visualization outputs 1`] = ` +exports[` spec renders query outputs with error 1`] = `
- 2020-07-21T18:37:44+00:00 - 2020-08-20T18:37:44+00:00 +
+    
+      {"error":"Invalid SQL query"}
+    
+  
`; diff --git a/public/components/notebooks/components/paragraph_components/__tests__/para_input.test.tsx b/public/components/notebooks/components/paragraph_components/__tests__/para_input.test.tsx index de07ab37d0..4841064a03 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/para_input.test.tsx +++ b/public/components/notebooks/components/paragraph_components/__tests__/para_input.test.tsx @@ -7,7 +7,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import React from 'react'; -import { sampleParsedParagraghs1 } from '../../helpers/__tests__/sampleDefaultNotebooks'; +import { sampleParsedParagraghs1 } from '../../../../../../test/notebooks_constants'; import { ParaInput } from '../para_input'; describe(' spec', () => { diff --git a/public/components/notebooks/components/paragraph_components/__tests__/para_output.test.tsx b/public/components/notebooks/components/paragraph_components/__tests__/para_output.test.tsx index 167daf25a2..a7f35a6a07 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/para_output.test.tsx +++ b/public/components/notebooks/components/paragraph_components/__tests__/para_output.test.tsx @@ -3,15 +3,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render } from '@testing-library/react'; -import { configure, mount, shallow } from 'enzyme'; +import { render } from '@testing-library/react'; +import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import React from 'react'; -import { sampleParsedParagraghs1 } from '../../helpers/__tests__/sampleDefaultNotebooks'; +import { Provider } from 'react-redux'; +import { legacy_createStore as createStore } from 'redux'; +import { + getOSDHttp, + setPPLService, + uiSettingsService, +} from '../../../../../../common/utils/core_services'; +import { + sampleObservabilityVizParagraph, + sampleParsedParagraghs1, +} from '../../../../../../test/notebooks_constants'; +import { rootReducer } from '../../../../../framework/redux/reducers'; +import PPLService from '../../../../../services/requests/ppl'; import { ParaOutput } from '../para_output'; describe(' spec', () => { configure({ adapter: new Adapter() }); + const store = createStore(rootReducer); it('renders markdown outputs', () => { const para = sampleParsedParagraghs1[0]; @@ -45,21 +58,65 @@ describe(' spec', () => { expect(utils.container.firstChild).toMatchSnapshot(); }); - it('renders visualization outputs', () => { + it('renders query outputs with error', () => { + const para = sampleParsedParagraghs1[3]; + para.out = ['{"error":"Invalid SQL query"}']; + para.isSelected = true; + const setVisInput = jest.fn(); + const utils = render( + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders dashboards visualization outputs', () => { const para = sampleParsedParagraghs1[2]; para.isSelected = true; + + uiSettingsService.get = jest.fn().mockReturnValue('YYYY-MMM-DD HH:mm:ss'); const setVisInput = jest.fn(); const utils = render( null} /> ); + expect(utils.container.textContent).toMatch('2020-Jul-21 18:37:44 - 2020-Aug-20 18:37:44'); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders observability visualization outputs', () => { + setPPLService(new PPLService(getOSDHttp())); + const para = sampleObservabilityVizParagraph; + para.isSelected = true; + + uiSettingsService.get = jest.fn().mockReturnValue('YYYY-MMM-DD HH:mm:ss'); + const setVisInput = jest.fn(); + const utils = render( + + null} + /> + + ); + expect(utils.container.textContent).toMatch('2020-Jul-21 18:37:44 - 2020-Aug-20 18:37:44'); expect(utils.container.firstChild).toMatchSnapshot(); }); diff --git a/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx b/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx index 710499a998..f2b054ff3a 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx +++ b/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render } from '@testing-library/react'; -import { configure, mount, shallow } from 'enzyme'; +import { render } from '@testing-library/react'; +import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import React from 'react'; -import httpClientMock from '../../../../../../test/__mocks__/httpClientMock'; -import { sampleParsedParagraghs1 } from '../../helpers/__tests__/sampleDefaultNotebooks'; +import { getOSDHttp } from '../../../../../../common/utils'; +import { sampleParsedParagraghs1 } from '../../../../../../test/notebooks_constants'; import { Paragraphs } from '../paragraphs'; jest.mock('../../../../../../../../src/plugins/embeddable/public', () => ({ @@ -50,7 +50,7 @@ describe(' spec', () => { addPara={addPara} DashboardContainerByValueRenderer={DashboardContainerByValueRenderer} deleteVizualization={deleteVizualization} - http={httpClientMock} + http={getOSDHttp()} selectedViewId="view_both" setSelectedViewId={setSelectedViewId} deletePara={deletePara} diff --git a/public/components/notebooks/components/paragraph_components/para_output.tsx b/public/components/notebooks/components/paragraph_components/para_output.tsx index 1102e98282..6fee9c66c3 100644 --- a/public/components/notebooks/components/paragraph_components/para_output.tsx +++ b/public/components/notebooks/components/paragraph_components/para_output.tsx @@ -7,16 +7,16 @@ import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; import MarkdownRender from '@nteract/markdown'; import { Media } from '@nteract/outputs'; import moment from 'moment'; -import React, { useState } from 'react'; -import { VisualizationContainer } from '../../../../components/custom_panels/panel_modules/visualization_container'; -import PPLService from '../../../../services/requests/ppl'; +import React from 'react'; import { CoreStart } from '../../../../../../../src/core/public'; import { DashboardContainerInput, DashboardStart, } from '../../../../../../../src/plugins/dashboard/public'; import { ParaType } from '../../../../../common/types/notebooks'; -import { uiSettingsService } from '../../../../../common/utils'; +import { getOSDHttp, getPPLService, uiSettingsService } from '../../../../../common/utils'; +import { VisualizationContainer } from '../../../../components/custom_panels/panel_modules/visualization_container'; +import PPLService from '../../../../services/requests/ppl'; import { QueryDataGridMemo } from './para_query_grid'; const createQueryColumns = (jsonColumns: any[]) => { @@ -53,44 +53,19 @@ const getQueryOutputData = (queryObject: any) => { return data; }; -const QueryPara = ({ inp, val }) => { - const inputQuery = inp.substring(4, inp.length); - const queryObject = JSON.parse(val); - - const columns = createQueryColumns(queryObject.schema); - const [visibleColumns, setVisibleColumns] = useState(columns.map((c) => c.id)); - const data = getQueryOutputData(queryObject); - - return queryObject.hasOwnProperty('error') ? ( - {val} - ) : ( -
- - {inputQuery} - - - -
- ); -}; - const OutputBody = ({ + key, typeOut, val, - inp, + para, visInput, setVisInput, DashboardContainerByValueRenderer, }: { + key: string; typeOut: string; val: string; - inp: string; + para: ParaType; visInput: DashboardContainerInput; setVisInput: (input: DashboardContainerInput) => void; DashboardContainerByValueRenderer: DashboardStart['DashboardContainerByValueRenderer']; @@ -99,15 +74,37 @@ const OutputBody = ({ * Currently supports HTML, TABLE, IMG * TODO: add table rendering */ + const dateFormat = uiSettingsService.get('dateFormat'); if (typeOut !== undefined) { switch (typeOut) { case 'QUERY': - return ; + const inputQuery = para.inp.substring(4, para.inp.length); + const queryObject = JSON.parse(val); + if (queryObject.hasOwnProperty('error')) { + return {val}; + } else { + const columns = createQueryColumns(queryObject.schema); + const data = getQueryOutputData(queryObject); + return ( +
+ + {inputQuery} + + + +
+ ); + } case 'MARKDOWN': return ( - + ); @@ -121,7 +118,11 @@ const OutputBody = ({ {`${from} - ${to}`} - + ); case 'OBSERVABILITY_VISUALIZATION': @@ -139,35 +140,34 @@ const OutputBody = ({
); case 'HTML': return ( - + {/* eslint-disable-next-line react/jsx-pascal-case */} ); case 'TABLE': - return
{val}
; + return
{val}
; case 'IMG': - return ; + return ; default: - return
{val}
; + return
{val}
; } } else { console.log('output not supported', typeOut); @@ -194,21 +194,23 @@ export const ParaOutput = (props: { }) => { const { para, DashboardContainerByValueRenderer, visInput, setVisInput } = props; - return !para.isOutputHidden ? ( - <> - {para.typeOut.map((typeOut: string, tIdx: number) => { - return ( - - ); - })} - - ) : null; + return ( + !para.isOutputHidden && ( + <> + {para.typeOut.map((typeOut: string, tIdx: number) => { + return ( + + ); + })} + + ) + ); }; diff --git a/public/components/notebooks/components/paragraph_components/para_query_grid.tsx b/public/components/notebooks/components/paragraph_components/para_query_grid.tsx index 6321c57026..28b986076a 100644 --- a/public/components/notebooks/components/paragraph_components/para_query_grid.tsx +++ b/public/components/notebooks/components/paragraph_components/para_query_grid.tsx @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import $ from 'jquery'; import { EuiDataGrid, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import $ from 'jquery'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; interface QueryDataGridProps { rowCount: number; @@ -23,7 +23,7 @@ function QueryDataGrid(props: QueryDataGridProps) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); // ** Sorting config const [sortingColumns, setSortingColumns] = useState([]); - const [visibleColumns, setVisibleColumns] = useState>([]); + const [visibleColumns, setVisibleColumns] = useState([]); const [isVisible, setIsVisible] = useState(false); @@ -108,7 +108,6 @@ function queryDataGridPropsAreEqual(prevProps: QueryDataGridProps, nextProps: Qu return ( prevProps.rowCount === nextProps.rowCount && JSON.stringify(prevProps.queryColumns) === JSON.stringify(nextProps.queryColumns) && - JSON.stringify(prevProps.visibleColumns) === JSON.stringify(nextProps.visibleColumns) && JSON.stringify(prevProps.dataValues) === JSON.stringify(nextProps.dataValues) ); } diff --git a/public/components/notebooks/docs/dev/Zeppelin_backend_adaptor.md b/public/components/notebooks/docs/dev/Zeppelin_backend_adaptor.md deleted file mode 100644 index 054c1a886e..0000000000 --- a/public/components/notebooks/docs/dev/Zeppelin_backend_adaptor.md +++ /dev/null @@ -1,129 +0,0 @@ -# Zeppelin Backend Adaptor - -## Contents - -1. [**Zeppelin Backend Service**](#zeppelin-backend-service) -2. [**Apache Zeppelin Setup**](#apache-zeppelin-setup) - -## Zeppelin Backend Service - -**Apache Zeppelin** provides several REST APIs for interaction and remote activation of zeppelin functionality. All REST APIs are available starting with the following endpoint `http://[zeppelin-server]:[zeppelin-port]/api`. - -![Zeppelin Server](images/zeppelin_architecture.png) - -1. **APIs Provided:** - 1. **[Server:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/zeppelin_server.html)** Get status, version, Log Level - 2. **[Interpreter:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/interpreter.html)** Get interpreter settings, create/update/restart/delete interpreter setting - 3. **[Notebook:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/notebook.html)** Create/update/restart/delete note and paragraph ops - 4. **[Repository:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/notebook_repository.html)** Get/Update NB repo - 5. **[Configuration:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/configuration.html)** Get all [Zeppelin config](http://zeppelin.apache.org/docs/0.9.0/setup/operation/configuration.html) - server port, ssl, S3 bucket, S3.user - 6. **[Credential:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/credential.html)** List credentials for all users, create/delete - 7. **[Helium:](http://zeppelin.apache.org/docs/0.9.0/usage/rest_api/helium.html)** Contains APIs for all plugin packages (Not needed as of now) -2. **Security:** - 1. By default the APIs are exposed to anonymous user - 2. Recommended way to use **access control**: **[Shiro Auth](http://zeppelin.apache.org/docs/0.9.0/setup/security/shiro_authentication.html)** - 1. Need to change [**Shiro.ini (Apache link)**](http://shiro.apache.org/configuration.html#ini-sections) in conf directory - 2. Ideally should be used with [**Apache KnoxSSO**](https://knox.apache.org/books/knox-0-13-0/dev-guide.html#KnoxSSO+Integration) - 3. Also, [Notebooks](http://zeppelin.apache.org/docs/0.9.0/setup/security/notebook_authorization.html) can have access control based on Shiro defined users -3. **Deployment:** - 1. Recommended way is to use stand alone docker - 2. Create a **custom docker** with new Shiro & Zeppelin configs and set interpreter config for OpenSearch and OpenSearch-sql. - 3. Sample scripts available in `scripts/docker/spark-cluster-managers` -4. **Storage:** - 1. Apache Zeppelin has a pluggable notebook storage mechanism controlled by `zeppelin.notebook.storage` configuration option with multiple implementations. - 2. Zeppelin has** built-in S3/github connector**. Just provide credentials in [properties or env-sh](http://zeppelin.apache.org/docs/0.9.0/setup/storage/storage.html#notebook-storage-in-s3) - 3. The notebooks are automatically synced by Zeppelin - -## **Apache Zeppelin Setup** - -- https://zeppelin.apache.org/ -- Web-based notebook that enables data-driven, interactive data analytics and collaborative documents with SQL, Scala and more. -- **[Installation Steps](http://zeppelin.apache.org/docs/0.9.0/quickstart/install.html)** - - http://zeppelin.apache.org/download.html → Install using Binary package with all interpreters. - - Unpack the downloaded tar - - To Run the service use `bin/zeppelin-daemon.sh start` - - To Stop the service use `bin/zeppelin-daemon.sh stop` - - Service starts on port 8080 - - If on a remote server (like ec2) and want to use server IP to access the notebook: - - Make sure your inbound/outbound ports are set correctly on the remote machine - - You may want to change the Zeppelin host ip to 0.0.0.0 (or keep it localhost) - - To change the host ip use the `zeppelin-site.xml.template` inside “conf/“ directory - - `cp conf/zeppelin-site.xml.template conf/zeppelin-site.xml` - - `vi conf/zeppelin-site.xml` and edit the host ip - - Then restart the service -- **[Optional] Setup OpenSearch Interpreter:** - - - [Zeppelin OpenSearch interpreter Documentation](https://zeppelin.apache.org/docs/0.9.0/interpreter/elasticsearch.html) - - This interpreter can be used for OpenSearch: - - - **Note: current issues with OpenSearch Interpreter in Zeppelin** - - User needs to remove ssl flag from the OpenSearch config as Zeppelin doesn’t support ssl request yet: https://issues.apache.org/jira/browse/ZEPPELIN-2031 so run the OpenSearch service without ssl enabled - - Zeppelin has “no support for ssl” (only uses http) in elastic interpreter: - - [Code](https://github.com/apache/zeppelin/blob/0b8423c62ae52f3716d4bb63d60762fee6910788/elasticsearch/src/main/java/org/apache/zeppelin/elasticsearch/client/HttpBasedClient.java#L105) - - [Apache Issues](https://issues.apache.org/jira/browse/ZEPPELIN-2031) - - Zeppelin has “no issue in search query” in elastic interpreter: - - [Apache Issues](https://issues.apache.org/jira/browse/ZEPPELIN-4843?jql=project%20%3D%20ZEPPELIN%20AND%20status%20%3D%20Open%20AND%20text%20~%20%22elasticsearch%22) - - Zeppelin “No support for search template“ issue in elastic interpreter: - - [Apache Issues](https://issues.apache.org/jira/browse/ZEPPELIN-4184?jql=project%20%3D%20ZEPPELIN%20AND%20text%20~%20%22elastic%20search%22) - - **To Change the interpreter settings of Elastic Search Interpreter on Zeppelin:** - - Open Zeppelin in browser `localhost:8080` - - Please click the top right menu beside drop down and select interpreter option - - In the interpreters window, search for elasticsearch interpreter - - Edit config parameters similar to that your local/remote service - - You’ll be prompted to restart the interpreter -> Click on ok - - **If using the default settings, add below mentioned changes in interpreter config:** - - - Change transport.type to http - - host → localhost (if running on same machine as Zeppelin) & port → 9200 - - username: admin & password: admin - - Once configured the screen should look like this: - ![OpenSearch Interpreter](images/opensearch-zeppelin.png) - - - Start a new notebook to try out the below commands - - Run a shell command from notebook to check availability of OpenSearch: - - `%sh curl -XGET http://localhost:9200 -u admin:admin` - -``` -%elasticsearch -index movies/default/1 { - "title": "The Godfather", - "director": "Francis Ford Coppola", - "year": 1972, - "genres": ["Crime", "Drama"], - "rating":5 -} -``` - -- **[Optional] Setup OpenSearch-SQL JDBC Interpreter:** - - [Zeppelin JDBC Interpreter Documentation](https://zeppelin.apache.org/docs/0.9.0/interpreter/jdbc.html) - - Zeppelin has a generic JDBC interpreter, we can use this to add our OpenSearch-SQL Driver - - Download [OpenSearch-SQL Driver](https://opensearch.org/) Jar file - - To Use JDBC interpreter: - - **To add the JDBC interpreter settings for OpenSearch-SQL:** - - Open Zeppelin in browser `localhost:8080` - - Please click the top right menu beside drop down and select interpreter option - - Click on "+ Create" Button - - Add OpenSearch-SQL interpreter with type JDBC **configure name: “opensearchsql”** - - Note: The name you assign to the interpreter is used later for accessing paragraphs in notebook - “%opensearchsql” - - Edit config for with OpenSearch-SQL Driver details (Please refer to the [Github README](https://github.com/opensearch-project/sql/tree/main/sql-jdbc)) - - **If using the default settings, add below mentioned changes in interpreter config:** - - Edit the url: `jdbc:elasticsearch://localhost:9200` - - Edit the driver class: `org.opensearch.jdbc.Driver` - - Edit the username to admin - - Edit the password to admin - - Add absolute path to the Jar in the last input box - - You’ll be prompted to restart the interpreter -> Click on ok - - Once configured the screen should look like this: - ![SQL Interpreter](images/opensearch-zeppelin-settings.png) - - - Open a notebook and run below commands to check if interpreter settings are set correctly - -``` -%opensearchsql -SELECT * FROM movies -``` - -- **Appendix:** - - **[More on Zeppelin UI](http://zeppelin.apache.org/docs/latest/quickstart/explore_ui.html)** - - [**More on Zeppelin Config**](http://zeppelin.apache.org/docs/latest/setup/operation/configuration.html) - - [**S3-Notebook Storage**](http://zeppelin.apache.org/docs/0.8.2/setup/storage/storage.html#notebook-storage-in-s3) diff --git a/public/components/notebooks/docs/dev/images/zeppelin_architecture.png b/public/components/notebooks/docs/dev/images/zeppelin_architecture.png deleted file mode 100644 index cc1a64e70c61a866dbcf5bf6908ae772d4de9be6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 193552 zcmc$^V|S)a6E+&#p4gh$&cwEjE4FjR6Wg|viEUdGPi))v&i%am-G5~Vm zS=yL_fB>_i)$}az)i4A)oXyF|r&Mg%f?fUyB2pkGs=1l zrHxH-v37jqD~Kt2GoNN+4ED7ACZ>KsUK40!OopRa{YOkK(D)pvcpO=Bmd?wXZrNV9 zX?p2>mwlDvb=hWq1$o?7u9*^#0g9DTO^?m8>l_*z#{V-eC^QcnDqlm1;HO@hEEtCZ zynB<*mPo&s!OFtMXKq!CH~}WHBav~#;UD<0SkUWIwFx&+&)0Y&!BcI*N8{rb=3j%1 zo~=88r2%IF-)Kv-eY)NGFi15OtFTm**uO*1;s|fdwTlLMa#m;M#|H(*nXVgobq-fX zpSiYu7QuJ61EtgHxdK5~M!Ii5*(r{8lm=zW6LQ7hVRo%;eVXyN*HSX$Q3avm);BY1 zN>Hyvbt~7P%e!-ns+uB5e2;=S?&x;u!=^cp##Q>3E6NjxvijTxTiCU4Z+Rhl!`pQ- zZbqEmY*lh@j}A`RGbX$|MBiUKJtQA}oN~D@PP~urf>P+A--}a|0x<^z!~F8ag&vP~ zKkL?|QNVv#;T}l&F(ecs=`il;*_Iqn%OGHL{v(pXI(zk3&ifK=h;;g0qXku=6cw$o zO5b*R_M$X2h3%c|9n4YT@7Imk01HW-#uVBGC#MOHYC`=BY71-GqAU;ziUp$z<41%+ zz8N;&Q7_*uPhC*>`>;Ws?g<^IQuFSQ>~@W*7lW6xcb3PW?^Z?Ie)}3XElwla ze>>`B2hMFYo!!-NZK3wCu4mJS!iNa%9B=kDki~sd#?p^!?)CVz>=}^6D^np~tzOiB z$VZD--Ur@?Hqv!b`A~lhcTM_?`%L;w`Sj_5#q&;N?{)7mI1o7-3_n`k;c9PrVR_)R zn4hFrS4xwR*gQbQX#+qgq4>4Q>xD1uyPr5_z92zM2Dqsy4?)wpy6+I zVE1yLYHF${4oNfw>h^M6-9qS5!jiN?4!9JDDJr^jJ`a6(39V=PocS8cWN zAg?y>WA8IrI*`$I7$maZ5PG`>Z_eKsc4HbrQt*HKkC55nOpilvLUP>&t=6yg5yVS+ z!*Bn4I)ChWWdZtr{5x46bgn$2|J}sNo87Z@<+3R7>#G99JlYR5FV|abou35Z+dgoa zYD$^O$$`**(=Z^Qp_U*J-xTO~!~JgGrX&yS|8l^5^T7Wv4Ql@1U>+r~?7z8EqC%>` ztn+O5EEBct#~!M69W??o&)c|AI!P$02uVfeqeiH~tPNK^J@<|6?v9FTOSNAbstCV- z|AvDBlSUci9$@;5@E0Lb<;lf;p^K{1FU#3Z?ddNFLuK`uk3eotMtS?ry7P3^UDi=* z(9@<$p?EYdyVbuxN_$^?`$Qd}|D*rM{ABtQZt(vO77pjaTJ385-^n-T0U6H=f0X`D z|2;jA()a(9^P~L#Q!Iz$=I+Km(tt1d!r{i=dP}eg-R|+G2$^GR610Tp3+u^;_NZ^i zt9nQ3rs!g|{|KEU&Aa;f6Lz(;`071HaC2PF!N!d+Z0SQVI()B?=ff=8FQNHaYK_mb zRB*vfj>leMi?8YPg@1VJD!9~PovL%Qbg4QV;BNi#IXL1s-csfd*x?7mbQ>N553;r4D;Abt79`jb zi>WB*NCD!5g-Z-MD{OMwoRQcqb+x z>P6O6>pk*e%i=(vq(_x{?{@#6>!tv!@TQ?s4>`BQs-%fWou0s-7=3J)7?wC>U%fBt zbj*LUmP6#d;OzFpauf>#g0UsfD%eM~=}VobU*X-X;bblpV8aprsJmr`ViSKaXSZgC zo`WJ;M&_urli_`ezQ7+>K#D;q81K+pr3+n45j>*yRkC>{zZu}NwdaqH9UCY__g|`_Yxi0$T zv(BIgJ=i2^pO}SMG~*urM*&;K_8&n{ra{8F0CFNjlQLTHfQO3hu1||XF&sSkQGY*0 zlNzZ5!?}Ic389NMkY&XdK9I&|zns&l;bPEFLavgc_dneQuA*!z+k#s^+1>o#K0A*EkVaoFolhI~*hg7ANtTczT zy{P&KZ$UStDl|^g{!&s=E7VssiY*!geqtCYGw#`G@uV#4V#03VGU^BUN`4%H9P#m> zp_&U>U2huehO6@=Wc(ue*{$2eN`o1H|^i(VQuE(#f+!>}jD`+qU#@RJy%%Xp^bwi3_&12pD`WJ2tGc#U_3k!~ay0 zc7wHiSB9qYug{A=*vY7pu=1}Xr18e7NY2B&%nlz>?XulM8!R$9 zpz_VR3@L|ci+C#Ou(gPd-pH${rduVr?No}EpqUeJ>UTyX48P|)hPAp81>;Qb9Lgl_ zjW|Wm(}=$!xI)@(U7j;GX_)3zk@1>V{GXZ2fy?nXn554H>yS=_#Md4p&WRh$`UOSD z@~QOkBFPu$AA<=dYpUCryt`vV?)MuBYNYy>dLu{+5Kvt&CdLr2ww0|)nR(=`NK5$# z6MZxys;iEIz0kT@7Q;TkqebJ#YJk>E;;21e26#RMgB@9++5%j#qQ^pvzGvDQ$3NT=7VnsOtTyD%Q`+9G4LfN{kVvO1W}fGV#}u=5YJ}LU zbG(bkXi=NiOai_PYEN!u)xw&n@#B1({6R?pbI_!FGYbM9- zXRdJ}yVdhwtG*sNY69>VWBbL0GQ<+Q$_S!Z4Y!gga$jpWW*3|)S4}L3eJu2mqkh8r zDLG{trYSi3+XT24vQY9d?8wtSH2__E*bKe0#=@p0w>@YTL=$OPIfRTvbj1P^SK>UITM{rKo5-4|{3C&Pki zopyI2#}pv~jUWxaVcMjmWYc!iQx59$K69ntaTrqxW8q{*1*c1E(;LCC_k7q3A5xd- zi7|)sSAmr|?97%?wr*pIg2yMJml}z~QvdVSKxv?MAA#PWRP%^vN&rz2L}#yOY+?k( zS%`<>4VuNhQP;2MU&(VO+hVH5K?Cry5_!qw4Fres7kevf_K{f4%AoxaO8_`o(uCve zM$+9UBc7`TBCwkTegOkLCsrl%6H*!yN8v0Jw_1po)wIHa?qWVx6j_ZiD@F8R z$a0d|ic#@@u04T5?sGA<0`u%&Ebq&(o_}y8B1NZiPYj1|*t&yIHI7<@lz)nvK}cyC z)NwlSiUB)Vi&1(yBEmE)t^0?|&wQbEXZX+R#4lrXMk# zZg<)H{jA}(V8qCHso=aBBFYI@i-_$!6Ft1_fCocNgJmt34&u6zkH6;|GxJC4mI*3{ z?IJzinGb>aFyv2~9Xe|cj=eizod*1+&EVSXJki|rJkP%`ye6!yGtquXODj|+Z)QrD z2&ma7?^l5gyYJ7Z9a=QnpK^NA}%zOAp?9E3K@~gNkkN~gvKj$AB=k^)R7(9X+I{D|xewdI@=k2mrADJ95_oXbVhsA_XkH&r(w+0C29EJV_^rc>M%&e z&C3*C#e@Z2*aS&0(C%+`gO;G%Rs6X}^bxI$YK{>M+_JrWPbR9xPvYTUCp(E#a)OkWxzt~!I5YfO=ylrE;r zyxbd^lCp7?m~=Qc8Vw|v5UBTEC4B2iGw+JeBU|_LNsqABjB-%0qHY>BWz^jAVs)8d zbXmAe)a3&lv*l9GRr^4FCQm7ZJog@{UF#i^TNZmSw%|wj43(Hm6g+i~swUJ*Gaj?S*?aBB!JR`pTj7B4(Nx`cb$5t=}iU6Z_E-@Et| zIQu|Qv6?{1CgU!COFS2}Sh(teEoXU>R&dsyQ<6zut-Agfe2OgS*N%|rM#Ftx{W!Kl zu`jb|s92~g<>8*pr%JvY!=Wx~@)9ACu|MlVs4XhRigcJi!U6qgKf=9xU(;sMu!ft} zVyO9LSuv4eLnP!}KWVARVxRUXqX{Nkj=A*11vZFAXhq6U+dPSlLa zGA&%5Lhw}<7zrOOk~Bp=oSD>;PMHzbkA$9WIhdgG*9|Blf#4B%q0=8bV0&n^Y4&oK zvs&WX#5NcKi25uOkcj`(TA2BW7XTTHcxt%B+{|zyit#-d;jwYcXh~2;flj#VEaYgI zyKQI{7?cc`#t{Xr1=j@?9HB?_MPI1GJ6R&q-2V4!pZ@=W%2>#&0o_!6FD3q##F@9Y zCXycOtIxzInX49;7~E-u9RCC7geKjmo)hHj*`HF|qf7|W(^2aUa!70JMjZ&v(?%Wm zhZf7#`DtZBF56i|x#4v18K#*nJ-cG~FO})k*vSHTO+U(*glDmsq{BcM{N*w5Dl8-* z7u`Nic^DuvfT+s0QLs1~Gg!D}4Ja7fiQHOqjyv82cw8#57!bmy#d#=-Fk(W1F-v(3 zC`wW=f0$@ONF2zskWC6v5@LSKNs7l~SY*v;{*rKZJ&_Je6u@d!ZJo;l*kNB8{-FSV z$iy5pjR>ZCG?c1m(Z&nlt(^uB!qjqWB8m$`k5OlE@W|duD`g-V^=I#IGwP=DjmXq9 zf5brAb-L5+$rHIh9Rxz&BTqyr^#=Gq=heYk&C)H%3rRG=JX3~^h1U9Yw`Qaq=!6pzk<_}rs&tS?!rw8yxD-r^Iou3 zx1W|<=@8w8`5yc<*m+#DAMu**`B>|5oZ>zr4(eS+L7^%(nJ;CAOBfXDYZoe zqOdliVDljNL>8Zf*&gpRrmLZ)zE{K>V}kJf?(X~dc8B>jXn*wMX*YW0QX+CY$D~Ta z60>x1+ngP&k}E5RrW&R}ijt)$Cv;q`6ul(b9$L%EWx5q(P%Bq@NPF5ho+ag=}g!K zn!t08DNDsvDLI#cadKV~Wt`OtMWI?z<8KVwcuHTfrE{DxbQ@gwtGCyF4|cQ*K5cV! zdlLn_;cMTCy`6+Dy^+?Z^QH?~{nzsoT^{S-Y88tP8@BFuF&aI@x*E~}$6{>goV297 zwDU&9bbGzaVdkMiLWFWJl=^2d*URlnJc|illWq;KX{2B+c4E%Hl=;U|(4-ZZ;>y_- z5Gk=dMWHL;2`S`J%x5PPxW9-$sWGzuK3ae*{H z8n#`yjw0lc7UB?0c7SquB;?Ai(ixbvMeNu?jS__dbc$H9OKBwhfcN5=)>dD3r^8iS7aMe&)L{HKFFvBkqUg=}J)K@wT>;@Yf%0g-^wN|^BfOKgO5{ zv3{p2MzL|JZSzhvqd*2Wej^nkxfa_NDLDm_=rC8U;Q{5;%L2u^;}y6|grcP?Xoj=$CaQ`V@4It)C?w7CaLzUkCFxtggSoaNMpZtnGFaDr$^Aj;V_wPuM(4 zxJ%wkBH}Otipvc08a7dOHl>Sj?Ir z(EQnvij7uQ<-8xLj*PmF2C)eSe7&ajDsv6~R>7xSk2;J2Ew$R*(x!b+B-kR@-xuCK z1N6)gg;bT`xBZhdpI3@C9HxDq<~S(oq9VwUT64xrD3c-s$p6n6A;54veIC#@ivvt8Vb+Y8MoAR>DqT> zAl?18aaDge#S5sSn5d_3EE;AFFO_=VSO4q@hV-aWNvyq*;1gXavBDtOHgBQZHD6j6 zFI<^cJ$Y_X=*;yEh)s;t+KWm;0LR{N!Y<8zV~zT&NHFl*Zad59!6GyAsx{SM%ZHH& zcu)Zx+VP6_lA`P<@62ypzd6=kW!c;9NWGqC^~D%1wV7$AkhdaSW*Wwx?*83@P0$Cu z38YgYMby^AT5mpgazO-Xk0DhntX{dkZzYh@qFl1GK80Dg?;#oDKr} z5=ju4mVxN&bwC>Fd&lJv=10)iqG3-Q=K$W+n!;?(Rxd#4UH6@j_+rhFjsm3)SJ@Bt zm1Q9&Nmq>`WZoj>;S7*+V}zq*dWV|al?3~kS; zI4dKVoa_Cl9fYAmL7Hl)VMVAjmc1W87iY)bfI!vj1?Q>lItgsZm0YxRbBtbT_^;1N zqTAkt5$35m(YSo3@;=Ja=YX4KlwDB!7;o1-g|Q{_7XxT%W43?}$Y1I>F3x z*wz;+j*-^(H-WsKHh2?=rCST55DAAuOgHjoLO1Yw3S9Tt8t{HVCo(PNrdO{d*ttcx?$m5Bf$m&R~%AXbN4e1|gpP7xF@v4M5J?v+<)~fa77jbSr zDGwA@0*>)dr0iv=X#L~xz7zBt43tU9Zvcf#>K)BWs14(NYWL#4>o=IAL2OI;BTE`& za-lh9Z`T!?dD))W((|d-c^%gkFk#6T`ovL<>*f7Mi8!qNT%ok*M>verxVZ3@tPDd! z`%ps-2XZ)>IpUoArpwT}4JPn98@t_OYr@~gRk9& zyQlpGt9ip0?{jywtM=CpCV+I{;puL}dCJI>^~ly9NEXWHCD9ouCNskI)F0V;H7lm; zwnu-?;jW{9v+i?D^~~$`+xpTY?CM*uJ1@o^Lz^_u@yE zoLAe9SX&;jVpiO}h=&1xP!P81CF^Nx=gqinvkN;#+3+0Ss3+m&N@qv$>9QsHJH<AD(kdMX1TDl)xpe`VSH%b?e0-*20SfhaSv zQO2G(qp8+ojik+nGdE|CB|QC+>a|8{Y2a~P)B7eiHc4 zz`3X7`Dwss`9sp8)B9b_t}7LO-@5Oz;kHIkSMnW0M+7s(7SA32=MMVS{$NXYIDj+i zZcnEA+xW%SZk(TZzh~Q31m^yvS?0S>hTS0xtCZToB&aE8 zTLY1&u|Qy{#?5lv5v$a057Kkb7}@NY0?6l@)G|heZ*4Ti5#>BN%jVowZn*EcG79>4 zxur!kG5O7UPH{c)rBvz;`+e-|nj!oA9&pP<-<92+4l2kD@Md`@Cv*5toqn3fNg60W z=i&V(3Hl>|F8p5{=yW?qbvGO9oAh?olPea9<=78P1)@s1o7(e!;C-gEWjtFTcHVh6 z<^9yLb?|ZJC-}7Kbrt0N9dQoKe=VH5j)At5_}?(D$QOg;UkeWl638@oLz zn)q~ z3%oM8;iUD4rDVCyGX9{F9fa%kECM?qpr`wKzKL~s|KzQ8hT;X4u$|u(6nJ%W!#^&h z=Qr1Vml2mL7XhbX8J++;fdBkp+#0FK?IPm^?~vp&8xi>M5 zhY+gcxmY*I8|88sA$Yl!*c^;sL#_v}Z3xOp`=)ZVB;wAi@*pA~y{5F{!537LtryLYosFxRqm97%|#({BO2;2RNqx~45}hsF0MZNYbj0GD>U+HKNxJp#ul?}MH8 zLL%~cm_6r*48cENK)&MhwAc4o-=V$&8m_<*>go)7RnO|rxTKIE`#p6(-Be=}{?%78 zL~_`!%u?vuqmb>}F$vTm@{U8=ZW@3MO+_l|6>|=SXwEHI@iLqGOY@PmH-KOUMf|GeqZWB5-yX4!}H^A2FY7iO#d7xK{5;l7&n`CUTZWOIgifO7Axr?pcW zK@W_RzdkCHFfH~bCY7jLFxPhj^?V+=;kO5_JkKMF{#yZnW&+7w@D*l)1M7SyK&07{ zxvN;wG_wTH{3<=d+;)B%15yt5qCT1#QX0s@=$TL?s)AIfT!U({A{thGGQ{nmzdOdP zUT1Rv7sJ1c(*1hVavPp1nCedG1rgXQ4!_0w#+Rgg-g6h@y%T7^3&|^MDq)BXIdaYs z&FFpFC7a2gD8D(9F4z&rVQ4H`E17IX`85TUboJdZ>y!BJ`H^zRcrdxCndN~JYzZ&g zT6Q$$x_zlD1`;0Y`nWLGX;I7kX0fF^xx2gD>Wgo>Zt?cN9?{qHh|PLyyz?}F>Qm1NFQ?#Q5u*H+4dwOFDN7C9*kYLv(Pm#5%&r|{Wk!~56KIGJsqr(?%w10 zDTf~WyiC;|yf5w81X?Q2%)$`Sjp?5p09h!WSi(-@yNOX5olOGn!0_;Eim8pNQc_?S;_tJr(1jf#ZA&z5eI z0fhPB*BcxJZ0F4$*VF%||Bw`drZu(6x?b^pf}Lxlg+uQjbRQI$E-(@~seZ%a z3y8L4w)5I18K(ohW?BCI0e3-2QT%u;o^{zUZ6`@{0y88MnJP*|=OApXmKvi5G5$+D zA-kk|Vit6J7 zH9gPtBGmdF7sBth8q*g2tA0){Q8*El*ZO($NMLM9@Pj#q-+_@W&jjm?m-lhaMoc?{fcB*Ez!1RBgHmU^r14ej)4~P^Edxfk%Co}#(IOHW9-jb z@Et1i(9@SbSPxJ!+taEW@?~xASEkQ0sI*wir0S0gM4Y|n*QFlu4%U#nkT&Nd7VAZP zv#G4fN-gI!cI$6#+BpGvpWJ$vD~_7X=EXk3U(`1Ho7VRY!_x(Pc$^Ra8ol11B(pRx z>3f0JdOlb&bloe1Y<~MdX6r?|5{2BU|7LPKQ#P1Qmd_Qu?^6kuHOnl!t=ax3&T2ko zGL^;sH;X&bx~2>3cT491{eoM^MKd1A-rnAS?EiKI7)JQs(HKL=IZM7lb5V0!>vh+8 z{lxS4TCfl@a}P@f(+#;FHpFz)+0LC$T~`PHAsOm;kLR6*PgrGXw@nXow=ExcfzO-R zyB%adV1va@qtBIZ;?==73_2-~Rrw-xnGph^)2a{pu8oEMErQJ7xa$7jzS5YR%9NML@9z1Z)>-p=%qBC2*4vybe5D5;>n9ih7pqMm=ih0Gg)W(z8qb9a zkk-dlYppgX3Xr$UHyggb7Qf-jlfPN~k>4o`*6*YFPyYHEs<&=w(<>otOKnLv+>vA< z`EKJ=@*`<@JaH$Ty+i}uaf#?JxbWDsLon>mVj`N~1{=+BKkeHx`*9BCe{8c}`5=ox z?3(((7Wku~Nym;0<3@yHF;H4_EdTp)^d{JY%P)Y{-&1EnUk8kso12wOfO@bTOvEdTHhJwR))%1B(+E)STEI-LM$dRsk?zpKCVr{7EO8up^a)HO6<)Ly5XKG zaK|||LnQAM_rfX)t4SL~%r-CXW{JRvDa@KwHULTDNaMU2Me;(=q`E{q&0wn0?#!l( zQLn_Lp;f?S)NArlGInN_d>uY9?=2NXeywoq_#S70No7Jc(hm_I$Eu1rEe=VN_8Nz0QP=vIM)P z6w9RU7JB>?EAX`cj;Vqfz4Mia)hjiXC}{NCoJ@<2xsOKg@Bs*>TDQI6$c9lyi{!@`mhIJ-{dl1#_kyYC2wM@B50wCVKP8%gDa!=(Mexy~vZ z%Fld{^Ftk`X04il?>8eA07?o*+texejl39)QPAtpr=Hxc_e0L}*1Z_u)rxtp&+|;E zfa*hCQo@no*9!~@*GW-g`I*8S6?)xM4zllcp!Z`#?m^qM&t;HT(rQZc)d#~vaZ&=K z?@grl=Ur|oUErQ#t<8@tXPcG{xBnW5uHVPQujdV~@2{f@&)Bt$-FXZDyPf3R((USb z-i`GCwX0sOE2WhpN%PZ`wP{B}@D+86TmQZQTg?O3zURZOaS;bVOPU$;wtghga(eV_ zC>Woo;YUsvDjbn)PIh%MS@1N^Jhnff9X3f6&s(-Ysmq?fv?HD#3tJci2&J!ghd2G^f{D~b=&W{08{jYH}&eH#ybX+MzZuO=g-S*s=9&S ze=uk$>fy)+Cpn@T+m2uHEV*RN`1*OJIb;68l{&-yjPLHtQ;(`4iYa0g zwLI-4(s4Vsb_@TV~+Gvou1s*4$)!9-hTJi+CC`lH#UVOUgVNb7+t9zOm+8*{HEi*f4-+k=aRN$qefc2 z2~$cgI*>en*MS*s+!9xqiH!@ThLI~MJPnIl)pmA=#4*1H!SH8deo@bg!9_U2$Yr&? zNQU*!9=pWhPj47_C(^h9(LBCw7o_swL=ty^RBBRGR1~RcqU+hB^TT8X>R#bK3%n@R zJdRPkbX^(sXr{_cNi_X1>aUJ_3^V-XCuZAK;t+?}TbxZ=S*C08^s@=(3j@YKsHr6Ebf!>N!Km(cOTFn`9=M*}Xs91N}fJBV60;YYB7-d{Qe;QJa= z?2De-Rajz1wTp9hEwkEys^S%!UHry1G1j8u}xE|o9ERI?LH%s&YYfNf=6t7)ba{!nOlV3N*%w~!U_B%45Kq%VY%9iRf?N z6f_G3Wj5mmP{*@*!w9KH;8XH7TP#zNY@yvT3j>%8$Og$RjC6e5F!N0A)GR2^WHj~) zhL715fXXjQjE;>Z2`cVc5Cu&~K!(jGg%JlULgRu~t|*VrxxSX7Ik9Hz$o1;wam(h6 z#U}W`Z)A+`FEkY_W+#^Jbof~INK(XxlSef^45TI!tPkY6Dk9HZ9wT&c*It9>^ z1fAa~(TV4X3*IK8?u<2=PGqr$Cvu|4y^<_$M5s0S=XljW#8yvZC~YSHqX@Oom6rqG zzFb|C(ZZb$FWC5H)d;Ja(5OZ$kgZcBtg#=JYcGCLBZx`u{Z1g`pHJ(jA(|@i45i!{ z0s|$cR5ss;Haq>MiA2fwpGqD4B-!tYBEUM;8#$XQ z6xssYgpCSES6HZCt|1J%1DXT4JD$5-VK611ahL=a!>6mtWxEEw5)8+=B1B*LVJu5U zit#7DLs5^Rn~^xBguSs*hmt`R@wp!wkNx!%e}8Ov?Lx2|GNF;*rYfR;s~SO3WgZw`pk*T=v`Jlo#T|+KBJz9az$m+g206v9t{Nm0ARz7rUi9U zd=w)u4UjJtDV+8?@IN03mzb~dG&$M{+;(TdM9M^^`w;UZvKJX zN5(~fFc1O@GaHnd>yT7_FRzf}a=MJm!5msGNkb4?gKA1NjhOrHrN@Ty!z8;H zWDp2C%S`R46c{2O9*Xk1{?1Sp_;|xc@_vdLK?o7jOodPsHbB10!4idZ?f9v_d{A+Y zm|gLuo4F6SBF5$lbb~~#YT7a}_YBscdgIZkIo=?oLHaa8ik$7pBX9k5`++9vP8Ar8 z&0+`dW1nGKA_hiO0cs@>_ zN5zKj{}GgwBzfs$16zH6&SZ{vh+sl};xHF9=Rum*;G9s9df(xo*T>nk#=P-0R!-z8 zAS4C5xLuWA8w*l`aNpooDF;4ajg&QF*wR7uT%laXAps7{Ze zbRa6dTIaI95vfjw8&T#%??z}93>jfX96SssCk1y2^Q15J<2e6czP2P!Gr|)omWT%IiFgS>{|t6GhM{)){N`nMr6Fom?F%!=Od4lkaJ6F4 zNS^Z-EYt_ZXvE0i_lwRUFl%a7A`S>J{}_s)6J|`Bx#s9vM-)^_D&!pbL!3s*oU2)YKkaL@S3BXWssHPDxY{7Dp|} zcib&8A(=%=z!=W<+!`CV&l6|)9mVya3HenpQoDP|YlU0|&1u>!m{+M+oPyJ1ZhFdU zB}fEpF4-ujgt&FcvD&MS6$eW`eLv_lVqoq#O)0e}bemOZQ^Szd)*((V=ZL55Wr4yD zV_498S>YT3trbZ{o)yWuX8$q|@9Jsl>zr9;ZtuE{5V&9WA^XP7D+u}oXuX8f=JJtI ztnnqHeR%fD1UYD$D=L#E<=N(ATn`wmkJ2O44|BdPJHJ_WzOggHB?h!emQm}bB>`5H zz3UCoDZ|XK>d|92cyp60QZzoTHvSC7xmF`qYO?-SU~I!TlG%Km1FRS=$`&aCtt~9X zPWauY{erS{8_elAh%&bN0KwbMp{b;5F*IbN`TAXei&J;BXw7e(wpz|lU6r~m{;0?*Vqw}o_2Q*GFKVR0#( zJ%+Tqn#JU@?c>21Khgf!)RE2@rE|m_ zzJs-%9Ipu_5qQ0HXT9s? zMFgx(YI@by503%TX_O@ zqSFfcdGeEL&tyzu%NeZ$;et?h=)z5r*IAF=*Mu#>jFuXMaJrQ_35ConQ2WR~bcshj zHfHY@v?y~~khILD+u99rk9Xhe?{9v$^_YW-OVfi}%tn6M*u8bIMV%xVS05rh!SQKd zUlKWk>-N;F-s-i#Qgro89}c9Ha`^04Hi%uI;9YR5;Z!*|s(fcO<4BWc)I^lU2=FF5 zfoHMUK%y)PDCkls_A>m!o*GPDX$Yb0>A-=w2Er?P4>I^W4b?&ID)n~)<*<9!Q(bZm zqhHX@79ooXeKg=0*+|;Z0eP+=a~{OT0w@$Gzs@M9(hW=8LcUY{nrFDT;!uRRlYAK1 zaVXviGWB!-huYlw#%0YXeM*X6FPD;lU@_?|cIv7==!PQI9q5MoTeXrH3xbGlip|iE zs_-|edz-7SUzSpIHVgBd8yC9!D9W(1KVZt{c1ZD}#a;E3p`6>5^AV+CvhGaMBhX5U z=)`~uk@e@_Y4zPEv`tPGVAi0vNQ!0WbQ}wvPGtYg($;C)=Ju|sts+OUDwny%!-I+o zk7U^_Pg+J-3l3oH_E$i%@*j&5J2u>?=%xW5brKiGIIie9!(C%gVcx2yq~IZKk-{DB zZ`2H;`CT;DU_M=~dRet&xgwjuEsv^Fc0Uyc#)pfkAymw&iHN<`6y{vh5kZQMfQWEi z_S78>+W$-wWQ;*gkwlA8ufVPr2|O0q_JBT*99H3)R;cOBMzXA?u1m^?qQBmosM)#o zB0PMBjAeFx#7|P~Q_}n0kTCa?;q^i;3QU~_KV?pbE+D#>yXS?z&^C?P6pHt%(bmR) z0B-1yims=vq#W9NJ2_f#_=3>X*Mpi%l1X0iihXK28w~muGxT}5y}h9(oUhwQc2pup zUGMI&ZJ0OTMS}J7LiSzTLtpS{r$6Q5oa?WV8Dfdm%K>ulKf1c<*7?TNn`%jqjieGDQ+h2;Gj9| z3%rraOw_9BwiwEdrb;Nf#pftvXj&TQLcC}iS%OUq?i*&I@wIzWwD5IiB>bYoqoor- z2ym`x^+&8GN>(t9<$sJ49U#f3Y~W5;NrvUt^@`>7KQ4E}DksLKQoah3ullFd`-~*d z6?IoOGIOIZc-8wcsyg)t^SgY2KPrJV+Z25xUj!leAy6xgQ#F!1VFD3m3$pwT@5Ilx|VL3*6+}`uy1CO-pVI00)v~j4DX#0#CfM5 zsoMJ-Nl`y>t$f(-2MWAJRQ$Ng48TMSs=9?oy9&ETI}l-W#tjZY2q%S(+4K{HYBIza znXYax^^}*^DG2I+ySB;oJz>}NyaKb+yT)N{P8Tsc{@m68u%>tD@u12|>!=nEr^J=Z zLiAKcNf7xY&CV))XJ!HoG|i*T3S(tkX3Aj&P{L$OU*r;KTtB9uY$Xb^{wxX+@0 zS3ue=&X^!ZkCQ7$o<40x&M@Tn+gFa0Yl+OCTPv;>y!PB79f_Q}71hLC-TaYN&xIG` zV%XS&e!?mL$U>G*QB=&R@9GH=!M=p0qdX|uj=yHlFj5`rZf zz8=r-v6!Iau+`V8x?fh&3vLm=+V)>_p^_I3^;o;J-Y9RgudZY+B^2}ox}IE8Ql{+e zM-R)ADAY(eL_}Kd=(tff=%zdeO&fXUwmSs5@I;hy6g6$NmqLvXIpzHpg*S7`mrudz zoXFESvUCH6b8;KCBN_|>Ty|`yk}=omchS^8+l6{p1;IVJ3xf^glpknhtKZ(vI$zu0 z{^TTS$Amm?6==UJLwT?|tVakJbka7~KWV%WQzdSW>C?Lc#TL|Ibn*Bw1VGLdvg>M) z(qOY2j>KKFSqg_t!p{fH9fwJrcHms#g8&VPt&V-40N<^5Edgf#6Ims`+9~$!;aUFN zte?<-5ES@N^)3trXIQe)*tovoY`u|Yd|dE#Cks4YiDza*mFb~PoYOIDfs83^?En;} zV~JK4kKCD@Wish9b=^A_4hF61_kEe^wH#N2F%G+)+*{a73OIocgPGUpcC+>2F&_~# z7?-YN2wryvx2yJtZkWM_2Hr@$IlamEeeDytQT|sO2!WGdQ5}LBws?N7%3g29mbloX zN7g?*o`njnPs^2!oRkrd#k8wmQy`OXBJnEGK|w^B?@Fcgi>9wOm;% z!lnhSPA#r2E}l;KYb*b(*OR5=W20+p<_P4&mJlC%?YQ-hCY;Y6ao)Bb?olyFk-+q& zu*V^q@y>3!6FUZefZYx6$-rso)H2eBnXTuw9dWm9^rv}l4bOC)BkwZ?*uhZ@mb4@a zSVo+CjUu3w$|%{E{^r5Jtj+WI6KA;L!ra?)MCP$+EnSK#@Up5RB|SR!bNk1}(RHh7 zLSe#oMIol#^%XY_C{8i&MlijW@t4IaSq23yn3avHU&{q=w+uttQ;@GGZVSLn?FhvDkPdZ)Yo+U;d^Oic8C_^9ZYN_52r3AE(^~ z&HZgpXXC;q7N6f#b(u8kbmCdV^*vYfvs~MDUUVI4Qs}DUOZrTIm0;SV!dEbcquLX$ zK$(G;gBSQbM!jvU1di?Hj67#7&Y0!!HfOfIdtja;Cot+vjg3jQ+!3}O5qDod4O&yB zxHu&O9n!8>NV}_fE+fs3>Xl8=sb9IuUg{nF_6T?{yKe#JBdO?S-dFzN7fuuI(72_D zy_lQuO7w{o1>neL=A2d75ly3#`%wlopTc6*QQuX?9@^98{($~)z5i7ZdMw=&uqN;Y zoo;}96Jtuq2m7WEyE63*pbVh>rlq3O+1zYD zK2@j)YA|7l5;b?1n1s~HCW9v=ljvQZL}ct7g4KLy9MAAG=g zZ5Kh|LLjBFk`j&*`=OHf-a5;Xp%x}zQ4~|HHVyhJ_%UU?{iAp2xwp6|=>4`6GFau2 zjwAT!q~J4n&9N(ySPe z28O2DiHlnB?e7iV)qre_c>4Iozq=m{f;p|9Ka#DR%Arv)3weDy2|A{Q(7{a`rBOa{}0ERcp%8S?XMBq?ZKME$(E zM!B7Co;AL%c4zcEQ*L79+2rC7_?(0AI;t@o(j%JMHTXe7VhX^GWjCusq}DmI8iJ{2 zF}`2sAkr}<$fSR%re^^L<82LbXwEf;W0Qp`SPK9V;`yye>Uu7y9SpnSHQ|9LGCNLq z4U2yivDM{!G&~ie6|mME+jtRn)b40v)IVmKnTn zgQJ_XRYwL>6qA}aa})^Tn1#0-PK54Ppzb3iX3HE#0UY8Ov72Oy$3xGRt-c;v&26Mu z-2@z7^43$V2%^5HC)^`F4<^%P_HOIgUo|Up6HpN(Ey9<6i?UyfLN=2L{_Z%O2YZ)( zEkjN-zll)VJLWpyBKA!xRNY?O%Ohub|F*zKxZ-0U-2rs@U8B}uI9uITT!HZb*N)Tc zgjM>cbq~e#N2%)T@qFGgQqxU>KlBs^=Lh1P>`U^REO2}_@Z&Z!UQ(?93Tje)-*F-7 zYKE-DQNJvjuAxqqZsDXO605GJho<*yy|TkbC5V<`$H1>?Cvo^x=;m?*$efnRCG|qv zSt4Y!G?Ti`wVSrSY&ga}reu%1v#$RRO$z>ZFctK`X~Z@V^6sL5b_qY+5N<~9!7xCLO5G1Kpe~nq@Ks4 z$;W-HD@*J4RN#lN2Y%|5VNZ~xY&u($b0*!i5c2BjnjL?t`Y)$E?pU~K$w%RlmEJ&T zpcm++K5(`hx>je=_+vNBZnZt%`aOiOWy=8Z{zS`vph!h4lqP%;$c1->|1}WU`+&N| zBOtK&_q$5&oWy9W$D<6do-@_OmFH3{o=?oI9x0begXcu!{YPCvR^-J4cE8rypb*Y0 zUp1YK9-WCaPU)B>DyUDgDKQm0x1V7eFe^CmgAu)1O?YWnIkXe!NrcbNt|X?O?bnu1 zH@Z5dt5c7=5pW`tT)G%F68Mw7Z$OXJnh3qm+Yx8oxSTIrO)8OVH?Y zg3~7i3M(5MFx`A=-5KWLuRY9*NnnMK5+L4qRQ?~L zN2sc)?3$4jaaQ!pwn+Qg-PMPMp%!*~Lf&*}s;jXV`M7~R>noXa^~bS1a(5@&2G z!Fc3kRzf1FjdEPQ9s06HO@D84SKT;a&(PmIK)BK?jvH#E7iW${EC_&&o7(H{lnH_o zoxF?$kktOC`QAueiw+Ys3{0ETrX_7PH>pCo$--+(9xMicVT8sbS9%nH2W4d1?wQN# zUrnAwZAy}c3T6Ob%W9^wh}!TP8=F_qW|+oRD(ly0EcV1(^e^mN_ceg+ zdZfqlzHzh`!iSvM7xtUPXL+HYw{CH1v^uw)me0#J4}+hKtW7d^ANd|3tGFP~K`@!ggQa(6 z?L?{$jofmq&%~n?%_s%(fed3BS0|-Hf_a@HHhEkVcPp$WE}~WDVL>;8CV$dUh>A$x z3@69|>ccv1&ZDxTDH+dY4aMA5h8=SM*gmx=0k=lohecq5@>Mt=Rj^KusdF(33crXS z7c56^I!cOAv!yTEb%QdREhByn@Co_gkN}XqFFns2@VVESRYWz%lMr|;toX(e2)*-nkBZ;_{}-M^WD5w|Iw?` zVNd@m54(SFBjyPsq#k?u-u9jEnG?#xOj}AhR&r}17H_fj;KzG{Q3kK?j#Z(RTJIAa zJ$mk@Km=Wqd&zuZZ*M`00$8f`%)BuA`$@ZJdWC2M4JGAH+lkJiIq0m~Bm)vqmTt?y-Jy8Z$i zsw(aVz8DlLg-#0zV75MFlJg}r)KaUP+bjwj!X2xbfWuqP*e4&`r>o1K>27lRfT>oR($wS@HC7ae25;9%_FX`$XCEw?vB6vC(-l{NNHhIG( zZ}!@v@(H31mqtesi^jbFtb;4e@RAe z-u1erVI<`=F;jPp#kFG0rO?F~00(03*iW8TJG%S8Q+uN(cU~Mi=!U}F?@U0=!ayex zbFzp<@3VlJlK8U)6&%vXlh6#EXC6e0nqM!6nlE4}Y6eIG-iMa{qSl#j#uY7XPHeys z>BKbNk8Gw~MQ3hiQN}?|lTA%*`Eoi9mnJ0h6m=%L|Lt0Zl$7hkWngM&t28gRHA9?; z5a+>xqsJ^O$qE~9mfcm!r;w?zOR{>+d3JFDs~=B;^e$;OO;mJ1*X@}f3Lrso7C zMY`m=sd_w16y^1eO7!%9VE?Judq;(hMRF&yuf2?nXh4lWM8T4Yx-P^*Pl%%27zSgM zT$Pqu@AV{gBQ?}``pz=;;A7k{y#GQ&MEpuA2Qc%4gAC$bH~5!#&Go*^}l zpD~9$zHYP!XJPHIv5|ON-5rOeFS|!xsmj=OmX--Su{$1ToP`!8kJ4JOG<1i>;P6dY$iRj+DE!x zM)TqA>$u)%UP_O9l;=tO`QZ2&&RS%q^D^TryyLxT7ufdj{|KHv4q znXr9S8L*MyY%YhHk3CdJ4+X1spAp9@m&f<3dXB9lbbXN`!1^%6M(RP?xZiy|7Y2VK zOuZ8;rua>q(S40V9gE={DVC`UOdDJ`1bPgC@aYGzrY3H7~K zEPAJoPOGvZ8!GBLk*X>~pay0gPC|Puaj&|% zA2+MJ4gV*HQn*JL6rw?ygS(gc*j&~2cO3>sD3xOoNIz1~g^E-$a>y|+`eBY|04<5S zxyf-Q>^PV2XcKm5%)kx{plT3>pB(Z66|>pQWX1U}wz(+9A0>i$NQYR8MbQxCXzc5N zU>ss9rnKcV(*;#b{IF)CQWYLu6aSFRG*UwGbW-sj$(S32p%dQO17s3YG*pn`4X_K1 z-8r}5ERk06Gfj-s4#vvrQC2&Z=GNma7l+sBT=AY^ZcXIQQFjolnNJ$9B=udd7<)A& za0ypIqI@)wr^2X3__9uluXuL zdh4_{A9I5_gKgV#=iN*t?0%dl#rJxleyc05X6!Nlmgjob^SGI6&TCw&IZWsU-M9w+ z7XvGn=q!oRchI%lZ{ zy{1&~>)-Fv<mi1$VG;~-G6b{V&zrL7>Vc)kyF_N4tUC)~kyGmJ4#FaoSJ&4X;Gv_PY(IND z^|1p{B$93YAMxamW~-sh9OV<4`sdGo;d63H(-&a@f4Vb}d`nwvir#e(5x}EC9^R87_L0|?Ow6%LLkOqogw_lza_jJC2>c@VuL0GkcL0$SwlO4Gj zF1T)YFQoXVo`yN+@>zh+cVkapFH1Ph~<3q5_H@Gx&k=t2%eJtl^D~tpBmT zPoyzebLBLGOOppU>!ahaZ{md_252nvhZ6488U{qQ>MNY^d0E%om2QT)vE-vmD4xp| zXz&~VjTqM>O&ip~@YC_dloqW-AgG4E7sBjED&`l2>HSV7R&04m3FHbksbic;AWs|B z7A=3ZPVl|y4sxZ+M=zrgMb)N6tg{Ju1V5E;=USnj#7{M+-K}iw_vBj^hb;h(z+c=2S+|=Q~M5+WqG&^qimhhKM%UJ4k zTz3ya3763#g~rNi6abBns>o9 zZyHAm?yc{{vO_YIFwjNxgv`|Tpy5Nwq+%IZ@ROKPw3L~o8Q~os-KT(Z`a;?*6^kHalydtYp7wF?eTL1wLfP*xzA!%DI9Z}qwLmX zU7MH)7B1GCJC4u?X`YKqGXGiL6&3N*h9nWDi@f%tK`#k5Xb?S)>K zAz`C@wfC5kLCg4gGA(n?1O&FT6S(bG)e}^kp{Zn5I-x4yKEW2j?lc|WPLzCmqz!@o z$?+ur5WD_$F1%;kfm)V5)`P15%64K&OjHLhUiTHb)7oOcZGn?v1p^?7bPj^lWJy$u zd7_IetHbVEsff5|v*(X^88Ks7wQL!vc%T24otMF&ss4mCO^D_jkTB;Y!^VtL+Sx+7B)w7fSo9%Ww63}u~~Q`c3zkV zj$~9k2wBAjQv1$Q!;3jlCFw-Usm)m;qZoNpcS0=j1gsWI9j=Kn1r{x`ESXn(xiqQG zX-1ik0AiBT-{0Sj&ptZ7pFa*0DHU65rJVK;ZJ82~B2*$;N{+PFiKj`<)GmV2vxT3) z8PqpV1t>#`wxUJDQCnzYbOH@^1{>g-vQoG20HF8~E`ZBnEgDg0&;u2;d_h*$Ojo%0 z@=>f*BeV|eUl<~F#{TOik%4HQK@)b$!nN+F`)yPS|n3PbXEhr5v<65;aBpx z>igP&9_BKM>TM7@K=as;j!HpCiBt`lO}r9%xn>{oF%;GwDs<-q=Aw8n;u@r@3DOk3 z(BEb>=xzt9ZTd2Lh5Nt#2VDI{XBDQ*rMr%Dpdupsha87&dCAK^qFaqJfNPX8o|chLnY6oin38BB{MG*vL>3!naPPRhvi$4> z2n2UxVzzE~9oJR^A^}1>_j>>J-mB5N9nx-qv5bJrMA=} zqye(VQeJ+;#klqt0QX8&;nLSdJ1*}T*L>Bu=J8p8{Qq9dkxv<~OFVr1>_Q*PTdNnj z2>^IfD5}V4(_|gq%xpKLi!FK*Z!r!NZ<7S{(>xFJfYyleLwsSd4;C z5VsC;oe!mD0!q51ngi(QqRCNZNX9g@`P4pLco-6Z zBe_<&8*P@V6xMgbb(L&nF`rbRg;CI#1dJ_ZJ>_Powmr}|vYu0%Ts`*n zL5{N=(`qT@V{zpHIDSCY)|6Un?anh}0VpzQgVpa@&x2mb^*Dxp z*N~6l1_G|Xd~E-FkFw-fa%3-Of2B_%6e`%soLw_;v!*eVJa|V2q)CQWokPFNjB`r@ zW;C;6Q;JoMUDWZ=ZUTHN==ig31APT$GnmL`IsXHmTDKR5p@NU)oae4DTq1-mUu_ck za)D$mp13I~OVURplXe0X7{N$?xZAE=e+v22RlIHcn`w_B(S*ex+L#qK28H4n!O(hJ+pK;IF^R?-YTLy0Uz2BP^b17UQI8viLN zDEr*hhBc(iLAzMTNlpr+I<{>URxc`I7@}VrZtH561?a@#Ky&i(aRD? z$H$1$QK``5cSbc*ffIpLk-8_n%=%0I`Yry%UALIEJRdNQAPG(FPj!f^NNOwY)FOAU z94R!r;l1->1jbY@=u9+GI&!d>%cLJT8^*ZeTZ#fTvl7Fu09de~Tk0nx{#K!YZ&^^o z8r+m#A-8+AB-&xlRKWJ?N^$Z(>JKQ}=3fzBje;zl>;EOPe$udHeZ!XQf4M)k=D^3G z9v%M&VCfI%kY2_c8R1^AQeaCOMppsd+{YZMkYLz9%fTj+Kh|L@^NQaEzIS-;tpX5=1n> zOF~P9{kF`5u8RdDoSB=8mXm29wVP4btTkGS#wx0g|5^)@F#`J;?(j0$#j-ySPYs{R z+P+K+15k^tPSJ9$eGE;tV1TAtJO(gpO88unNkmJ=0ol+M9*l{6pGzw<-U&;qc@dtk zU&PMV<3v*^nFBD3Yp8Pfaf5CkE;{eYIGA!z=Ef+8z0h6(WP)TmtsDU6;@Nzu7-}GZ zt!zFZv8KK~>yz&Q=&@$)neV!druTjd^IY%v0cm)bGKsB2D>s@*Cuzt&${!*YH2(XU9IrB466IYlt6XYE+6Ww=nBSJ%W349)TX^NtuN_boN|1}DHssd(Se`>@u&+yf-t=u2p%05r~ zOoKL|NU9cM7gJM?y~-=+zwg{`JT^k+x+Wu?j%s-^ci$3`9P&mGj-y48 z44)eK!g3n~V$8sD+2dR8P)x427j6Hsbuj%1k-bvb%axcQG~LecI&wXzcQm=Cu*1`S(sO#)3%Pb|^mHOyiQr+!z=1CtrGYSh1)~pPNh07vI)GO`l&L-lE$fXr1<`NOhVY{Ikq4EBRFm$ z$|Bs~>>}BzW^gci-aDNyMzLhS=y4@u$QXQoWHh=i9<}~gl}a=o{2bHqQca-!vZ|AM z20?^xX6bOp|kT8**Nc zdnA(#ksqW&`P-I>bP5dcokp4pJVqNCWy~gIaw80+i>Nab6Z$@%f{?sTK&u}9i*-QT zH})Jdx5Kl#lpiPv5cZ==*=mqOC~uo&&=}! z#qVjF6rRie>}tARfAVCDx8OjQ>w3d+YixG5i|_#EntJcI<6ElX=Rf2TE{nILz_;wv z`VJDN7p-0oimi{?9NzbqTdkX8l~UNKfBJOV?_N3e0oOh)R^iFHGbNz|>q_)0?TC(S zTy>m&r>VNn{f~h&69+j@Cu?mkhbZ46a~f@)@h6;D8!U3;PWiT9cuu7=oIiM18k~Rb z*cyUz`5pB|M%=rWS+0sW>4brypxJhi%l17k1X zD|la->&{e!nU0`1TLjQU*<}0PArx#uqp^LC)$W~e{m!0AO>&u{#m z(C10b;3`;r&3LrqSRi*Ob|6CNDT5;?I|L;llR0qbDEG=lbknL~3pBgiAx$hx9dYdV zcMNGoe->?aonPV?{?iqVkXUoC(N}1^NF7^2@W9RBe!{Z(^}^R=!>KamLL5(uj{M{v z|I!6F#IyI5vK&#)ygy*_`49KZ&iSPZq3>UW_UG)dv$jhxLa*(Q|0LIK>%DUw{mw5@ zwR6^*>yMfY^=JEGh@+h3X}2LR1Np_p#Tf(bGEH#5Qt&9r=19nEfl!7o3C-T)3<(3; zV9=Y7n<(t=V-0pEDs`_L_@JlfUNa-3A04iB2gL~WJ3ro0_P(;Ydm{vnySx#^Qvt7p zV+*4iuJ4X3w@dZ@nHJ6ccSQtWqPs2c)wW@Qr3mIpW^bh?4Mz&dBIGBI1wR zJui4{CLQ2BoVl6jUt`bgxT>Oe5-$RX5Ih~(L*bT4eg~2RTf=uq9a&`W857Xe^DWO! z!`}E1D$iV#s5Rp1_N7(aKo=rAJjwfc6CSaOYEOHrRizW0$K0Cx}CSNuALKm+BS1I6|ENeToO(tm^z2WiYsohD))_#vBag zoj=BEMG(@^)p!LLTd)YqN#w}m@s@V2rxm-0ImqlX4VisgeAQ~RU~2p80`qnG7`%>T zq+O1UyY}?KkY9A=Ye_8H^xLE@8$1qp)-QQNh*~$InrC0&PM2ew1MDL}0b%Iyy5TA* zs^!Q=+9dQk-vVFTKBG~ul5u)*FIP~>!`Y)-%X$M@N-gO|LuQGQ5?Of4WRzoBSXe}O z;g`c(D3>F?l<^kt{iKNg!Hkc+NiY2dYs^>}iJ)8iL>7CWlqSMq0u*$$Wzovu4Ua8i z!Y`6rG0;K49SE_{6N4=rd)~JK@`2A!_Ml0<$6PQvqvoTcB}xVgzZ|pa5rE#xRIfz3 zh#Y_wxTDYL9Ct`Mv0I@Nz|uS1EjV>~0n|ySP0vR^4FOQlx+Bw5jiQ|8L?73-v6T%; z8BsZ->sN5RU*T4yk~Ne*tL+w%4e3ey+K%^pk)Y-T$7q!^hN)vN)to4njfl>aLa z`bZF?8~zUsf~=aGs^dHi5FCB{lMT)FR-Un+11_bztood2+j+Ce=fEais5M<2nK$1)_$;7i=Mq`5g`-)5Nb zD5RGFOP?^8GzR}PWhpe(`!-3Z=Q)T(==__QHjQ0qvpflDxN~^O&l4R7F7_6>s=pKY z7?+Bka5?xEN(JFYkKL8a@5z)z)Es(}RwV;hcfp2}9E_lBi4f6z)s83rK3wjHL-=hf zwSFqi4%^3w8!@o9BbD15Y-JtpJp6dkvK=N&@N*1BZF;!5xpiQi0EDAI|TLe#Oo z5jdo}E1_jl0*+MLXL9;RCb`c!5WKwTbl+e(ep}hzdkny$(k;!tUv?kEN4FJ|X=`*I zBfwMf%Ed@6+38`0rUcAOQ)GB)LZ5kTMN&DeqQjv16V2x@0we#9)9=s(I z{@{>TcTww5GfDYi^%}TjnB$piYrt>sw|xJV@W0JYPr`6VgkK<#qIs*kr@Q+nvHPco zVZq(g{To|F4P)b5HNtP5MQa=yPA`ua<|ic-Ucx_SptV4Lyo=@BSKzfNiTzFHwn1PH zozDt7s>Xk6p&2ymiCiV2C1vo@oDajAQcwh2RT1E-C{S4Jj=6&z4+SoZv^2h z>>T#^e44R1gFN#iQRtAERJaVQ)+^ezYu^TQq%!&3xgbK=Y55A(6pYV8KWO9nyyFtH z*IX+aCk>n)6a&VO%LyfB${Sf@!v@@xvLN9`)~d0uE_^QrqOq>Vg0ZyN`+lu#j?C$0 z@>(WZv+HgfuO-K=y1+jQH=qh=0Yl*jjhAkab*8uD*Ds_G8a9wsuQ7RcL`*D`5u7SQ7j>f?qo&)WE$@6;uedLXkr9JBO1_+vK zJIwEMq6wzdqEvCTo(&(hetg2cDJi)j@?UndUwTQ8(^cd`{9tAArS3l6aEw1kQ|jYA z${qaoivX7>qOIHA5W2$VA>g6z4n=QVTIhxIMaO9@$k%ygBSAIYz8i%eqc{XLT%i$; zll`YD*{~@Z)b6i~31t@`8wVd%dP(7 zCl=wl-c67;70kYaBh;Jxmy3k1t|0yAkb7v#Z*5^344_1^0P1J`TC2^ zTJUkg-=foCOhoAW_Hfqq>A>i>uZ%ipX9HXjrO#w_rN8ufG|qMTr#S957V@v?sX5a=(o^)zE>`i8IbUc{kiWyvxh7mvJX*E(0tOv`VA zNY|fw%@-Pj#UWOWeF>!y8rrK8R)}p%sIou*$nd)!Yjad$Fi0u0DMYD1fpL+EmVXkHo0EC#ZZz38?f3 zxiiU>FI`}kZbDfty=abyl3@8~kz+%F6g91W6uEe1?t>LUX?YUdSE~iWNtJU71Xyl;vFMG;f`@hX>Lu& zzlVs7ylYoY#8}L%4^zaJy(YrKP?J2G@$=B|^TGvmztLOXa+=f$v9Ke7_#M5Uk}))s z#U|L+$O$b`Xe?a8t2;J`#DJ{3Tyf@LIHETNPt~(LsSVaTggU*MAv*X!jJlQXLr#ty z`6kAi5dQyB9Y;Qi)!t6={utb4I*A(yMf?`+cbzFL{f1!R;a{-OXkxx~Rc{1-l0r9J z2RQ5)?~JNwQzwgrG_y!t2pv;Ao%aSDGL)6mj*5zxm@91>>GP}gHeka7%|At;aYO&A zr7=RtEW>j8mno%MU?$S48|hba6qk+iVK6wu<`PJgqT<&wn{dKKQ6Yd5w6CHV5HZW7 zlUP~QVLmw>Q76c9OPEoj)jG=u;u5*3%J8IRQOo$YvTldY0jafB zEaf3MP|G^yGj^n9CC9i;jDx-r?xto;c$k9Bc#bHGHk`Pq;B+xxn4M&E^wU56jtY=r zb&UM`-e0C5in8)~JEin+I#mL%H7TATRIc9x>f3o0N!-V&-=FxwBVJY zZn3MNror&u5SM6$Fuzex4x%PL^ZZHo(%vl-feUgk%0@V@PIuz8pn0pel-U6zyK09U zoE}xWgY%}{E2f@xRp8kPtsanl`5t%we?oyUdhiK@yesQ3wr@Sw7Lv0PxVsMKvkxiT zaJ=$_s|(xRg|K9KW}8WLV6ESZ#KbI$lnsJ$D)&_nWFetLhscySiy+$p%eg6)a(j8k z#U0%8TXp0lK_T$D2<9`Th&CGSkfYlzxtbvJ?^1SsJ|QzMv93ibu;6=(pH+TGN=37J zMqWFvSXd;CV&TUoN0DHV_t=6&8mk(xFa`#=F1xPD!{$G(N3knl@4NauCmqs6!Rz!Pk*kI z4K{sSKJn^Cf?#if5p4C_9#1ba(V=L(>p$?OC%+PPbRX_|snS{dUqsGs1xEQ|hEuHN zOX~X>w)hArq);c+5MVC-d16uM+&k6zsF4ik_pJX(0!R(~Pth?XUQla)=cIOXFK{na zUNFgIju{gD{h#I{&JU|d?Fpal)+=GL;g|e#8q41`)T4k+NN8K8OYd3V2Chu4uteNF z5omS5MAT@~X7NODc>`c^L>q&`pJ)?5JFYf5(M>o0DASFoCrxn3V#}NH9RkU~ZD5{f zB&k|A;Pk{;*36AfVs>kPmb%r|wx3}mw7;pETshS~+~UD?%2^sZu00uFZ6PQk|xyBT-`F$7QEA~q5*DZ+Kli`cS1?d}=bzg)ccYJa{;cv9Ra#iOEh zk!}v%takL(ZSK?+7yZ&F6}{i@iH3zA2J3sWmg6{Em`Z;HOR1)_;>}}4oZF{Q2)&9> z4-GPQ<$8m_3pq_i{a>P)0aNsD!bbO}vlcMouo1*01q7a zf$H3RLRdBXYZ(iUh=^#zhT#9d#9tl%;_%VTaw%efj6WO5( zCJdDti_PfXH{aJOx&rK3`&fC+1tgoO#5e8yD`=j4+Oz9&fn(SFY0|)f$nhd#mAgXA zFh{K>Va0*3*oBa6vb?ng4{}NZP>Egl5=C9zw-N;VWYN358HS@41Je=dbXFGa4l#bu zebR+BYqI8_9X)2v`dQSR=>6M{{~zh-T3QqfqDy^+QQN-l*+RDf5K@p*yk_^sdg}^3 zmRf3VLkI#FyQj3l4i9TH7&K}G+pucz@ygBG!=9oJO}1iQy?O#lP?R{n;>EsJ2q($X zF-SJfTN%ZQ+RArr#4G>&d>uE0{|*p^5=jGEx`d^E+5$e@W;$$Gl`tjMS@%%fKa&@- zfh}-oHsFs^HxI@Ctw-HU2u?*EE*qOsEBaPQliiqOQbt4XzTkIA(}P2vD!2Z{(bKxR z5+nVsk;bOx`7ME_l1+oQ~7v|pf(2^A+*qB6U?WPbqr`R*!=(Xgz+yPF(^Ryj6W0PgJ12MDcS zvQa;Lkyuql+W54-(&NJF6mP}pV5jfT(Q-9mUf#r^;f{{j)F5e1 zQjSkx8^)CLM79T-aZC%T+Gvn_(UG%1+rHD0X5ptO>Q+a*RhU!Q{!hLSV7v zAfpL;E|aH$+tv&XlgT%bCQzz)Ddsb@ZaBce>kjRAK-2cSYhC3SHXVmg;<^l)oyXm^ zm6Nuv$MhIJv0XfE@M6Y6n+71lH&6`H{&eD>vM^<@V1kqRCYaWlEa#w3l_(6RYDo^; zb4i2IHNL)bOnxvYYj&BiBz)3cMf3%O^9+3S>>GlT(eL>0sbW5Hs+mr*0NRN@Ee%#; z4kkeT37smKY;`*k7?w>^Een|jpT?7nYW1^E_xO^eP!-or90J-7Mz24FTW|cY3lslL z_P|-&JJx=)=&Z4spCGxdm@ifcUM!>GwB39m>+exEI=sOYI{6d7J4MX2KfAd#Q!RmV z#d6pW-kqqIOBBgB-_tvAb)T&M{(d-hejnx6|5gnP!`r3Z zkjp1xn>50}t$IW}qs+iCUyF@iA!}Fpf-HCCLlq;9ur#u9Ih2zv@sEfKMQBA*6!D7Z zvcqh-T&AlkTt8M6Au?E_M_yy)%=Sl82oS=GaL-A7f#Fd+;}C-Pu4}b&%byl<8gv*c za|32i`L=b99cx{Wx5B9f7UaitaWTvIrig~Ge^#okPRA>;{`()pM0{f_a?+xtnh8Sp zHnLm3?HoCqs=XremiJPV6_nItKeS zoUderU;-PC`M4n|)Eg}ObY~-2@$|Yi42(84bV58b_;7_UQK}1Sp*Umlr+!}eg;|n= zOIxmF23`Uc1|B3uN|Wgua`U8C!bihk6KKUm9X@cExSSeJNtJpbuMMuBjA1}SFW>W| zN(xdWA?yD8nkuZQ_7Sg3{e{uITvUWhg*B{}vHPll8yEiA6V}+02jTpGnGMs+QE5|b z4SA<44zc^7axek1FiGYG`bDd)ar%$Ct+Lqe&lpysq#pnoV<1}|S?EwFIun(c7%x|R za=cu`vFs^$hbRIh{BK5soBm#iBJZ6dY2^uQuej1-JYj(Z3_}l8Ag0``uHC=b{~V=K z^P?wpy(pJ)Qzz(~R%+h*Rmp@?dOrt~%L
1jzNgQ(ff9bahzAI!=(g<*DAWMfr7 zUsI+9NoD;$-tYvg`)e|S(0W`bLH|L*J>h>oZ=E5gb?6LW^`Eii1e|(E4@A@9frKPWaD-st^ z(jH$Lhv=nj#0?QVZrQsmo{A>W)?PAy;j``Ii00kioPV$F?Xh$;aJTpOW-7$@rOdqC zS9@8sNyUTq&y`P~S5Z32baQp&L1DF@|F)H@_@_ z33(zrnyRLI|4`SVze{QDe2fAGf^gGq+Fr$`uIGcVRK@R>U~4;;eKywqq5DC-{_qQS zzef@HypntK^RsksWD|BP}PYUuK1WRT;Lem96iA z51ag(P1W6>f%Q#~bZv4Z7U5YzC*tZ#l(5*4SPU8xH=LNtC6xHLanFYtmHpu|za2sN zzG|4tqIN%^!05oqNqaJq*bo=j?T^lymS5f*AO2VtxLy(|;SG5ecyFv^HP3#xt=C_+ z^v^$f7SO-AHrbjGI~Jpvc%v@8oy@x`n6S#W5n>j+Q?o!}eC$jW9Ido@4x`N5u!7~b&p@r?S^TMZ*V9@1qRaW+!baicuB|%X>OP@p))-QC^Y-HN*xxj=#9?(S~I-6<4zm$&?RgoKcgo8{i!@0>F;XGWN! z`RwVq`D}3!2;|!KILiwoE7(V_;=@UzHegiOafG&Bth}9Y_;J8@&oca_^L3)Gn;Uor z8g$&(fuxofQnLE3GW}P3BDyNy*C^JQ5M+*_m1Tf^>{U-B|Yq*p4T=2UGgyMG%4 z0ZC>0v_(69S42L5`qmg%{Z=!v&kd(&`l7RcK2o;6nmM7Z>-R?#SGIGV)Mt+_vzG3e z)Ox=A%nHn-x|*@Mk-GNCzT;9}HBAF8=oFa(R4eeQE=e8t*KjMcqmAb1E9i;pUe6ah znCrWBbeMfBw9{CAp)}X)&1^(I!LQShrYr|ui6Pw13+`xE9p`mdQ-t$|Mg2Hd{7HO= zA44SG$+VHpe}7uXD>=Qt-aF5$s=J;U7V?&*5Qa>1F(`}%XC)+%=WEoM{L3*JosOhz z6lWzQKqs_H%;pF!S*#JR1-w0bHI4v-H8pM- zDy*#iURN~r^ESuRG^$&RtGLSD|G&#i+sOK3RRVPs%26*FHB)0&#W@xV5eP(dWHik4 z1Fq^`n;aWL{DuibI`NN4-o+y$Lzl>?fI54C2rB< zd%qqg`-P%JqtS$J0e>nRE2D!pMkqkVWZ`o0_bW0*=?APa_kT*?81@QZ0`GEk|s_0VU~4rBEIxFKJ9>; zl9Fs_^8>>z-8z2*Tr`nVLeQEfedwac77p!Tv@~CZ;TRheD z!{o2nUqT+07hf$U2|hsHS~ihNx9ffd>jJomGi!$$=qc4r_j~QPy^H7RLdaxi9gl?$ zOtA#@9cb=JatwFN{vZ5eGe7L~nZ zG1@RBc&ht?5L-3Gf*LHc3cjgO3&U*MO>MHOEIV=YnGMzzrZzU^hzcG}RkWZ##NXV= zLd>ztPSRlglfZLmdi`YQ{4uNT_zVy^hD5rg_T2v0)@rn*dvlEJ{;B)_O6H>tcM%~(l+btrD?yPp2By}Z>+g%2c zYWH_qb6W_-7OezUNRBf6khiiDOr!|x8`ij%O8?0S#IP&I*JwvBhWME%Rak>BoD1~e zM(PVkT&a`i`YQx83-oq>bDoRp9o;1c=s#aL7FDZlm79O#dnOqDP3PcvsUESP8PLLT zzDhs4Vw$q+&KkB}o{3fqrQHVOmju4Ox!(JYg9=yE8t|9i^OQ?9-7o042kc2qf$t=( z#D{bc;J==kY1ayGjFhJ5MF;E->HQ-qZMDpH)^YhGng#e0fa!RjHe|UjXs&r4Wba3n zj1nP`Hv@q?k3gKrz`VNty6T1R=v)6ugpn)!A>7d`@6GTpB%*2oqb^mS-NY!oGCCci z%dBA3zm==!g5P=?7IlY2adlN{@%7)X(zs8!r?#=HJfOkSblvc|!r~LBeExo^U$x!1 z&bfDZ*bckCYwF4dB7ndhM(KV#oHi^zjf5tPI^>XzAt}xY!VLuR9qeWev_wz&Z`YCa z`Xb+YC9zMyBWW}drbY+_Gc5fg%lt;i07DEDX%~P|tL5Ezi%A_;18<eKSCCn?CGim;RaM)ngStIl5X5Cy&-;L#Q#_ z^u*|-6bDam{n6h5T=WMs>9xl>=_1E=#a$Y!d`uI?aFa@vBGC{S;KpBfGbjVz^-=D; zd;ezrQY+ie+U*BiK-Qe6MCm@>zKP6@8*0EKT=~d1G;L~l81BQf0w7KNk-9U?b4CCk zxnY6#Yk{;AAy;2(ruQr6_g?LdE1b@keP+mP+?8a$4%__g2~|J12%=u`tv?V z9kt0wb3WJ>1M)9vpZCqx_2d}jOBRoP9&S(_CvLmIu{i4_@*ej&)Sdeirur-&`hUMA z9ap|1`@RO}hjMoTceO;AliV=Yt~mjg`38(W$EybpS9HIxD(pdhU#H$U7oHxP z67r(YeeY>}-o0_RvU1l+w$IeK~t(|1R?kYzKacN%5Ff09QKv)2g{x_^6V-Ke1Z1jleO8ftsxf){aWh*c&CmsK5_ zED_d#P^n06qOKv9KCZbNXqIZa+snsuIwiCxfy4%;nm{=c#i{s$t=XvCU2gDNDr27J zx*s2$Bta&>N^3X`KGX(h9+fVLUMagWixOBb>Q<%PDXT$+p9n>#C6VxOT|D@Z;=3ZL zO3Lg>New5cYqAxu2FWF2^N8oq2fqOpHo!`oL zCK>X>4uC~wY4jHio=O9ge?eH(=7)c^mz)P>p3Kh!GdT!I>%93C%?eO46d?v9iJxc-m%PtAqQ=!XPu|{O zULmE2oq`h+wfHDTerUAc6J-(fj70N(1$_9Vk`8dp8S+qd8P`l?sdP)G|rkg`dJd`^_sBtT+%PyBIQQ3x(1lf_dJv~Awz-;GeS4j6f`_Lr707P6VB z76%n@^BKcEm+jD_nL#da{vJSSa!fys5Hgj3ErruUj-&hsp&Z3d5i12rE#3kC-quyu z+E)EgtpIy4L}npCg6GnRIKfEDZ0w6m?4=SNi4FJSNca?6pX}GQA$jH(?6lVxGtP2C zNZenMoqvSAs7F1!^Fswkh-`kiAnY5d^pMa)%>+82{Nqhe%^zu$y-1PY3|wGcFq1e? zz&dr$zOM391GRJ$NR~)NqR_XK9D6|Z@1xiGvS0!%jI)-597o56LV`Z!nU6M# z&U-~Gye(MnudRKp7}1a}__}9~^HIrfC!i!bt=cxFJg+p`5&{BmRvM6x6ZheubI(UG%jhf;O^sjY@fhSl-^550sP<_Z2ea^Ue-ZFaAP8F$UB&lcOXXKcPt9p#+Q)es(0)~eg6*FPz67^l}-vZrk5_QnNk~d)Kwh3v51|2 zJm9Y#S4+{FV1%Rh*?oc!WS}LE$sCEL6f7 zjWqPa7IZ>|SS0nNn1mvYH1VX^l1i>Bsn)F!3Hk^>=y->!K`WRyslSv|D8?S-ONS~@ zAFIls#_GL5lI446?{U~8?!y)y(XzeRbOmrBgD0BK%Rqh7@tr65%=KxxqhrS)ZGnskmGIFQWZb#2n7A5R)(?5O3o%Xb>3EdVnKquY*^GA1B|h! z+BRS927#}@G2oHpL|-!=Afpoz7EP>v&1qXYVtYd6L3=Wq$u$q744~fb=UO7y5$Ch3 zEfF$EnqfCxskb!IT)-BdcD27PtqG5SaanyV8!kzf0kOCo_dMPWN$on}5xd#w!445! z`=nN-_XWZ-{RPApecA5((W#Kp$kv<{_I1A2lNDbp3{U%3Ydn#Y;Oh@X_JVm`-sOOd zhYp+d_L+Ow8^Y--} z!}t!aEqw1wb()R<*t4n%G>m@d-NffMA&9A;)l++})6s>-v4B1MQ z{`~K1P{sDY7XB4q+hJ2}y~8k|yfwh8K}-D><$x$SIJo^twQ2`wbeJ-dKd5|jf#>Cq zjFV$LphPli)rSLhtMDUwggNbf(f<{f*`o(h14#er>+93CYqm;wp!0rOZYHn#DpdDW zNKj`<6dhgtw0R4W0ybel_M1j8(f1LYeX5*kr^`Z(JaY3EM`etEY<+%7C)bj%54WW=o3LFG+oUQbkNZV2m-2 z7Ll9)V63q+y<6V;NY9(RA&a}d=nUC>EreRpx~jo;zO6qE$C8QtpePvfdqo=$ z(Fp;~O3VrH4D0yb&ink6)~A@duI%AJ3ivptX|fz32*27D=v6uQK;t_6%`i_Lg5%u( z`#M`)DWgjxq%wm_^k6ebAvP6oWtkpd!0R=nJFd}6^b-vab3U>(z>dp9SuZJ&IJq9o8u#a=FbFXjG97)1fEiIujO~;{TQp6KZ5z~Or z)fYiS(l08|nW3-m3SFj#;xJuzZ#*?H%i<|l{xiNCSX#ik73&9ow*8w`XSdhlZ#ZHL zXwAH9r%HoM242~Z(C;_feu$uY#@hc3RquL&1}qh8OpQ_Kz8jI?zFg1{xjleMr%qCT zDM`yytOWoFJ5|&-YKTg^#Qk|ys)Y=tJ?^oq{8g&yzk5lbu{famFY0=8m#N=5Uew*0 zzvaw=i4<6w^j-YDZ-+WC*qs06b8uq)5Oq**$xf-pxS8~N?+xQfq*(R(Gt=HD1QOK) zhgx2!n?c`|EBH0o#*YyiG2!O~v_NZmFlc?Kx8rZ(zH$3wi23Dpl-=If#rL2>X#(Ek zW!P$$N2F-S6C(Uiu|o92*HDGNCx#W&k}E&7)-ImhOF#xt_FM2kVr)t(HQ=kH8Y-1`zXb)@)<7Xi{90l0!Gu6{0Vfmeu<-wBM{tsQ%d zu*Pf9f<8v3_V?Fw<~m}L^uVr7ZZCVGQ8|-@~<|J(}gvc*g8wS(g{` zx2E5~FuU&ae4>Ays6KAo<%`7g?W`FD)NvlYyHQbqH2LW^=EXK&R4h?v@Fom4IJh!* zgbqZ!?d?P{n_1A(ND5jzw0s{S*i*2VC}2AbvVEC)f3`J&5+}YjS~*3}*I;vx<9^xK z?~FWM6$4s@Fs}QxW6w+Jzs}unurn6y)gdE+RV(?X!Msf-8A9~cYGjlMILe0Eeb}y1 z(6wJ!A1Sr$*>oCQRskz4e*Vh{d70+*Ic?X`BA|koE;si8b=khgXaIo(&`)E8^F3?@ zgE8uN`edDqehi_LeE0t)Wa|OcoL7^Y;p8JY`P@atyjA<_sr>=)*&^t_?BH?(K~xr; znft9$B}y(X2sf|r8lQ#?iNyEHVXY_Ql`qq^V7po}w0_nd@IMq*{Xa zZmGngx6m|4UaE~)%$S9Gvqw_!Uj(8i7ee*fV{T~glYM50AWAXaT}X3LEhz4Pdpywr zO%M*TW^&pcOny5TJ4zPwAKac22uLHUi`TZ_5G*IuNb=0eB&5I&R3w4b9+@QmmGyk8 zz3jgE>N#PpKX5dU7>>=J51zk7sx2j++5oQ=M3a4kP#&ml*GXLQB$3DocP_QqOXK~( zxH%Mcy?T4T?TI~Opc{+UsdxY5R$CLWAUj-Qu`SxUI3ONQ5NF!^S+p+?iZtm2>`}ZCdEV#&C#_e36&n}V@HE;Uz6RzISl=)l5Wa_Ojl5v0 zaomCJnS+~u4F+={<9Q-b1RbxA9bY+EBR7AP4RqQ(%d=Gc1z%hG@cLMXh(tQ{4m!?# znNj&}(5x5Ax9ex+j2#@}f5q{)N^kUNjg8QN##>#Fn|C4^xGwuKhdOM^8+0MhKadGX zz*7&1?i~2GaT!~=wZ}p$km3XU5D6eQf*LM5IR$Zk4j+f-|CNAzSq z(SQ3DF;zYQOG784##$P;3W}U>0YeDGY%v@2tnl3Lz739CsN8EV-KrTPDO6j;OR9F<^*VALo(Zy5uXY_nk*RDx$a#(@_y}7BI55k* zo*YCR%%Pt{3j$(R0L7)r4^bMg96=d!({16yfCf(*EC{!l_kscWpUhX~CH>n-PVn>phC3;?ge0&_Zud_WySvloQ z(`!wQAzb`Lsc-B#TXcJK8`$up<=TwJ9kuxcSDjoGvH}Jmm6}+1AYe&6$Si+i(?jMa z3rgxgO%Q-&nG}a5`xHyHg4=3=WcnAA!OWAD%DOe|-c4fX-Zqc2pHcHBaV*Pc6{8T! zl^ejxT5WZrscKf>zM%Koanzb6)6u?Ix*DdDd*tYKuM91_y5~1Mhw8?ZU z4b!;shmrbujL4{3%cU(&L{4fD)0>3H_x#&!K^qKKKR7p5E#ch$evXY`1_n}XF_`is z^1YGkynkH{-(9tc=^4+s7*&6VX2VF|(RCX6)Me-aCH7-`ze+1Kob67p6^{5lb;@^} zHy=-}{KR~965S*#h~!!W{KHKMQ^8=L#hW>ENNB1vj`ZaaM$Yfp*-?$6e(?Yio89Ta zCrh8);e=(MPeo6RwWhmH3BnpNl^<6EKPiJ#u4r7FE5=xl734hCk|Eaj#nSnpmqGth zCq(L!V#MK*+0JN44sQc~sMAcKK#fte6$x8l{I`HNN&{SK71358?2i#$!aS{9vMB`h z4m^fAXDvoOwls78vPo1w4`>>Y6B4uM2!-EG-?w2ld472iN|oP)egFyO6jPOd-}U1k zRR59RZ>L87woHtAC*U>}tb(bH4iJ;v=jsywj8zJQ7CZbnqV9jn+pX?`=xAt05Y9Wk z^5j-A$jw=jerC9GVk6HI!Aj8xIR+D@;n|gEZbsZjvizB<<+rVc^ZeqRqb9#RyWYC% zNKKL)_*sGFPq&_K6}_MqbaI;$^W7UxDWr-mg@LM_`K#yfMH}ned;T*KHDP_`wHSAOv^q z`%uQA*6p3Wcypt&vO-xtUVEG4&EYoak|JR%Mnxcmv;2bN90^~?6R=C8`$W0|bp=eNz>xA#`c z`z>;bAzmH)Jsp}Q6yPSD=BvXZFE=w)(`J~$W*VnuvdvqKlSIZMbeWAd_e1C|K`)45 z?zCytAym@D4aE^y=oM-|I3OyHXiwwm`?N+c zSjOO6xrSTEoBe{@Uk44E97-5Oi{yew&7=Zk+mi`n$(LjdF2V#;d_9I`Baw2gPnnu< z>0iuoUSD4&poKkNd9qAw$|oRE%k!4;ZWhp-NDV@!aRS+Gi3GkAqzz|?|RFtvyb)u?(F0ofeE}v zU3O22&Fw?P`kiTKa5pE5VY%}$^xS3^(E*w({~`aEA%BIc#faUr+akO!I&Up)Jd-jq z@}=)5Qn!E4UYRUpp*4fM2URpnF5bI&Lj&tZmqM1vtt$?ws5B1D-!ev`xDbWRoa$$q zb7hKDOCp&Y{OPzCk>P}&%LJ*Xt}&XSs@0(D!A(pw)$A~z80wo4E`q+n44Mbu-kk!S za4VG#Po1x-9!PJnq}BJf&P$dC(s%wE*%o!&+M`4?fwaAJ^`^GEXNLV$OW;O*N&WEJ z%0{O7Eie#Z%Xrlqs`@xs2a|JaypTnToK*R`e)jLIZb|chvivdAI&4fEa)j_`l;_Lh zY34*#YEj0BS3KEYyi!CTfMA>HqI6ZT{NSs}h!Ek5V+n4&asu9d?q ztBk^XA$SJNhm|Ai&DSW=Amc2PROjtTT z^5%FPMGarEvNa`fMOIT&mWpaPt!Rn?i0{a%#xex)W5$F>;fU^9!aQ0ad@p3AlEZr8 zeyRIXf5u72u1eeb-@{*VXHw~9mEWLWicTzT0~^XvMhykaAj0(;EdXn809*!3Wz>Xt z@W8M7DG=7wacWEC&<&pD|Mm#1HdKLcy}x$74}G-Xoc0^z9~g;KH`37KusDB^Qlz}^ zk@9~MD9u#I_n)v!fZ6^>G^_^@49f#Hwl|*L1nYlYIjhn1#+rr6xaLf*|8`z=1%N4T z(V_l)uP(_qx8e;8CYpn*8+V+*RrCTO(|j91YHX4zzInDQ4gjUH0W`O=QaEV(r>%CPwQuwstqAEQ)?Wyg*)#Ws%qR$S3Y0x+Hdf_JypFEb`G60z4bEFKSki-Cxf=TWT;2&iJ20;Gj z<*6%RM8u{0B=FFK>M>!h*Jtl{P3Q+`u~W$Svu1%XS;b_fmy0lgy@o1Zrlw85Z$K(0 zurKE@h;JTGCF%KD5zDb3UEJa3kGS)GinUtxlX}Syhel=YDFL|g*nOO`~t&x=a z9#mb;FBp0b{fK~OWDuY9lO)Cuw-Xb#K!7?odxKTYVGvHuw! zJe#LRL2b=pW(XfAcZypPO6L(to_w7v&3hkd^nJOPuQ-0s6JbHgUV@Q{h?@t4>J(?f zZvs(|Q}8FUvbDt(qd2{g-3t}%m^NHV`?oYKW56J%;nOiqh!`B|=qP-ptBIzt&TK$0 zp7hU#s0h`!W?#M<*j}1WE}p4!rGY&q#2v0XnM}z)!sN+RV?z#~MF8Jj_9yJHE(@Mm zEIMRQR>M@e_3C0#w5JlYI7*`5Yr20T5nEm{ z{>t8)J7Qh%Tu>Or(-F4A@V77C@h$KbYjmYspewk*e4)iLdBjuFRM~qb0`|sMtzAMo z-8o9&x2g!HwM}Ea*M~8yqf)Ff-#}FG6J~XDmzl#wIvvV)G{)PZx=d{<(17L9$L)C8 z*whj8@nQJhE&nzdhVWKNae)H`)|AX!>7I4w-jQnj2AeF+9c*G?TFwLWp{PiZ$}0#7 z5}MI6zC4YoXMV(lDL|t_1`v0*Z%pabiYP3aX04OrQp*ENp+cSax;aIf@Lpg?x9N;u z3NMbnn?*Amr93%&r}M?9t?=H3dG)HKuUoj*UfSS($Gs49)%Islp+*36Mdv(2h zGE^O8wK*@uuf58F-(uErFND%Go7Tdvk;)(6A!L636QPb|dwY6xh&AY_qnBp&!)d82 znV))e(>pQY4>~EA-idxmv9%Fp)Xrrmrv;7k)Cx+tqnr!)6)>0m1auxQIyQ#Y8yX`5 zcqS>k%AtA1v6{VM^a$Yj**#BT+~<7$WA&m@o+IDngFlVsR-KPn%Uh^{`^B&VGh%S| zH}}-Vg6EgAJ`mLe(W*Gf_V2C-`iM0!Cn!p1AEyYg1JH8KHY=y?GK{EXi-D1jU|0Xj zWF1qAzde;?&;t3K@H0XELNIawmsKJaQ^a z_{_Ymjas>W^Gnou4`ZkA$I5F}6)JfYe8}Z%O7y2S_f21b&4VF2IsbzHyt});qYOalZZJG$vaiOfc}ra9K7)LHM)AB?(g|3ZKPyk!T1{IAld-JL3=1?aN=y<`{rKMy~_Rltk?G+0t~1fjWw=h%rmsio!fPtoUb&g1@PhX zd)XyTi}1=LGCQcu0w!q!?RnQle6nw!$CmpI_?(%c6p}T!G;w15{paWuiF* zGgLH|g;b|VNsqbi##!o34zY|=c-Aq~R&g^{4R|CZpX;i5X%Stmkv;7!o0Q8N0N)>0 zUS64;1HgbBh>*)@+_WV0GXt5`YuN(n!~o>p>3hog>lahL#I_Es0{ADeV5>DP*kb7E zgX4f7)-&M3hWe6;uCF+axDdj6QO?PN(%~raN>a%jEFBD4PNYg+xPyQ?0XWYAbq4UM zYf4t|J#Y>t6p;`ecuo!vOE^}{^oAavNAWg&S0v{>e&Ng$XtG<^(z|AtZG$L!-Le{X z7BG~U1>jAD)I0aLnTcNqs7d8gCdUkfD!JJQ$!l?yIybPtv4~M6W6OMzA z&vdf*#KcUMrftdTaAaSQTt1{C!f+%WTi|+6GigpARp7kiK6Ogb!BL=OgaoCvR@>W6 zTP&U%k&v@yH&0xn+KWSmFr@g4qi1cBys$+WYmteg_pWcB*ZPY%`GHbuY^K4cT5>Mw zp^DFOLLhoRSmumjc2)eWRYSa}Q6!KBWpTxO3ULFjVsQ!%YJeejHM;CInOs65=PLu2 zy9dqY2pwBc$@#po@tkrE_9CtLpcPyjArnj0vQ0ueBPphwMkwH67yl_+(}DD@ku6um zq@rdZjCpD2w9xU|*lpADXy@bO>B~sd-9gAFLd~?oB=uKHidq8}#M@Z|={`Ecp{%0C zVc6dldC%@UJ6p}gj73vF0k=jW#Y}&jUSB%qP^(8#sCnR!>4jRr`mWjS-BJOzLFB9K z772>4+9#106J=-MU2+L6Q8~Z-fNF480n6S53+<>pDOPhuU^qTCmZH?xEdZ`o=rO#V|G#G(H31^T+6fiVr- z#B-rRwgM;Rd0bYU?N_V4dbnHi3Fx$+fbJ8^LTW!s%}8Q0qhtbCNZmkxFV1}& z+0^TMQh)qG^1WSiT3<)2%`)3ZKjXM;n&t>7m)N}TZ0uV0QxnNIDqQ3Hz^K8rRd~Rd zf4Uvpvd0*1AI+zC@+-8$x?tark{*U51dy}b>c|#R`2sGbxPNMiuv%%Rf6AY`_pjAAfDQUoRQQrIbsdy&_j`UI+uUvR1_pv>>rxLO+YxGbd&mj0iU}~n4YC31tu(MCDD3#yfFIhi?kuRZ8^);$n>pk(~;oaxF zu6ya_SlhIT&&ZM(Ak#%}w+~MqgqJQbVoM52nX^hT4=(7-z{`7y))vKJ;=l?1?pZW} z5Iq**`aQ8BgOerPvBh7Kp;)vARv!n|Ctf4kT@^lRgh@_=s?;Ve`7I?i1B;0g4aJ%< zLCw^`z1$iv?b(ACIbjC)`BaMG;Bo6s)9yWpz$|S5J)F-sgPmX5nHbD4DQz8rk?6%& znI&E^Yu~#Q{VA41Gf}xrR01kSEQWC;K@07eKnc;t?oUlGz9!M;ADacqReT`p{SGzx zfDWslhoT8z%A(-PCM%W-Bk(M#e#oRoKP(noL!8LE8X`|{3~tw{KKhl93_JK0vd~02 ze5wqa)&8_Vwn9xrxfz6DxBgdNRQebBz>kX4(WpgUrkM4Ei6PpB<_{b>0@%@dmzHNYz;Z}cea9Lo zu#8j+tf?1$tVSZoQry!lb7K%4JuCQX3@DT9@9q-?>}c|as%Q|%j5{)$(LUc%%+2F{ z8C@Ephr<t4Ip=KQIKlHx*57=gBw2OBrLfxlE_DW>obAn>rr4J{A#$?R8XVZ^2aK2tI5kbJ z1~|6ve;-2@_EAn^o=Y{f560N|oZeA*+Ui1D=17xtJ7#h~e3!XnFE#JnP9gUN zY(1WD^_H*s9$HVW&}+>n$eN55dWnA7Ep66jA%ojGzNaV9hytD3gl~okGh3vgqj#}| zlT!pf!ihj8``?5qk>9lA_|MJ|GoXU$b*LXo$lp?L^ef8BAGY!6}mNSmMp6_r$P_;T|Z5sMY-jA849V7blud6osg}L$d zT&q6;vGnwMarsd_UTn`_)635`GOtZ?=>VI2?ECWh8@=5g0@0VKZx%b6=}Y_R}|~W6f9U zbHF_wAU#@c2lz!M%~!jk;w0YvP0|f(7GDES!bbM$%KZ!zbt)Fgz+?!z*MO=*vx8W4 z0{~7v%P4m{YRTxfa{Uy46O;-T?iKVV3MR{!JEV%=GU`V&{E)Rq|Lnr=%sJu1s5dt~ zXFWlYw2=I&yw?Cm%sjrNL1^9n_4_}CHZ?MYOY379Q==Vv+j`N}zQ~ z7`s!H)dph*7+Lz7wR6B3WkpyH&Y}tD(X&R@hB;I(u(7lE*8^lZQCCsGW-=dBIe{~- zS>hlb39t+R0C0A(+<)=zfciD_DG~dpRL?v%m;v+bSH;$6Oxr0;)+EnM-2(*mu;gJW zTjG*#9?~(&&^t-A!7d3`MFV~=&TM;^N4d;HWgN{A!`GqblXd;`yZKG@d~5Hh)%~kY z^RBqXoRPct=bZQ5Tk%D_8Rwk1#SMW1(%w51#X)|0;cVO}uw=0Ef5|^;Cm|)!ci2?7 zOR;wG{;|L#T=MrI>tN@;O^5N%r6}sHca}0e_E*3bb47l^iZ?CqdZgA$pk6U2ExxZI z+fiKvG`8Ylja zabriD-Sz#??p#>Cx3imHhsO4>UbRzb5jKMh=6(a}8+g>OAFyAXT}P|&v>_4T)YhN) zNqY1F?}B+7NabeZx6j|2uin7qzB4)I+prI)YTk+_U4zj6=ZY&RLG*^{lR&X8NGssY z2~%+9c^W|>CB_(ncKFB%5I74Jb0Ea@I_^m5;?Im_c(zcuID6UpK) z@oUDG3B$>6)DJelr)Fj}E*hot!5&@iX7`fXfWHuXYz6ANN{b*}cw9ra38Nod&fOxd z*bxqXvdCkZ+#Zg?;}EUGH?D)>S(p^uEzbYGOlVWHP)GMV6)5WxMT|{u3k@dI@JGIA~A1UPFTG1LiCqo0ln$>paoV( z*;xzr!*hG3vU!EkA-`{I)aK9B-xdw@7s@R#5;A-w2QiHy&Ip=0Uw{xExo1n}}y!o!b*}Qvzb{zfu^G{dj zALU&(6g^1fPawF#jeFu>jru2 zQgnB#_5yZor;S(ZGwAkbL~BDEQpwZ(ijbe4fc_Iya~fx;OIS!OY-Z{dqmnA799*2Y zine&fevM~{JqV9gdw3oZ5W|F?$&^tH#vZvTy{GfqVfA(|X{c*(M_oa9EGrc;odX_A zA*$~m!xbCJUCxxgsqlNrC{)Bk@>f@EQfzaAQJrvap_9x3l4;3Ts$HE1=zJF>3{ zDY+Fo)0X~+za1;jHhkrvLTuq0Dl`tOAc}Fu3(9SF)fonJia}({K;z2(lf%mO)L1?A z)DLGc8`e~&%3z1l`xBH#L_tKIEWxejT)t)Jo_^1XDrnvc8W(Hi&fdcwV@PfM~gM8><@FomSTMbG4JeUYM z?O=Sweon|bb|!#a5j`-P%Qug|z;!(>?^lv394Lp|N$@PY@h_M!PAkCOhQvLq`12>+ zy&Jx2H-{BMSvydRNRPC)e9^42Va7RMmS;4@Wl4qrEg|m;l1BT(1TlPN(aA#))=-D* zIXtADEt1_0ge5W9X!itGjdB2LkWQN=;neBS4TnT~=uUj0_Pk6lIFl1O4{9>>d;Ge& zmbG2$zoO4V$6!ybMpygcG=;QS zXg+^yyY6*Ay|rF<#)OK6^ECVfv`ic+jOK=X%Aa&m$JDr)buKr`?xv^=Y>5mKQ{MNp zO?zIyRWf@&bKm&e`>S<@z1>L?yR?cY}Sf_}qJqAM0eQIRPh!o#rWWP_UrFvu9(*WH-yxqQxq_=9qCzARxki36IlLEzb?-~0 zvAX{yn_9Dnmd25w5R3xC7oc{-dZh&pm0|9foB2fcAp!syMnKo=YS+HB{?p7k)}}fV zUv$};A-MH+>T0p}Q15)%Fw9c~woHIF$Ytz03&8gP_1HRg6W8arMR2*DEYjx87@?&6VAy##nqM#Ax(l~LLMvGO z@~^x#KuTU>@jLNw2Q(?dSgb{ypF43`i*@bPrLin~O=z+*{SUnK+O9jH9VC#2rFO)k zIUuY*afQNxi4~~S1-7lMVoU#KFj+TY%szdrsH>7&PX;TYMCS3`SZ~S$kUu}*ic;Q*Hfo@1oI+jpzJdrt z6Zd@Vn}AjK{ZB?fiZ;%dr7ab>zD9U|2$I!A=Y7@1nNgx21ab?Gjsms}38P=Y<=kK( zL9^S?382F*dg1|@j_2!$71AjDQ&ZD-8qM1^KhWFl3{6g;ldw^F??^sW${k}a#*dgj z{4_o1FQbha8*}YXreQPFZ*SM5Hg9%0C&eUlTolA9DN4Oxu-=3leEg#+-Jfk zNXfvW6c%SBir(isvi1ohP_h0TspKzeAw*19Kd4RR8Vf;9!HW2X(ewnMA=H z@*h4&o;&Sq`bK?Qg@n)r>oDrde_#j4qesB`=C*&i>K=963|gq3AqyO^g?Jr=vF(P!jD+A@$h z|9b|_h-Rp2uN}W2_FsE1vEK*wHI_^`M8MHR$(^7p+@k8U z6AED}2>9C=l>h}ojl?#CmWPz@#bt1+}qEg)21<_yi-DX&x-r|M3{`tTbAIX;acA)cOkT-R5J~$&2W=lAqItZ_=H7Lr0P?>X={H8^WqKxuY5+3LQqs#id6gUZyzL zY=}=l`ttap{&=q^g9eK~Yn_?gZ@t9<6VW(A{b0WX0Nd1hE=W@7CtLa$Lfh6$cg6uv zK@^SWVIH6qX#qn@p{|#o5<|X*f#U$!zdCc`6oN`%&vj%AA~*<^?9IDJEV)K4R3>N| z+UMph9Oe4&Zfrq8d;nW6G|K*ZD_kQAzMspVdA#-hNNP`T_m|vLA^mXIp8Yp)rd?B7 zvxNNVV-E;w#?Q%zbb6n=l57@dH@ps>+SoKl@m}OvRv~$ZAkF>#sbR9hS>?!fVZJ40 zf%H9TX7gF~TyMyc77*{`pDx7>dNL+EaKvP{JL2R#;xS8SJW|(_Kf%Moz<4Z==`9RY zyGLBZymohMDeAO1;5)vhw*=l2dbsS0>0PxN=i&US7c+2NICFEaa*oxkGrN4d?*i?{ zeyfZMJ$AcanTdhVrIf7uV=!31TyPq~D7nO~Ml;rAXd9x!s%9n{ZAN}<4B5641MT)` zDTLFzA%A;N88YRcHU)ogO{3`_v@hRucYei7{jF;AxS32;XDtrBcO}2w9I7$4Hkj6o zbvk|QtI zP+j@1GVRXddcXhuuV~c8{L;9C!jKX5U$tTT@0M0LODtaZ5_KLJ=m^0pDv42R zDzIf!dNWFI$l((6t}Ig55OL4Gmvj+SAMRv(ti_1Z`cLa2gK%bD2co zx8uMQW=n9-lRDT2)2Y0g9)+a+!Hz}Rdpl*qy}Ur=uT;+HpX%h>JW8!vG{Jn)lTAF2 zIyR?#-;PRukH?!>HWka`8w(5%@9AG2(ZGPZ2=O$L8L zBN2;Un7A)QLxNBs`L|6Zn&@6@Y#oyvvBNx~ea{>AL$)$ZGFqgp%&z6hI?It-lPji{ z^8n-!5g^n^YBJDFXtl{K9V6;G#;H`bYb|w5dGA6MD~bK9{p74Tc*7oo_^&2(ScEr$nOMup}Tyj=H5qjk#~d8#b)G zydN0oo)5=|izi{baNo;dZ4?o}wG5e^#ae5&HJw*zLbTf`w%3`t;KYnKk*UThzFgx%}+S;_GkmCzm=!Ovl zd>1=15ij_C!JdPe@b zj1S5FZ;xli>}j9>tRA-Yww=MXshd8)NU?vZ{}!alc<7{ zg#)xnH664WA5ZeZr-UGwPmG*S0xFDOM4z=F-=lv2D%7mEp7#Hn6Bq1JAq1LEJ;Vdj z30@gWvmC~u5?gJ4MOXhWb%C*5a+o$hH;R2#wcGz?8%RNG7j2Sm7l7J<^ZtKTwi#p4@yEU#1QFb%x20F}VOptvQUKEzH^ zh3k0EXM`vr=ij6Ch{bs-k<2ALr|WU~Q0s0-vW@kplF9BIQ8&1zi^X}7@cI2IXTr2y z2ou7%_*8j6lI~Yj^2JLrBg#sa`8!iO&SE+B4oJ4i)5jFXQJPiMM0eiC=A$?jmwAix z$?ECI7Inf@^w8W6i^EVxz*XQJKyl(EMo1JC<3>iOh-3yy9t48B9_8Fo^8+4F%jg!e zO@9>I=(6$)_pVNxS)3{x(C%jRepICx!V6OVRhOn=?q|+09c;q;EIXI-a@FqHTWPYe z8E9OGl4uG?$YM)RsLk1~)k;D)prKjs)1(m{>wWT{6?{*0{uLXcd0;)6Krd%t0EM;l z3@zt2M78h6e2>k{<(dNV;2*@#t3aX`KVYG;$r==Mb^1kT!~PR7C&8p4MlnEx-oUOl z7myzSjr>!W<1KXe9pvi4i&^39!)i)pGOi|rax?&E*;RXOn)cks=MwqKA2IKI@fqtydZ`7 zdp*mMQLGS&=6r+5WUk>7PAJ8{5Lz@uWjf=KaMC8FU;)DJnC=n#G1GzD-Zknu{;XE( zPfWDy@CIDjS^?sEg!?7YDmtV>U!(04{u}_@39U%flET)rxc$%VTDtxI4xFI9jPIyk zDoDSPDnH%wwO1YYolM_U1(0(5LU{Pqj6B~GSthU}%`dOgfHYnS>P>O!m;lfEYYb1* zf&xD8(`8qtbFn=25?t9O;G%M~m#!E=E;atHy3u8xlW!3GIqv9f1+`pgO-##v<25RV zeh;^zWd1kmujLWE2P=pOEAl93YBqi!6lzO7K)Z0eq&owEyZe`V-fq7v*37A?j*Ytz zUlG{6Awde)y4~_GAA;56^uCbW+;4U&FOn%SeYp0tb8=Do;yCr=jFSx9dSx+bRPP#S-811#%XWb#T*hF?&_L2;{)sW zDTTiJJ3zqINp6dte=qawbzQu6u0~xr@vGeJNQvV~3h6j&OY~MD3`oqNn?Q?6ctnq< zl>KZW&0P3p{tG?@J9G2)lNYztf-@+cHqrR6WPx!|9mFp|IEgd5HhU#nCpn|~aun6K z8hD4d2Zl)->UIRj`6EpM$|W{Y?LnQjC2U zC$OCRHUbx}h^9^{WNy=WEtGM4P`PU9mw_cV+(C_wW0$8yCDP&=S2p~#0CGNS2Qi3C zNK7o}S072;XnDe>MQv^R;74LlQhiC2)2Sc{{#x(bL2G)#@KgFx=v##3d?QA!&y45h z08oL=6{}Az{PSlm*i!{wir~?EVHd`o54NKH1~u?DB_5FIWR7yrd?93u+?_!XR@HTJ ziYME)o0eY&GCg8}XE8$Pnx)&cykvBL_1|vFMj02|PZ#JVe>5ld+funNcf^quW?e?> zgX-!Oipr<80t6#3(p!V^TlPv4a6plDnvfdUyJOR|nKH$^f5%yOGfY|R@`{HKm>LbC zNI>MjmSi1=A>b*z`XyyeTC+hb-QKjU&rf-_s4HngU&)H>IrVE5-M@_cJ3>$d-v_nz zV_Fl&KoN4WWCo&ZXE=5tCee!u1=A@(X;Z~%Nd-IVZ>ty!1glBU61FyKPPX=9wFzU# z=j{D#fAGHY6Z`7_13vXg%xYh>cPbE`3VIQ1gnIp^-fsTDJw}|QtH4S@vHaTM(TYEipN@>d#{MdUnXBxx_3hh+)ScGupERF z5wbF>v`9?fKu1XI7+GRv9{P4`ctK7udk9D4`SM9c> z4hoKz+4xl(i>kT8Ma)n}K)tY{TMWK%`qCkoGU0#Ob9>VBlsV?8bomn0tg@zXCse1s zayIUPkq_2 @;=bY(&0oUJ=F{)!2LBN=xDqoasovr~INV?rW01uLAqXh;vKKadWY> z;@|hC+*Mngx-9|*NwQ}oXMzp>h-j=NK^@iuX# zHus8)84g-_XDM-7qT%L@=;xb>bx4F!n{NIED98olS%mwFm!`#ah#$3C>&vR<#^&l| zOKE*~6RsjcPj;>LkwmdT|6A5XyhhTg7`GC69s58V0j4{OAN`_;n$fJuPXd9P@IM8- zu15|*{kE|}V)c$25#5z%tW>p4k)3l=RP(jTNi9>c-wo2kxPFp?jgX(NcVW(cerE z{AAsHAW5jdy+-WQf!_NI6Y=HAD>YkUKHTv~0`HH27!cSgrFGD0knCWhfxNEtVpjvpMenRYOmMo$(bU1YQzgWQxeFQv>diN=3O!zYm7 z{eNfh-lI5XwEPxTBdzYd@{n_zXDJv3#c`#gVSf6;BCF?=c6ZoxfUPiiTO_wJy>N$Zx2MH$}~?4Ochz&xQrSC z>MA6uah0it6$yrHOQyN&kw6dxG$~>nXX96=&C0$*xB)PdyC| zg>pH95qJ0focZ?!7c7g+`|o|c*xo_65_yhg&j#R0`86F(hKP%+)pT2Q6J-k)VqxMO z4Vp1(r$Ml`9hhrNu+Jxu_F4fH0Cqq|IKFbj8r?iQ_|9iv#I#m_07j1cQb}A~{2|o$ z0Tw86n4xCht4O^6EO0XR1U)k{Nfd-SD<&hJl+*HkPs%GaFIOtfsf53r9(rmA+f*En zxzoSJtqD_fWp@0%F)&0fdqa*G9Im+FMDZf7GVcG+VgY^2uX;?kX#-;|3kVJ>pkBb3BP|-inbx(X z^4LTF0Pt77;+M+VgLvw20TTuC64wOy9A+zFGS%$}3V$BF3*2NZWeG;is(xVx?!W>z znBUv& z?S_Dl67XUt989DF(#_{O?`N0F|JZgH<#0p~z(~CqAj+BfU_WenG8zN6Od!~bu|lsWjhU>iZeBP!7NQ?(c7X}C83m=;0N{uEI;Q9^#BLyb_-~kj|%KPca)nCW$CU&Aav-eTH-%nv7(jw-Nzy$2 zDSc}Q<7*;t`Yj~+uWxg%ctX9&0&^m@hHhpn^fOHi=Pq1jFfcQA11?m+u5vRkFj;Rr z0l-7pCQ|5u8*{)5_)Em$#4<{N$Kr}6d~xRbu6_9>{@_-v*$Vghq=AT5wR%5P;AZ#m z+;i)l<_xn>2$m2^ve|ZP`gq~F-eTb^7UO2-#|2ugMC|G)0qHRk5DC%v9FG5rQ}dIy z`OY6TRjM_{%NHipx@M$P=(Gp(L-`R%2r^r&_VYu5T-hv8bqBKH3y;9eHH!;dM1f+X zyQ)qu4Pg(>+9{Taf>grAn7mJdYZpixjsyVk7js%I%ykY;??kWLzuVR#>Km__j;P=u z;~E@B?QeSeObnp#g*cW+;jSCT@~L$jFv66wG1(xBMmS3XEz_HOV~t95UFJg zeX(9y4}WLzps*P-&#nhb9y^-lx1Dy6v>u$@?w5z7=}~^4`^%)G!%-X8!Tbe-7LWr7 z?i8RJD>w=8xXLBjJ9o@iUT^ni5Ks_E`_Mj`Y!N#4P z^H5DghanRbWphZ(yABF5yDTq6)_-%7mE~nV!Co$BsxrHqt) zfv9cj9#&OFS?1m>o~-XMypOXN7I}T|t&$W>=c6i}YGiJA`bi=C4{m{^Go?D;WsY#z z=fBhxqbz}rA)GOhMJdP^cWc{JOVwaI!ud^fvAIV%$yEuh`P#!q#m`$@3Q0(jF=FEygwX;+2| zMDdoZU6jugnb?Eh;ErmX_qPW8>FMr3T5goWFBQZSFPB{yiKT0=w^xbRasux4WLh#c ztSg^nE|!K`m?zNfJ{uC8y5Q4L0G+rbqho8|8rlDflp6nr!2r?RhoO7IpKMaG-2cy} z_I^Za{PkOsLv1w-{qt5*`E_FMOI9R9YjCh93UN_iT9j-iOH#=F+iND*)6mQajzpIN znYpVL`&%D292$udQn6uHgBmQBND&5m3mi$;T0Zr7b;(T8qG}<#BiIvJ92bvxGgHP% z?*V<5zY_=tzr~td_Ze*Jv0_DJrj*^5Ti84ci0aa|M;~ATO(c9eZ^ob$;)#@8l>}Fa zAHy4A`)nw!aD=*aXvpg#@Hde3npH-j-@OfwPLthQ6t3?ioi(h{9>9^s1y$^GV`Wc`yh{qyxHxH8LlsO? zHYJ@UhV3uA2bygtGnYL9W0M{S9Op@lOO$Z7qfXl>@~82V&Lic2LQ#u!Y@9{xeV;dQV07LQ$AqrES^D~?7zUM;HhplL?b9}0 zW7onJ^9P)EXD1%wrQ{nP($zZE#!%fb4vNn!IWd)#;@mgJSahQVh`5{km>~^c2J^-K z47s(}R}E^eR^ie`ji3viJd+0Xw$%2D=kF%})j=wP)6LVIZ}0C@odv6!%!+vmgUi}u zxG$wQOlWh(pyMWgXTj+5T{rw4MF}Dsp#Q*_ToC2HAkT2`bA8WcjwWZ$$&cv8c~21Z zxHq>!Tr3jppZiT*IiMG2JSu33jsVNzpKooXxiq`^1ZSly(`*g-@9wlnN)C_h7t(Z* z0?{C6Uvs{7RCKfCuy}ZOpET3wD@lWk-RuRhZ#xn22&5965|&0(92;(scnAz48iAkv z*ox$s9WSL*sy2xL9tW53BRz>0m))s~F;?)784|pPl*NGv~0M~?S_XC%P z)Ud?N17{m^88sy<7dr>s4%>?sm=~X#vb28!Rw9L@!_zU4j@uE_gd!knLWh7wf8=M% zzlK6E{e*2B|M`f3A`g+<_kDgl5B-e`ZJO!@>UA8IN?mocC9{$B_|i#ZOzw+%Zk?A; zP(mz9J7sw*Afij>_x7M#;~%|Q7S3dxkhE#d;fnS-q}L5kXAi@G*b_Es7vbD9_4F-5 z_zWgYz=7Z*`yqNF2pa32kTU{W9D(WO%>Up*Rd_Y8DE zD1K%bf4Ld7|6Xwb9t6vQ6g38~_Xp+$Y|Ekvp*KDmQ=%7SV0qdA2En=yF5Z!Kf@?U+ za()K_^d5=70xF{&e_KQ+3r=gu6wXrnsiW2BV=NvXbgZRSuFFfFj9n-3)+7tE%l@2^ zG$xrH2ZBrig{-ADE`CNy^95XyXgq^|RMQuw5Pr)RNPE3Va{l*saw&K;N6C41;4pjo zJ)zU6Hw)zF^_Lt3g>7CIhVEBfqkL)@KG3|Z&SaDh^lo$E8sB%Ms0;@r|w=;s`2A;~Ofvz>t=2g~+v4g*a! zEb-_o|EnzdP-0vI&fF!hr}|MDZ*d#6Q$M=J2p0lI2Pyu{H$KBQ#=1;`lmARLCV*JtV?GjotwE&9KFf6E#vM|bKrO$taU?c(a8JiI`P?Q*fBjj_z z-fySm?#dKd+7g zqInRbfdNtUDz&CKC+3{0hYn%MLP~+N(iVvF;!9N0We<6+T20QHqO0@Z*v;XJ0+@sE znJw?M;vjY~$~j?aJ1w+tZ>c6;7A{iYM2Qr+0Ky%LTS#s-GMO(f5&Q% z-JK?(FOFVbcVWS0Z5)fr><<=}>W)CMf|8F9+zHV0s>X?d2t6tlw^^$8d=2a-5SN864x761^AdQ+G$v z?jSp$WCcbo5RMG$d8_!0pwJm4qqI;bcOq}^nUYsy*i5ZM3B|cLl){V`E>6mnZs^tu z-7DBYWF=lnKiurK!cnxu;)q2{(1y;r5}rRR=Qn0AZJ0$t8Dd0b@@!byacco%6cHEm zv`v8Lh&9Jy$*A;>_;fTK+AUNIK|&5`F1#UCghM>lnHB8tMURW|PBrCAL;t%x6E7knqJNtme{9qBBKn!@pz|T>s8?+K20?@YUd``84b8&3D%__g$Xf?kQ9fk($2OlafI+ObX1+%wKPQk z8JK(Cike2fU{Rc!G+V0(wl>BERZ0T&pqn_FhJMwdQE!&WggFFqS}_wuMN#O7>m@ZT z(uF%Id&lwd(!YPghTSh{2;@X8eg59IpRCHvk*z$&^iF+huL ztBgux;#j&*>k{-&acrF!${?unbYs93eY)qX4KXEhfGe*-t_$d22rDdaOH~y+P!`+4KUf zms<#8I4fnGlL0O@%YPsct$Jmx4Oq@jXN43P`2GVHRyJIB9bB+f(D(MRCK?X^&}LED-x(Ax{~u2CXyCTI_|mbXVp>Rk`hmrYfTgM0P{+%#R z7K&qvkTvRelBohxhy2mZKu(0Ya^aZbixcipgkOl$!F3BGZ(P1_b6io5Iu&wD3W^Dt z05h>W7R1;SvM1-w18e{(>S(4-$g7wc+V(#;Td$0ttT=LA4Kb3~u-h-9eF8uQy8XxA zDRVZ__ODG#`c*r%M*^wPktw9<;+5Z2b-d6jR2hpGV(2*; zIq6ywhB2MoJxbLPCMS44X<@9m=fNpHCN~#fFC&Y())`!{J?ws7v3&e&@*Q7v2`{RV z<*9V)uq>5ug3;$nB)6)JaDb=TVJca*&i6yDv>8$IHqWPYFOLLTqJH5b2gAfjguG?*F5rpA6xEpM`tye1kU1J zX>YiW<`jnCoCg6u-}t8IS@@3x+Uo8QGToQYSSM|+N1I<@pK=fp#^jPIrUT8XA`o?G zUinT4h65S`c+R}zC_HC~6@ApupmEUqw?H{s7pJ!*X!S{Fy^m2#vo4V)RpFmMn?~f_ z@sa^3yA>9e$pG&=|7A$>K{t-KpF=mw;EFhT`H)KeJmuqQO7u+Mx}M*v_?n5BnR52n)j+c|x3=bMKADn=*Nk)We*wz^gb>Y|RT?+1gy(Ls zK;_8gkp)*3bbViY;YU$uccuB}O4T_gxCQnyLiHZL-WW$Zz)kcw>j;L%;M{;>Xv_fB z<+DhjmfNKs4dS>FF@_5D6}9k`=I{$7(!6<~wTQ`RXlNYdCl)LtG9E8FnRlnGKh)Z= zh>C7?)Af{PKh%UDziP60H|yakITJ52o7pSOmTw!1TqreOD(j=Y{Xi@)8qmjds$R`anBaoZ;*5hmNZu%vP8ahmsD_680mQ=JIQypE`$$O=AcbiM5+V^p7 z>78W>u~%s$c+abG`hH3EI?AlnY7Z)&;SBp`J-gW*p6hkWN^qM>MY=o&8X*>inKAP zn~$NqP#q>nA_e{p(3w=2@fP)lW@+CbF~Wr5HcZtqJ)4m8h22}miRt?eQwETEDkJsL zgdZ@@>w?%?)wFgc`I&eZic-GT!fjWN6Nex!eI5N5 zO|Iwyb&I`DX+Kp1iP?hd&xTbK#jxgJ_)#ccc7#;aWG=oNo4L zBxqZ3TRte9+*KLyPbbyJS^ZYyb}{L^D@G9aC$69_VEZ)8I9X+Htnwhb4fy@L`PheY zf(dzvW4%;3jDdI+h|#h7z!KXuUHDmVv?l{INKs@0BW{a8?;IT@`PUGl*S}VA^mE_z z_d*D>?1ynX_Xy8-LIs|VmCJ1baQu>R56H%Q?vEw`R2 zgm6?*$dCM(KVs=%&*Z0Cxf*>1tn#%Zo3!cdwrGZ8i&X+g$h2T2iEZfki&8epV(gA2 zizfti7-@IZTd#}h+jLZ2w}eFn{uC1FSv5Xq?`RW2vEKUwH*8N*;-T*6Kv!E#;1RsW zeZYRS6`8@%$c)O=>jV+TC;UzvtdxBwo+#wC>4&-9?N9 zmobt|^!q6)oPZ<4x5$}U>-bCwK!DySD%-A^PtiVbn-|xSKK<9aHwHmYjdM_7^w2Ov znVSxzxo`Utj~CBxC>8I!b~)~&J16gz1NL8siOmn_r}?iVIaw!5-}bKmSR#oJvtQr) zUfcM1ytxzj_Ur05u-nTvxX;7z-;K$$aC(BPo3w7PpI?D2ea7ZqQHW!pdfuj#iP3Z3 z1IPf)uC32)q^ObC$v`Qq^{SK0*>bNy=h1oVg~XBl%li{;&{ViZMm|0MV{cD(UlbR@ zXf^}q#&Pz-a}U)3!~DWAr13%VE4K@R7B%wNRRE5N>0}d9qFe{TO&#wy07+4 zn8XcUj}F!CyV5c4W3=$87&$strMwPnr<3cRwrR|S7Hrh@OW75Ie@aoGgZ*o<)0E$d z7HNzV^DNzI$hGW`?%J0j99hw7LD7#D%@y$9JvK(VCZ|OM-5GzERjV2&mou!|*>v5x zjjUoia>U=(yL9y^r{1?`Qt{Z_wru(!^r&j@b@jHV-XiTOfN6uTD?gJCzCq_z@%PlAqeq;B1c2uNv=`RMT%<>7r-)mGVhJcA>+^Q;k z9lEFEF~LIk(JfTtr-CIsjOk$Is;#`7``WvK8|=E%UN)SCnespyqD06K8fi_aW-x7J zsR)O3qT?X4)PN|w^jl^EZri@QCacap3-)zK!j-$G*1A*j0Zp`j)OD6K$Xm7A=D$6 z=Mt_-tk{j561-&v+D|j5P&PIs1y7{d9ci~H+PaBD5H=Y7`+Dr%P}eM1$dy?b!ymHa z403ze*$R*pGNRM&=4k4}jcQBNc>f;baVcYFt2Oc@u@u+b+;Dc6$+;gVA}G~tebrw`DKRNbOObv3KgQ>taOA< zzt)SqkxZJJCfK|9cTYv#J{y7@hiTI}c#5~PHQ<2^RkiEy3wTz`bzcU+QQvJw?}7P0d|;bqL_#FlrlRrT}Au9 z3WhaQS9K)lv@m8w_H-MIWqHm;(eDPWL=`7C3r*{5DCo>-3*O2CRP`SjemS4l8qLuG zF!G6X_B~23Q8Uoz^-c(ZA|a4QAmCa&JYlwl;lx?xf{}|iJ~hCiGiwlrByw` zjV;$1qf=fk$XCBTQfPV4&In1uBRt?VRkjpFJUZjaq*e4wx z1J16)y$94t>0``tB#oqwFcbvCc#W0USE@ZRs-3%-h}Iju2dP@UGd+%2n$0#x!HjNS z_CqQdJEAHWyTG$xGQm@Z3x@L8%yS+lb+rfbgcfmR&~se(XSKX{pn2W4v?sR=u`-Fg z!c;q-)#yH&UIDc)yQKm<*aB^plG2iW@Ha1FX>|9*{E`yX!A$X{vsP$Z;*1^i6P9mA z7rt*km+vT@xlj&!5gvuaNrP%q7)ztp(rbbyf`bqBek@9QXx(tU$>4+SEOJ0@FMz7A z@jeB;)=TVjHS@it(y|z5ClnLo}EGzCB1uERF2o)P?9SJURGck+~*DK2C22{ zVCyYZsX)W&zb}0M1IFN?o~y%Nn-Qf54WV4S47x*jKR5RBFy<>~*eHi{V5EI=Q_t@h z@X_^KMgaUV=uZ9}dR@%f3xioen#=RA5Zc@0gJ8?X3ebVTWE zr$X$%&cV9h4gM#|bC;zNxOSdSPJnnDei?x2;*3F?qe6!{ZO@~`7tnQF(l22x#J9 zl0?!FO<9R0)(B`ZK1ZT0qGU-{hA|0a9W;HdHa-F^a_2KiHeRQf=srKG)>dKJdM<6n zm*FA%EBU-;B*gLPT$Z47{uQe!v}Ri^F;g%~Xd;NF7B>%WjNxs;QEx2`;DPe5&tow= zhEY!_w2;m9x8y5}m$XnooVK6D-t*a3i7XA~bgEC7A_p5A) zywH93+B94E7apE}U5Ex$D}j-8N-T*HI)ztA4bvy{=4e=7PohX{1`@n1e|xqklhYwk zj3+EvVE~Bn9SEJ+LDRz=>Ar)y^L?WoHGgN`~#n#MZ?an%r`IsJxn55^@9zZ<>-n$C)~6k^8)3SH>n3m}u0i|gkwsh6)dbwKezsV+F?prcHT!#b zsefNHSxsY1#VcvPt{X*Z`w}`n!=`uKZ>GKZB+cavAxGUM+70*AD8mk-qGRIpkYDOU z`>!B45bN{&7ifa0ER|qH5Qm7q3Pi{~Jw1(=vjKE-2nqs}7dD`lryb}ai2*)t-Ty=) z^Q}|9!hr+;*+C%!Nhsgp?5C3*m==n{zoy|bPBlDRt1@hF4FSL|_p>UMigni}Gq4}C zq~8we8t7=%*=dY_H7o;W`u_OxC3_%}kG6zKtxM-T0k&A}oYF3y^r2 zfoD`3ksNVTa6+n~qz^5OiDUYy^m!x?{m=1ys)7Js26Vn>IDZ8StkW%;;H(LMjpHVp zQY?@pE;ay6se|5jQ#A}n!4iZ~Dlv2u@#T`UOL!5f<@CF!L9|*u$U%uXsbobnNhx~X6g+Mq(V3lGKADjcpdxk+b8fg*fjb6C<0!EJw$j@%%Z2z`WE zbo1lzh}_YmEwBGl_p$R}d-&Lk9-rf#)~Lf1tO+q*AaA1!Bx4p|rq)9IVyXgZxxTh| z3bMO7+Dwy$ES)pe zaSO7?cr0h8pav^}S6H)aI1LpVpIV7#fV7xgF$2Z-hRkbUgs6Ds;<7LbvCd@*4~tPt z(Ath$eO)k|6rZ#4&^|4UYOS21tK8BFv{*HnD?mg7^+8!zuQL!E^OK?hB4E(d*etPt zprtP(M83RWK#hR?Be4TW*z`KJ)Wtut@V@WZibf5HWb94@ zU$f;2uh(BagVhxIMV?pD+=!FC>I~T}K>AsA% z1q9RQ9!nkL{1x?P9Uu+9_*Z^3JGZ+P)BmypzsWvcrS@o56Zx%}mu817X4H5c&~{#D zpNrBAu`Ov003p-ENF3|E!0X2ss@m$0kE&h#NSLy}=!3lm>Aq(Spa>B7vCi-|K!xS| ze#oyK0Eo832V@k0jqYXb0e{17>-)SWl@P=T06?`FT#g7zvt952BI%;K>1B*-XCUv4 zI{+kA2gD{HusVQp=(@z5F-7_D^#KLUb|6Z<$E`&-P+GbMZW68^0%KzMq)fh-=U+gY`)7YzEikFLKEk$;oe279xtZX5@{{ z@kQ9P>pI+9{@-kf@MPb_C8MqnVuRR38HTu{wt+7>v%kVOgxF4T=H=V%(vZ-v07vbz3mQ3Ou(VMmM%TU0J#vvgVGx!3v;f^4OvE z4@r{LiTnT5iBj4&?8p+(H5C$NmXrCM=pQDiq(v5NY5%nSE#+lM|K$qyh^erIYkylr z0m8<2TZMu9-Rkh%73{U)=(;ntD?|6~!Hc2i!7JxF>xik{n=1AFiu+Dr|MA2VKn@Nl zXe15I56&%FHw#~{AMxcP%sdhFbnPL(9g3Xyu5!&jJt>MR+}7OlD7$ zBqYSH?mw=F)g*u&_2v(rqu_8zqMD-q3`829RVP5cH!m+hNGPU9A1AVU7~^?%_$bj0 zcPtxJ?!IIrPxtc^fJOjQLprW$2AG>QP_Tf%xwM0vt(>9x!_Ek$VCxOil;OAsMu`eT zO}lq6Dgj_@^Q*!Basq_nIgTwV#Tr%_PiJuzS3DFY%6;wY>)5=lW+-8(l>-CTV_)?z zjE(j)eE0hw_}k$oTC4Z^)GkCrL_j@8zvc;~btYMvHud!1DOa$_?%c~Tv;|dZwm1NY z%xgdZ7V~3@Py7COU`AKsom4R(hoV?1rX%+3zP;-K_4X=fWtpa z;QU7+R?~s5@yOcNQv1|^hxep*z;4=plqA#+csaG5SEMg}B#G87 zUacSTx2)d*wND8ty_;d@k0g_WL6SxpOKqLRV`hjv-?g<(m3qgCR7TisfaQERN)~%E zURzy14oJiXH8cbGj|hMORP2wsGk`YJ>kEL6eYTPXGCkL-u)f6Y?C1ylHe+kvN0~al zHFg5q2@Zu0re!V7TSb~S#sN*Fe&XH*XbMDTHeD0SE2RH~-K_cip)(DGMaFST4KuE> ze&+J!?pViOjuN&!J0WE@JjS!_FUBFa{DySINn<0bz*C#$tBvfXQ-OS?#WH9rYJfwG zt@gy>XiTgs42XT3=y^&my6HAY>+9@#xG@!&pw)HdE&R0;;MhF?ZJG^@RdU)D02%8> zBWF%WAA~ijThAfzwj%%dgxv!%&1`ti$8}kCPTGCSFC7J>QlvYUIk!RYhl`*d%? z!OLyOrVIn9NNOBE))%3l-0OVp=&Gy67dETFcSna8R6Ag1x-FWG=pQc|RY$I3h-M_0 z?uUIul$E7ll#@Nf}CTE5s53)$wFMxcd)r zxz7wAg=UNgLZVN)cEKDJL}Vmv=xRI%G|gIAy5F-MAKubU1DFucx{|ze9lx3}&A9ksoLJqu3;)51+Po>@_>g(Z3A{Q1zl)el?lhUg z3a!N7 zy81lDO25LFCSF3`q~{waHt?S;3-;ukWQA31oil%z08(cw^v~=8Yuzn9RZRzNQ1`aC zK3RXcrD=eu9PgD@domzWo$lOy#*_0f&Cq4Vx1UdEd=juf5Sgi`BpZj9bJX@Q51cNw z<^N*n!wVaB^X(6o8Fg6fv!LRuIjgf_bpBSsOmXdT1Q1$I82H0lC}B@2 zW(eWjqDleBDrYZ9cr@(~#!A>+L2@kPYB$hidU`TLo+h66hBFUqIPJXWJ2$wENbbD0 z-5PkZCga3qc3gEL)gEZYNj=#we19ef^q-TOo7aq1o9TJ8--*&fP~oXCzRr|3PXBN|C+c$hbk79{k6xXmK?; zVsbED077zr8dkGd#E$%Fv_DL`>!Sz)EZ{Q!b}^Fh;-M|fEG(j0=cTrKV82Kaku-UUk{>6t9EPlGn-pW#w_dRS0GF zY%eJhL}fumK>DUR$?BjRhhm%WezF4#P;_5$&O9Z>2ETjh0DL@(R0fKeZ5>uIq? z4fy$d+Cl{680iCR)1b!02g=@YL0R<@*iB+wG}&P@XpI8CC58V+$*TuI(yzB(W0&K- z6y3bQkTeGa2LqE56;gVCyO&>1vEYHIEr;l#n-~Uv_#s!rp+Es9po`xES?fi=;npu2 z4Tj+f?nN*{5mzy+073B=VJ*m$LMv4V)YSuFiF1~$Q;P$Kq3gTC8aG1Vi1@L;270{N z7aslpA^!Ynig9ppg`~%Ty>drDNp2Jl6-g)2g!)c}k)%Ir3b{3AUwsH~i&|zXqlhJv zqFH64@gs+Tba=nAin|{WHMpK?qsVqx%^$bo?VKPR$$Uz3wa6sRqx2__*_+>B$Kr3U z*e+P5D^X#4Jvkv>vKDLTO zLSj)nq9cQr-nR|eR~WZvo^47}2#y!%tW*_FMhad3%`{KPkWR)G zO^O(e5c%VLilb{a~9)+dan?uSsf2(y4Bb}*T-%~Q_) zHd8xXP$h;+)+9=6cEq2^xro-!Rg}<(9vO4oI8X5FRWCDxfQHmzFDzp|eK9L084pNy z3LnB!PK7qNKrIhpj;RC-qUt^=}G1cjIIA3q(A6J*Uhw(QrV2YBoJpL5}hl>}H+YF7W|F&Yl&WzCx zh-uvJI!e*B(O+w}<^@!hSx#9$lZQN?rxTA7&gxRO+>c#zJ$xpf^EvQ27E(&@HIf9q zZSSQSLv4?{<|crEj2KUzrLLvA7lxl(^UoQdJ5vG@KD5RQ_p7Na&%_rzIL~b3Ne`<{ zmI~5(d@TtOjJ8(9KL9zvGvvGpO7-_idf5DYWQr(to=Oo1rdh^~=A0Kr=u8JXb_EHV;HHQ2BI4!H>xXQw=IP76sx z>;qnP>0}Gc06%&1P#J}U$mmBm>Esx=Gs`gs@<`3&yj*vF*=o6BRrRSmL8rS3xjBW{ zr{8oIs9|V$Ik+gVa>wjd%<3|w+7#}XrUqn4&v1^!BZpLKW5cFM1Mm%kGQ)u$lFJlH zUW_6C^t`*SWhn$v%BXG9$>)a{`je9JWr)InUG3~k#b96xECtuwQt-F%Cub3nv41|h zwDg%bcpVlbSGpG`5-xsrQSss&K{(}4{Bfy4{7h9(t5(QAvRiM7TWhomZ`w}3urqE9 zr!4RmMtTfN$8Ooo2%Dq?4cMzKA5Q;r5ybtu`k{Au2JOX$FRBQQ*zH$Y0dzia?Jg|j zl@DZlaDC*4dDI_M{CEdGsR;E{Y?VEUn4pq@gb!VG?&;(_}=8*o% zkGOr7SM6wZ{p*Y3qgAgV;%a=<2Ibiev!x+od+Myx-TSNOr{k8hI^8pCkI`l!e zEWf3@i)u3q6>JeJN_Mkh(&*JO(f_FstvnG zmJZ;9a&}r9p$rIt9JZ|$GVC)1rLj?IBhPGwKH}l-T_EW~EoIk$ML1w#-&;DJz>R8N z-c&JMlDu0|bFLDx|FLG^qvR*FFHoUdsAd@07DlZk4x?=6jr5a)NP}Y^E)O||hP*@A zCE$)XVDKO#0J?$b@>|Rr`$UzcLVXD2Z~b}d;`bn(zWbeq`j?MAkSg;K^i$I{|I^s1 zzLk!4eT!Pqs}2bmGOWqg-nzF=D9SjxwAFmoO}rk`?Pwf6sPUxHbNS!o>A&>`nu+aI z8hVOC*y8YG+}n8c=FKn#_D%UMx?*MUztA$7lE%pBXeA2?=rBURLGZY-CARI! z_Go*boTKoty05h-k@Jv?TG=HyDH|tNiAPV7%S`K#8HW1d-R-2+f@%y_>=yhhUKCI@d*dx_yg@d}QL3$bBtEVg z9LrT9H_t%643D9d?XK#a7ZyPr*4l3*$*5#>t-@hoA?7z!q@WB}IlPg`W#&iH;RrCF zPoiigRwc)*aEl%131QwN6Qd8HzQ^lZmKqC3A~kAVB@uFJ6!06&IZa|1+9bX~_p(0W zW5F*A-YN^_FEGkG+{8z28(H+9BG`F5eW9-Lp1p6Ipb{MjrvPHbrgY6e$_V1EKKldT zOiJHHzJI+z`1M}ZKcpM!ROyO=fJMJc;{RxlcdzyObkDfv-x2+Hjq3pc4MBmvpY=Yq zXuq~2Hp%d#k$QaxK;Io@9p`O{McUtT;PG;qHYUasjz zrPFzUAQz0x)&J%?!d?gU~~V_==3QF!ohfM_J%B9UL>|AIb$-?9GQ2uinS~7LTdbA&dZQ$*Mq9% zGbE3NGpv_I=z84OBdq4CK$NsK4g_RLYSI6>kT{A}y=}6_FG?HY8;g^Wk9+vF8-jRW34sG0$I^Eke?8^%PdPZ^8GiR^uJxNu)cpp z-z=!)DgVkwJo?MacA;)s#6Ibs)p2BgSD08(vF5e12P>}_kF)^56tf-6qb~d24iHjX zphGTsE_ z!&O|<1sprmK%6dBLOhDUa^%*hFRR^={3KBvwd~;+>#aJ{z0|Dsj+CvtLD9=BGEQZyD;hpx#v|>DTk9q4zsgWEAhvxht_%BnEh89{g1)_pB@Q3CuA(G-2;~>P~p-{rd3T9R0t&DK*8OZ~HMhDBvq^EMKqqJp#2$_(YZB z@NKIn`pW<42M$w>xn1Yz&=RNb44hTo1AwA}eFh>Q5YD79IHYoHNFLTVsTy#H(Us9Z zAtgm;OMhmnt~*r|S2qr-$zN={7VC}nsnM!!xsaX)ow4V!JJB!j4%{;$_KMCo2G{gx zsK8G#ic&}&zk_2`P`K!{2;Dso(6ZpdlyPUBpeaZdQ}Aa=9kcMQWym*(JE0%ClcxSw zFI3$hfif~7f-VZdYMuUuE6}8 zGSSL$CK0|(pP6B}I<&DWLn)mdB|>dy;sN3h>pdn}Ql!c}Dh!ib6m@!ztH?b97G5zl z3ZW4~3g$7_5Nrf`EmbE-eY#M(S0{!rbZF?lz*Gcc`q*Z;Gr6V_z7TFc?r)U5G!|NE zeFm^V%3KISI|I$CF-5SH1#op!$#`rGN~{5i{or3K*!aK*{l{~lME9SkhI<1K#G5ov zlPB8!L_DU%PNIXofeERgV>8XG=pWVf*4Tj-hj(NU7$%E29o#dHE5JeTSw9^9nmtfE z{WUCfZm5>dRt2M7@ZE9?2w$V=lm3lKUVq4FGDIWLxsWf^^+3o=?MEFmL~}&CPXYO3 z(K?Gl80uqoclTq0_h-MybiU|!M~F0z|76a$pmSC%sJ=@Ux(xyu9z4_rd{BY?r*kN7 zJ_L2gtAHzvs`Mg9+_m1w|DEUmV@wA**fs*y50}g{Cy=+Uc8j|6*0=V2-i1f0A6ruB zpILr;K=4+3m`S`2qx4Fa-b2;hgoq^c$c2UhJ)zn5_6!`NeZjoWqqv#ic z5@&OdvI=4D8fUfa_f=(57?ubocO@+V7- zF6bht$l~=_wEog{0=Mk?kD09n9JVeucCI&wd`v7%izkD0HWkf6$FIAOuBRsb>OV-@ z%7NWDti6o6jan6*PysVIWZ^Zk2lXw?B~L`D#cBS|=P)oZZMPGQHqH9FwogAGDa-aj zee}v15moQj%Z>I%pG`#LXYv1D*tBaq&l@Ibv_gE|;4j6ZwKWJAUo>5C7keHRL=sG$i?#bKdvs`Z27^OCE)s5jKvC-ihpSy7-27QI<{!h} zn`qNJAz#k$&C)5g{*QZAZ!g$Lkmh|XCcrG#?KH<1*ednXWpIKWs7Mnul8Ks2)B79W zV>SKGxyBRZqP`=PBFp*6Ax!x36;PLgjc2BtQM)(-#&n+I_lnxVw5|o(@Dj(>&#H}w zQrKZ3B~>VlrnXQvyfTis*=R!R17OY^{CUgG~=`S2I%V^1BTFX z@dOOf``5>`T2RfTgx5#FJ{7;SVyzuW(h9or6#Mje8Xs)=3jDe>zYHuGjd1y-)gYU& zGvcj+k1@1D2r3GsV>nVb&`R=|buXOI1wN1P-bShT1$Y1d#grK=8L;L5L>_8#vpbyP z#e*v2rGxUg>E`LUMltKENm01}XRV6PIr3EInB3xfVwf)ZJD_SBT<(&X0KS<3@sO3G zlCPL*1ZAp}?@4xO+wS(p`Myaab1PTcykDJTw-`wuB|P6DTtZpz1zW@iy}`R|VNpLp zojt#B6&a>3U%IO8?zG^#=&oRZqdo^OL|ZIO5a+4BM>vl6+pW?M;M@&aGm_Jyk19q5 zRQtG^cn}(#o*PrYc`R}nA3mkq_QVdeKVY~1V#u3tPdv*|AxbBk9jOKwY7LhH>lD}u zcQ98Y!O;>j)`ZTF^+BZ+tKl{lW&2_A1CDYJOt6_kY0>Pqj!ZDFO`3K($_?3K8$pgf zD*Ehzy3{5-o*U>Lig0mrD@d&j@q9Z|65Y=pZ=Q=Cx%*Q!>{MqmQdi$lcm%uPgH9zI z`>s4eBn592MyT(s=N+@j9y39u?)7MEd&~IQBKt&nB*M!1#J#eeR^sc+<=!%3eo6I2; zQa6LiW8J$G9|ad7mzivT!v`}>CL3orD#4dF&7Y%5xnz!jskbTcRSqt3RP(}uE4v@V;V`Ob=sOLd@tVe8dtdfkU zefzfa#)=ypqBik+;QjTo_=ncyh~HpiUARn&%ZJ%9;hj!m8bp%XE~aj zXp9m&6G7VQmM+O~ThCn8djx|+;=wU_uI#rg{!An>vuqJrZRGkqCTLb93edMf#J1T5 zFc5tbGc4#9P>zLIeY>1Y!qQ(d>-Bj(3w9}}%F7WU7Q#T1l(g#{H+BLKOZQBbVZeNz zLY%_76-j^8+s2L-+ADxr zlrFXr?rct-tTDB;G4DYqpa4~PPxJd#M?t;5$X1&Tb$PBc%_3~}`)N$UPFI#c5f8eg zVCy7%&Bw)N*HM7*sKbw;qyev>>wYG^ZE=s<IWZgpovChz}sBAoe90uo|Pwd9I?qY@YvTA`?q<(Vf!5bdT-wcxoQ8)OEyJdt9 zX9EB$Mq`)esa@Yt0a(Ir8|c}BsB5-rzkf|MNjWlh-x8$ei9a4>_@X@%$9@n{4g z6Aw8XdLs!j_01~ewr)?VGi~_YO_QmIo~FBujO0sh%^`)JXPe0vAsW|0M84KURUm%B zi7?`4uOYIE*)H4=+iiN0`b#Wktsg^fib}%+@R7paV8sw_3sK-deTMriuZQn@k33o9 zO43{b@C(?CKfuM?R9WtEQVy)wii{1uQ*$T##N80%CoPLb{o&%Nas4~7&S!DLzRiq` z^Z=JTdCe@t1SK0bNsJPbOolwfFyN-ION(!f#FiAlsEAd=sVt#?`Fg_p+~;lOR}!I~TPbv=@2BsTKZ-uVMrUZo!mBY3bYmVcfqdlNgv2ryc|>VL`@*TwVV+!mGg>-+Nu$#>uD^+?!wqM}$D`OV$J-qwnuA&Nc>5lBYhltAoK|n19*5 zu!&>2zq=(b=rJqwe7Zpv{2O#mT0}vKfktoJ;!hra0Q43pw1(706|t2TN7| zf0QMQZ^s5AX9YT}l_|{l)A{0BYmH)tppC%^zuuq*$vXF}*Q(pfIq;Js7Kxwez(p;i zFK1}u7FB27;vCR(_(NEzF8nC#mkp1Is2E0AC$aL|{PBAF5eSWoZ89IcEL)D|j7cc1 zoO=$@qq@&IoX~mPpj!Hw_5}WpZuhMV;XwI>{5F~{K1wNKz4n6-NZ8{vjqocAXG8)k zh_g;2;WouS9#`J0D?fH(DqzOslGy@2-@AF*SQS7Xg5*6NyXdNz zk~m!EBzEBEn)F-}(Yh$USaY8SneUL!-?i zzjBJxtj=dhJz#`hg~9ytAA5hX4JSSaJx4*GjS~k&3c7%#NQTj?l<-HH!dlCta?kCK zr_-&fh|=N+AS7NqDuCt$cij{S!HBqy77{Z6 zf!mdw;?LsDKQws1#6gHMtCB%%d{$b+zYZ5_m??HC9q1FU;1CGpY*|utAQ#Nu;or(k zP}KB~W1zAoV}n_uZwv$fvSwN(B-#kx4Bruvc8y_sL_h&r5A6|$JM<5yCcVZRZQ-Xd zm2E%5RA!)gv^{GRb9-DgwbJrytv#7CLLKjIK4P7rC@#hST#5<&Mc6>RNKqI zi7ZB{VOnB@7}ckus|& zFD-B0dw2PLN!58so*tCc7XR3TwjJiTTS!0CYyI|x-bXPr8kt&5l<fw4kx=>nQNt_W9~j^9S8RRps7-4Hv^+~9lr(7OuwK=4|abL?&)yguKm1+#luC;b(X9rbH_JAkaUjAW4-QL z9F7KRue{G=1>Yt{$3iw_hfHD=MhBNkn*UDdyfU6_EO-;_il0+6ou@L)x^7ZhHi9O3z&`nA%wLZ2XPd_T8eh=e)bOh^k|c75lv`j)_Nieg-^hR`NRU7^A1@? z_mYvH2rzz?``lIeN-uMTy)HJo;VQDt(r~bv$(6&074SSSU(FDB3xWwoUG_Olip1zs zvQOf|zYoh1q0^O;>igUr)sLhp9ZV_Vs%rNn&U1q(b5j|#pKTb#HNQn7RmqSmRq8Vegi#AVEWw4KcczJ1ZR*z4ZNv(Xh#c9&ew}0%nqeb-$OR8cV@Boq@4&4uE(ZB{nfFu`;ZO~x{J@8 zRCAz>869<^LH#~1KTl&uVH1AqWgGmHSryY<5iomEHGu!Dkfw z=`S{Wkg5-nSTxjRW59Yz<`1S@l3eplifn@NsP$N`ZnzOj%@~iga|k@`DmFL1YjTJ6 zHaDKtYP~7*WT1D+S=I_rJb(vWwxM)9y{-Sbqgn9a#gU@lc`~7A+PI)2)O(K*@8le= zCqRvO&&;DeQ%tZ@+i+6j=)w8;7*cU|RCNuHV&Pn!TeH{fE<8@~;=zjSj_1kfOCz2j z6kR|p^vGy)+rW1H{ly9x*O-1~_>M1pJ+z14BrsmO&ucIe^_^7|X}Z6He}=#=zi4;v z?~uNl#iQYNRIzGLpyYrPS%e9zpcgKarw{1Wy>c;F{)5mduW`{y`{atM{q+HoOVMNsk|Jd0AltoG+EZ$7Q{!8k(k(RZbhuurRo|t-C;<~ z!0449P&JutvAl3Ly1qx%xoG`%i@y$$o{1QVY*Onee>m;^nZ5_NV&)6=@x`Q3n5S@y zabhsFwNDIqedylU45kd?&23HfF6FY9F3;PQR89uCAgu?s`hS!&K>@!)shRFOvq*fo zvO&}$fN5Iw%}hb988P?>AlStdDiihn;$)GJe;H|A5RNg{oM<}yl?!-XvekeRIfsXY zkqPrYEIw>UBYSO~ww*il9O}U}bWw7B+c+cUV~wv1s74j3$M2$2=hn}%;!@w}ap#2O zv}$;LGX@0?XvD_TYsyS-_S-8)z5ApkQ7Ry&)ygLHJY3al zjD(TV_5a{zf{c$v?#N@$pV~%T*_?%`9{N4|=gkKz2Jsb&dK9@<64Kj}S z-H?sV-fRnHV^GgzrHSgvmTOm4aB}b@qXNK}@4y=r_QOEQVKZq{2tc=P_JTV9I}17YU21FS7OIJ+>${31E7mb8@-D|M&+NdnFAo zwj`q1Nwyi5Y&=_~lk`fA^)8|o2&n@9>h}P^v7ka9*uKMQHGFgZy#F1Bl#QaVujt?; zzuojJ<=$BZ1+veLpU$cd!wri(c9_5IF5exr0FMNZ41Ycbh?r9_o*UY!3P$TLf~4`o z%G)Kl{?RHuH?`xo?vngLv;3I6>Sb59t~lWUPm$_AYJuPbL5m>I4#fJyofLT zO%>mBy}eh8dE9ViC_J|7<_Ph5ws1u6*Hp&%ZKR}}oA6Az@tt41kc+U-Ie00giuK=R z_TapRer?gego1UKB;O$=22@>19Q}0_D z)NaX_JF@u4B$S6ydD5%R*Glq7>5#jgORnW38*1sN_MR;RujbEoaNjAV5-#L33q#?c zjja54437GAE%KA%<2m-~28#mF=m0G&rBh|9nZ=SD2LBL9wSSICBp5Nq0FN)uod%J4 zHGjHxj|?2ViNoS*Sqw87?Kd^T$Btj~%j_cltfQmRmq}fUma>yPxi_v}9af$lbJZ_vi1xK$&oqW{cutuQd=8wY>5FOciwc1ULoon_YDs{bPYI+v>hqUt>IMh z`JTXJ_K(znY7o)fE=uPi4 zRm42wx5P%Dm&~;LDv+d{h8r0^iS-qTfaW|PRGr!$lVJwE;c`(6XaJYU!)e|~j>Nli z@hd$!=J!k(gg(k2wcL7xFX%iIh7k~86UJNUi0@x|)ys1*-{Dgke98TK+vvSvwPLmG zVLRstCTvM4jyUA72Xb6?x>GOmuk)E*t0sHo*iWAr`mG}vJ=Unq(F}|hI?KYcxDN5B zenvq!NddZ0*9=)L$kP(vxKw}c57axF&`dta;7Kq9$UzeFn0dr;e$-jAu|Ylf)OU)e zuDcFp-QuH~rxw;$F{R?ib2PR(Z1Nw@WQ+FfH~iLEh)LAZ<|xvz_5qJ#`2wh^%g(@{!q zN46dGT(|`;CRnrC@!v}!1*E3&)@)ZnD`k5lu8cc{e)eE`D|b2)RH}O|R2|l402IymNtjT4=_Gw$I z&4BH*{R;iey)W2T!am?1-trbf6ar~JvnZsE$eBV%Xhxq$wKT?_u=o+7m$Y!D*sG=E zqJl#&xLt{PPaDIrk1X-W1(2AXV=#v|X_Ylc(2g%~`lEg)#-BmF`Th{jwzTaTRjXO` z--f67&GqE}PvK$Sjkf(cAm7c8F#pS*1HLNxdZ%%rT()5~s6rKv<{H<@T+?bvG|{%1f|&uY!6oXS zXw`{-Lz%!Uxx}T!f^w|(1(uU9{tazkGvSbSMAlamuGX1T_+{vFa4hYEXYA5=1ZIgI z;~`w?*Z|Mb&y}vpeb%2NuO*(;6ZurCFg44E|KvEB$Z?Xp;OoPv+T_U;*8k~Oxip%l zbK2j~i*;*}U;zbIM!vVnCepCy{T{KtRBNUI;%gdORXI(}#!@Gkdknk(Y&sD!>s2-ri5y*FKCju4P`1?gA*quA_h4~HZ8KhN{)QA)@=VBB^kb6)jl1- ztfT|G@kwDX(nFPO7E`=*UO<20>HR#+0Q{6z?BxJgltK1$E^yn8By5Sl9oSQ$hW4;u z8(YvwC~Nl>s!v^Q!e%ifYV1h?dExrLc>?=~mYJ8-wkZ<{=&A5zXfueOo?R~yi_@+5%C0} zaevEP(&R9&-w4tJ2>CE%+#Hj-lN_*PPo?^`Bx^MEnyaM!+duw>Ohi{7E;*+kSQ_k_$3g$C9VzInoo0|TNnBT5CVs1{b!8;$iw9XbY$yn=BQdFu^_b|x>} z1Lg=$I6y6S5(z)wmu5hiKpxEi@lU)GvpT&Ymh1Ef>UD^KD@QOGrJn=u*&bZ=(#Yxc zI+78)Ez4f@Cejdw^g3Rdc*3q=2pq|3M;)|I>*d4D4MdXu1RgfYwLTw(1+dUmPk*82C9KLWsc~-Ipb9ZXd`xpx98P} zucQEtyG9z}6s=A)7Dg(-zXS6?gz*mz|nllsl@RF+r#F*2l@O2-I(Py;I zH7Gv_rJ6QZ(z+o~A!U$-4<4EdfR*=9Se(I5XECCXB#vUSvvUJ*>yiGBMfE#2pWEj6)uK=8;O_g?8Deo>X&OcA_*- z$#G2)3y;t$3p=PDg;3#U>O5c`IwTkNh;bk)7eYs-Mun}8)rS&PA1u2fN)w2{!=*c> zzq$;yjOfBBIvgnK9Wfk~n#NGQo?Rbm*dhQ12%IyLFJc33*GURnPF}3IoMOh)m=Yb8 zP)HPwSt>I+k_W93aM)h!hs^EkMPx_Yq+I89x^AGmW_I3tb=6Qh4GUp(c*>^D(NM&} zdhhI!%+}oCET+Jvc*-V6di)df5~G0D9i1RtVgHyLu6p^af@eR|$IX>vH^LslS`;Mn zilcsHB1q#pq(%y~xorDzfcb%Z`8tidwuvV_#?LGe|G*aDLpuJ<(gjYg0ywy4wX=B+L+hP>Y;MhytmF$Jr$W@I5qX_!-e3HO)0tw%l>O3WY!fBTg}F`LsQ6rh-#9+0wz%QuR&Wnb5UP;J zCezN1z#Gn2PTQ}k1H_kqG+ySr=f0KFUMC|Ok36rk0Pik)kK!EtFiG@Q#i0YbL2{8| z*?P-KnyLpfXpro+#$;Ga=Nt0O&NpeS2 za@skIH+{YDubJTEaxUo#uR-(U!xdfR?ltUJlOZ0yzSrLw$Cpn6b|P%+QAW9wE+cfI z@))#d{pRrNQ1x<386_nC(TTvQ_{K^k>~bE~+MCrKBEg<#7hXUNJ? zUj!N6Z1Y@!Ey5*st@cOzQ`SO_q{3&&N6ht=DOR6+4SfAwn^Ru13>soJ8EU1huv8c;!4?ZY$Sa z_=t!%6*zr8<>HW69#TiOtB|9-=F~|?-Qnr4JLi^3euHjvP1_z`TNKbXAvTI{Ry3D1 zvd@>)Ab7cIY1ekC?eIDvb)E{3sjSwDKPW1+u(}Z*TOQ<-%l3m=V5RB^Z~HVxc!&#i zR>*vNiGF` z?}a*2U2+fm}`_vhARz)bxp=?J>a z6eMhY4Q#z>e%MeTsy6RTxP=0(O4Kf!j8xJHo&X;ukPsFBUMYSeBM}28IvPY=KkLqH z^ux8i|Fh(4aQZ+K<^w9Vt)r^_DZdpw`tQ#2@%jLg0@E`Yv>*ivfX)&*jy+lBf(P{P zpr#YpHVDbV(yWhc%dBJU&z-|#zdOY_YXNdboddf&#qS^?A(;%@63yfJeodeOz4qd& zzIEc5Bi$WN??qC^gRPdZ-vve9!gM_>8$|78mc;Y_IUS`7HSzPyUFkaH_5;~ckDyt- zz_&o9YQBSe%uHc_eu@*Y3s~>~)7L}_F}$d!WWw6cG_A^I6PHntfSK>?JS8e6Gb)OG z7l6>Gp&tt`Y{=pX=a?ug(_SdpjNyV$PB*3E3`e|U7zcWUf7r>gAY7?`m{EeuP`6b{ z+-!`&0Zp559)kE{r!v8t3AC+|2v~!SQ|__+Jtgp`;euEv$J@k##>zc7MT3&o3#oBW zo0D*frF&GsB)TXND1m)lGNp2*W@l~~5r4A-UBP;TSyLP+mn6@@q%M3N+TK)xSCLaX zf?+$B*N;WV}@2b$ijQZ+?dtjupvYm0#Sy7K@Gf3G&opQ89CpqMfhS+I&;t`u`X> z?s_Xk;Gf5m8wylq#+g%a?=QZt+L@kdi5w0b+|BQG-Z+Q&Hb(jeA?Dw5fso3wR+~R& z_%0=j^9ZwkBi|V7Z1(T5yc_(Z;@S>P9g^mC{fvi^DG@x-Rosff_36!257s zml3)05U@?f=OJ3*f%MLromyiz7GA%%7{6!xfZ>ZL!M-x%Hx%cELUZbo0eq_hB?o^~ z|DDwycS=f5Y4Jlj*@Z5NQi)0q`oS9(kx#%bfTor2>v;$DE(p~Ru8bNq^TP#$Aq~6; zLQ1N)IAQ9Fs>=%{_5Wgt7xW>CBabVOQzt2SYV>%30qMRbHeNWhxEr}JkvydeQAC|X$fRaI2UU64S@ZHfhtk?^+`POC@p%Z@h> z7??9AU9J#1`PDiHSb>&-?7ygUTjy3Yf22| zI*9Go8bYLEtk?w?sN$hOEDllw(!dAE&P<+FlgHeXOvO5nX&R3!f+Nt%#miqVU393w z?3td*=8kDT({_Hpr5wxc|Hq^ohn=5u|9gYfVeYB3M3>o{{bfG?)L2i7@f%miW~I+2 zCkg@JYK4x|7xX-;AfM%v(*QiJ)6Fn0CqMrL2yg(3Yny}i3rV%1FkGXtl>B=t?OLP%(MibY zRzYq!P^)Ag8apj|DmR+1<6XVJJvq;ws)1D6!azip&hvFe(ifsHkNHu& zti=S{!BvomSx#xuAtB{A3>i9*yo*tvJ&4(_wv@N1wlvi%jm(P41UO$_ui@okXQ*VD z8sHfhZ|*M1C781oQ6TDiS>#n-&%hv>Rw?gSY_{m`|K7oe#yOq_B#>buk7nkr?h0Q# zT)|g^IdHAwg@eaOSri#UHV|U<9$ojI-1^{tlBBdi4#L35QoTPy{_*{c*aF`f%Z`C5 z{&#-j5g+g%utaL6i#o!zICE`Mc~_*iBW)$uov8i-Z4Idlbwlo?9%y#cqy$@(vk|a` zV|3ecr|WtJBmCKIeEaK8xs1d;(jHceRNO`^ogg(bGU_^u{Ia(3Gzgvta^+$w@(tBg zF#+1Wu~kw&xY5gW-aA4JNiAC=1!D)QJ}N&zTfCDn?hNNgjm!NoFiu^K71JZ*wQKu8`9;iM@B{+1??7hwS3^Xl z!EJd4nO_;owX0as!V4O%aTo;SrUT^YF6>q|Tv3fXp3Ok0m5~?_cYUjEHz%+puf^*v zXKP5n_=7_D?nLEIMC1i~7VvSSxR1WF;RhQD6*_{Ez3h7x0^iv94zg_HZ@oUYRyz7D zDlr0HN-?j!Ub7(qS5d{8g1l8-Cp2BSf_wN!t{cvyMdtmp0WYAsbP6Y_$Hx^p|}upf0qJm5S^9D&8I^V5~M_PtWc``xM_jda zc0+p>ik%lgyZ>d-$YeeY!c5(liyUOl`h8DG3*2^2@Fi*m`Y`rB53_o`Od8LC(7d|; z=^jBUM-Y~ht}JpdQ>XX!FISGzW!IhZ=hfF|!hbliy#$f`%9;*@BO+WQFLJqLr~k*% zIY!6XbzwNR(b%>c+iYyxjm^evY_qYQOl(_ioHw@FRYas(&r$?6xpmU=G9Q$Fped-U&i^$(%hKc4EkvH7pToMuNQ#AW<&2BlwIkDgjwwL3FEQd7DuJTm;3~9h|sXR zBk4H2lLpc&ChoDhVnvr!%M~V3UARyMnC!@!B6THL)q|3i_@a0@tibD?-!wr_IUct+ z`X1})FTkCopS$**4Q{ex_Jt!Mny7~aN18M_xY>l)8;o|aN+N*ECK>yW1o~ZWdk^r^z#9GmM~)v2pFt6# zQ*J?tgN`DX#X!;Hi%(9=4_^pwf3$Pxa)%rxbVnGF-s!hs)8o3_YisKx*m_4g{Wi-s z>aZuDZ!~_puP7lCb4LV99^xaLgeyWfX<&wgW%26gP*E948eF-laomh^icVG(i2LK% zn@$m@J(eUH(cnGE9N=p=12n%Fo8$28Yxg?s9QQMigo%&@_D`(Xz7qP((vy$v2IB~1 zmpEvmROomp{n7fp2LzXwvSlZ?EmIm3rDBFXpmb z9|-QvYyUXjSJpIUoxWel*&Q$8Uw{OJeAIqqq76gG?yDZp6=*ah=t$^H;58g;B|h0x zOe;3HMoxMCi@qyM*THr~)=SmISnpMU&nP}O-M*IQf}G-~@W;Nerch_MGwc2nt8~<_ z$-s0v%_~8G^12(%PzzU8LwvBVW0gA$xU;Ih3=T`}lGMLv%Rk(qXw!PhI-$970V*9;MW%>N%m#A-6Q4?H}lI0eelnXLL$Ok7ogrR`&epUoIez8-@|s zl7e{l?CWDieL$!^;gC|-!FPGmav0U7ua>u`fiI3zYbcuAew?{C7ZPFOpcHg5TM&*+ zX~r0zz(ptrF3z_|jJ&re5~_(m8~%UYBWGx-w}axKo&l!K&P@pueuIhmqK|`)vmi^r zOjG?Iu7=Yz0%bVW?A@9{H^x7|3$ZsQ<8#r^eM0;2Lefc-VTx5kmSJY}nnT+H&(-XE z-YiqTMeV`NXbbSJSWaEj2|r)WI!);)nY)?7@?#E{_JR_V^IrX^i(rgC-hs&Gm{~@o zt^HO0h>!3Pi=O9g_)%}{cY1gc(awqMw+39bCjX*|rHAYHlA$gU(FE0jnl@$EG-zqU zaS5#XGVC4au!y4D(CtW;9PluP%i~~Sy-FJBAX5`0x8JOt*;PGm@bSKb+agW*$%=gN zC(zl%YlPITw6Ro1-R~|Ao!(UWuTOzTr*^e~cz9OAcDSM}KS%{RI8GFkkQLe)s~ltd z^^hC9|Mq~UXum|;7#@UZz@Ou4U6ZJaRr0LrrW@u!UUtJ?WiqWG4SY8!oU2Ze~ZalCWQI4ii5VE%|EGnyk|lA=lql@FogL;QYoUy*7##hmkM9xR#FxW5~@Hw{8zu57$ex=M6oVNIp2lqpK;|9 zN!i?vFsPq*!ba2;{m)SUrOD+iTi=^Z;qswGzw@l{oI&m|faeKc-&bs9JJ4?(W2dBz zmv-O00}1@UXa~czf8vl)P+S0>)b&61=fA_d!}Um0rxV@6&gn(sv!-$Lgx}00__BPMAY|9^L4cC8gO~f+~0hoY-P>YWSjZV{AY$8x4p&T zxK$&A`|&I3{+7!zo|5D%*YEWmdD^evQB|w1I}}MP%yNcbQhM&eB@G}A>r8V6Uh z0<<|P4w&a4lki{P%w9p_Dn-g&@bS02QIGGZR@C1KqdO?MFNML6#s!>mS$J`$!OCJ>eI1-8%o6K#q+ctP7ZaaP%eM zuL)ZPq0A?2cn!)a3cp~1VD^!>!eU>b18*^e9x|00AA{cTve>3Ldppw9lmxNuTtS}J z6_DIG{WdhY3e{M-0!K@jN+VIssFn8M%<>0VC}xdLk?geR-$i|$m??#dX$Sa5;Yaf# zEeK0d?(gZY#loi4Sz+K}$SM-aP3=9i>-^oRW&>yFru4oh6F7o`G@k57eC4w&3q&hf zrJJcm5=bR6Uf(R0>o^k!c4^v3 zFYp=6kuX745n{LC5TTjZ??5eWBdL#Qtc&UzJ$5=yXNBED4wC|tqv!7FMfPpil+lQp$c)B??&;4J8|F6;){(PC$>2$LRo7v|% zjOCcw`PUBkv;DyZsOA5$bIk#cjii+1r3suMAD?b{9Jxc+1sAX*rrqgAHyiNSwBF%L zv#Rfk3{2oIg{BEe#%W(*{>j+=uY5^#?U;L>O%G@DXZ!lVI4l*)dV5J~C`fUcN6EKB zy5_PME~l#xGs$GL-k0r+#`U&unLqYew))rO^(KIUOfts|PcChe3>`fI*p&e-6PeQt z>;W@cN{hi|^-ys)bb8axmd5>eBl*qPSVr_p2oiwD9V|%79@KtSw z|AdEFIEU3VLS?~BL8{s`rv+}`+WM!0-q1jT{2G&h*+}8pH&wib2Ckvc&CayqymusO z39(a(5(g+f3M({RQ`_e65y^SiNYuJ`EjOD(7}dA$4|YmsjHl8?URTmAdq8Z8`j&~% z$^0*xV&K9{vkV&hv$!o?$j_l-slvC2h%>6#bf`yLF;{IPTIXEo_R-mt~{#a=rj*$#&(Uc5lNImh`mQxxn!=q<#p`$O(;jm=~1YVPn!s;1S5 zy>ENUl=bhS2i7$RU8pf=CWST4EJMh_z*$|oRz@jOXuN-K4Y%)iO@UoNE=lvfxY=@l zI>4;(-=9dkU8dqHSKcNT`f&raEbne-Ek_b z$Dynof;%y+HGA`OSW0a)d0;n!|Al13+_w=WgKOo8K@kkS^1pJBl9tZocI2vggsO3i zwfrRQ7*E9D(~01T%jB^7vcGSaVSQn~a!$5TEy+(LNh_8rt?QCarg7W@IHJ<4IuWk1 zKciH;1B@{lEDq{Ddl)x`gr56RA$*U(g~P;_Ms+9ejN8HG+Ug8J z-J^QsS1xF_U-1FZ8FaONM-u;KyIGRwcBWO{_%M#q6aWJ9U-)_eC?aZYA1tzbo-Q}7 zYjl7@oI$f%#BO>Z46y@nFe=O4XaR?8j{oC2Kz`ZY1%AdA_U1nEu7M9|mk)T1%i`T3 zSJFba=v#MrPfjCvR@a57{*jhV|0t(;`1nP=y*kx01vw&5hr(Z5?qsg^XRHX>12bY+ zUMRQUN7`P-;{(TGKX*-2dNR^`&hbF!2P|TvFh@-ykx~Wb3d-|*eOSWg(ZqMb6Hc@A z>Pw^TvLXyFjyIKmaMZ_W&E?G%&H2l3oNwK7<_p*FXAMM3FhS`&G-5Wg3oVsS#Oh-o z-*ow3qc0*-SsnQQ>#NmGeuI@-OY;=5VGLHF4{-ieEtox=?87JmwmW~lc8j3CiWg@ z_UH0Cp3iAKzBbm573st$LZ8>fU;s}P{51)(NI6_^S(vREvqqaJL_i%GIQ(Y6)|r`{ z?}frHcinT1t5MsTM^&dJw!3;*&#dj(P99Y*5II$U82B0ghq*Vn`1f~w9VsSR(&@3# zoms;dgc;vkwY64%n*5h3s}P*rd(sL10n&k6#uN+5V2UFp!$*W_PXLTcM=GGo5a2q9 z{9GhI8a1gTc2*!J^suxDQKldRawc%!V4M%(ZZy6-m7!A_u&9tIjMgPNgl9%eP8|TRP z#Rb6Iv$D3k6GqoGH2X`q316gEy>Th9c`}tSF8F^ ztnSo-QCF5O6X-EYDeIpWBZh!WS}x`ePt`WoS+Z*i5~s)7hRQMDO}{Fk3YFJx~;X4Y*(W3Hwn@2yA+_M5>x;_F1OJohuKZ~RKwE4$*h}EgC~!i!^*QTE_U)IMUnqCa^VY#jO8*fyGM3<11fNpuov=Y{Uxz9l zbEX(Ada~%%FlMlc@lk>x03hu& zOr7_|OY_K|B?5dFB&9UIYg_(S?H{~F_=L{?iN*1pIxQ&1#fgj|xqYFhlrt94UOaT% zx~Eh?$vk6qy@YcNSXE1$F!>g7!U5}j=9?$s@LNLH>GovQW#a~5U^>S(-A9HIdJh5d zK00S8b{DVwJI^a#oEj=Kvo6^rD4G5~dBa;Vmo`i?jy;K_BcfkFm%JDG^M5R$nDN6o z^6ZU?^S)n~#wwNEqkd(hmZS}-Pg|T32-;T~i?kz_pHUel9%;0Q5wB_*D=tE+;dK0X zqcQVCB+>yqM|R3`5slmmyP^UvREDKkBVEBpx?er4yAXWugF6i&txB-~rspo;t=VSi z`F+*rNKeiL9`f_h5Yxmmw^~(%F6Q)KqO@w& zGQf|dE#*1Nas9_D2Wo4}-_15~WI&($Na(5K9ZkG4X-#`#=C777*zGq5sLTF*s*JoE-;txM%VHfU?B_ona>K&FMyqNnG4p z8H)^mnPjc*S0!stsh7jgkKon6)06Xwl8MGOWrnDNn)1a4^N>FF;83v%{T%^^0ttMy zs5QtO+Q$!A3p&3b7y2)eGg_X)&oe5)5zaz%m2nCNyn{v)D8|iW$tAIOsu&4i`9Op4 zQI8n&!Vym&PpF9(o0Q(sSYN833~h2!zn0CK3)m*{$(C?N#f__lun|WirINWNDECz1 z)`F3u&@2WIChegA*9hhYUKy+uy!rDQdhKlM^B_PDRqTY!+J7^JnQA9&$V5dE+DM!U zj~o4}_r7avg*Ko_oT)b1J7@{Pv9Tzklzt|D5I$yJ)IM2)vs!JfJO~?^J#i;;PA)+w zBHhGAlNn5rfe0hQ1LJ@Z4zsT%VHfoF0}66mt;kQA6(4&9`~aLLG4$$+82S7|m|e~= z|C9*RryNx|WeN17;ftL^XX;4|YFMI;cBxZ`XlBAqa%KO-40Fr z4YyVzW6tP3$IM}zW<;y%5OMA6x>5%KMB_OfJ5b2yngT{q49aMoYB&d6NBsl|PKy)U z%1l|H|370sw z4n;WNnnGwW@VOi-dq|1d63KEPC<^nLzgH8EPJU74ccD>f7|{)(g5E{{O_l?`E`~5y z@*l1`;rz@(k(!L0Iu4H*-gctOS-dPiuN-3aS%O{|EiI?_RzG~i3fl}c*$!m^je=*; zyA$JI`uVY~{L52lb+Y5T-9t(RQ7XmLi)#(lA)xPTcmjz6H(CcJ-)%D{F#7`Bn;sV)%DDZBF&()6D#M4$)0~u6S_>{Exc-d}SMPCo!+|&=ezDQW9-*vhOXw`$ zFtP(*;>+U$GyvWC548^b4Lu6~IqS#8_Q%iue$mqhKA_M62%d}9Uwvxt@>ZWwJ3eHS z>b)14w_a9a+OKEx#`C*R!?e?jQikvcubV~D=1$x)wzx$s6;d;?$epyPyROrz5?K3U z+C?wFIk75prlhSc1SpN~l%{`0V;+4~8L{JtvgC{^n++>hnVX84%0k*6?=ls09|ki} z(B2e~oM!@$xPvecCgFiv%m|t*%!buX!V|B?h)r?3ZMKa{ISBVzKwAHrhy(SJRE5FS zPw6x9f{kJXHp4Ei)$^_0Wn?C1%3$e$3W3`j2}{Utdm^E;Q!RQGXPpzAt1Ys81O<}5 zcK+CrHA`2YF!2zNvqBuPdq8tg?s_291+%qX8Y&VBBYh$HYg{z!-?lk&Um79y2{x29 z`~FroK+au{QT`q&ag|*b%bS-#cGbM@{zUJv2?_%y1TV290a!H5`k^)}QQgu7&&fF5 zxMXJZ*sO(Ng?A{9f?^M`B!)lQQocLL&1rRvmS65MPf02VOVnTvyv{HrRbe)lP~#-NngmDsrThL%K=7@Z@YmQ?!p{_cFdtql=N#v?w!t zzKmJCgPx#kNWp}YkCuv{>^i+lp!_UG>J;Su0vA4-fA+Me&TZ+Er9X&ia-sN=D$uxq z2Fg?8V$w$~JwDv!sx;kjqSO7XQq|ErX>#FkiMC%ZlP-tryRJLitpII?<#ZNyHK&iy z>xwNWU_e8gQGq9xmXfNpKkj)B1LB}zh&Y;8Ou1c8E5zReB^Q^lDWsZ zD^I5dA*yCB?%x01?>tzj0HRm&AmCOFN}d7~@h#it`|k<(S%0op;pYRYh5=ehN^`9) zGT&JlFe=?)Y=IS?F_*Q>+q+q#k^6lmseBLWd64)V1&7S*8zikK1|25IN!w9H@{M~J zqCgrA#N$?2>9M=*?GRODtm;rB={0_R%?gMB3stJ1DQs(E6wLdcwp7-Z;riI-*N5W_ zp6*U&{|dWp^hcqHd9h~~77xU3T9Hk%sG*pLp6ApZ(wHIb@9)cClZA@ON;>e#l#VrG zQ4w)iKC+!{9LN;P-mxXo!Xx;RiO`pzc_~?k(S^Xx@Q0*8)L@8cel4A7>Cc9s4L>!= z3V#_j8~@q=#{N<)vzWpI9yRhGnq=WsU373GUxoY(&mB|_;?asO;y|&v`r0{9wJW7; zt^l_T_I~xCcT^IV?FMVD6*U8^B<;F%r=dFAfNLaDMF^2bmhWD>V%Z{jMTDeV=FOMX zcF5qOwsiZ(Pq9dmxRw?2R565~1=+?3btlwQJ~xZNI0A+6`fPej0XoRygZ0aJ(){*$aXt-jQVSLI0Q4B6%5;_F72JlSrae$maT4 zSV;DTQCr#I%`na?BZr1$o5TZDDEr*C^_Q#XrgwXH(x@j#$;gcttP|!)_)MMoeK8#a zYtTf)N+G<33yDkaho^?790J>LHbePyXcFA6vko|ZC>+De-Wz{i@Pn&ai888KN%9;C z4*ee_>1Q^?D+qB#+JPUY&P7nlwPUT`Nt9&Y2|{ZVvyqCF8wqo9%QUNSWD(=pcxQb5 zoO_|?<JkmVd;}HkjX?L|#*J%KD#|jJk5z}kiR=pB z(O7ZRVN?{;mYoYnZ`l0TsWNBPf?viv5bVXNBVXLWkqd=5w}Kr5(;M6gyl($|p!ELd z1cNh09%HswvNbLDMy@B_FqTlLub=Bv(|K2mU{(}25&no#YV237@w4CX8SK?Bab2yj z)_{-B8+AnwbYIdcuI`E}EOjWYGGWS=lD0WwL8*e_w}nFbXAxH%V{~oGI975P9YV>0 zh5VZZQGZT#Iz$`<_HOqHCm_jpe;Xf7sG~Ty2WJP#m*$NbOjt9@@gI8dbld znJ0kgs@JqW`uyxAig)_h=*c`4lv_=Np%7-{&r1SRj}M}0S=nvRg|j?Y#cma3%T+9^ z$6=UtV>$LU=Lk@pm=h=f^Q^rmmiRLIEmel@=zV*+!4BwX-2OU+COsJHZBsCYM|*;) zMv+Jvoj;2+2ySddlyvOSqLb`4+!jCSY`Ze8sp+JXp$l2->j>V#pUCL80PM2^@%7p^ zslrngA{86b-g!?)y!TtK7cHy*CX5P=w!lU1Utc09C`gv-h&}nmnEQ}Kb#GOdjjJcb z(uf2HbZ-NCrkXW*x>RXoG<&@#10g&j!QrUuVkEW4dvsy@J%bp2gr56kMwwzmr!b9Q z*V6iygN5oVev7y=@mI!05RhnLZHG@VR6kITAbUQ9wu7hO{G}W#Yv0d-B+#4SjHvU| zZ9pad%$!*~Y9~ESPrPQUk1qs32^6u;VCP^3VOYGBD9^q+@T#@Lk@f8*gt2p#2qXMB z?$lPsT36a7Qe5EJkyR(dNSq17(Igx%qF&v%XEE>;Zl!IjG}T&hN0Zx0fjUDo#E=b& z!Lr$czbN$EIrJtAX8M_~VJS-(ZSBV*rXno5a!JWtYjcAw4gwB2y$bG#!Z=3_(X z>S&(r;NUwjX)B!9uKDTgwL)XZs>#wiX^+BDTbZS0!}^jB@E}I-cgYmeOj9W3Oa%;T zJML`SM{^{i=i(HU#V24Et)?sne_iZW(FHxAR8Ukm9JfnlWfPP1L1HGxow%vmcE$@R zK}KSr5Ydnda6JLZ{|a0)u@3Gy&YAWC80uJTG2IjxJC_u=y>N`LEqcWgDp;Ycx~2-z zniy8D)i}%gl%XZb;kV4>=1?^)8ZGh_<+#NN^TFe2(D^7TQ#D?iIkjtxr%H!J|OO;Q~V<&FX^n7kZB# zw(Zq?6<@=OX0l)AJbhGjeA>AYys}(^MqXb-9S&xCBgHwUkqul7x-(1rdjkf9H)Y5BOR`e9^k9)Zp?|*Iv0-Uo)6-xf}S6-w1 z08Y)y%PRwD;3ZJP%h9E%+HbU@(@_DcOWHWk*`x;&%$0+$S)4hu=RqVs24Pj>u$zDR>i11{)wG}(B9R7s-n<{G_?;j&+-8Shu6ptw(9OD!!INrxIzoKbTPL% z;&6B)DT0+-RE`>+O%Ypz&pdX_y*W(##R7jTl*sZ0V+1PZBgI$#@Wmu`LX=eAZZvDq zY05+m!!DPyV7Baz`O>s6w7&s^)7m_SQCT&&b7k^pSvmL>DCG2}A6kUYYYhVRH^k)d z-h85j!x>A3Zpit1|!M{ z^QobQNz#1T#h&5A&=|Db)u*Bt!y;m^cbKmvmh05Ss3<=zNG=WcVaH`xB9Bc^Oo2NJ zVQYS%t-J1!j?B;=flAK<`&A0J-}a?YsZ*Ie$EZ#Qp~0*K1Ed1pY_V&Yh@n6u35Um+ z13V)0%h09&vx!R*RXF}R%r7ly3k4#h;yNqL^}@avV;@*2-`)3>Qrb3n7uq0;@sDU| z{*N3n6drGa)s;4m$DUVL+JucVbLfmAS++(qhH;V_ymxDRgu-%1KfwAb;;QiL$vBu0 zzR32{8i7m&H4^s=IGK19YdvCcy6y`;T{(4tM$c#nM)UwIt|(Zf;*S9hOfA|S-W2tq zsG8`;JX#tVgzR<^eP#`xUFUh|@}jQX-&d$>6nZ*Bnh>p;Rl|Vu$M#<;bnCIT?WWpF zFeWGnJUl%7-*QTeZcS&ncOHk?NUUsrQ`;H8M$mf~i;PY0?u{mlVhnW(tOT1VKfIx- zf1Ky=(W|Lbx{5P4L|`$-!1MJR*T8P+AdeDxu`)t^kRx6dPcs;UrhcaLF^^b#!7tZ^ zy;O0T`VMJC>_!9xh}9{qnNMF589Bww)Yh+l{kww_Wyh6?Dx%){neecNadmy>tci34 zR;fAstJ$f8PjIXg7>t{%sR+i~Z|&Llua|a{y#}5G(vJea-*o%JxSTf^cbo@c3Gj~S z6Lj)WcYTM+jbl9IzeLPI>@V!ujrSokaMV(z$M=0=mW~zkupAUHRJs~S#M^TGP+N8F zA%5L4iZ-I==k25uN${_fCW$WR0dt8e{{r4Kv8F2mkJ{onE|`}H(JiTBr3lpNs+Es^wr44qF# zSKMFNpO3-e$h!8D=>{d1MTdM#mS{`Y8q8n;+CsSu&bv)inu-=KmSLQ#2?3obBY9Sf zNgV$>qh?JHa`vSZZ^S*<)Zdw%gA$8-aCrD-K?k)2@+?2CtjiAiFofl?Y;F4nqPaf!d@##|Bc z$9$6#xEIF@&&1nAnP9#FvD(X4#gUD$lm(TcuAK_H;{co2Pd2m)e7O~#+ekC(O7Tdy zCb{dBdAo|koMOYe$98eRZR#{8ieJe{=iNXIdl=YRi73#l^ zQq3zfQ`2B6qn#h6Hf1|uSQfVkDJjaJvhxEvq%01ut={Os*PSlj^LlZfw`x}&+x2lI zq_`iIiF$e#kAC28CHL9{?Nnlw!3EXz#DuM5BM8CFzozgfWeZ>?6sDFKXnm>QnMr@^ zMfyWD<<0shWK@n_@HTGW%+Q4_TzPlM`=%uMYK>gMI=!dNp7aDZj^^aw`>j1Kq0)ly z=jGoxMO61xRh7eZOYcUTF>-`_m`D&bPC2|PzoYDnzTgqZ?rsq{^sQk2H!m#~SUf=r zI?D}26&%$>GD(gzK%2Pzk1iv}Al*P}kQXKJo!7j{s+gcf%tUX&qVsC|&TazM+u^FP z<)2#~Jn*$FP%i5i_D||qB?wM| zu>r!8cr-iaQ30)z_5}DGHlz`l<_fljzKVH_zsenx?Q*f zyBKD|zu0Z{8&*3Wp&Fj+R_%TtWFf*VY$FThPxCew*4V}7u}v_gR4OTR4sAe$&ZFyR z9Hnzf0{Qi0=tcO0GEC=6M_h0xTK`4c z*2MfMam>{Vkw(mB^qnm050Ab|4gHrvlI!O(fvCgamv$TRa$S(s?@NS`Y8(B!zc#pR!92EKO$L%c1)%DM4%CJe?fjlT zhKEvx{^XWSPu`Qf#-#lN?|p_gfr)!U>Ry;|Z@`RcxQC$17y9?1FPPbbmL0a%$MUM` zzXID^>?;f(hq$~2-@GHxLyhA>Q4I4QcAXb=o&SX$uqSD=_zT!V5DN?peE5C)m#TrA z*4p48;@K(fCKsB5(OI2AQICg!x^37q5b zQE0nF*ja;6o%X6oCQUagqgt@Q?#zOaKch3PpuBL+0Wova7A4f`H%w@YCs5?vVxX!@z8>c^tsc&z`(!!k)>jA6iL3^Z=aCP&#^W{&4<`PE5`8O+2xr$b* zPjpUjI`$;ch*7|o>^Il7ouJ9hssY;L#vnT-nwVKchGmm%cOsg!=rx-LlwPH8X^Ozh zxDrffD_^%Wusc+Z9@+fyX?E4Ue@Q>l@_98?-QwZx<0FOvI738F3Lqf3B-H!DiLoW^ z33(^xSEHk^8eHQ>!)e#heM2aMAv{W>NtsIK#C>j4y0tPhw@Emf9)I&Ik&;moZj%fi znOIzPpH5Y0+SR`G)DeK*nGWC_V{gD=FPpe{q4>TGu1!RA+&l%Am{7^t{ldwzF4}pO z4^XiD+I8Ln%ki`YNePDqAzS#9QUpo^Huj?EGU(11$lQ%S!}KMQ zbG$HE#|Dq;&yRt&S@{#>r8Qet4OjL-cd^z$z&UvxQd+F+iWgn9>@t=1bt`NSijR9oHQyADo~stU`BO zHN^CH^UI5ATk%YMpVbYYSwQDk|g7P(+A`Ey9PW(9^*+1hVL6Xe+x`$ zN%rwiQF@y&P20nkKla)lB)y+62eD`z7RJE%GtU1es=4lyzwP6~ zQj-fig6LDAsAN|pd^zvoCZ(0CD8iI6)U+eaDR!@Di(=h5q0PVF8-4UW zaBmOEEAb<9x>I|2(GS6YVH-C+yI}%=ZoCIr0oNfUZcIG;JdgWIUV&Uam}CW-F@N`P zvwn82ejvVjPB2i}6OewGzXXEd#7Ciy;UX`G1C7i6n$BsPU%%X@^cIS*i9~ENLVTD^FQ<7HsuCIjMcyBrh9Aj-4cKbPpj>k}wg{TT8%(edKh~ z>s*R-Q&*B-CD)SU@!RGdc);xexyZx+l9i}sladYy2no+IT56*mJT=4*-nt)uS1sW} z&<`x=!3u=dgO0kdg2jw$OijP;58;6Fl9d9;>I5Dv3Xr_>ipZldBix_#vG7zYUJ?6Tpt`+tbRA6|C-K_RjDRZfiUBEwdIAq{4*=m^=NW3iXRkfs*o7MIwq5V z4gXy*%;@uyOf$BI9DyBc>)l>q_s&3(%Q-Zv9P(CwsH#8BcwX{pf`%q9aE82KU+t7i z12kUd8)i8zM0Dz?<;eRRt{B+naI=-^xB{-XliL zE|y8)?qIPXl_g&Em~f?T_Xx+ZZ^QqwDDd;|c$q@~%Gp6GUvQ~ggF}2O__@VpNy`5f z&VV%c3!n=;1G? zm9Y6Y3e#rIqhm}(uvFu)vdG-&kr?GY{~hvAL5F2h+I4O_rhoMoO|BcpzfAi3-Ru#a zk~acSYboUNwWQdB)5qqnjV*Om^mlP@Zm!@Uk`HTNDOs7Oi>v)m$Cp11H7XvM%@+Cr zISTs11zQ(c`<{=^%R2+hQnVA_`^MzC{ldEa{wQbo_?{aTu~PUStsEZA8&n7^cUrHyKb!%IFPl6G#{mL*c{^#9B_hYE# zrVbjT_V-AOsa57qxEfdW4cHMK_mCTLV{~trbi{phjxDu>6(Q}468&vm8e-m^RfI<3b@7m z_lX|Y%uxSXq}lyn-AE+Ah5><+B-O1X!an9evwt^86}kEC?ayaRiwa8>w4yiHb+P5E z$~S`Qvfq-!dO{`r=x``JJEUnWe-Y&`b!Jfme7GI`rU_HjhZ3nKnWC(NtW5+X=;u&2 z;L)(mDQVdd$nO+#dn4rTog$acd#tn?rzE8iPlBcNuM^CER5y_RCxz0llopt_7t+U= zQ7O_?6CQzQ<$;XI$k2dW?Tnd}st22fgx!Y32y@X`W{x-M(22KsE`HGgyM&?sUv(QN z_B=gOqFh2^$(UM-eL)F_K71~PJNVhk&6#6hHY5tqkHZ=MVk-q{PJG2>WB!Nrgq~9x z;TL#d-Gdp(yD8$C!?=NG$D!zEqzq~r#}Z7Y-1p7>K%18$rN}Afd(pxf>lUT%26r!1 zAN}o=XRBMg73Q(Lqy1 zkQ}r@Pka8;HW#V+@wHH^--9O*TOiyg_dHXq-n8XnkXQV4-PT{LZuxKZZa~cwQBC7A z7#V5O&)+k-b*?i5R%JfVyAfTD-uEk+YB^qw_St(nnSxKhQ-eH4zJ2~hcKDc*Jg>Kn zwb^H~1ZyD|hZEH!7+FjKW!9Ut&$ucEax#GrCGGdzdZ(c-CG{*KY0*p7S z-Bb8qn=fF29oPH{1hbi!$vLnWB48=^nG%FkY!C>$fjG5;Io^kvKz@20^dA+=Zturg zZw9v`G4PW!19nq^W{)SdrTzGXz&XF~qrK%!j?OP9-;HSGUtd10C_b5G_)H@(Vulcc z^v)!3$v?X;s&kx#0uZ7SE-kzdRYvFJkZJO6AwE3#89S|@p}*ZKsHz!uvDAOv%|sUB zTl=mJ1EhSUT7pO^I&2dD(y9keu*y;2l)1T?x%C1y>2KnWCu6Y8&$!ctnzUxuTDw{?B z466XtG(NumA)XnY^}TC3%tstLDCodYkUX(>{sw>@jVZ;L$qLOy;*@-pC=@ zWoxsxim8auq?dppWAH*)nD(`DNa%t2UF+$ zx)5$`bt^E3RA8esAs&A93NP#1Y$DzY&(pdpd5bSpFlX&%zJnjj&dY3Ro76*2c?&nv zale$GlPQ1ai?kkr9D@_{`c3MDjMJAF;i9{l#8@-~nUO(hYJ-O zljQHAd#qX=F9^a@{TN!P8*j-LNe9Oqkjqb%HwiO;jU|#fh5Y&&j*A-%tA%A<6{#M( z>hw@B+tPKvnkv}i3FKii7a|uHRlV|sgRIE-OF7VA<^qwqIAoY)%%>=oUd&&t=)r?1 z;7=Aqk^e@xaJgc-La`5!(!~}WKO+8*q;m|Y^KZcL$=tH-S|{6fYuUEBY}>YNYb|ZH zYRh)3mTkXJ|Mx?mPo19U$NRo64|xgcXbEr5;k$*RfExl$r#ofy<0g9KSm6?8vQpLK z$BXXu2Sy+@*35-Emk8ZNc-k4i;dl0Q>^iGd5!y`}9wvS0+D_d3HD|Wn2;o=~gqi<( zU5-ac2);_^&7>=b1VQ|c7!^Nh^UQnNU{L-Wf93_qQ>wza`e86lf#m6kmd>7L#k z{vfQQGCSWr)MZ-L33oaoROyk#RjCGXu4-WQg&~V6cRj(AazU@ap#IgY9e>d{Q;X

(pu69{#)vTD;;QrF8uczbQ*Zp+F92?hg?h6qpOeU^) znreTsvu73nPyuouuJ&B^a^2HncFIPR`yA-yCVh(+aLScgi$^f}yNY7dU}%Gx8OyEH z+7%nKCu1vIMs;3e8y`UoK*+$*FhOPGMxVN+K@5{>^q1S#PSgR=niRoLydlp$flQndy*@BIoUA#v>Kjl-s zTbmTU$e;t@1O{t16g;0^7{AC4eYo3G7bRE=T?r|GpIT9XH4Q|Ana*}9QPRX9Q;ijU zD%pZObZuaD7ZjX#qdq{?O;+#xO(dR|h+#=aM`?+mEzQo7_G=?^$OGb_t{Yu}0JX%# zFN6v^OdBj6GK7scy)kI=>OH_yPxA&{$FS;n%JzDHin-o8fR6|T2oz$OSpzHD)Xw4> zu#JMx5>3*<^Ghyg`m$ZdRLyQ{a|m4C52?i^bBm&=Be=G()4dv@MmO%gQq8H2 zV{Gxpw*TaRY?4aLDNIcv`FxxAi@%N;uqxo0A42#ANUmc54q{0Pz(@>0Z|Fo1kcEXd z+#k}g;ZUNS;$`ank_3E@wQ!P0a&JXz0*-By0k5p71Ux>~T_M9dT$cszi zq&+>?t8C;t;!xATWBW1tW&9R5osdr_ZEZCMCcxF%z*YE>#*yC9w{P|x3Ru6Rn1_&b z=A-$-Z;=p{=2e)7!>+hD&7YsD$F4#i5du~rMcqI?hB&EFjq0S4bk0XbXSL$mq2omQ z5&YGjfabuk*6j89VHJQv?dBX|Qzq-|=Ry0$+axC2!e|dD(GY)`k7!5)EA_Cz;>kH9 zOVkeWQe{e`E8AtvGgid5(q2NkZRCDSW6mn0#S6Z@_1jhFU)~X)=iL(k@a`Oz;tg`3 zNqh&W@z%3spxiRSbIT3Ho%-GjZFSwJqvz+22MY_9?3|Y?@Mdzqd;0f8WVOm1kb=** zm8))x3{6fRkKs_121I$GL}}coe9GYKMzL}R%+r#|Xwg{y zBr78&#-VIn?gshxsM^%j>9}YRj3A;SA>3+9e( zg^B~V!Zbq`+#_7c8aRI)&!>2G0F4&6?Ys)1*-)dOT_o zAxg04?Xpe11bjcsFrmt0$7f(~M97v*NfuQ!y(THE2K(!z`|LXipePXms4jRi=r7*O zGyG!5<0Qi@KplZ%2p1?ocK*wqWgK+m`By=FkqUz$z}_|udym8$1P_m&)bUqJT& z)}QWBsErrkK6vI`0Nv^IB4(ez82O(vr(a)1Qlbuy2!tzf=}gw2 z|FK1q-%q?fgSGzso2*nRPxBQRW2L619)+1G`)6gumEN@kyPMwo4D`rfueh=WyvRop zXma|1obBWO9j+ht97oL9(o;CeYepvqorcfr7;=0&-tBdJ1WYC!ZBj_8y7R!w#!%5~ z2K?Afeop|vxN(|Jf5o^A>g_p#n#EZYYfi;DAS~$@;TwD`8Cv@T@zL+a>NoE$)g+D) zjAhLNB8INl(wpO!2IF5H9N%2F^{>3?&zn!*u8!Rur)2%UNB-Z>Xdc6OSJED+rQp!|6@^4v3}}`D)DJ_K5Pp7tv`;l%Xv^PL z(wB_Fb2}f#;W}@}T}z5&GIy9Vpio~o)^Ub|CSjB@e<)Mf5y;3Ih%vJ!FxymwHH9J_ zxv!m#oCuAzHQ}PIudYn(yF{Y}50N&CDqHq@=rsGa{Mol0^`IWvuEhM?$l{O2fdA7g z&u>%znA?Kamm{JykfXZe=Ceb&fM7oa$qT7h{92^-3n}Tap{)ox#$^p@Bzcr*F?#4P zR?Y6XY{3^i`|ek51>dLMq?2Y6>vdgm@EAwdwfRXCIj6&ihYVRxqIsSkjx?4D4l1K%aBijGmJHzP zW&O>4(*x}dJVEms^eguE+iLgJqq38gep;zC;ccTLZBb7drjk+3(#a*Uv&ci*Ls~$% zF{?d~DFg5ETRdXrQ60Awn=r3A^@kkGo}(e74KdnCWtDx*kVG%$V0A_@jDgs~hR{k& zG}G#I04-)uFZ;4qxzo!<-u>9w(6M1ro@`6wSC+;yY8`@)W>^hbLr^~kY5DXb$O2p@gp1I0D_$x)a zxw6u!;tKjdo0{u@5c|x&xb0Onxui;i>j%wZEu>2OJ}ut)@I;}Ku?66N!S{6E+P_@2(GC?=7fZOw7V3S=|3yzo9y8G$QAxHimI3FJ zEYClz{ps`Sv?k*A)7IrjbX50yuCF!^{8ObGGZw4@g>}Y!q?4QKMm#W(I2>^3p97T* zWBSOalw$XCUt#BIqRgxRnCvf^e3^ovx?X-+MeR#?uIb9iJ6Ng93*NkznX zs7CY$bW3scbsa$j4jm8>HTun2@p(^ZsAD0D%&dfo zTPvL+jwBKHLpqdm2zXoA6P;Up?3JMh4!E#jl*?yLqbp;b+_Lzj^>#76I9Oev!dkqd zmrai3&RW~Bbb5R0FhF`8d5jRAaQm5~?sKZaWhSFc6AR7EbaM6Cfg9aYmAwnDq|!{d zHmF^(SV{Ov+F&b1$9=}n^KpZ@clG2dP^LwJ2_F#c^!%@Rs(Rq%N5I3Pq%l9DaK(cf z@Pgo*AbYO$)#L09$Md>H4KRp?X}OfiCgDwFOn)yvMzYp7w7|5ita|lpXmPvp@{zdF z9voYVLw)!X{U9xBbN(Yv2Q1(PWvAv8_dukT$lNcbB%WqgYDNJ9*K&B~rI)exWnN6T z=`{SkR%EM96lI>u5|)+8YNJKm4b~$SpLgq1-h0+cbL(@%^puRdbh%#(%nmC0CFl4T z6ju12Jo+f!?T3cFI+JYZm+Gm%G~Oc7X{teCSqvxrK6$!%<1R$()iF?CIY@aB5k&2tpQBu37&`#f>AFrzP3h-8UFDN80HuIkXB zfE`2AF)K4bsz?i6$>{O+BnUrEUK{mw&?ga{Gh47p%v^_Wo0#jTQ1k{6w0Eyp~Nl;a)zNF z3SlPnuB&-qfzl)ylj)7)?Hm)ne=f1=ZhODH9bO@rp` zU&wje7;4g08t1v2n-VaG3~QbC?90L5AFyZpK*w!-75L~mNI|Ye=dv9UqIRv_)kOjP zKc9E&pI_2+Jf^DkBQtoQ1o*|teA}{^_riYSe1vp3+V#cl1y(J?Ji|4v&#}Rt;EbP5 zVEWL*>MrO0dd4^t>sEZA-!p5?0W5oE%gU=HaK>>0%;%EO1LW1hN=Fr7H_=a7zy(Az z=_!5epu~@g?5SVB4m7)+@(ufi{sOik7N9OQrx)w-1*uOEKU7=ws$D$QiYHiNbFX0g zlv@$i_HwLyuC0mOOjUX1EqnCKX`?QA20cSj0fs2>D9R)?`3^RRdm)i7?+(SvNpiNx z-1rgFp3vu4AGYHKaC~*%Q`f7gvcr6oIC}}P0S0+CK|y`UG15Yeah(&PIbN?8&?pVz zy_#IA-iq>4f}Psojn{*ym{d3dp%7Msex>+XuVS7!^ZaECMV#prm%s4NcUpru1RJf5 z2<``ijo(DmAH)GpAs1d#(40z`mQH-Z(%z)F0Hl?cm7(wtb_|4NiIC5W89n*FV0wZe z0WnAmJ2d6<;fAnDh%cj|o(?Bi0`?iQm5KMI9BAYpS4>n^=atBq|#bPa;|fM52b zrrL$9yOA(=a%(nb&O}5uWzdz!&HA?`9JvzmOL77D>Gn*6|p{EVV%~HEOpb z=NDO|#5Qrunh|&>R&7e~s;j@-@&m}eW6Leie}|p2dQy^Qo_LcadOl&^-85g!ARO2d1nXvSPF1LN&FL6{sSU7YU(eC0Mj+N6VT5 zNC*?Uh&8aK#)KSNAGDV)l_lft-BdJur`vWq7#LQfw!B-$MrBDVJ1A@q-5^ccl>}@L z1BTso*_6@f16whUd!LX`yWBa-^a3)XgbhP~E*JTv*Pxzms2keFF)mXk3X7v*^0a7! zyAP;g|4jrZRw_CqrF{25F#L5Hqq~QjnEa3KvyssnEMxDc+>h=v`Kt)AU*+

$t+3 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

EJ1{un-^)M;2cLXSk7c6Fn1s)ge zb)QeCd0!Dfj+`WgF}RQuy%@d0-FM(OPS(>Yxn`I^W7ObVhVp*#84U7e{KxeK8BEe} zWaGR&KJER0j=yO;#*0*_gp!a*GK;Mk7-`?yS`#&Y>o0LJFE1`;mU(K{m(YMD8dhm? zzPdR2-DV|CTRAUjsl?D9>XgZogbbR~ZVYwEn2Nl&-RYMc6@~;L0R7}^p2tgIik+05 zNGWpRdy9g&M#GO$U;w~x1hHlPf-M`@rY*ireFS&g5p0v6e9L*wqV-_T;`BVASC8xc zntsLd^3Nq#UyKoiFaXo*CS2!wg4Ic-I^fAemr)5Y#AVC?up&rHontLcMB8q~P4?ao zR+45?3N(mX4GjGyeO|L~3EE^?2L-9#6>(n4O&4Hp-Bc~xpBf3~)qi`11(DN^>t8zP ziT%j%!4iofj*FbpVrjWeE@-n8FUC3jCa?v;C{Y;#govcjWDLoNe3ZczOpulq)HAE} z#{IwVed2mgbX<7_Um|tDt}bbW&~WOqNw_& zO+)IiwiqWA1`S+=j{4<2DpG#Z-22Q;8xhbcl`Hr77Ln9k>sF*t?SSj26)BZ329+;N zI|w7oS1;pgGa_+$;4-ZZ{w`I*u`?n_dA++kR)8AI8ocZ2e_|i6gAuyjYRy|;ncgMH zP&+GFe82hNM%NndDRFrGxPB@F`W+fDh3{-O$CF8s<_-_z1nI4S`FZgoIexglx#uOA z*OU)h>|b?uFAo9Z$iO9rk>QDP6rIF-jniMY(!dST#q)bi>pSTpbn3gk=(azC2ne+^(sNtfZiiin5q47U9^2X!Hnbx=9z>XR zSaWctuCKE{)5dOMx5G;|ZuBRB{h%SWIJ&rH_s3&Cgsax1vZzL7eVH?KXF0xdC0W3O zmqz(?-Mg>gY<@D@qPtn>KFq$!Ee%R(ZAi{Ue|XZ(%lPHdNd8g%w)OhW1ncy5DdR_A ze$;x}Fc!m6>act3dAv3 z&+2;J9k>Vr5*lS-8O%zKET*I(oz!1{B^i1?tRDxYUvEEue9Ttz;mS$S)JPmm8A`_Jhz6Di>;t8(Li7*{0tH*7_gH}QV;MU!*^u)p@&^Ee7w;?}tZ zCMS%{_Ij9FLQ->2EU2Eu?h}Qqk4}5o8{W-GK=vx|;&82}C$2JA@4aY_K}{|yDjMna zJf=BVkj^F-3JOfunOZMhK8t+(cz~ARXC&xYK6_ATJn(c$b>7EP=lR-rzGK{i{OWkV zqi$=MXDd{3*ip3DoH+>JZ<(!1Amu8fGE+5_AFfMIyzy`J{S|vb`>FMQ5lDcRB?#2i z@z@S(%ge4_#d_gh^AP&8#mK7?CsjN9@ZN-5tfxj(AMU@@y&?c z-i7^iPe4UFw2#?~f{;k-Trp&^%CD~`apTYNX2;9*Kmhm>q)_SYk0$IDLWdF!yn zicvMk)ax}lf@=AIaFF=|%@n01G4se!F((ELLRiQky_KV}r|c-YS!Gz6qvAAMVMUna znNk9zQxl#7BptjS^cqe?V*nrkHAG&2#)nK1{1j%{Z8B9FfyBhC06(+#qC5B)_wD6< z5?1&(bA)(|VHlAT5s_4|!dPeutnZlWho_?))nTF$*$@x2Va>g)`RM)`-Oz$Va(Hi? zz8z#vp~QcxV#do{Nh&n@$ofcZ=%ehnAaNoM+j~z?q(b()LbDH1r!~UB zST@+ISp*6k;5*A8JOG9nIZ_V23EG0o9oI16;%l?K(&_M6Ouf^wC=N1jM9U3wwcW6A zf7pc+)dBkWcn^AYYa{pUJ~s9sZ^ElYk-);vQ*E{>E6~BS1(R%++bur25~T~lzw_1H zu7jE~An6V64k%`~t9tSv!sLoim%1%2{Blj7w@V}Ws@(vVjO=YulHs3Zrx=@Euz*QYOWBI|ezWSrk{O;wlzUu?m(<65> zqV{75{~dn$2b*V?$dMN0C-Ebr?hOuYp}g<-J-UdlTu?2$*bh5{TQw{R7>Nj&v-`&Y z01zi=l9zX8Ek@z!>Z#W**joHQjvgH4qD};LC$Lq01r|qyAa+t7Q<9z)WWa_Yyc+H5 zX-q!XZnwY)uE_ITmct1E5_1rvuFg==(??Njo&=*54x6U&7bVF&F%A4oUJ#-XZeSw} zJvdY>BJRyE#x2cIZau3hFdps`6yup%_DHMR2y_ULmFtc+mE*saxg%58ayYVNED;UO!Z);zWALr$tOwLe9vV6a*gtes0$qcZ zTA}CX`~Rx+HU)QK-!$6X20IA$er|W87RiBQlPZH@$|jMBj#Nneu2IR26r+X}`VWV` zEnql`mZ1!x1F!#=L(N;sph<7y@z}pU+uN-~4oy^o3FQUjFcv+z`z?Xkm(2?kUVsg1R-eTFiQNKvtaygZmHa zp{tiOHAA&sy19&J)*>vCfJG$FOdY$G8Q@gt@1}kyt!bT49NplqD9mK za^*o9tS-g*E$rGa-*!FAxSx(eL;!q47HS3bxYGbSyNQt5s)?jo4x4$R0#n(IsY#5y zz7P$o6e4>eMFSLWF1*6y%A}SKAYO3Hz#EJp3USsmQ_Vf)y<>X zpI@By=>}F;t5V*VgYIgE8Xr&MHJ6)zyVg`^S(OvQZ!XZQZJ5o9u_s=R`&r#Uy^;La znfK=IPwn6pL2m=!*1YtuKvb=2h;G#ja(2RuHIQJ^qo4=}#r zsj>FyI*!O2QcPdulKdKpg?BVB`x|L|Q2~0)O_vQ|jpC)q)EGRFv=DwwdD?_xH0vNS zLlO!Uv7(XT8g%Efxl=+IZmn=k?7}YyXby*)gfOsjt-!F@(V~}EYd$n+V+20PlTsFc zt?dH1SLc|LH!FGlcl}M~98{?Y$NOSGJsI7R% zxKp8CzobzZWyE;T-NZ8ONSe=1WB1uQe6vwTvw2}P;42oxq_>7QCFqWJWYq@e)`C<@ zh9kp22@wChWgilp9^+<08-$M~7xR%st%xk49sJu8Zgi0z^JhXY*?GFyX-U)iZISVL zgqzU#vdR4>>#kc2IccVCWQaRi8`8}x!N=uP)A?n)Yx}YwqZ}<`eNL`plGzB?G%xiC z`(kdKMGmN>DUL*@2wdDJ z%xGc>p*Z~0WR$Hy#*KP1r>$Z{$Us#8MQjB%iGWI|yQ5iaa##X4F2T3iMZEgOq!7r* zVEf{;ObjX%y$R&`BH6ybJo*X!f5@PW8=jmUO((ojcW7Bm2cK_A!j}gPyI<`EB`)PCc8%=}DdFc_cp0I*?*{{2<8mpn$y zoQUL{nRDX@6~n)2wD#x*l5BE1NgJaZ_!=_y-2H?kgeR(Hg3`5zk1{j-L zgCAjuMjT|*JIXsxWt{~_l(W0LyF>jNj{Iz-oy=W0%Mm?rWAHLkLuytb78WV9+Qh&7 zwtQ)5Rqyr8jQ%z-)Y0XW*PJ-I7-Cc+Vg^nbynAdU)WI0B_bH+Dck(@5Z=XuTn#M9i z4s`}JwoxD&j8WuNRwqB5*CN`vQZl^H$~FAO8J9CVFrt9cyLIbvU8>r=22f)CInwGl zdnEXsy_>mA`lm_n0vD)fpSF3Yf?5KmN4w#;NiT^)q9l?Rj6zj3nbK3`ZW)-6Bge0E zSR`4hiMT(ZKO2zSmx653gLC#5uN>7Zjz}~d-AustTS15S)Lr#Fehu7#V?B$fBOhRx*6k1f&LWUG}=^pfm(X zmULqDfkR9d6vcF&%qCAHUAHS5>({+wZgYDi%Vypmyp&gs%$meaaL7q3h$jj$mNL=*E`+()kf6`7=m)0E<_-r zby{!E@{z@*cdRm9TYUILvfvNqqngyh+v7)%i$T8*Ec*(Qm_U>rm5v{SP$K z^wS;Acn}oSYE}v7@3XrLk|X{~vR3T=`+D~BkX;rqGt%zyCFS7y5p_oe4c;#$LS=NB zcbf=r2-yLkMNx?>RJMM86+fR>A=O@z;X}{!XTk6MStnJOA_hX}{TB7UA~AM`4Jj*x z1vvT9mEwg)Dyf+oE<@-;7Aj@HRRA-4^bE7E_qs83|7-Q2w(KT`82FR$Hsv%N0nwoG zyP@{|bV(e~rRR_ANhk`-AbiTqrZjLs(nu05^anW&K=N;+ADFpVl6egS40WX{oKk4D z=DXG7x;IkO)}JOBs#z{|Y+@91E3C*RocLBlSYkx!Ry9!zdOJ-$Q>IZZesux$SE<;> zjRewVYtlB|i;!~14rhHbUjqZViX^h~{5^mb^pHkgNkAH3s+;-BXANnK!K}gitKU!i znda)Cs?ZX9u{i8LF8!C=LM0I2p;Yyp)g$dUwi9NAalw~EkB;3fJ&Q8|ki);(*m#m~ zn6R8%#fDC$1ju_-2_Opx%g4?^hy3V#;qU;(A$4l$IDiK?_AMlHBgroF*8ck4}c$492LCmjPBl4K13s2kES<~6$<29 zH;lcKUJt1I>o{%G>&+^KyEu|amf18yt|paSjtpt*3xQVUp2hjn^2nMavq4}d3+dNU zZE%+E&0e8p+bQ0WB}?Yp1hAI3vK?Y@` zcjWo#iP0FY|0L8yOI8B~$N?!vJfML=o`yYkM7O73p&e^SL&5h9tj%M{g=NO=qj>Rp zyTh55A+EPKi+KxCCttrX}jXwE*7Jun&)1ri^vr zNvGxQgcbRY!fU@G7RppFzSQp+2OyF7dPt-BSjIf-dKo(d6?e3cCSVZ$`lOZ2Buh4} zwFYCSTSE&=Qb7N{YM?BGfL!Dmf)9`fEC9kuG(%~{IPzGG$)zQwr7}w!7bm97nfPSG zr&cOOZT~W`z8s}D9aVfZ?|#5qORI87l0Ox-(RD#(%bil%==t+)Ra2SQ>T&EfJ|JuW zt5g-h0uk3|mbcr>@4h5j-5gYvMemoa7z2pr;ga~uH3mE`>dIRWo12+evh8n@zk=7T zJHH8sAcxU*z2IB7-8ds4F1@@ScJCzp_L}~?N8;kZv< zq0ZWavHeWm{dREypvA^1xvdyN%Hl5w+rJ+JH=sC04VWEOL0$9R|(Zo8lMBBtz63CjI6pV{}oE0eOO;E$Bf% zV~pm11S+A2Bi%Q zgrebkwu0EZOhdL)+{Y1fbHAjS_4=SH8bP!uhgG9wy@*}EZw#g(ChpUT-JH_Y44wCi z4ND*~6m{g2>EDG?H4AJ!JWET}4o>azn&M(upRFzJs@5Z%f`rJogX~eo(g6_v1ssTn zcd%wrEe_}n27#fmJU6wMG&-x-z62+$DZd<%HH4w;2-4UP)kXchZbiBc%o1oK@WKq}q3H$F zg$PU}r}QgB@FdaKJ0lagA8+UWNZY=#bX6oMCiexa7^~-!BLIh1Hy?q)iu~Q$e~58l zM;B-}uX+i@tE#K`qE$` z7$HhPbJ-qMW%1My8-@xeCug-Cqoza-#;|U`nC|%V#4lerVb#1fD0uaPOUV5umgV-! z-1WZQ>@X{SmETBz5IRh_HsTm4we^JW$9X?EGK_EZZz=2ijqOBC$ZkKVYndAHIGq|#ZVisjOWKtbfxTTfk2 zk?x&gk7eag%I>3+-8llkM*Rhehoa!zh$%1(hidGN7>l)@zbO)doA;CwzQ7Yl*_D9T z|L#qRFjp)@hxrlnCj-bys)-r|AYC#Yt(!7)T<5WBTnrGxlmJ2rcS@V&AUEm-je}Fz zHoCDIC;%L*8Yys6vF1z+(R(goGP)fr3PwCDMW`A^M_A;jt$Pcy%%Euh8{W2R!BFcW zVX6bUVlk*3&6#l?x4pgHilB(fMud=ujYP&|Q@J7uwLU~&mz2~S5!QG~B2efw?&<1{ zTb2J*u8p6|72U-~XNd8a<~e;&i$H_%Hl9@kwQCi*h0XwUhN{2Pf+9@;B$GS?}3MobMdN%cmp-Ul)V9?wOR_{@JFUMJNJV7HG zx|KKwLHnY1xKJ4!qyz^6TI@D4B-I#bLt=tkBN$JnwN96Rp|80}o*~m7N34+nFgnuZ zpdrR^sWE8c0n_cNfL5j6U)d6EQ=C$m+mgEF1% z>eois>JUWdWNLTwhW$5?OnVO`#W)zT%CC?4t8()-C=3TgPv*ArLB?r-D!u0VNVXDapWMzI=^fdUn*z?h=vF=SS|0l z0^MwK(xd+jrC@(n^n!T*Z%ReY3DI9MmrRz{bnwxCgHUw@U9TeD#MF+-tNa5Ri0 zgy2*b0^%1kpMJqN0A@tu#sC(gPlPBMPzAt0{6}I@9}90Q-zLT1CJWb>)KQxa3iCt} zMLbjL5=?V}@M8lL(ih0Ul^()CY&Ycc{9o^qnbKZm8qi+xq+qsHU9P4|%3KXub_wzmLfrX#8d zfT8V#B##Q@b&4`2D>SWaef?Yh&+U}TqzB=C(WnYpQ^`m%<4@{M$RBeDWuCS23skOjr!|s?Te+YXs+8C2_CgSNTnigd zXHC{m3$*{ZemDQuKaNW$qB>CD<#oH=PMr}wf~+;ZeD zcwCP4M=FWBa9uEA0u1K}9R)#!bb+^*RY;dWa z0(Z8OCIm&MEQj|et`L-Tq)Ja}f;B{Q+_=lI^^h9Q-k@S&^>;*=SvCzZJ{3pAmZAtV3hxP&YGwCi?-0Pyb|;JU|yNP!<4=Kyp2?`P2?=gjCxEU zB4c`}*;tFAl3oNV>Tn*sjR8(9RS4$$45p=l*r`j?Pc?2-9P>`h0`NirFAuH@!dK6d#Jlu$k(#{s_~|0fsqGN)n|1w{CRp^7POPV;b-wB3Fk{y6>nHAb z=5M~LFKfExS{vA%=l$s8Dc-9CP<*|`zyL@9;B?-+?FACimDXN?$b_w+-T!~!Mdej( zVcZT!5pi;^0!tnQ2Aji9na+0~z;TTAce;+4x8p1Io9Dr|6_4+qS)AL$wORCn!EP}p zMxK`)cR!Noe?QXfgBnuyN2+-uAwz%5N_@KYekJG67C>Q3EU@^;qWomg0D0BlkZoMo zn>IY|x=T`HYAO@Yyt)z~|1>s~f#vz+x<$%Up_^%Ik@k+Dld>jE$}f=w3a$GnHL_Y9 zf4tmBs@Bxw_PczwBMD(5*#~ww?VGs+${anlp2qi1fnwgDjI4S_{vHrWE!oqlj)aELF029<|9xVH^zXGE(ouCt0ESooFv%H$H{PcL*#yc*atzN1bwdMYU zC3AZ2F@x0$JYsjZ>}_1$8q|GI|IWXsTfma>-j&&UvZpf#+9L(UZ+8-6h9Za@cOeju zqWUrAD21+i1^)nv!E!r!`xFZ`DoG%G;YDglrcW#cj*HY55TOEK{3nQGks$&Hj3MO3 z$`W(g!>XoK5+YCzq=1UL13~@*v_juzL~{64Erf`0m~%yZ0Fcu37=GOKr;XUWU2o`K zc7x3&48I{Byh1c$6lBZLM6ihi;%^kj7K#6y-c=;7z1i1zX3?*L>I_i$=^+)TX{|~X z~nutIj^S;I>4q&e>Wq_gT~Ps^#E>p$^X! z;WXrTA32WI0RG1n|Lpez$_%g9Q#FhKDN-pxUJ!+0o_}Vizh&iIe@pWJKN_x9e-Qp3 zTm~iu`>ZWuu{rO1wsT=;ExQ)_k8u= z%2N+@hjI1C^TXAbIK_Cv?{+v^`AQQdX`d!4Y@f!~^tnSa%<+;?K8k4#;jjZA&W=!< zEf;8;6%(ZI4StlW)d%Ux-ih87n*FNXcfUS6O|dtB6)8@#M8Gqf!O*~}xq2=W5gKq{ z?o$L1eb*mTzzR=#niHU=plgmvMIg3>Jdv{Z-5Gk=NS&2`K_#c6m=opUfszdeWihH%^>BU8Uc{@fw7*F}_ zkpI&myZgDMOnQL|t_aPF-7)le%?E>0IlIQX2r_j@1RPrXKaGojQJ5k`=FhHil?uHt z$Yxjtas+Z3s@OF2d_<8I&4E=D`ffJNAmT=iNcli98WK5n-w^q_KsP}EJ_-y5rCW2FJw2b*dtVLx=5&qA^m|sV&IvNnPyl_UAW~Cf}dL^KN5FJxWFAXj7?|c(#MlO zrms@1%vxhkLXVn14TJUV0n+3+kJOc#F2s64vHWhnC|<(7w79)HQ}2&_8r1Mn;~gx z^skadb3}R?!u&c0n%9?UUKr&mE6;aNjToJEPq?*Gfn&%t(1u^o0jxt4&mF zBvGz_SSPlm1+|K@U-RXrSF>bv!y_~_(#-lPP2WRf;8^BDdi2Gl<*R;KFBc%_8fJ~0;3yKWfv-Om4AD3FxeX&2vB zR)y8zNn|LmR?~XWAOeMEiwOO0%SH7fM-i}4Y6P9fSB}%sDRJLP>SEdymHJZt3w8^IO{%eS=H$h1 z;>>P)Om2Wm638U>^AphMHT1n5znkB9;ufmckH{RVVG8?a39>jVuxiP6%o$^iW;B?0 zMJp}#m&9%{M<)4AD>1Pe4Af>;S-MmJDQEgoyx_L#bY zM)wp8si%TtKv+_Pm5M||hFqocytukKIcf}DV;n!hpm>)eK}ckr?nI|PDk)HkBDRU4y^jk zwv~1)5HbZ=Zme1eI|lk@_m@`$^EpWT>%z}N_{*fS5Co9^XP7?&rProgw}oDLUb*)G zgl={P{ztZU-u48A4Sjz*uSeqN=g-%i8kELjeEhVeI>EO|$6CwA^77VJQzjy$#0%2P zm-lP@5ncmwF;94ELwi-P$(Rqy7U^T+x->6_6FO=g`a)DP(5;~7q6s+e>2L@K~DTC)}xE-YD_Tewwy$xO!x=9hxAJ&3RF$1UE^xU#M{+B`o9X+|0% zD(S>F%Z~*2s|o6E!KsW^hbyn7CTqG4tY{;Fr!8uevD7<(tEaOMw|sx!+_D*4HiYu} zN|@z}MZ*bh%xK1;dwb1khhsU15vU}FOsI>PIi%CBK+pF)1i><>Y!5?sE(umwh zn2LVkQb`;C>48y;NN$AZ9IxC^ZPPu#L8H$=c`WCPtI-SZ*+<(GTnPb%Md1U8f!w`) z|GD(GVZ>K>4+-~5(!eKJiu^Z+On>60$PF`j%6WLPXx!_kBY(E4$z(BF#T4B;@_kR0&Zp5?Pr6qg^Q|)@W;ySmofk`B) z!xVR&#_*vpUs&kx%GGOM`Z`UoAA{^e#DV{QYmH=pC(c+rYnl*G7~G$D=9`}gtwd_foQ9mLX^I4#cBwz|sLNBZ1B;<}q) zHEUk~=MGwYwCh%sUaHa>7kE4Gx6r=B0d(*aGEypF~mBeb8E{Ky5%&2gz9|`siT!Qe`$rd z7*~`S+K;)P!>m6AvPxQ-t+-yRWodnm;4iV8VuW#=fP1klL*8@`}D+j*434byO*Rh;kFr3s{@1pgxXMv z{}oTAhg0f&P6t_~sbfBpqc;3lZEKvI*x(2wDK_EtwV=kEqZIy{kwN|#NYe7f`oLPg zW^pMa*tkx=dNOSpdXPZ6M+ByT5KDm9-_djyQ%|3#OkF|e}YGPs%g)TUZT>$HuP>(tmpEv8Jtw85zQ z+Q2UoG%A%x4rfHQ&JEQCfAa`i%%UesjS~rD^Reu`U#)%jAKg;0{etJy^(c~eK(fFQ z7}pUOaK0e4i0OOjTRfice+gUN8d+nc?H-T_tBSH6OM(bRG|4NIybT(aJB|syw5fk9 z__}vxFHGMbXrT}#1s40y+Gjp>uofc2N=vCh&<&d!&$mAT$Pv-eS2bM;aUfn&J`2a+ z2P00KEzQcO3KRbD?azztsv%#8DQ*)VndcLsNE3Lz6B+Wy;peH!&2%?8#T)k!k0sZqT98Zi4aX{o;*tX|XJ@-KXcU9-{?eGj&r!^ zO{T%zx9Z&*&!@gu0uA$w=usp+rhD@AY8{ycXvWM0^iGJ>A}$kQvh!{_y>#m9p*R=G z3Sq#1d1!0_8WB?WSA{8I7pq7)kP_RO5f2QM_#USj=<@fXTu7Ug%G$1~11tZp!() zVSPWbm=U$<4Hf^eX^T|MEamxCU|)y4QfQ#7c+3I=VUR2PdJ6`{_AEqILFs0*1{orv zo{Bm~M8A`KoaOUo^D0kuVwRPEDbt?C*=B9v>wS~gWw!f^(sBjX!=G%?SC{C|eZdFG zkhAZZyNHE<9wb8622AXsL_P)?dq)qtK*Av{f1I*7eu-1L-cR$FtdP~IMa=HitkM(u zFf^A+_Mwz8UJgw^dW!4C{zeUpTfXYLlCzL~A%wfnzM(*!ZwNt7kOMQvI00urt@SUQ ziD41CM%vLyLQJj@Nc(L9HltntT+1lw&>^~C%5QYrHSW(5Tn|r*ieTtrp303#2tOozumMn0S_qO zI$&+Goxh(d)7Py3dtoiG;n(LUP>5_{qqK9CL$b+(Cy1o?I6=Lp3?##9Hp6yl7tT{{Oc6C%v6H7Mg zgMzBS`5Gh|?UdQGD|@zWc?IH=FVDm)XLez~ZX8=IQxhp{6>2|Pwd9RZE%5ezVXIVY zYfXy%A^%nWosmrB8~lINi!-|O_jsybCmIZ2pOKo*qAtFKpU-evfA+xf>~`GH=egqZ z1H`0ks|=;!R6o8E&+h0s6h2KSvaV}a|EJG`*W~EukJ&gY9^C(=-CM}H)l&zFWXyq0 zN!l>17957hk0RYHWjIq$|CZP1o1k_kD(r4T<@p=QFf3f0(BfO0X3-T6Z4ffd{HU2A(cj$BQN_N<~6JU4=uIGBCw+Xy~OGt7NC*qf6 z*G;*+waC#+jm*9$e-wJ2+zx#+&BWJxU=ZR&Xe0z!-#) zub^^8dUKVci`?qw)X#9qT1AM45)?UDmh_enamVM+9wweW1>WB{+s}V$BK)5y-?T%+ z60cHeC=lC=^ju}&dxz0DW$Oq9iBVe=F8Z|wTeW&&p!4o0TG`$03SND0}N$M^TjmA zCB()@T9(J{SUNf08G=MvuN?zt*n8W>X2Bft> zg5CT3ES@z~jl)M!=yYB5%~!tezQhemH1$FxVU%r`8$~20-A{~?yqGPFcyO`b1j#5a z>~UBTEnYHwog_%I-rdU*dkcYukb&E6j2*5q-w3q2?30gU2t!3f50VH{7Q zE67D}N)hJ{#^S(h!=n>F-TfADxg+s2qfi*>)E_~@qGIq!tge+^c>eL)RTxs31~2t& zw*q3mA5uL@{=RMGqy?=ifiCxaXO;&pH8?XX_Zk~cz=d}2=Vwe(ZKNWC3v~z5 zP)WZk#cX8l*K!{bS*wbeYaGfF_*0xLDXcGSna1YIV zUw@y|VQ_dm7!lnVQ;4e}sr+4ph0L`iCPtXsR&{xxf75w;vP{|C2g^_$9&JB_z1-}G zo09K=ZR%xU*ma8-wx@QuPYsu`ugeMVK=b;~Roz*|(djpViy8f6Xbs8BuKnYRO=rS{ zM;E%jWDJH3+#<;-NEyILaIXCfnB$_Icf11U{oib2O$&eVMG|yv7frJRYI@rg-;2_# zp9uudiaY#dhb_q1*lS0e7`qo)3oya23p2>s@}Us9&O7M;Wf^UrK&sW~f)Ay*S`bDf zbff&C)i=#qwXCz-9|E6aJYTa^=k9?eR@tX}t1_*-RR8b#cH7MM>4>|k?FN(KrFZ&y zX8AH_(lu6THLFY6x*LI6Ytfnvm z&R8?|1$&xXcY>WbdrC=A6$|O31iTZZecNPLt;1_*of#)s>Jh9|cK2$|4ed*rwQ1 z33`<+MF-E@^48;^aQIb210jqo+Z|CeJqJ!N)4eaur0yKSTqlx}D!ZSWcO^KChPjJ@G0!^4cdWC7_He^c&{67z;r_5qPcvS3o1s%7L zTepBg3`Ag_ymL~6bdA(}QKD1YRE*FXyzB_}hkk_jB!YrBOHtQ5gm!2bi$2=R!0NWS zfa@TCuIH?~bqea7J;Woa-p!G7EW{0&%C%gH3OiL@{{%}iNnS-m-}2}V0#mBE;`8oh zDHy?n4g!8+y1TtTn?dyM!vA=URzA@6TU1RNNYtQ6@lnq?>5Ga+6n3_ELD11>Y#(5+b6WrSD29kxg zUGI#i*ni=-_VbTj?UHiVbAt-#;A5lnosiZyGBy8qza*s$$^}>laIKLL#gnicXQISf zZ6om-t5sc2^L!tsCuOO$jf$%8RmyCXB!J2wW}b!taXkzXHqG2gS^mQ%zx6BK+oRZ9 zKC2OLak6V(ETqU0uH>M;9AKwV{=-LLA(A}odEW^+2b!#u-*#YTc zN~V#QVgK8shE> z-^3jY;&)VZha@3Dj*MJ(&w6itCYPSuce2;ptclsW9>{@zyv*R+^xnf--)2^oZB$Az zEfr*wNU~S?dD#z%W9FHBP?Ql53}A<833Gz60Fs3ZutvIyh_aHWv$WvKD`iyo#DCt2 zXpt}{@DYf@+@|G$!m$VJ{p*)GW?~WcEEw-kI+f>guVobPmGN9YlSRA_T$URdN{WX> z32xC8uMfJ$EyTYtE2!KASkG)Nvw1YA_5=YSF3Tbi^)klbS<=~=H6h&op(5Oqj7~oxF5kV zN>MUP(t=cxKmXaUcZMFy=WDppWx|{Gn7Hwx8yRt1Dhm7cPlpr3yrnbG`{mkHvktBR z=$=7?6O0EJcl%PFOW_v35!pg<{;2<|XV8Fl&42=#6Ky@I&!1mSG% z`gmpv%SU|2-pH1_Sh8k5HB2PBsg-k_nod7%(~f^OrNfZj)}E+^8GlaZC9+Xj&R}P@ zK`)O)YsMFXKduhxWoOS@zKXZh> z{1c{Om-i>J9Ut#qYbmZpUk_eyFq|a!>w^)FsKM;0AC`P`m-!dXo^wk!$A7r<&J#7U z>kBWIF3#GDWSVKJ*+)?=0@FQ`0mlB9L!<#WbA3Y2Qo0=9-68-gSTWg?lX#W4C%$_g z`6I2O5c0eCn+m(G9hpKNNyZJS%2`Z@!KLQC!c<(wfv>|vTv;$MI)+Zez|{22{zg}* z7v=G2>a_J9Ue&iBr~RgrAZb1K@)C&!{x^>tX&`EbsTZgG!?9lPpq-j8F;sWs8WFP+ zQT^6_{&d2>e;GMDR-qvXSrhJ4(qCRCWtE$V?C_)}^rm#%(#9D*MDh&l$?Sx|M9eeK z-SH@R=U@-mI+?Rab;S+;T}iCL%?UOA2%T~iioGpy{_nZ{VNPs;`kx~O{xKFtsNIq|dNtmgN z{^ij|pXWl7{5sc-9wU=LYY^(ZtieCF<nkF^QpORD?_XAB{qlt&U zbWr{auIQ5~#SX^ZDnYy;HXU7}!c}2{8PC&kZqGQTCpsfpmbN}do4O|YPL!HIwTO)? zTpJI_h~4kA3E~+NZxtt;iq$ z%$Fazxb8dohA?-6S31VOgjdv646CRzgeB`E&7fqnXO%S%W4?!g7^zNWE~OCO3V-si z?G`L%Rg-DXLiDLJneQuw!UE>h)1jQ$c#O$Su|}nL*t_50N16$)j!m}Tx>cG4YRQ%v zQUQ{7rIG#nh!?gyv!aNrc)X`y7*)2ir06BI*eAGv<$p`u7cmja{wmxQ5GBM@?uX0sqG zdUNLuNxpxfKi!1Api=Oqq~VMBSu7}?T86l7*VRf`)OK9a3Q5lB#x{kWDb2_RI9%ou zyoyqYJbSPhHOA+Evs84ypK<;wsc-C(zL0-Oni~vK<;TjoZ+p68=7*MD3B0dw@0Ze( z4Y^g2hD~<(9B}sRG{mqUjFyli%$qsh&uM9Sg_-hyz3_t3UR;!-@S5KXA6!oiq<7nw znf7rYEdcX_M^U8Gd6<6pn7_{1-o!zN+qW4kfdJopXcNk^>xSFN)Cn+fu(H9Uv2fym zCH%8IPN}%D-Ab7OW+TgoBg@JavoAxr*V4`f{Uw+3I;50@SW?Uu=Gu1*d_ z4pte}3l`=Uq;s3GlS2FyOs@K1!fRqHG_v*G%4WaBDW@SG0?O3>&OgtM-E3LP_5H!~ zJR|t?n;l8)K?fvVhmRF=C)0_avHcCMl>Pop*oP2e^+#|<95Q{HyWq$?KLvJbCYLH- z`4CQg(XM-rGZ5W4S?UeMaCPzGxT!KPUZb`b6x5^h`k)wA?cTK~$M=?+KgU978aB^3 z+?m(~AdRV5W689gAH}{21sM8VNSSINt3e&!{UzV9nYer~MSU1^;eKkSBIB78tHc?% zr}|B@JQujZKl*KrEWf|7^Pfpkd@oaCY~=6)p4vLfhT=R4%VLlQPAW`RrcO;U;-7mn z;G`(o*)U!NtXKKop1kL?Z+i+c#|va}=iiZvJ|EQ98yv*euKf|~xjSINa^1Aj<8D1L z*}8VQfcXe54`bn)8==NgVej4_6E#66`CqpTZ8AxQpv-c6(StKzJ z%M)*dzz`g4lSj}?D8gZMG)CxFDO7Mv?GbwliWq`A6b}h&WC z5o-1=Dk|XPx3AqF03um#!&UFm-ubU8Z-=I${Ul9#Chl{3pC9F-*`(r(2qW20t z4Aiak4Fd6r{sk;G-2CuG(A@LyH>PUad*(}QAt)rapu|=5Ax0|g=MqSt8`a+VC-TF( z2ovU~Hll-|T>Gr@`wUYN{`r+g3new$l6V zm%0FaWRlokpMRnz>&Z7Jx+PJe8TI7!un9s>QKarS3NLe}UBPOBk2%$%FHx3$&VP8a zb8lPpJjO42<7>u!R+&xv0-tMa%sa>W5>^ZHirKv z1)v1ecfPLu#URzP7MD+sn+8XUk{O_Yg$(v^Ygy}j`u;~ z^}oF&Tg(0@A{(z4j@@!=TX})(;P=VKil&S)9m@8G%Io?$qBhS!7!9-N!!j z)%zbHt8bxGiCLHqD^+tE!#}IZ$=Uq`hGU)BiEjZOSwtq4()|8ImjZ zAarL?pNqbuHI%D;>bHN%AYA*U0b%{TD?Gs;vMEoGwN(EN`X%wZAfy*N(svh=OJ}NX zT;$#3!Ee^At+IDuUni+k9Nk#bSVqJsJykg zhehZ}oeR=c&Bs8?Nr$mT%?>fy3Gi{Q9;-rCclcsj6Ipsgj+D!1n+8tmbzi%_2)q!+=x@KRl3rpj!B2(<0H;n`D306_ZhX!{3=0&MNiADu+NGZYgrD zQN~=+#O5zKtD?P>CNRr^Xvbu)Dw-eW1ZYdkFK&X6OLc$cTaV~U zUd=p|r89I{wXD^$G+!Zox>Eap7d=G;aSdGmJ)DA-;3_R8uHQR~9vrPO+9}0}mZFxs z$V3sJSn>EnZ=4bG0E92X+t`2BID|9}6sd4{36MO#qJr>DY-7DCVPzFNsLk2z3cJPW zV3t`{T>llg?p&M7l-LlSIN4m^5?nrccc<{ZEx8fauFT&|j-pl&j{^FDG1?&;M36r5 zH0)el%QMb@f+;K0s5U{n!Y2Oz7O=MV922awzqj68k^aP)2OKsGUHjA_}gT3ajc2>lD_;}Zt;TV4Y%d~ za-{YTMBy*i+j?Q*+K-cHh)~6;6fy_?l9k0WCzyI8c-#hh3>ACxAottH86#muE^{@U zt*0a*8WwsxV6CP4^tPAxK4fI_PS6zGf)SC52RW^H$A7EU471@2@1YjZ}IaLagKs5Giso*}CN6kbmNI z=yExIUN|Bo8PQWGee=wJu6KdVtm8v!{BSesFk8X!g3}f;Y=-=?N0P-Hf}^H{HkXPi z&2+YEEG3*Rs9CC)eeVL1M>UcFoSWF?}XeG`$bDFmf&|y?klEQOswNR8NK@hG_I~$b+@=msguuy70kk6mj{B>>3obwWhv zm=FYfRZ@V(E$2~2R*IH3n7K*+ooO=Nylgr$dCDTm43vSG2%J4v_HpBz1%<7_K`%fFqa|X#_^zx$?#*NEu|qjnwZB?&Z%_WGBEZL)wv!G zZG+#Q|3PFEzBU9 z5;1`A|L-cXf0~JhSQq&EG_PU`;!fi(c;Y^{3{hGGq)Z$ghEE>DP5xpO9Fqjv=NdO3 zjdBXQ#|qp+GCb^KYe)y0(%ou_KnUK`^H>)8SziNKK-$TN;d1;jSLj`67sR+sWOAhz zxLg)F3yuzK2h+n$@qq_9K36C2Nj5!qF01>Rj>&0MF%WEwMvrji5YB2H$z51#r}=Ed zSZCJ33Y?eaNU;g0du6R3ysAxgydSZwrxY1Ud$8-{{KeS)#{xDkpXaXhY>3{-QO~3^ zOm|+x2|A}xJh9>Fr4faK;WtZNk-ij|-|}BPUwE{gys?SqS-M{$6Q3b9u*h_hN99=j z`lv*o&5~_DXZ!j2CJI5ERQ~G$D%jWxmWh_B#Kn~ck?5*nuUK<_H~Tl|no|0XE*rRQ zA%D>D;_-6AWcsY&31I9-J9+yd3KJWIZ1U2O;eN$P@4D;FCXN)Gf?SKcgwlnYU)5#d z115RBn2k@ySMR6filDcVi2 zk(@QzwN{-0ThIgFe?>KlGt8w>2S*~h5t)&z`e@r*3o(_DW>HIicw#ny1Pss*_EZ5j zV%vPSn=f8&mMp}l7x~xTfB))ee{H%G%fc<5=s@eaQe)YjeIKltb8(3!5PImpNwp|K7XW>_{riU zY!%&zWEjsaWP!zl+0$n;)APMx!-vH1vqCa_oIU$*G-UX2l`*JSM1<;*!J0g{Sj6E#Ip`!G8I=quT0Xh_rPm$(e9>C~ zJ;B1(kiGu;fVZyj;a9Me@($g-8St9dIv{28n?|uX6->eC5G%RH2}k(QFJ3<9ED&1? zWvP|1Cufhie589Hw;h@MuEy4PJwm{eLv%i^2J#UK4@`kj>NrGk){Q)m_MsTjUj{6% z9nFD#MutlzT)e;nX~I@fluc%%1fn*nJ`4(MTh<=L_&jN;6>olU07JGg9fz+Dc9lZ= z!v{a^!o{m6?Ic;}#iw`r5eYZ%D#WShBk zT=z!WE6*9%rQU_mR?5ayC(YUuN_&$yty-);5XR7#6FS7DSQ#+(UhVuS8ZTz}FICV6 z&DS%rY4?td2Uad4uoa>>8Ud%1e$9^HW$SWDIM`zd=C~BPy_16OUCPpE9lNf$()Za% zI(Ha_k25WNo`tr@e$wZLTxFL>mWOt&OVJ{2p}6PF{v}=vzjSJh{q%krzYIZedGG@g zZJ6du!FzrzU|52hG)u@k35qSUP9oNNkR`s7p$W75Y+Za`mref%9d+#K2xwV0EYMW- zk#i!|%_Xu@xN;$82t6s9pgcLG;6&^c0*1MAZgBfU@iYH*cw~d;5%xTf#awc#n*ZT* zuBUq_bt|A_C?Uv`+rz_y#e{3@_%rk2ZA(cC>iWdPQCus>a+dk)R#jH!xGUU4aUUAr z3+h5>vtKPWGu%nH+J~vvd&hrUeu2~DX?}I!d_OLs?zGPHCjq65n*9pCP36u_>)w%S z+6yNMMHy=P_B#YVD*qG~hcpw3aJdget(#rp&~MfyD5qxzgdr5B&GsKN-RdOWPPfKz zx&hCEf1OI@lZ=F7xcCm5nU-U1-*4}Yv1pNu>(G;0t1gk@2sA%XIH<+w!VI(tX{2zW zcHqMo_lcGh&ux{uiMqg}(mJ$0d?NEQ87a+mB!I;CJt!zb$B*S-6TMiH z7u{Gu0tB9w8&#wz`jfULGtNaYA!`s`Mg!Ml1oxYySHUg0TnG+!8IK3u-yIdt6y}~F zG6EBSG+n;2VO{EMoF)RclEr-J8Zke+)0s189T7E#uS5{nXl9>my(B3jT{7cms=>4M zwA2a>3Y@NtMu*$)mV~nI>g?UIw>QUsi#<^( zDF?fTlq85mFtc~V;M69e1bDscq`W}lo%AziucGcXnqQ6zdg6lo?sFb8 zykBV9gFs@*LYRP3*b7idIEwESvuVT~Y){V2rHrZ)QWN7tx6M1-_zdLHLv%VQ;E<)} z`SM`?+^Kd5B00W%%*W zbMJ`9%tiu~G;)2J;GY7grBYswf7N&Y|p%B|DRTd$JNiOmG{DLKHGfCT^I@|A+fAE;D^16 z8jIVW<$5!T6bH86jCJRp6r2KgmK;8(A(EArcBxuD$}q_>c+;o;_6?5&o6V;*|6`nt z%YAx^!D1Pdhk1e4I{yP!mM=?R*O%D9{^#)NtewGSO6$g+b1Vp?J_z*ro}_v020b_J zBnHjrvcvRn2&8osKkcv5YW?&4E_kbrT0;^{eHhgOj;S2ixnf`i^T>@_C+j?qzCzus z*X`GLxdD9DAPb829~Hv4W_Me42Z22se>eIOPy?kT8xKdPsu)LObz~EN& zJo_#;o{=@g<*%Lhv8*BOXW8&D>p2HD-Yy^!iZ>YIiC~1&fqFU85ZspSK8LVvtiV^y zWWbk$EAFkLg;bMW#@L;jPCmE3@Qga&6I4MvsvH!B1~+ZqzWw5~COUjd5$F%^o=u;l zwYzeo&m|!WfeIHVr);)8e1)NqoPRo0&jIIpp#Rss&dipS8qIYlyPqed$Z3e-6ZI!K zmFP|qHdGH-xM|sIBa*U;=I1fuyKVM-u)P~-#`{x7knA(L;a%H)!!_q*-Fx53=owFf&>m&ijD{v9#Bz!SpoY37!o<{r z@xrRrW~d;nR*cue;kelp=f%C@K*g%~R9M^Q6H8qnI|fg3XE5G}KoL@^z?`!)!ng4U zTDCo!K6TF>Al7wC+Z(P*TBZ9%Y3qbjNMxkEbN9Z$8-A3WS=Ts$nRF}UUSz431XMRd zhESuzRC@s3x5kfkPW}vNEI0eXViDW-GR)d}fC=Bs|IHPP_D4G%DnSkLtJxR@`z z-Mr=Q%9OG1*tgufR2-^E8_{dn>?t}U-h(y+pK}jY8Tl2L0Xk@juYJBx7g`h#dj3_a zR;E6t^YW(Hc0ZaJ&&!xYfkgUe1O8hYw%4tD->vI{F^t+*MrfKT`t^P@q4RuWuvriM za!K*u$B10iTpww-p=~jG)TN=M`L~nw3#5EE(Pui01BMAe`B!H`3FCl~K&01dzrtVB zpj?m?#S>=GLu3#?(<*QOyU6hiJ{4@9DuM$mbT?S{g;o_z61?9}$kQUud=n`^3QOh5 zcA_KoNuq4zv=;Qh?N)_QPfM`_`JG^91!e418ds&ukbEnY+~0M1)$ZGZtZD51Wr&t$ z`Kcy|0vX`wF%fAQqS*wU!lvy+H2Hz$k@7s~!Q$r8)IN`{@;Y>>J_(XkrGJ2tQz+WS zhgsx`jD}}!#>vU0XsN1<9o&gcV}6aE(d5f7hhXFD0SX!pc?!uN!Gum#GbIPLcMarQ zaO2qi0q0c&Fd!C9o10jAg%J+jqU@L?yPad&DP9ncl0u`KW){cNUt7L`jipZgdi4hX zU4RmS8Yk}(wL`hQi2nUu-%c(_?QLJ}VO+0AvSNX1f(sf5*vb;0g9QA7zsWn_4aLa* zj9!gni|1n){(b8&?)_PN^glZ_>s!zr=-lsOcY5`#`a`_flm6EA*1g*w5c;6+49VM5 zZQE(I($So?Kp4Om_2!)uhUML2?a z2J<{aBOGh0_RJ1>d)>4OlEW1@#AVq$Mnt}Hq~~QLD?nq1@*<7mjW; z>y|Vb)$ed9^a2n!qIc+b$*KSi4ay7@Ia&G*2S5<^iVb>CP;>ngjC0y(&JJ}kN3k4) z=vM2Gy$2qhc!JG<%(i8hsv#k=+{%VIQ*_q&rOK-fRwPQg-?hn$I!e*>nm?<2OVqjK z5Lg_3Bq|M&K_T!sU+x*6G*0+!R5S6mr6r_n)y)c?84-Px3HyF-42_t5cKry`dfVgI`aLuuXzv>FqYQeUvu~U<#}i2$ zD%kIHi3>yDr*ZT>ud#K4^Ki0p(sHJlC#WZb_)xExsPbF4k{p?NSmgJIKa6-cgCSSF zJ2(Xoux+G8*wQ?|V8v%UG?^S1?3)vril9^0gb?TB`2KCFxrwZEXGtrq!Zm$Kk~H;1I_TB7h*Un@qwk;yF$zx;Zm-7``AFm^Ngiy6v*55LDI znOFPu--EF>|Fx{DKV*JF6g(5koZ=}Bqk5ERT*zf;5X$2~^-ZyJvwSfhph0vd8>5YA z>`k(?F>%E>lsx8^&SD518e(h{5~#F)KZQr?gIbeG8J``#M3*oiJ9iw>4G#~)?`L8D zniNN(u&7(WLZ#IRAfYyNBTVJ;p`%M@>8n8dm(FJW{HjE#1~|%hD9m%)#oLAgZd#o| z@tx;8R_p-+>;bD2Sy^YU+X462%PvnOCjP@IZh~bHsHZIB>OAOKPjDSglA-H}Bb~>V zM@ulg$f0i?#`LD`u*&q`X+i+ZiFy2z&gKaYW@PhkKV_#hlr1siG)#4uEUy{Y$*6GN2)km9@G+5vD3!W zPz>0Y{MXKkMyqRAM&3I21N)lyT_;9n7Bioj+y6c5 zU6zoXvkWA)@DvIw3WeabAT;%-dDL)))Tg@hY+{OF!D$-AdX#2Cb+SNkJ3X9})^4Ab zr?udspGerEl}}Fq11({)oFpx(hI>&sBGHj1aw&eSTK~n%`K7#-LQ1UjvYC^F8Fv^* zK(@Uh2-3x^HrPQb5sjG+U6xIf_EvjQl`m5H=9sb zO(?0#)VkixhyNKmz5Y_Pb&QtYr?)~QOpx?mKN3m%F3cMsoI*M*yIH$aG63S zT-)CvHCmZG8S`kO(!G{D6gvL&>{#Tv$e~ibb$*CsIyhpxzjk=!J?EtrB6ZM`En~YD zX(u{){Ky$rY-B1sdGqwa3?5YbxggfNvQGD+B=HOR*B zsAvdMDQRU34uQ$A&Fdd^>DEGqv{ZE2Ovm@aV+ct|P_hC)T^2)bh9rugI(R9ESeDrR zcTtM=%{5>H+7;oDn@ZnWj>hV=4enpQA z_A8+4^GA837xmvm-|eUu{wFyCw}0u~4$b8t@2>-5uBXtFvxgMb(nu+kD6V8_#9Y?d zxY#U{QAbC+I6je7R{oPb7jW(N6byI95aSVs%)bie6`_HIhXj zQ(NEW(h8}MHK)R$&6QKEXBX&+gU^=x74&-l9R}prX7Zz^ddq=`+q5@$b=6L{b~W@U zi1z8F{{ zgI$jnj!ES^tOlR+pz5_CdGhy0h&I({v{IFXuc#IT5f>_vzZbr=Om2h|8X*IbzEsF# z!%e3b=>0nt#V5{JH zYmYCRRt&y6f28{t(YK!a>*$Og)B^yt8M(l9V#ej*V(U$n*JX2>b0q)!jX;KR;72(! z-~Tjpq+I`F8B)K&B&dZm+!*+HK#7~3YGR&cSofpfr8Gzv-CwiQMJPu zB`n<&$Xyx-Cwz7!cbQgeJl^{@;md%8LePI9{LFy5P`&NVNDzpzb$k9^wip(0XEK_8 zmVG6>D@qBC2G ztuv`{`iXjq?zH#)EAOUf5XTJwceuC-b~7Be>$J{x3|20$Ees=Y2c`kP+62cEUuUnr z=LQw1{!@x!WbL-9{WMv~XSq{Mp%L!n&bh(P0|5*TPaif#@rk-h(T)!8=pgVAlC;fH zpGn9<9E(={2AFytr;M`h?p+6yA~|{}L1Sv0IWqxw$ezgEY`L!eOzG@SA+So&Q?5KR z)Cl@Hhqvz(HTUfXjek{wu8%vz8X2rr&DTT^Tanijp%!>dJUmikzRm+(V?+cXe|VuY zVX{ZB7s`Uiy1-o<4gcd9GnC)gJEKq1;u4yoYB2Ti*MuL%mSMxOa&XW6>Eh;%o73ez zDD1~pPsbd=^Ti2Nz|QM=52t8{lU<3+oO$@MOLM`hU=V#cOtB4R1d_+nLWU-O{1vAx zD zc}0V_>({@6K>^OWLBY57w? zVGJ0Dkkb6Z9FsvFS+mkJj$?wz*;O_k7;1g8bfygT%ixj*UE#Fvbh*&P9=t){sl!Az zu5)D>I~>ZfYW2{OWQ5F{G{fXcL^W`J%>UZRlom0U-l_x*@!_s@8VmuUgSL*09uwXL zQ3@PCu6{H=OtLcd?uTQNdyPFI&uz;O1!$}kqfn#HFCjvR?B>h4^*$ie;t0bj9FNAR za-3R)o*A)%EVT5OWoY3qY(mGsJ6Ob(kFwfHQT}DwGD;<^$Oz?*U(){s{+GC>b%UyC zb<+{g;uH=LN};jTP>c^lCZ>wLkTminF^=gX$j2*_(*LU!VG<`X2ETbudRC8{;^)fR zK_=ChMu}TAuDmz#icn+=7O$e~HVIB<6vQI5hWaQ}IKIZ#?cg;K4y=%_o))Ic5yp8l zO43a#&&=6JAj>Kw!>JZ#_8E-*dlsg6`y=ymbdn!T;5#;#TQ+yqhe&`{eJy{$*%Cu( zONcBse0oFr+sn`$RZ1tGntcK%+N4k^ldh3l1iFX|x*;x>=y_^1Xj~BrA+9dZR8b4# zjm+q5MqCLboczr?uP=@yAZLf`=Wj9?t(x{nm>@WnTT*65H|^dQ1h4c2_{~Qo zc7pTut~`Z`=u_=40x1EZYPuYXy#w(0B!!^Lnh1}&bnQ(iH$QdDUSLxmFoI+l?;4L?a-=N1qk zNeTX8+t3c%TKE)}3HVrPjirll(Jw!Vx5qJ{sB5WSLPRf}h}y+KGka+hk0b97-asG6T~4-Ck-y_Kkgj);_bBS!4hoZ* zU<7h$9Y?9YsET|^`fd1W%J3oNPyK_P>RXNTIeM}v$7B2hKdkz-u@!?3hT&<3^sT`y z$yO@L8apx=KViGOjM5dEA^n`9WsK2&^4u!BEQ&uG;(Z9RkcqSYScvcMuEmfW!VxPi zPZdJ+F_=)yFPh3+YY@vIn!$+?s#_BD4ayOSIBvniZKPl{u7xy$F>D{}X}QoExutZ^ zXoCj8z^cK#7vvwFOtAVde+Ja>DTQq89D=s~Bx@o`!hb*yTe8D7rjKA$LzL>zmBXm> zejV0O*oFpnkvp*4ZWzDm&p$+@;pt$+6r8dge#w6|${w+KaIAJbJl+0+f4S}&DFMTX z+AQ_foro;I6&Rx1cNA#Qn!Rxl8e-=2P3CAcLYBflaE4yO@k=Gs;rhmB5ElWui;XlH z?6tp>abbJ2QsXEs42_e`UJSVS43IqB2fg1X8V@2gP2Ve#s8oA2tvmfIZ=G}G zm5N9&{EJj&CA&zzqfepzSC04+l71Or8gL-eFOY~$i)WBt?=zN+sk*=z_k1qp)RAzc znvpdvTS_d&!!k|Jzbef@milCoUV>t^C23Hx^7w_d-U9R0z+~HFCs#&Z2oeY}N!_b2 z2An_>dZuzdSdBgd+|-I!`em2kPc}&OSk!WeO2?AzNLrq*n?#0dv&yI_<%TR@_@OhD zk^z77kIQ;DZR#fle5s2!LZ(|(?7?S4hkWiC;l}yXDuR%Nh~jVfv^oXcLgG4!I@{5t z;u=5k@0J^_$i4)V&3=eYja5g|H>;MaD)hxq`6GQc*n}G9MGN=e0onBJiEotgu?32$ z=6kt45eMFF*-$`|KcVwi+DGD$8;`ct9_IrS!>-&et0kcat+sZncO6X&NAyE3-y$B^ zq^32-CH~SYfZfh`Syr|07KUCEj#=ZcZz zE2T9&J8h|w3`3T2MFC@0XmOnA7Z%iVvRf!b>k8ByEun6rK0~Fl(dsG70+F}nOd~|e z6F^!OxlnBRvx$!8qC3}76R$(q*crE_#Lt#TUm`ZXg(lgNY9b?>e#nQKiu1uYj*tlJ zvoPTT*_33k$UF<7sP7DAGgMsbW1(78^8JiewL07bbhgoy7uL8Oo5-yAtR4lle)qrFGY~` z*R|l%-9H%yBmmL21!e!2uDzb({dFaR2plfb-g(j&K3T2>|66yVQ5Lg0_;>`>+6(6> z+WJMGqckzT5#5n=2b8}giXR!tr;79PfKVzzGPo3Mo49zL)<*A+&=(qw4ktGf=w#?( zuvUE-elu~C!koa)msK`Q1-z?L`U+vmm?*Iqu*VH#6G;5f$N2o9n2V81g~ctJS5TmT z0lK4!->&67)|GzD{rgzaPL-4|&0l)cBVlemG-M9<%1O>eByY%|zf61sWACKz@IO5T(ve)IqpRCD;D(*QY8Orr<_=2NpprM1W%D4we?u zGyx!M#L#r!RCpQ>msjp5d`(ld&2fP%q~{%(jE(efJ8AvE(8hEY2g;a4g;N=M`1-A2 zRCpWui$d*?W?u+-bYU+Pl?mU30Aev@u0{X_%zCoY@DyoAF_v&&B5jLg&KOZBH4h~_ zm#AML_VS>HMUD5SNPl(8w4=8hPg^#g%J)0%JM-Q!!0ITPCR?X9nqm-d6ijkGH7^61)jUUHSwCPyqJMrqc&6bs)XI;Kfma(0{Xk9N!x!{&ONfpjC zas@=JoOEXCj&~+UTCtDe8sI|@lX%fiH7N$lt_ZXJNb0_K2p<2W>+Wkwo3^LU2H?ld zGl=#_X9boPQ%}kIHT-J7-#hqYaJe(oD#vuFGG%Z$)Tef1+KW{ZI!gW5x~-#0$qVvv*>5$)!-r zM0g-+5|Ro6Du0mH@nbdCdN0lG4Wh)>0~9oUzcNX!D={>mV*h4T#>o5S8ZT-)XZO1f zzqmAq&&YyWHct(^|e`DVB zAfuN=S`zdu9)G3nQ?g~qXqWX?+6=-K&r_RTLX^JvNidm^a$({GJ}(EQs&nFMD3t9E#H`?N$dZ`=O-#A==S zC+N*i>k%gUn*@T0!8d2k&COTWqJLD2Blo@#_z?A3dp>#Si^GrzIx>xAF}65uPTn0E zl-sy>_-1VA1G|ZgZD_D%YL{uohyey9d6twRz;2xHD_IqVVct@?wZyHqVcQ?t{;V>^ zR4S3mY|&m!QRku_0(=mpPH-Dcg z+MzI770j!vy87P`8ccBKU!s~^Xa6r9gyw^M9Ex%yf*Km&#BWjGTPc(`1j(Q+<3sNm zCTwCl$VXIQDJnRb&pcd^-mBUBP9BFATq*p890wp1hFmm04}iyu|H3PSDQrEAzeB6Z zV`~$Wc{CNQhAH~VisLF!8(TV*EIQA|I(>QhzOfv%-1q%cosML}HF;4olmbR|U@IS{ zGk$L}X6HrsclVrPIxS_7>#AKJ79c(|KPRgK zG`6h3haT_3WqqdXy9|o)t@pMCW^s~h_@Liq`WKM3w2ccgxNOkt)O8nb5&ox>*ao27 zL)n0UUN{f1+vN7OLhRMfxAYV8?PpwAoh1?P>7(HF!nW&cM} z+}Zs>lOUPiKdEON2p37=#$WIN1w!m91!aI#1v9UE0@$YcuoM#L^E=qNnE%l0(;)Vr zbh1rw_LpD<46rLp7w-}riApdSp{P)=ub4!9{fKbp%_B$d#ifLQF2U@ORH;5+Cdv!N zcFdhc>?d0{DwIG|ywK-!QNn*rZlG2avFlKVz29&G`d?2JMUop%9a?A{1C!;Yce>l; z_8LzOSk?nXh>W)}e?5EcFj{ zW~wBbfeQ^J1c^rIpsNMbO0TN0Go$uuN?)#2=^CKB&B@l{TprGCWNw4R@h>k^J%@aep*a4B&B!ITqhjR3F(R_NQFC*6vg5WQ+azRD7xCXW_zx7haC$bqsw#rwht z&qML3xAGiBL^7kO?c=hsLIKK|ZD~BELhCL4uNBbA8zrl9K2k_>91OQBntm_vIFq2^BPDk$sSIvK7$8IU9q|s zveQt2{yyu`^@k!DDi=WrUqU=fOYh@jp6te#$69ff`was)`JLA+uM|jr@6-w(D_+x! zD$RfJPhXePvw9a%MnmDkEGhrbFH{Q_UnMQS@d#!k?u(;2WQb^#b~EaI04zSfmjCHf zCFhFMzz6&4^#~H$r4~#L8*}O=$TuHrf%Kd~9S$<>R9rBsSs3tB=*@f{`4xS2Gpf!x zsWa$S{nnko6Tw9r^;OLrcNJNeH?pm6s(*G8Yfk4?yMH@LI_r+F>F$zd0i_f!WJ054 zA0K&iy0s+?6gUujA^@8M-1?pn;4R2`TqId9ohP{sG|DT|-@kv)w$K3ZN-P`?`R~>E z!>J;IvGDBT6!`?8uQF9m8&pD@IKz z|BlDoUK2NyziSsqipNPBRC?Bk$7vULCd(|mBS1hQOC*Yy$H+32>!8B3ZsAfp&0mB_ z#W-O1-|#-)uX2BFQyQ0r*?u<5G+5#9rG!iEfP>B~q+3Bf_W%=K)eCR@wQUrdNEam} z=csJtZ!@A2XM7b<5RwKe|JsM6MrFYIyq>4n0cfO$a6I4Tz$@8ifkk zoo`yyO?U%D*FZ_9>A#i}n9+3syAFmb=Wj(?w;mGZ(*U*(U_4$~Qn}n$A=3W;*z3Q4 zyu#G3o6ZZgp6kksks_^-%_jv8K-L`4CE3q{hUSzh;lEbRIf9gdv-jt*Ht+nS`rcDK zg<2ui$k_V<_yCV+;NM5|qYk?Q1!nXSX}w`FI#JKyuWZ9VXY&lSKc*eEk`R~c>FU!1 zCK992#zxz9Lw38OvIV)`?WV<{7GKOC4icR{j=N{0brOR=$~7{1^|Z_k(qoDQ0)~7b zh@ND&+NuR0|J{0@TNOwkwZ%oh2iZ|gXI&N;~ZYq*B< zj>3VQvVT2;TumTkm9g=?k>sB{Q0VOT-ppj%2O@R&{hH>YTmdb;lqm~`!$nWhrJZh^ zc$`v}dFhU^!e&N`y^(V$M}$S&UKR}SFqIEQFg;te85&#mvKpbhESh(O70*pI*<50l-G0$^SW{B=cxy z1hDvW7B;@O@3dAMpT5ck$L$3r^VlBmCHbp_A~F&($WgG z6{hv-sT>ol#xykqG<{$yhl@Mt&GR2gz#w{C1R1yaq!`oJnL_##H!x$Vvh=qmc(|th zA_&0UGu64z%P*WR5b9raMkPG4mMQnMU_8^%QuKoxAWRMD5!m>kbp129Z8`8f0OnV~ zsR9$w^+f@Klz%*FGd;zG;YPk;yt|CCQOxL`7~I-&kCHXSLW&+Cx!x~T+ZW2F+n zh_YI~n+E2CdoX?g64pM&U{-Nc$ zLoDgXsTv*N>Z3ji~%;$f@UcIQ1M3o2!Dw{d@tz;@Z1)j{XHAy-tf*lz+?);??~_s_Fcp4zPMl6;Cvbwa zqRdqNL*sEdVGoPx2i0GM;p+g*F^@+tO1o_0Z^vngJ8U9(Y^rn_yV`oK>-7F+&Cl<> z{sTh`68kJa5!!D;@33p8WYlkm%2fr>j$qi7x)7&{nNh|DC5sSyL~GcC6~b^C$M|~F zW?}zEU`pZTN!yBe9KpsSn0dkfwOekf)2r{E`K!#PT>L9xMLHoztP@!r>Ww$e8^0LJ zC7cW0qet;eAi`I>8Uz{z3Zgq^**Kj`#`(ZTn!zAP)ry&CrU;euF_qo)Y*-9np|9@# z*;FP1{=;nOXxeo(!J-YR_>bnIZ+Wg006Hx|M??V}ezqD&Xk7#MpbetsW873!Z+V(?jLZ8Tox;lyu z`y>s0v7R(uN*)Fu9|@=`ZQCY-2C=+t4H4q^=?WH*PQqQ z3H#Eul}wVH*ry5_?}p=q9|=XPk{dzTV4#z<+3#;wfe_*!;8Ss3(1?gbe+k%ZKuiZk@vsj3^W(z|nDG2{Gs{jd?sw619(OiPu0{}GXeD;XeZqkZWO{5Vh?_7!#b5h2N;2Df7|5o%$`|#FpGB}j zR|_BfBW#!wblUG*=KQx+9wBO&&|hSGkIsWYVV% zNtvu{TRg#66<8>8<>_p4o4%={D7rAEp39zE2$-icKJZQ#{>ftf2HStirDE-@G4EVW ziUf6OJwVs=Sf%rtO5X8+E4G0hMk8h1DTn1>+sc%#9t8lzM_vGFg;|!q->)++fJ5j^ zo@Zz&7|y9>a>Z<}y=CLI(m0!Q6QD-N4=lEC5k=6SJb9vs|7zs4+LNp+XbB4tCziVs zmB_9JV1pN}cPWZPdd47!l7I+KkF(0D<&f+ub*5?FX(QC9U$D#*+jDvZII!n@MQecq z`};-NFlIL;biO}lnbNW3&TIh4@`JdXcpp#$CJqco<`@dTxTnI4(Pc{8f&fC{jj$Cg zVz-V$8UNR44~A9-p*b$k3qjGIAMbc3mJH_|2P2D7uXp>5fD%2a5NvC<=h-drYH7h) z5;hF0F7Kn1$>@^hVAFZu_JUFoLe=I&&!6Y1IB6G7a=Oz5GIdej8@cRMP_unYFObQ& z)oLFOR7o%c6j5x`wb+FvzJikj{v)_VIJk=;+3Y8c%1mDKH6=u%?b)?@$yEf` zv6yH`CzaQN7bej$Dwj1I`H z{6f&_DxalY7XEtB^QT0Q1iZcrR>;!;g$i5PoreM_i%6F*#|5BEkZ90y;>Kai86jTz zaqg=sid;ASd$5h4qRcMs`zdbW0ZL??2h2dgryuqf;rr~?X4MLxd%-C#`-?@?9^f>#5fw80=*cTj#fqo zz6}OQ1?r6ADyRjj?(1fdiUurW3$y+_DY#%jh7S4UyG)y8Q&#CQ@%jB95`j_%%#nrFuH_3c=4p5*Us5H4H27+8( zh`e6sJ7{}6vuDfz;hj&Lkd%`82}Il`@1?=Az`U_?_~^`ZOjQ@9{lO{PGPN;<&2B1f zl0iXDY3;VseP6X%UcCxWZRlj>!Pq8W$Y&KFpQEc?FoHC5q0rNpG`8uNIz;pTAWV7ndo06p z7iQP15Hidl7=MJh>OieyZt8zocu~~w`3_VvR`7(AT(sh$g+5hFK0A+6a@G6Z<$P5^ z6GnGY=amCwFDt0aeB0=4lrN*8`f-&eKf}E(m6%D&(Z^%kYXT3tj$K$3V-hw4wCJ6Y zg?BVykbw7Qh0>M$%z4W&^#?=EZRr#fw|V97(+qx-_rP@dGLBko9~hdU$J}MadwBlA z(XDA27L9G5b77156%oKn1?g4H0%L15HWsjCJW*wkGaAc;%@(6v9pLkY1>K(ZWn*7Y zqD?FIj?PQjLg+_u7{68qEqYngFF4=ZLeJQFjxvIM+ z)uZ-V-ho^qe(wiM>XK4RPNjV}ma+X)XiCkNfPWL8TzM?Oc|{A7QSz6>9n(*6gvmH? zk_fU!A{IeoVx$6M8si!HEUx%qajX{dNsgOUq(&!nph&o3h|JN@ z3XkJ(o{(chO2}V2nskNVRHbEQ#B3xG#+qm0n0tHv4fK@a0>kO%=IcBI*&jRV^1EJ} z2oyFEDIMaEr1QhzgP0vR{93ajnI{d>OJ*$c%?i+RJs(}|ylS&rgj16%Q*PVrU4grs zuqH-|L3mj#KbSSNF<;9vb$p{{@~rAX?L>BuQ?&M7J_c=>l2_418|FNtLAofpXgW&4 zuj+8G+FR=8Jb&E%Q;KR)UilK=tQ@aT)5lH}!Xv>&{dE7NWSTAB&q^(2{gS?^xXCVv z{ydh$2m3{>m(IiIlK-@Di@7T~&Jp5IHuEKudmz_bLGvBRyjL)RbAbv7A52|gO_HCs zH|2k(HqvoAUTg2MGBP=+7?#n^yeY|AEyT;wll|fa`bXzXlr6M;0*o$ftWw9Ax+eAM zrB}761TIuV?g|1r2UqD52T7cMDZ!Ta0ak5KFMEZoUkPpu$q4OYwX!i5ay0FuZbeqQ z#alqG&Rp=9U+W-Ij1owc4dhC>ZDjmCJ?l#-Gp}6cC?~7mt0xnE6xD10y1mUa-0u83 z^vFL{2|dvF(!6q#CdPy3@tjbhdpB+S&%Obi$L_~n)^X(B8WkA(1-z~Oq;2y}%C_GS zO{=1;o6+b*4(_5}S)ws`UD|1J*Yt3DG9BjGh3~&S1 z9*6XPZR!lRtMTY}W8L7+8bKo%)u-zFpTPKALdxEGCy{RpB}5S`;j|JN)6jAY!S%wk zoq{)aKk4C^mrWv{$r}i2eQ?T>DVcu#R1_<3FGpKHf=gI{2d(Hr&{;Yrg66RqccEfI zbeL|mGSmI$c?^5^i`471*@Dlx(@s^lWMUBty<5MjhZuRS91T){T^v*ZQ_SaE)f$D~ zs!IZUikZO~H{HAw6nm$y30{ZquwQfehv%(>f#KZ&F)i>NWZE4DSs5)5Oy_=N`b}r5 zDsj~A7o4n{xE9oEb2LIqi{5oWNK0T#r1oB!giO3Ysq-5YC-yP9$J8ALX~K7E_|#{O z^*6YfscTqvef3;kS?ghpAHCD@9cL;WC*RjYY}J=vt@bZPGi^W-DvtO!!e<##iMO4 zsi(8fR^)*)(#yew0ls$%3&}YZ%DdI%A<4?1rB*yrUeVvJyyx=pbPf$93J^QRPVQKMU?BfIK?7!QEt<2P~QN>3Ck3fi|ZwsuNPoB*yo zS1$u@UY)WJt9(9N>Z^>%Red?GO)j1Pu7gOv!Xda-qJfGF4P2%`3l zwMXl1FnaBY;Y+}Q5<^#@+%jItV!KbgmovI9E+>aYqyQ-kS%REro5bQ@dYv&PQKLzAu|~x`abjcNiX1nBtT5ef5raVw=e*#0G!)^u28aU z4^x^gLtH>`G5l9LxkD$gid2zHGng8u?U}|Z+lE{`X3}CifpoIqe7ay6w))8K2xnOQW4q??6=>Y90wSC`ZoiN{B{V{$?3OJEar1oE)6rP{YJ_y?)0@$rT zx<=icg=SKocz)3-52MMWLRv2FSCByl?JeVVKE-<|ce^pU(P=b0dwD+#?2AD#iU%#F z38@L`i+X$}C#O)CO&T;Q;px`Z#U+p)c}lySe@v6UX107jRTkGrz;6u)X}g6?0QOKv zcyqeJ%gz^xt<_h9$N9`n;&`vC7clQQ;{AU50Ca|*jedUDG+Cbd5M;2S3x0ntyjOui z-(38%+cGX5GUO|l(c&2>O!Ewxoin@2Zfnv{F~-b}yb_6|R+y zS$=!me_BpfG&&SmLhTrDT7Ob)7@jnqGCe)LX_M{5GWgP`>{i>qUiU^EALZsEO<5a= z&D-~G?Sfpq79{=)Nt_eY<|tJt*sgDvu41#fr<=*JKm|=LK$DeXH;t*n#wTOWI!|`v zX);f!YDDI!LW?CJMmD)G0t)DNzt|wD*(QuD?J8R&Rh0_4IVEaTgtbigd3vvwUEE=} zh3@z!M;ktGa-Zu7W<>Id#KY_G!Ozit6Hse7e54sX*%FqFK^iG$=hNsTMwRPmv`jw7 zY!&!q?$$Djo~&ngwIyyUYpse4-gu57d=FbhV+2 zNGqj>TF;Ol0E^?Ie!VaglTbh@Lw0z9q9jW4mD!v}K6#ZC%4;(NpC21g>3>AjJc*$P z_CaJg5YeIRCo3Ix3|f8Qn^|QF14U;B2B+XQi<;T(;4Q~LZx2dA2rFxjyR4*MpO{Cv z+&xMPG{HfV3l8Mr%qDnl@07@^hWjBss;@1mZ%qI?0emD{2xwRmGsXMfuWTU*z$!gC zn3^o6#P>Za8Ptrv_q{SKsfC{GoON&{b>6ad<-|7uiNhxl>Xer`+W5Ux^wLwErX!>s z&Y#C&9h2UbygK0~bg$&`cqfm2i=4PAD3w+q|GHL%K^a^A6ia?LNvw;!efJ~MxSZ_; z&)0hakj?RwTAPDI+Bo;(_;SwM<)CJS{o+R(8b zr?33bjBa%2odDCT^zN4fQZR`6i&LDq--ovhlgSXoQK0j71($Oh#$1K4EdA4WlfdnM zO9eUky*cb$nHFjRt{I0~_`j6U54>0e>m=HM#N1G9V*xb_&H;c z{&~k@ztci$L$@ja?8_<-|4^P=ok-q`54r%w*-ZO6pU!d+_ce*F2HvhOjcii{Hy@e* zcnXqXR|MR~|6IQdvHN@Lx6dKK5tx3cnC`blyOlKC)QU^oOII4l>{sq!f=U(!;?oDM zt5yDU7V%U?d9nX\n

Hi Everyone,

\n

Here’s an intro to OpenSearch Dashboards Notebooks

\n
    \n
  • You may use the top left buttons to create, rename, clone, delete, import and export with notebooks
  • \n
  • Inner top left buttons are used to run, save, clone, delete the selected paragraph.
  • \n
  • Long hover over the buttons to see Tooltip helpers
  • \n
\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958104590_901298942","id":"paragraph_1596519508360_932236116","dateCreated":"2020-08-20 21:15:04.590","dateStarted":"2020-08-21 00:55:58.362","dateFinished":"2020-08-21 00:55:58.368","status":"FINISHED"},{"title":"CODE","text":"%md\n\n## Greetings!\n* Yay! you may import and export me as json files\n* **Run** a paragraph with a keyboard shortcut **\"Shift+Enter\"**\n* In Zeppelin each paragraph has to have a \"%[interpreter]\" header (like %md in this paragraph)","user":"anonymous","dateUpdated":"2020-08-21 01:08:56.942","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Greetings!

\n
    \n
  • Yay! you may import and export me as json files
  • \n
  • Run a paragraph with a keyboard shortcut “Shift+Enter”
  • \n
  • In Zeppelin each paragraph has to have a “%[interpreter]” header (like %md in this paragraph)
  • \n
\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597972120089_1145163794","id":"paragraph_1597972120089_1145163794","dateCreated":"2020-08-21 01:08:40.089","dateStarted":"2020-08-21 01:08:56.944","dateFinished":"2020-08-21 01:08:56.952","status":"FINISHED"},{"title":"Paragraph inserted","text":"%md\n\n## Now that you are using Zeppelin \n* Checkout how to [setup other interpreters](https://zeppelin.apache.org/docs/0.9.0/#available-interpreters)\n* To setup Elasticsearch interpreter and OpenSearch-SQL interpreters [checkout](https://github.com/opensearch-project/dashboards-notebooks/blob/dev/docs/dev/Zeppelin_backend_adaptor.md#apache-zeppelin-setup)\n","user":"anonymous","dateUpdated":"2020-08-21 01:08:52.286","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958104590_1715920734","id":"paragraph_1596742076640_674206137","dateCreated":"2020-08-20 21:15:04.590","dateStarted":"2020-08-21 01:08:52.288","dateFinished":"2020-08-21 01:08:52.295","status":"FINISHED"},{"title":"Paragraph inserted","text":"%md\n\n### Hover between paragraphs to add a Saved Visualization or a New Paragraph\n2. **Unpin** the container to *edit the size* or *delete it*\n3. **Refresh** the container after *date is changed*","user":"anonymous","dateUpdated":"2020-08-21 01:09:14.147","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Hover between paragraphs to add a Saved Visualization or a New Paragraph

\n
    \n
  1. Unpin the container to edit the size or delete it
  2. \n
  3. Refresh the container after date is changed
  4. \n
\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958104590_931410594","id":"paragraph_1596524302932_2112910756","dateCreated":"2020-08-20 21:15:04.590","dateStarted":"2020-08-21 01:09:14.149","dateFinished":"2020-08-21 01:09:14.160","status":"FINISHED"},{"title":"CODE","text":"%md\n\n# Start typing here","user":"anonymous","dateUpdated":"2020-08-21 00:56:41.827","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Start typing here

\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597971380477_32105829","id":"paragraph_1597971380477_32105829","dateCreated":"2020-08-21 00:56:20.477","dateStarted":"2020-08-21 00:56:41.830","dateFinished":"2020-08-21 00:56:41.836","status":"FINISHED"}],"name":"Introduction Notebook-Zeppelin","id":"2FJH8PW8K","defaultInterpreterGroup":"spark","version":"0.9.0-preview2","noteParams":{},"noteForms":{},"angularObjects":{},"config":{"isZeppelinNotebookCronEnable":false},"info":{}} diff --git a/public/components/notebooks/docs/example_notebooks/zeppelin/Log Analysis-Zeppelin.json b/public/components/notebooks/docs/example_notebooks/zeppelin/Log Analysis-Zeppelin.json deleted file mode 100644 index 61571248e3..0000000000 --- a/public/components/notebooks/docs/example_notebooks/zeppelin/Log Analysis-Zeppelin.json +++ /dev/null @@ -1 +0,0 @@ -{"paragraphs":[{"title":"CODE","text":"%md\n## Let's use Zeppelin Adaptor to explore data with Inter-Para Communication\n* Before diving into this notebook make sure:\n * You have integrated [**Sample web logs**](https://www.elastic.co/guide/en/kibana/7.8/getting-started.html#get-data-in)\n * You have used the **Introduction Notebook**\n * You have setup a [**python interpreter**](https://zeppelin.apache.org/docs/0.9.0/interpreter/python.html) & [**OpenSearch-SQL interpreter**](https://github.com/opensearch-project/dashboards-notebooks/blob/dev/docs/dev/Zeppelin_backend_adaptor.md#apache-zeppelin-setup)","user":"anonymous","dateUpdated":"2020-08-21 01:07:36.617","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Let’s use Zeppelin Adaptor to explore data with Inter-Para Communication

\n\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597970420851_2109527240","id":"paragraph_1597970420851_2109527240","dateCreated":"2020-08-21 00:40:20.851","dateStarted":"2020-08-21 01:07:36.619","dateFinished":"2020-08-21 01:07:36.626","status":"FINISHED"},{"title":"CODE","text":"%md\n## We'll use the pre-indexed sample web logs provided by OpenSearch Dashboards \n* Make an OpenSearch-SQL query \n* Import the output of SQL query in a python paragraph\n* Plot an anomaly graph using python-matplot","user":"anonymous","dateUpdated":"2020-08-21 00:58:02.620","config":{},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

We’ll use the pre-indexed sample web logs provided by OpenSearch Dashboards

\n
    \n
  • Make an OpenSearch-SQL query
  • \n
  • Import the output of SQL query in a python paragraph
  • \n
  • Plot an anomaly graph using python-matplot
  • \n
\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597971322703_978148592","id":"paragraph_1597971322703_978148592","dateCreated":"2020-08-21 00:55:22.703","dateStarted":"2020-08-21 00:58:02.629","dateFinished":"2020-08-21 00:58:02.634","status":"FINISHED"},{"title":"Paragraph inserted","text":"%md\n**OpenSearch-SQL Query to fetch size of request and agent data for all the web requests made**\nSelect bytes,agent from opensearch_dashboards_sample_data_logs\n","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.576","config":{"editorSetting":{"language":"scala","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/scala","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

OpenSearch-SQL Query to fetch size of request and agent data for all the web requests made
\nSelect bytes,agent from opensearch_dashboards_sample_data_logs

\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173576_437396150","id":"paragraph_1591897831553_-310393619","dateCreated":"2020-08-20 21:16:13.576","status":"READY"},{"title":"Paragraph inserted","text":"%opensearchsql(saveAs=data_logs)\nselect bytes,agent from opensearch_dashboards_sample_data_logs","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.576","config":{"saveAs":"opensearch_dashboards_sample_data_logs","editorSetting":{"language":"sql","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/sql","fontSize":9,"results":{"0":{"graph":{"mode":"table","height":300,"optionOpen":false,"setting":{"table":{"tableGridState":{},"tableColumnTypeState":{"names":{"bytes":"string","agent":"string"},"updated":false},"tableOptionSpecHash":"[{\"name\":\"useFilter\",\"valueType\":\"boolean\",\"defaultValue\":false,\"widget\":\"checkbox\",\"description\":\"Enable filter for columns\"},{\"name\":\"showPagination\",\"valueType\":\"boolean\",\"defaultValue\":false,\"widget\":\"checkbox\",\"description\":\"Enable pagination for better navigation\"},{\"name\":\"showAggregationFooter\",\"valueType\":\"boolean\",\"defaultValue\":false,\"widget\":\"checkbox\",\"description\":\"Enable a footer for displaying aggregated values\"}]","tableOptionValue":{"useFilter":false,"showPagination":false,"showAggregationFooter":false},"updated":false,"initialized":false}},"commonSetting":{}}}},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"TABLE","data":"bytes\tagent\n6219\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6850\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n14113\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2492\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n1872\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4531\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3629\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n9797\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9920\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6936\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8489\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2860\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8535\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4529\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9888\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n5919\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9890\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n3039\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8766\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8261\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5028\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8130\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9934\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3314\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2492\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n1950\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n0\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8489\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9029\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2860\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8120\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3930\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3464\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8535\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n17403\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9773\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n875\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n18082\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n6514\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8323\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8364\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n6274\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n2108\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7174\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5846\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n7594\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7169\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n9338\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9217\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8390\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9101\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6936\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4634\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8909\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7343\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4939\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n55\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n1778\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8996\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5197\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2153\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n5223\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n178\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5400\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8676\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n6960\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n18409\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2441\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n3010\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9263\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3853\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4238\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2377\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8928\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n6193\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2372\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6942\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n173\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4877\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n15894\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n7468\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5481\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n6920\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9920\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5476\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2432\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n2034\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9021\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7719\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n4037\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n17598\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n6312\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5835\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2647\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n8655\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n17357\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4064\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n3034\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n1634\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3807\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8738\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3629\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n9446\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7182\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2159\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4861\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3317\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8663\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n1793\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n6648\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3307\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n5052\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4531\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n8995\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4579\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n8522\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7304\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n255\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9052\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6795\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n6739\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n7936\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n7309\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6254\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2453\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3378\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n9375\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n1685\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4154\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5919\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4633\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3039\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5375\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n5424\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n14113\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n3841\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n1638\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n5861\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3994\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n1828\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4529\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5321\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n15990\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4806\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4072\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n4617\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9486\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9888\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n7193\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6429\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n8648\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n7377\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9371\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n8590\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2765\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9424\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9716\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n1858\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2432\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n9797\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n2999\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n6817\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4842\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n16227\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n1603\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n19561\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n1936\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5540\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n7085\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5767\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n2053\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n4061\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n7675\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n0\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n979\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n685\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n6509\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n8723\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n9414\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3086\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n1872\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9174\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n5073\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n10103\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7531\tMozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24\n5988\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7009\tMozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)\n6540\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n0\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n9952\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n7873\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n3050\tMozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1\n"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173576_876823109","id":"paragraph_1591897481776_702487776","dateCreated":"2020-08-20 21:16:13.576","status":"READY"},{"title":"Paragraph inserted","text":"%md\n**Import this query output in python**\n","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.577","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Import this query output in python

\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173576_599341048","id":"paragraph_1591897902017_-1544489575","dateCreated":"2020-08-20 21:16:13.577","status":"READY"},{"title":"Paragraph inserted","text":"%python.ipython\n%matplotlib inline\nlogs = z.getAsDataFrame('data_logs')\nlogs","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.577","config":{"editorSetting":{"language":"scala","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/scala","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
bytesagent
06219Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
16850Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
20Mozilla/5.0 (X11; Linux i686) AppleWebKit/534....
314113Mozilla/4.0 (compatible; MSIE 6.0; Windows NT ...
42492Mozilla/4.0 (compatible; MSIE 6.0; Windows NT ...
.........
1956540Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
1960Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
1979952Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
1987873Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
1993050Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Geck...
\n

200 rows × 2 columns

\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173577_325755404","id":"paragraph_1591898708896_1841265952","dateCreated":"2020-08-20 21:16:13.577","status":"READY"},{"title":"Paragraph inserted","text":"\n%md\n**Plot a scatter graph for requests made per browser using matplot**\n","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.577","config":{"editorSetting":{"language":"scala","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/scala","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

Plot a scatter graph for requests made per browser using matplot

\n\n
"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173577_2096035472","id":"paragraph_1591899582298_-864868294","dateCreated":"2020-08-20 21:16:13.577","status":"READY"},{"title":"Paragraph inserted","text":"%python.ipython\n\n%matplotlib inline\n\nimport warnings\nimport numpy as np\nwarnings.filterwarnings(\"ignore\")\nimport matplotlib.pyplot as plt\n\nlogs = z.getAsDataFrame('data_logs')\nagent_with_firefox = logs['agent'].str.count('Firefox').values\nfirefox_bytes = logs['bytes'].values[agent_with_firefox==1]\n\nagent_with_chrome_safari = logs['agent'].str.count('Chrome').values\nchrome_bytes = logs['bytes'].values[agent_with_chrome_safari==1]\n\nagent_with_msie = logs['agent'].str.count('MSIE').values\nmsie_bytes = logs['bytes'].values[agent_with_msie==1]\n\nprint(\"Total Requests:\", len(agent_with_firefox))\nprint(\"# Requests from Firefox Browser\", np.sum(agent_with_firefox))\nprint(\"# Requests from Chrome/Safari Browser\", np.sum(agent_with_chrome_safari))\nprint(\"# Requests from MSIE Browser\", np.sum(agent_with_msie), \"\\n\\n\")\n\nplt.figure(num=None, figsize=(30, 6))\nplt.title(\"Request size for each browser\")\nplt.ylabel('Request Size in Bytes')\nplt.xlabel('Requests made')\nplt.axhline(y=10000, color='r', linestyle='--')\nplt.plot(msie_bytes, 'ro', markersize=5, label=\"MSIE\")\nplt.plot(firefox_bytes, 'bo', markersize=5, label=\"Firefox\")\nplt.plot(chrome_bytes, 'go', markersize=5, label=\"Chrome\")\nplt.legend()\n\n\n\n\n \n","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.577","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"results":{"code":"SUCCESS","msg":[{"type":"TEXT","data":"Total Requests: 200\n# Requests from Firefox Browser 86\n# Requests from Chrome/Safari Browser 54\n# Requests from MSIE Browser 60 \n\n\n\n"},{"type":"IMG","data":"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\n"}]},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173577_1190291426","id":"paragraph_1591860123109_1248831991","dateCreated":"2020-08-20 21:16:13.577","status":"READY"},{"text":"%python.ipython\n","user":"anonymous","dateUpdated":"2020-08-20 21:16:13.577","config":{},"settings":{"params":{},"forms":{}},"apps":[],"runtimeInfos":{},"progressUpdateIntervalMs":500,"jobName":"paragraph_1597958173577_538284345","id":"paragraph_1597047441935_258792906","dateCreated":"2020-08-20 21:16:13.577","status":"READY"}],"name":"Log Analysis-Zeppelin","id":"2FJF9WE51","defaultInterpreterGroup":"spark","version":"0.9.0-preview2","noteParams":{},"noteForms":{},"angularObjects":{},"config":{"isZeppelinNotebookCronEnable":false},"info":{}} diff --git a/public/components/notebooks/docs/poc/OpenSearch_Dashboards_Embeddable_Documentation.md b/public/components/notebooks/docs/poc/OpenSearch_Dashboards_Embeddable_Documentation.md deleted file mode 100644 index 2845d22731..0000000000 --- a/public/components/notebooks/docs/poc/OpenSearch_Dashboards_Embeddable_Documentation.md +++ /dev/null @@ -1,55 +0,0 @@ -# OpenSearch Dashboards Embeddable API & Embedding Visualizations - -**NOTE:** The embeddable API and Visualizations have been in high flux for past 6 releases 7.4→7.9 versions in OpenSearch Dashboards - -## **In Version 7.5 and older** - -1. [Elastic blog](https://www.elastic.co/blog/developing-new-kibana-visualizations) on embedding Visualization -2. [Test Plugin](https://github.com/elastic/kibana/tree/7.5/test/plugin_functional/plugins/kbn_tp_visualize_embedding) for OpenSearch Dashboards Visualization embedding - -**Between 7.6 and 7.8 - Embeddable API has changed at a high frequency, better to use it from 7.9** - -## **Embeddable API - Situation post 7.9 update** - -- Embeddables are re-usable widgets that can be rendered in any environment or plugin. Developers can embed them directly in their plugin. End users can dynamically add them to any embeddable _containers_. -- Containers are a special type of embeddable that can contain nested embeddables. Embeddables can be dynamically added to embeddable _containers_. _Currently only dashboard uses this interface._ - -![Embeddable API](../dev/images/Embeddable_API.png) - -* [Source](https://github.com/elastic/kibana/issues/19875) -* [Code](https://github.com/elastic/kibana/tree/main/src/plugins/embeddable) -* [README](https://github.com/elastic/kibana/blob/main/src/plugins/embeddable/README.md) - -1. Visualizations, Saved Search and Dashboard embeddable are part of this API now. -2. Embeddable Factory allows to create objects: - 1. with “.create()” menthod → needs input of data/source/query/time range explicitly - 2. with “.createFromSavedObject()” method → either inherits values from containers or takes from explicit input provided -3. Each of the above has a implementation has to inherit an embeddable & Factory API like: - 1. [Viz. Embeddable](https://github.com/elastic/kibana/blob/main/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts) & [Factory](https://github.com/elastic/kibana/blob/main/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx) - 2. [Creating Custom Embeddable Example](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable.tsx) & [Factory](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_embeddable_factory.ts) by Value - 3. [Creating Custom Embeddable Example](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx) & [Factory](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx) by reference -4. [Visualizations Embeddable API Code](https://github.com/streamich/kibana/tree/main/src/plugins/visualizations/public/embeddable) -5. [Dashboard Container](https://github.com/elastic/kibana/blob/main/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx) is exposed as an embeddable - to have multiple embeddable in a GRID like structure just like the Dashboard Plugin. - -**Embeddable Examples** - -- Examples folder in OpenSearch Dashboards has all the usage samples for new APIs -- Use to create new embeddable objects -- [Embeddable Examples](https://github.com/elastic/kibana/tree/main/examples/embeddable_examples) shows how to create new embeddable inheriting the API -- [Embeddable Explorer](https://github.com/elastic/kibana/tree/main/examples/embeddable_explorer)shows usage of these embeddable examples in a Panel Container -- [Dashboard Embeddable](https://github.com/elastic/kibana/tree/main/examples/dashboard_embeddable_examples) shows usage of these embeddable examples in a Dashboard Container - -**Embeddable Renderer** - -- The OpenSearch Dashboards react Element/Prop to create new embeddable objects: [Code](https://github.com/elastic/kibana/blob/main/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx) -- Embeddable container use the renderer to create/update each child(an embeddable object) - - [Example Dashboard Container](https://github.com/elastic/kibana/blob/main/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx) - - [Example of Static Embedding](https://github.com/elastic/kibana/blob/main/examples/embeddable_explorer/public/hello_world_embeddable_example.tsx#L59) (without factory) - - [Example of Embedding with factory.create() method](https://github.com/elastic/kibana/blob/main/examples/embeddable_explorer/public/hello_world_embeddable_example.tsx#L73) (with factory) - -## Embedding Visualizations in Notebooks Plugin - -- Notebooks use embeddable API with dashboard containers for embedding visualizations -- Dashboard containers allow loading saved objects by Id -- Notebook paragraphs store the dashboard container object as json string in input cells -- For storing visualizations in Zeppelin input cells, the json string is stored with a prefix “%sh #{JSON_STRING}”. Making the Json object look like a comment so that, it doesn’t interrupt running the whole notebook. diff --git a/public/components/notebooks/docs/poc/Zeppelin_OpenSearch_Storage.md b/public/components/notebooks/docs/poc/Zeppelin_OpenSearch_Storage.md deleted file mode 100644 index 256a9ff487..0000000000 --- a/public/components/notebooks/docs/poc/Zeppelin_OpenSearch_Storage.md +++ /dev/null @@ -1,67 +0,0 @@ -# **Custom OpenSearch Storage in Zeppelin** - -### **Requirement:** - -- Use Zeppelin as a backend service for OpenSearch Dashboards Notebooks and store notebooks as OpenSearch indices -- Use Zeppelin’s storage adaptor interface and implement a new storage adaptor using OpenSearch Client - -### **Design:** - -- [“Transport client API“](https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/transport-client.html) is getting deprecated in favor of high level client. -- Finalized, [“High level client API”](https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.8/java-rest-high.html) for ease of use and minimal operations needed for Adaptor. -- Notebooks will be indexed as* .notebooks/\_doc/{Unique_id} →* Unique ID is generated by zeppelin - -### **Design Details:** - -1. Implements the interface common for all Zeppelin Storage adaptors -2. Implementation of functions in OpenSearch Zeppelin storage adaptor: - - - Init - Get all config params - - List - List all notebooks - - Get - fetch a notebook - - save - save a notebook - - remove - a note - - close - client connection - - Upgrade client to Https requests - Done using keystore - -### **Usage:** - -1. POC for OpenSearch adapter is stored in branch 'zeppelin-opensearch' of dashboards-notebooks -``` -git checkout zeppelin-opensearch -``` -2. Clone Apache Zeppelin and checkout to 'v0.9.0-preview2' branch in a separate folder -``` -cd /your/folder/ -git clone https://github.com/apache/zeppelin.git -cd zeppelin -git checkout v0.9.0-preview2 -``` -3. Apply patch from dashboards-notebooks -``` -git apply /path/to/zeppelin-patch -``` -4. Once, in this branch copy "opensearch" storage adaptor to your zeppelin files -``` -cp -r /path/to/dashboards-notebooks/zeppelin/zeppelin-plugins/notebookrepo/opensearch path/to/your/zeppelin -``` -4. Add OpenSearch storage property in zeppelin config file "conf/zeppelin-site.xml" and you should comment default git storage -``` - - zeppelin.notebook.storage - org.apache.zeppelin.notebook.repo.OpenSearchNotebookRepo - versioned notebook persistence layer implementation - - - -``` -5. [Build Zeppelin](https://zeppelin.apache.org/docs/0.9.0/setup/basics/how_to_build.html) using Open-JDK 8 -``` - mvn clean package -DskipTests -``` diff --git a/public/components/notebooks/docs/poc/docs/Zeppelin_OpenSearch_Storage.md b/public/components/notebooks/docs/poc/docs/Zeppelin_OpenSearch_Storage.md deleted file mode 100644 index 2449ca6723..0000000000 --- a/public/components/notebooks/docs/poc/docs/Zeppelin_OpenSearch_Storage.md +++ /dev/null @@ -1,73 +0,0 @@ -# **Custom OpenSearch Storage in Zeppelin** - -### **Requirement:** - -- Use Zeppelin as a backend service for OpenSearch Dashboards Notebooks and store notebooks as indices -- Use Zeppelin’s storage adaptor interface and implement a new storage adaptor using Elasticsearch Client - -### **Design:** - -- [“Transport client API“](https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/transport-client.html) is getting deprecated in favor of high level client. -- Finalized, [“High level client API”](https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.8/java-rest-high.html) for ease of use and minimal operations needed for Adaptor. -- Notebooks will be indexed as* .notebooks/\_doc/{Unique_id} →* Unique ID is generated by zeppelin - -### **Design Details:** - -1. Implements the interface common for all Zeppelin Storage adaptors -2. Implementation of functions in OpenSearch Zeppelin storage adaptor: - - - Init - Get all config params - - List - List all notebooks - - Get - fetch a notebook - - save - save a notebook - - remove - a note - - close - client connection - - Upgrade client to Https requests - Done using keystore - -### **Usage:** - - -1. Clone [dashbaords-notebooks](https://github.com/opensearch-project/dashboards-notebooks/) repository - -2. Clone [Apache Zeppelin](https://github.com/apache/zeppelin) and checkout to 'v0.9.0-preview2' branch in a separate folder - -``` -cd zeppelin -git checkout v0.9.0-preview2 -``` - -3. Apply patch from dashboards-notebooks - -``` -git apply /path/to/dashboards-notebooks/poc/zeppelin-patch -``` - -4. Once, in this branch copy "opensearch" storage adaptor to your zeppelin files - -``` -cp -r /path/to/dashboards-notebooks/poc/zeppelin/zeppelin-plugins/notebookrepo/opensearch path/to/your/zeppelin/zeppelin-plugins/notebookrepo/. -``` - -5. Add OpenSearch storage property in zeppelin config file "conf/zeppelin-site.xml" and you should comment default git storage - -``` - - zeppelin.notebook.storage - org.apache.zeppelin.notebook.repo.OpenSearchNotebookRepo - versioned notebook persistence layer implementation - - - -``` - -6. [Build Zeppelin](https://zeppelin.apache.org/docs/0.9.0/setup/basics/how_to_build.html) using Open-JDK 8 - -``` - mvn clean package -DskipTests -``` diff --git a/public/components/notebooks/docs/poc/zeppelin-patch b/public/components/notebooks/docs/poc/zeppelin-patch deleted file mode 100644 index e6f8611879..0000000000 --- a/public/components/notebooks/docs/poc/zeppelin-patch +++ /dev/null @@ -1,3057 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -diff --git a/elasticsearch/pom.xml b/elasticsearch/pom.xml -index 13bc6d469..bbdde076a 100644 ---- a/elasticsearch/pom.xml -+++ b/elasticsearch/pom.xml -@@ -23,25 +23,26 @@ - - zeppelin-interpreter-parent - org.apache.zeppelin -- 0.9.0-preview2 -+ 0.9.0-SNAPSHOT - ../zeppelin-interpreter-parent/pom.xml - - - zeppelin-elasticsearch - jar -- 0.9.0-preview2 -+ 0.9.0-SNAPSHOT - Zeppelin: Elasticsearch interpreter - - - elasticsearch -- 2.4.3 -- 4.0.2 -+ 7.8.0 -+ 4.1.4 - 18.0 - 0.1.6 - 1.4.9 - - - -+ - - org.opensearch - elasticsearch -@@ -58,11 +59,11 @@ - commons-lang3 - - -- -- org.apache.httpcomponents -- httpasyncclient -- ${httpasyncclient.version} -- -+ -+ -+ -+ -+ - - - com.google.guava -diff --git a/elasticsearch/src/main/java/org/apache/zeppelin/elasticsearch/ElasticsearchInterpreter.java b/elasticsearch/src/main/java/org/apache/zeppelin/elasticsearch/ElasticsearchInterpreter.java -index 45b37c4eb..d7987a011 100644 ---- a/elasticsearch/src/main/java/org/apache/zeppelin/elasticsearch/ElasticsearchInterpreter.java -+++ b/elasticsearch/src/main/java/org/apache/zeppelin/elasticsearch/ElasticsearchInterpreter.java -@@ -21,19 +21,6 @@ import com.google.gson.Gson; - import com.google.gson.GsonBuilder; - import com.google.gson.JsonObject; - --import org.apache.commons.lang3.StringUtils; --import org.opensearch.common.xcontent.XContentBuilder; --import org.opensearch.common.xcontent.XContentFactory; --import org.opensearch.common.xcontent.XContentHelper; --import org.opensearch.search.aggregations.Aggregation; --import org.opensearch.search.aggregations.Aggregations; --import org.opensearch.search.aggregations.InternalMultiBucketAggregation; --import org.opensearch.search.aggregations.bucket.InternalSingleBucketAggregation; --import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; --import org.opensearch.search.aggregations.metrics.InternalMetricsAggregation; --import org.slf4j.Logger; --import org.slf4j.LoggerFactory; -- - import java.io.IOException; - import java.util.ArrayList; - import java.util.Arrays; -@@ -48,7 +35,6 @@ import java.util.Set; - import java.util.TreeSet; - import java.util.regex.Matcher; - import java.util.regex.Pattern; -- - import com.github.wnameless.json.flattener.JsonFlattener; - - import org.apache.zeppelin.completer.CompletionType; -@@ -57,12 +43,25 @@ import org.apache.zeppelin.elasticsearch.action.AggWrapper; - import org.apache.zeppelin.elasticsearch.action.HitWrapper; - import org.apache.zeppelin.elasticsearch.client.ElasticsearchClient; - import org.apache.zeppelin.elasticsearch.client.HttpBasedClient; --import org.apache.zeppelin.elasticsearch.client.TransportBasedClient; - import org.apache.zeppelin.interpreter.Interpreter; - import org.apache.zeppelin.interpreter.InterpreterContext; - import org.apache.zeppelin.interpreter.InterpreterResult; - import org.apache.zeppelin.interpreter.thrift.InterpreterCompletion; - -+import org.apache.commons.lang3.StringUtils; -+import org.opensearch.common.Strings; -+import org.opensearch.common.xcontent.ToXContent; -+import org.opensearch.common.xcontent.XContentBuilder; -+import org.opensearch.common.xcontent.XContentFactory; -+import org.opensearch.search.aggregations.Aggregation; -+import org.opensearch.search.aggregations.Aggregations; -+import org.opensearch.search.aggregations.InternalMultiBucketAggregation; -+import org.opensearch.search.aggregations.bucket.InternalSingleBucketAggregation; -+import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; -+import org.opensearch.search.aggregations.metrics.InternalNumericMetricsAggregation; -+import org.slf4j.Logger; -+import org.slf4j.LoggerFactory; -+ - /** - * Elasticsearch Interpreter for Zeppelin. - */ -@@ -70,25 +69,25 @@ public class ElasticsearchInterpreter extends Interpreter { - private static Logger logger = LoggerFactory.getLogger(ElasticsearchInterpreter.class); - - private static final String HELP = "Elasticsearch interpreter:\n" -- + "General format: ///