From 371e0dc9809c87bb0cc9c0e0adf650fbc9b2cf0e Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Fri, 19 Apr 2024 09:35:22 -0700 Subject: [PATCH] Add a prototype tab; support ingest and search with guardrails (#139) Signed-off-by: Tyler Ohlsen (cherry picked from commit e18bb11e2627519f9a5ad21cc2fa4ef367aec926) --- common/constants.ts | 2 + .../pages/workflow_detail/prototype/index.ts | 6 + .../workflow_detail/prototype/ingestor.tsx | 218 ++++++++++++++++ .../workflow_detail/prototype/prototype.tsx | 103 ++++++++ .../prototype/query_executor.tsx | 233 ++++++++++++++++++ .../pages/workflow_detail/prototype/utils.ts | 12 + .../utils/data_extractor_utils.ts | 108 ++++++++ public/pages/workflow_detail/utils/index.ts | 1 + .../utils/workflow_to_template_utils.ts | 2 +- .../pages/workflow_detail/workflow_detail.tsx | 17 ++ public/route_service.ts | 30 +++ public/store/reducers/opensearch_reducer.ts | 48 ++++ server/routes/opensearch_routes_service.ts | 74 +++++- 13 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 public/pages/workflow_detail/prototype/index.ts create mode 100644 public/pages/workflow_detail/prototype/ingestor.tsx create mode 100644 public/pages/workflow_detail/prototype/prototype.tsx create mode 100644 public/pages/workflow_detail/prototype/query_executor.tsx create mode 100644 public/pages/workflow_detail/prototype/utils.ts create mode 100644 public/pages/workflow_detail/utils/data_extractor_utils.ts diff --git a/common/constants.ts b/common/constants.ts index 6cdbbd63..7f8a507e 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -35,6 +35,8 @@ export const BASE_NODE_API_PATH = '/api/flow_framework'; // OpenSearch node APIs export const BASE_OPENSEARCH_NODE_API_PATH = `${BASE_NODE_API_PATH}/opensearch`; export const CAT_INDICES_NODE_API_PATH = `${BASE_OPENSEARCH_NODE_API_PATH}/catIndices`; +export const SEARCH_INDEX_NODE_API_PATH = `${BASE_OPENSEARCH_NODE_API_PATH}/search`; +export const INGEST_NODE_API_PATH = `${BASE_OPENSEARCH_NODE_API_PATH}/ingest`; // Flow Framework node APIs export const BASE_WORKFLOW_NODE_API_PATH = `${BASE_NODE_API_PATH}/workflow`; diff --git a/public/pages/workflow_detail/prototype/index.ts b/public/pages/workflow_detail/prototype/index.ts new file mode 100644 index 00000000..1021e1a2 --- /dev/null +++ b/public/pages/workflow_detail/prototype/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './prototype'; diff --git a/public/pages/workflow_detail/prototype/ingestor.tsx b/public/pages/workflow_detail/prototype/ingestor.tsx new file mode 100644 index 00000000..dee9be85 --- /dev/null +++ b/public/pages/workflow_detail/prototype/ingestor.tsx @@ -0,0 +1,218 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiButton, + EuiCodeEditor, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { + USE_CASE, + Workflow, + getIndexName, + getSemanticSearchValues, +} from '../../../../common'; +import { ingest, useAppDispatch } from '../../../store'; +import { getCore } from '../../../services'; +import { getFormattedJSONString } from './utils'; + +interface IngestorProps { + workflow: Workflow; +} + +type WorkflowValues = { + modelId: string; +}; + +type SemanticSearchValues = WorkflowValues & { + inputField: string; + vectorField: string; +}; + +type DocGeneratorFn = ( + queryText: string, + workflowValues: SemanticSearchValues +) => {}; + +/** + * A basic and flexible UI for ingesting some documents against an index. Sets up guardrails to limit + * what is customized in the document, and setting readonly values based on the workflow's use case + * and details. + * + * For example, given a semantic search workflow configured on index A, with model B, input field C, and vector field D, + * the UI will enforce the ingested document to include C, and ingest it against A. + */ +export function Ingestor(props: IngestorProps) { + const dispatch = useAppDispatch(); + // query state + const [workflowValues, setWorkflowValues] = useState(); + const [docGeneratorFn, setDocGeneratorFn] = useState(); + const [indexName, setIndexName] = useState(''); + const [docObj, setDocObj] = useState<{}>({}); + const [formattedDoc, setFormattedDoc] = useState(''); + const [userInput, setUserInput] = useState(''); + + // results state + const [response, setResponse] = useState<{}>({}); + const [formattedResponse, setFormattedResponse] = useState(''); + + // hook to set all of the workflow-related fields based on the use case + useEffect(() => { + setWorkflowValues(getWorkflowValues(props.workflow)); + setDocGeneratorFn(getDocGeneratorFn(props.workflow)); + setIndexName(getIndexName(props.workflow)); + }, [props.workflow]); + + // hook to generate the query once all dependent input vars are available + useEffect(() => { + if (docGeneratorFn && workflowValues) { + setDocObj(docGeneratorFn(userInput, workflowValues)); + } + }, [userInput, docGeneratorFn, workflowValues]); + + // hooks to persist the formatted data. this is so we don't + // re-execute the JSON formatting unless necessary + useEffect(() => { + setFormattedResponse(getFormattedJSONString(response)); + }, [response]); + useEffect(() => { + setFormattedDoc(getFormattedJSONString(docObj)); + }, [docObj]); + + // + function onExecuteIngest() { + dispatch(ingest({ index: indexName, doc: docObj })) + .unwrap() + .then(async (result) => { + setResponse(result); + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger(error); + setResponse({}); + }); + } + + return ( + + + + + Ingest some sample data to get started. + + + + + { + setUserInput(e.target.value); + }} + /> + + + + Ingest + + + + + + {}} + readOnly={true} + setOptions={{ + fontSize: '14px', + }} + aria-label="Code Editor" + tabSize={2} + /> + + + + + + + + Response + + + + + + + + + + {}} + readOnly={true} + setOptions={{ + fontSize: '14px', + }} + aria-label="Code Editor" + tabSize={2} + /> + + + + + ); +} + +// getting the appropriate doc generator function based on the use case +function getDocGeneratorFn(workflow: Workflow): DocGeneratorFn { + let fn; + switch (workflow.use_case) { + case USE_CASE.SEMANTIC_SEARCH: + default: { + fn = () => generateSemanticSearchDoc; + } + } + return fn; +} + +// getting the appropriate static values from the workflow based on the use case +function getWorkflowValues(workflow: Workflow): WorkflowValues { + let values; + switch (workflow.use_case) { + case USE_CASE.SEMANTIC_SEARCH: + default: { + values = getSemanticSearchValues(workflow); + } + } + return values; +} + +// utility fn to generate a document suited for semantic search +function generateSemanticSearchDoc( + docValue: string, + workflowValues: SemanticSearchValues +): {} { + return { + [workflowValues.inputField]: docValue, + }; +} diff --git a/public/pages/workflow_detail/prototype/prototype.tsx b/public/pages/workflow_detail/prototype/prototype.tsx new file mode 100644 index 00000000..dc1ac409 --- /dev/null +++ b/public/pages/workflow_detail/prototype/prototype.tsx @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiPageContent, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { Workflow } from '../../../../common'; +import { QueryExecutor } from './query_executor'; +import { Ingestor } from './ingestor'; + +interface PrototypeProps { + workflow?: Workflow; +} + +enum TAB_ID { + INGEST = 'ingest', + QUERY = 'query', +} + +const inputTabs = [ + { + id: TAB_ID.INGEST, + name: '1. Ingest Data', + disabled: false, + }, + { + id: TAB_ID.QUERY, + name: '2. Query data', + disabled: false, + }, +]; + +/** + * A simple prototyping page to perform ingest and search. + */ +export function Prototype(props: PrototypeProps) { + const [selectedTabId, setSelectedTabId] = useState(TAB_ID.INGEST); + return ( + + +

Prototype

+
+ + {props.workflow?.resourcesCreated && + props.workflow?.resourcesCreated.length > 0 ? ( + <> + + {inputTabs.map((tab, idx) => { + return ( + setSelectedTabId(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + key={idx} + > + {tab.name} + + ); + })} + + + + {selectedTabId === TAB_ID.INGEST && ( + + + + )} + {selectedTabId === TAB_ID.QUERY && ( + + + + )} + + + ) : ( + No resources available} + titleSize="s" + body={ + <> + + Provision the workflow to generate resources in order to start + prototyping. + + + } + /> + )} +
+ ); +} diff --git a/public/pages/workflow_detail/prototype/query_executor.tsx b/public/pages/workflow_detail/prototype/query_executor.tsx new file mode 100644 index 00000000..d8a794a1 --- /dev/null +++ b/public/pages/workflow_detail/prototype/query_executor.tsx @@ -0,0 +1,233 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiButton, + EuiCodeEditor, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { + USE_CASE, + Workflow, + getIndexName, + getSemanticSearchValues, +} from '../../../../common'; +import { searchIndex, useAppDispatch } from '../../../store'; +import { getCore } from '../../../services'; +import { getFormattedJSONString } from './utils'; + +interface QueryExecutorProps { + workflow: Workflow; +} + +type WorkflowValues = { + modelId: string; +}; + +type SemanticSearchValues = WorkflowValues & { + inputField: string; + vectorField: string; +}; + +type QueryGeneratorFn = ( + queryText: string, + workflowValues: SemanticSearchValues +) => {}; + +/** + * A basic and flexible UI for executing queries against an index. Sets up guardrails to limit + * what is customized in the query, and setting readonly values based on the workflow's use case + * and details. + * + * For example, given a semantic search workflow configured on index A, with model B, input field C, and vector field D, + * the UI will enforce a semantic search neural query configured with B,C,D, and run it against A. + */ +export function QueryExecutor(props: QueryExecutorProps) { + const dispatch = useAppDispatch(); + // query state + const [workflowValues, setWorkflowValues] = useState(); + const [queryGeneratorFn, setQueryGeneratorFn] = useState(); + const [indexName, setIndexName] = useState(''); + const [queryObj, setQueryObj] = useState<{}>({}); + const [formattedQuery, setFormattedQuery] = useState(''); + const [userInput, setUserInput] = useState(''); + + // results state + const [resultHits, setResultHits] = useState<{}[]>([]); + const [formattedHits, setFormattedHits] = useState(''); + + // hook to set all of the workflow-related fields based on the use case + useEffect(() => { + setWorkflowValues(getWorkflowValues(props.workflow)); + setQueryGeneratorFn(getQueryGeneratorFn(props.workflow)); + setIndexName(getIndexName(props.workflow)); + }, [props.workflow]); + + // hook to generate the query once all dependent input vars are available + useEffect(() => { + if (queryGeneratorFn && workflowValues) { + setQueryObj(queryGeneratorFn(userInput, workflowValues)); + } + }, [userInput, queryGeneratorFn, workflowValues]); + + // hooks to persist the formatted data. this is so we don't + // re-execute the JSON formatting unless necessary + useEffect(() => { + setFormattedHits(getFormattedJSONString(processHits(resultHits))); + }, [resultHits]); + useEffect(() => { + setFormattedQuery(getFormattedJSONString(queryObj)); + }, [queryObj]); + + // + function onExecuteSearch() { + dispatch(searchIndex({ index: indexName, body: queryObj })) + .unwrap() + .then(async (result) => { + setResultHits(result.hits.hits); + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger(error); + setResultHits([]); + }); + } + + return ( + + + + + Execute queries to test out the results! + + + + + { + setUserInput(e.target.value); + }} + /> + + + + Search + + + + + + {}} + readOnly={true} + setOptions={{ + fontSize: '14px', + }} + aria-label="Code Editor" + tabSize={2} + /> + + + + + + + + Results + + + + + + + + + + {}} + readOnly={true} + setOptions={{ + fontSize: '14px', + }} + aria-label="Code Editor" + tabSize={2} + /> + + + + + ); +} + +// getting the appropriate query generator function based on the use case +function getQueryGeneratorFn(workflow: Workflow): QueryGeneratorFn { + let fn; + switch (workflow.use_case) { + case USE_CASE.SEMANTIC_SEARCH: + default: { + fn = () => generateSemanticSearchQuery; + } + } + return fn; +} + +// getting the appropriate static values from the workflow based on the use case +function getWorkflowValues(workflow: Workflow): WorkflowValues { + let values; + switch (workflow.use_case) { + case USE_CASE.SEMANTIC_SEARCH: + default: { + values = getSemanticSearchValues(workflow); + } + } + return values; +} + +// utility fn to generate a semantic search query +function generateSemanticSearchQuery( + queryText: string, + workflowValues: SemanticSearchValues +): {} { + return { + _source: { + excludes: [`${workflowValues.vectorField}`], + }, + query: { + neural: { + [workflowValues.vectorField]: { + query_text: queryText, + model_id: workflowValues.modelId, + k: 5, + }, + }, + }, + }; +} + +function processHits(hits: any[]): {}[] { + return hits.map((hit) => hit._source); +} diff --git a/public/pages/workflow_detail/prototype/utils.ts b/public/pages/workflow_detail/prototype/utils.ts new file mode 100644 index 00000000..c4e3a1a9 --- /dev/null +++ b/public/pages/workflow_detail/prototype/utils.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared utility fns used in the prototyping page. + */ + +export function getFormattedJSONString(obj: {}): string { + return Object.values(obj).length > 0 ? JSON.stringify(obj, null, '\t') : ''; +} diff --git a/public/pages/workflow_detail/utils/data_extractor_utils.ts b/public/pages/workflow_detail/utils/data_extractor_utils.ts new file mode 100644 index 00000000..f4fd01bb --- /dev/null +++ b/public/pages/workflow_detail/utils/data_extractor_utils.ts @@ -0,0 +1,108 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ReactFlowComponent, + COMPONENT_CLASS, + componentDataToFormik, + ModelFormValue, + MODEL_CATEGORY, + WorkspaceFormValues, + Workflow, + WORKFLOW_RESOURCE_TYPE, + WorkflowResource, +} from '../../../../common'; +import { getIngestNodesAndEdges } from './workflow_to_template_utils'; + +/** + * Collection of utility fns to extract + * data fields from a Workflow + */ + +export function getIndexName(workflow: Workflow): string | undefined { + if (workflow?.ui_metadata?.workspace_flow) { + const indexerComponent = getIndexerComponent(workflow); + if (indexerComponent) { + const { indexName } = componentDataToFormik(indexerComponent.data) as { + indexName: string; + }; + return indexName; + } + } +} + +export function getSemanticSearchValues( + workflow: Workflow +): { modelId: string; inputField: string; vectorField: string } { + const modelId = getModelId(workflow) as string; + const transformerComponent = getTransformerComponent( + workflow + ) as ReactFlowComponent; + const { inputField, vectorField } = componentDataToFormik( + transformerComponent.data + ) as { inputField: string; vectorField: string }; + return { modelId, inputField, vectorField }; +} + +function getFormValues(workflow: Workflow): WorkspaceFormValues | undefined { + if (workflow?.ui_metadata?.workspace_flow) { + const formValues = {} as WorkspaceFormValues; + workflow.ui_metadata.workspace_flow.nodes.forEach((node) => { + formValues[node.id] = componentDataToFormik(node.data); + }); + return formValues; + } +} + +function getModelId(workflow: Workflow): string | undefined { + if (workflow?.ui_metadata?.workspace_flow) { + const transformerComponent = getTransformerComponent(workflow); + if (transformerComponent) { + const { model } = componentDataToFormik(transformerComponent.data) as { + model: ModelFormValue; + inputField: string; + vectorField: string; + }; + + // if it's a pretrained model, we created a new model ID, parse from resources + if (model.category === MODEL_CATEGORY.PRETRAINED) { + const modelResource = workflow.resourcesCreated?.find( + (resource) => resource.type === WORKFLOW_RESOURCE_TYPE.MODEL_ID + ) as WorkflowResource; + return modelResource.id; + } else { + return model.id; + } + } + } +} + +function getTransformerComponent( + workflow: Workflow +): ReactFlowComponent | undefined { + if (workflow?.ui_metadata?.workspace_flow) { + const { ingestNodes } = getIngestNodesAndEdges( + workflow?.ui_metadata?.workspace_flow?.nodes, + workflow?.ui_metadata?.workspace_flow?.edges + ); + return ingestNodes.find((ingestNode) => + ingestNode.data.baseClasses?.includes(COMPONENT_CLASS.ML_TRANSFORMER) + ); + } +} + +function getIndexerComponent( + workflow: Workflow +): ReactFlowComponent | undefined { + if (workflow?.ui_metadata?.workspace_flow) { + const { ingestNodes } = getIngestNodesAndEdges( + workflow?.ui_metadata?.workspace_flow?.nodes, + workflow?.ui_metadata?.workspace_flow?.edges + ); + return ingestNodes.find((ingestNode) => + ingestNode.data.baseClasses?.includes(COMPONENT_CLASS.INDEXER) + ); + } +} diff --git a/public/pages/workflow_detail/utils/index.ts b/public/pages/workflow_detail/utils/index.ts index 91b6465b..94f7e2e4 100644 --- a/public/pages/workflow_detail/utils/index.ts +++ b/public/pages/workflow_detail/utils/index.ts @@ -5,3 +5,4 @@ export * from './utils'; export * from './workflow_to_template_utils'; +export * from './data_extractor_utils'; diff --git a/public/pages/workflow_detail/utils/workflow_to_template_utils.ts b/public/pages/workflow_detail/utils/workflow_to_template_utils.ts index 399129d8..4db87cc4 100644 --- a/public/pages/workflow_detail/utils/workflow_to_template_utils.ts +++ b/public/pages/workflow_detail/utils/workflow_to_template_utils.ts @@ -51,7 +51,7 @@ export function toTemplateFlows( }; } -function getIngestNodesAndEdges( +export function getIngestNodesAndEdges( allNodes: ReactFlowComponent[], allEdges: ReactFlowEdge[] ): { ingestNodes: ReactFlowComponent[]; ingestEdges: ReactFlowEdge[] } { diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index 8c397b22..8406e34d 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -28,6 +28,7 @@ import { Resources } from './resources'; // styling import './workflow-detail-styles.scss'; +import { Prototype } from './prototype'; export interface WorkflowDetailRouterProps { workflowId: string; @@ -42,6 +43,10 @@ enum WORKFLOW_DETAILS_TAB { // This gives clarity into what has been done on the cluster on behalf // of the frontend provisioning workflows. RESOURCES = 'resources', + // TODO: temporarily adding a prototype tab until UX is finalized. + // This allows simple UI for executing ingest and search against + // created workflow resources + PROTOTYPE = 'prototype', } const ACTIVE_TAB_PARAM = 'tab'; @@ -142,6 +147,15 @@ export function WorkflowDetail(props: WorkflowDetailProps) { replaceActiveTab(WORKFLOW_DETAILS_TAB.RESOURCES, props); }, }, + { + id: WORKFLOW_DETAILS_TAB.PROTOTYPE, + label: 'Prototype', + isSelected: selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE, + onClick: () => { + setSelectedTabId(WORKFLOW_DETAILS_TAB.PROTOTYPE); + replaceActiveTab(WORKFLOW_DETAILS_TAB.PROTOTYPE, props); + }, + }, ]; return ( @@ -164,6 +178,9 @@ export function WorkflowDetail(props: WorkflowDetailProps) { {selectedTabId === WORKFLOW_DETAILS_TAB.RESOURCES && ( )} + {selectedTabId === WORKFLOW_DETAILS_TAB.PROTOTYPE && ( + + )} diff --git a/public/route_service.ts b/public/route_service.ts index 1ff6290e..ce57599a 100644 --- a/public/route_service.ts +++ b/public/route_service.ts @@ -17,6 +17,8 @@ import { DEPROVISION_WORKFLOW_NODE_API_PATH, UPDATE_WORKFLOW_NODE_API_PATH, WorkflowTemplate, + SEARCH_INDEX_NODE_API_PATH, + INGEST_NODE_API_PATH, } from '../common'; /** @@ -40,6 +42,8 @@ export interface RouteService { deleteWorkflow: (workflowId: string) => Promise; getWorkflowPresets: () => Promise; catIndices: (pattern: string) => Promise; + searchIndex: (index: string, body: {}) => Promise; + ingest: (index: string, doc: {}) => Promise; searchModels: (body: {}) => Promise; } @@ -157,6 +161,32 @@ export function configureRoutes(core: CoreStart): RouteService { return e as HttpFetchError; } }, + searchIndex: async (index: string, body: {}) => { + try { + const response = await core.http.post<{ respString: string }>( + `${SEARCH_INDEX_NODE_API_PATH}/${index}`, + { + body: JSON.stringify(body), + } + ); + return response; + } catch (e: any) { + return e as HttpFetchError; + } + }, + ingest: async (index: string, doc: {}) => { + try { + const response = await core.http.put<{ respString: string }>( + `${INGEST_NODE_API_PATH}/${index}`, + { + body: JSON.stringify(doc), + } + ); + return response; + } catch (e: any) { + return e as HttpFetchError; + } + }, searchModels: async (body: {}) => { try { const response = await core.http.post<{ respString: string }>( diff --git a/public/store/reducers/opensearch_reducer.ts b/public/store/reducers/opensearch_reducer.ts index d9a68e10..10735776 100644 --- a/public/store/reducers/opensearch_reducer.ts +++ b/public/store/reducers/opensearch_reducer.ts @@ -16,6 +16,8 @@ const initialState = { const OPENSEARCH_PREFIX = 'opensearch'; const CAT_INDICES_ACTION = `${OPENSEARCH_PREFIX}/catIndices`; +const SEARCH_INDEX_ACTION = `${OPENSEARCH_PREFIX}/search`; +const INGEST_ACTION = `${OPENSEARCH_PREFIX}/ingest`; export const catIndices = createAsyncThunk( CAT_INDICES_ACTION, @@ -35,6 +37,40 @@ export const catIndices = createAsyncThunk( } ); +export const searchIndex = createAsyncThunk( + SEARCH_INDEX_ACTION, + async (searchIndexInfo: { index: string; body: {} }, { rejectWithValue }) => { + const { index, body } = searchIndexInfo; + const response: any | HttpFetchError = await getRouteService().searchIndex( + index, + body + ); + if (response instanceof HttpFetchError) { + return rejectWithValue('Error searching index: ' + response.body.message); + } else { + return response; + } + } +); + +export const ingest = createAsyncThunk( + INGEST_ACTION, + async (ingestInfo: { index: string; doc: {} }, { rejectWithValue }) => { + const { index, doc } = ingestInfo; + const response: any | HttpFetchError = await getRouteService().ingest( + index, + doc + ); + if (response instanceof HttpFetchError) { + return rejectWithValue( + 'Error ingesting document: ' + response.body.message + ); + } else { + return response; + } + } +); + const opensearchSlice = createSlice({ name: OPENSEARCH_PREFIX, initialState, @@ -45,6 +81,10 @@ const opensearchSlice = createSlice({ state.loading = true; state.errorMessage = ''; }) + .addCase(searchIndex.pending, (state, action) => { + state.loading = true; + state.errorMessage = ''; + }) .addCase(catIndices.fulfilled, (state, action) => { const indicesMap = new Map(); action.payload.forEach((index: Index) => { @@ -54,9 +94,17 @@ const opensearchSlice = createSlice({ state.loading = false; state.errorMessage = ''; }) + .addCase(searchIndex.fulfilled, (state, action) => { + state.loading = false; + state.errorMessage = ''; + }) .addCase(catIndices.rejected, (state, action) => { state.errorMessage = action.payload as string; state.loading = false; + }) + .addCase(searchIndex.rejected, (state, action) => { + state.errorMessage = action.payload as string; + state.loading = false; }); }, }); diff --git a/server/routes/opensearch_routes_service.ts b/server/routes/opensearch_routes_service.ts index d162e3ce..aae7577a 100644 --- a/server/routes/opensearch_routes_service.ts +++ b/server/routes/opensearch_routes_service.ts @@ -4,7 +4,6 @@ */ import { schema } from '@osd/config-schema'; -import { SearchRequest } from '@opensearch-project/opensearch/api/types'; import { IRouter, IOpenSearchDashboardsResponse, @@ -12,7 +11,12 @@ import { OpenSearchDashboardsRequest, OpenSearchDashboardsResponseFactory, } from '../../../../src/core/server'; -import { CAT_INDICES_NODE_API_PATH, Index } from '../../common'; +import { + CAT_INDICES_NODE_API_PATH, + INGEST_NODE_API_PATH, + Index, + SEARCH_INDEX_NODE_API_PATH, +} from '../../common'; import { generateCustomError } from './helpers'; /** @@ -34,6 +38,30 @@ export function registerOpenSearchRoutes( }, opensearchRoutesService.catIndices ); + router.post( + { + path: `${SEARCH_INDEX_NODE_API_PATH}/{index}`, + validate: { + params: schema.object({ + index: schema.string(), + }), + body: schema.any(), + }, + }, + opensearchRoutesService.searchIndex + ); + router.put( + { + path: `${INGEST_NODE_API_PATH}/{index}`, + validate: { + params: schema.object({ + index: schema.string(), + }), + body: schema.any(), + }, + }, + opensearchRoutesService.ingest + ); } export class OpenSearchRoutesService { @@ -69,4 +97,46 @@ export class OpenSearchRoutesService { return generateCustomError(res, err); } }; + + searchIndex = async ( + context: RequestHandlerContext, + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory + ): Promise> => { + const { index } = req.params as { index: string }; + const body = req.body; + try { + const response = await this.client + .asScoped(req) + .callAsCurrentUser('search', { + index, + body, + }); + + return res.ok({ body: response }); + } catch (err: any) { + return generateCustomError(res, err); + } + }; + + ingest = async ( + context: RequestHandlerContext, + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory + ): Promise> => { + const { index } = req.params as { index: string }; + const doc = req.body; + try { + const response = await this.client + .asScoped(req) + .callAsCurrentUser('index', { + index, + body: doc, + }); + + return res.ok({ body: response }); + } catch (err: any) { + return generateCustomError(res, err); + } + }; }