Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate MDS client to ISM #711

Draft
wants to merge 22 commits into
base: 2.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions opensearch_dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@
"id": "indexManagementDashboards",
"version": "2.9.0.0",
"opensearchDashboardsVersion": "2.9.0",
"configPath": [
"opensearch_index_management"
],
"requiredPlugins": [
"navigation",
"opensearchDashboardsReact"
],
"optionalPlugins": [
"managementOverview"
],
"configPath": ["opensearch_index_management"],
"requiredPlugins": ["navigation", "opensearchDashboardsReact", "dataSource"],
"optionalPlugins": ["managementOverview"],
"server": true,
"ui": true
}
}
22 changes: 22 additions & 0 deletions public/containers/DataSourceSelector/DataSourceSelector.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.ism-data-source-selector-section {
padding-left: $ouiSizeS;
position: relative;
padding-right: $ouiSizeS;
&::after {
right: 0;
width: 1px;
bottom: -1 * $ouiSizeS;
height: $ouiSizeXL;
content: '';
display: inline-block;
position: absolute;
border-right: $ouiBorderThin;
}
.content {
display: flex;
align-items: center;
}
.data-source-label {
margin-right: $ouiSizeS;
}
}
63 changes: 63 additions & 0 deletions public/containers/DataSourceSelector/DataSourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { EuiComboBox } from "@elastic/eui";
import React, { useContext, useEffect, useState } from "react";
import { CoreServicesContext } from "../../components/core_services";
import "./DataSourceSelector.scss";
import { getDataSource, setDataSource } from "./utils";

interface IDataSourceSelectorProps {}

export const DataSourceSelector = (props: IDataSourceSelectorProps) => {
const coreStart = useContext(CoreServicesContext);
const [selected] = useState<string>(getDataSource());
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
(async () => {
const findResp = await coreStart?.savedObjects.client.find<{ title: string }>({
type: "data-source",
fields: ["id", "description", "title"],
perPage: 10000,
});
if (findResp && findResp.savedObjects) {
setOptions(
findResp.savedObjects?.map((item) => ({
label: item.attributes.title,
value: item.id,
}))
);
}
})();
}, []);
return (
<div className="ism-data-source-selector-section">
<div className="content">
<EuiComboBox
singleSelection={{ asPlainText: true }}
compressed
fullWidth={false}
placeholder="Select a data source"
prepend="DataSource"
style={{ width: 300 }}
options={options}
selectedOptions={
selected && options.find((item) => item.value === selected)
? [options.find((item) => item.value === selected) as typeof options[number]]
: []
}
onChange={async (item) => {
const result = await coreStart?.overlays.openConfirm(
"Switch data source may lead to failure in your current operation and requires a reload on your browser.",
{
title: "Are you sure to continue?",
confirmButtonText: "Yes, I want to switch data source",
}
);
if (result) {
setDataSource(item?.[0]?.value || "");
window.location.reload();
}
}}
></EuiComboBox>
</div>
</div>
);
};
17 changes: 17 additions & 0 deletions public/containers/DataSourceSelector/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import ReactDOM from "react-dom";
import React from "react";
import { DataSourceSelector } from "./DataSourceSelector";
import { CoreStart } from "opensearch-dashboards/public";
import { CoreServicesContext } from "../../components/core_services";

export const mountDataSourceSelector = (props: { element: HTMLElement; coreStart: CoreStart }) => {
ReactDOM.render(
<CoreServicesContext.Provider value={props.coreStart}>
<DataSourceSelector />
</CoreServicesContext.Provider>,
props.element
);
return () => {
ReactDOM.unmountComponentAtNode(props.element);
};
};
6 changes: 6 additions & 0 deletions public/containers/DataSourceSelector/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const dataSourceSessionKey = "DATA_SOURCE_IN_ISM";
export const setDataSource = (dataSourceId: string) => {
sessionStorage.setItem(dataSourceSessionKey, dataSourceId);
};

export const getDataSource = (): string => sessionStorage.getItem(dataSourceSessionKey) || "";
14 changes: 13 additions & 1 deletion public/index_management_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,19 @@ import {
import { DarkModeContext } from "./components/DarkMode";
import Main from "./pages/Main";
import { CoreServicesContext } from "./components/core_services";
import { PLUGIN_NAME } from "./utils/constants";
import { MDSIntercept } from "./utils/MDSIntercept";
import { getDataSource } from "./containers/DataSourceSelector/utils";
import "./app.scss";

export function renderApp(coreStart: CoreStart, params: AppMountParameters, landingPage: string) {
const http = coreStart.http;
const mdsInterceptInstance = new MDSIntercept({
pluginId: PLUGIN_NAME,
http,
getDataSourceId: () => getDataSource(),
});
mdsInterceptInstance.start();

const indexService = new IndexService(http);
const managedIndexService = new ManagedIndexService(http);
Expand Down Expand Up @@ -63,5 +72,8 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters, land
</Router>,
params.element
);
return () => ReactDOM.unmountComponentAtNode(params.element);
return () => {
mdsInterceptInstance.destroy();
ReactDOM.unmountComponentAtNode(params.element);
};
}
15 changes: 13 additions & 2 deletions public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
PluginInitializerContext,
} from "../../../src/core/public";
import { actionRepoSingleton } from "./pages/VisualCreatePolicy/utils/helpers";
import { ROUTES } from "./utils/constants";
import { PLUGIN_NAME, ROUTES } from "./utils/constants";
import { JobHandlerRegister } from "./JobHandler";
import { mountDataSourceSelector } from "./containers/DataSourceSelector";
import { ManagementOverViewPluginSetup } from "../../../src/plugins/management_overview/public";

interface IndexManagementSetupDeps {
Expand Down Expand Up @@ -51,7 +52,7 @@ export class IndexManagementPlugin implements Plugin<IndexManagementPluginSetup,
}

core.application.register({
id: "opensearch_index_management_dashboards",
id: PLUGIN_NAME,
title: "Index Management",
order: 9010,
category: DEFAULT_APP_CATEGORIES.management,
Expand Down Expand Up @@ -82,6 +83,16 @@ export class IndexManagementPlugin implements Plugin<IndexManagementPluginSetup,
}

public start(core: CoreStart): IndexManagementPluginStart {
core.chrome.navControls.registerRight({
order: -100,
mount: (element) => {
const destroy = mountDataSourceSelector({
element,
coreStart: core,
});
return destroy;
},
});
Object.freeze(actionRepoSingleton.repository);
// After this point, calling registerAction will throw error because "Object is not extensible"
return {};
Expand Down
26 changes: 26 additions & 0 deletions public/utils/MDSIntercept.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { set } from "lodash";
import { CoreStart, HttpFetchOptionsWithPath } from "opensearch-dashboards/public";

export class MDSIntercept {
private pluginId: string;
private http: CoreStart["http"];
private getDataSourceId: () => string;
private interceptDestroyHandler: (() => void) | undefined;
constructor(config: { pluginId: string; http: CoreStart["http"]; getDataSourceId: () => string }) {
this.pluginId = config.pluginId;
this.http = config.http;
this.getDataSourceId = config.getDataSourceId;
}
private interceptRequest(fetchOptions: HttpFetchOptionsWithPath) {
set(fetchOptions, `headers._${this.pluginId}_data_source_id_`, this.getDataSourceId());
return fetchOptions;
}
public start() {
this.interceptDestroyHandler = this.http.intercept({
request: this.interceptRequest.bind(this),
});
}
public destroy() {
this.interceptDestroyHandler?.();
}
}
111 changes: 111 additions & 0 deletions server/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { get } from "lodash";
import {
IContextProvider,
ILegacyScopedClusterClient,
OpenSearchDashboardsRequest,
RequestHandler,
RequestHandlerContext,
} from "opensearch-dashboards/server";
import { IRequestHandlerContentWithDataSource, IGetClientProps, DashboardRequestEnhancedWithContext } from "./interface";

export const getClientSupportMDS = (props: IGetClientProps) => {
const originalAsScoped = props.client.asScoped;
const handler: IContextProvider<RequestHandler<unknown, unknown, unknown>, "core"> = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what're the three unknowns?

context: RequestHandlerContext,
request: OpenSearchDashboardsRequest
) => {
(request as DashboardRequestEnhancedWithContext)[`${props.pluginId}_context`] = context as IRequestHandlerContentWithDataSource;
return {} as any;
};

/**
* asScoped can not get the request context,
* add _context to request
*/
props.core.http.registerRouteHandlerContext(`${props.pluginId}_MDS_CTX_SUPPORT` as "core", handler);

/**
* it is not a good practice to rewrite the method like this
* but JS does not provide a method to copy a class instance
*/
props.client.asScoped = function (request: DashboardRequestEnhancedWithContext): ILegacyScopedClusterClient {
const context = request[`${props.pluginId}_context`];

/**
* If the context can not be found
* reject the request and add a log
*/
if (!context) {
const errorMessage = "There is some error between dashboards and your remote data source, please retry again.";
props.logger.error(errorMessage);
return {
callAsCurrentUser: () => Promise.reject(errorMessage),
callAsInternalUser: () => Promise.reject(errorMessage),
};
}

const dataSourceId = props.getDataSourceId?.(context, request);
/**
* If no dataSourceId provided
* use the original client
*/
if (!dataSourceId) {
props.logger.debug("No dataSourceId, using original client");
return originalAsScoped.call(props.client, request);
}

const callApi: ILegacyScopedClusterClient["callAsCurrentUser"] = async (...args) => {
const [endpoint, clientParams, options] = args;
return new Promise(async (resolve, reject) => {
props.logger.debug(`Call api using the data source: ${dataSourceId}`);
try {
const dataSourceClient = await context.dataSource.opensearch.getClient(dataSourceId);

/**
* extend client if needed
**/
Object.assign(dataSourceClient, { ...props.onExtendClient?.(dataSourceClient) });

/**
* Call the endpoint by providing client
* The logic is much the same as what callAPI does in Dashboards
*/
const clientPath = endpoint.split(".");
const api: any = get(dataSourceClient, clientPath);
let apiContext = clientPath.length === 1 ? dataSourceClient : get(dataSourceClient, clientPath.slice(0, -1));
const request = api.call(apiContext, clientParams);

/**
* In case the request is aborted
*/
if (options?.signal) {
options.signal.addEventListener("abort", () => {
request.abort();
reject(new Error("Request was aborted"));
});
}
const result = await request;
resolve(result.body || result);
} catch (e: any) {
/**
* TODO
* ask dashboard team to add original error to DataSourceError
* so that we can make the client behave exactly the same as legacy client
*/
reject(e);
}
});
};

/**
* Return a legacy-client-like client
* so that the callers no need to change their code.
*/
const client: ILegacyScopedClusterClient = {
callAsCurrentUser: callApi,
callAsInternalUser: callApi,
};
return client;
};
return props.client;
};
41 changes: 41 additions & 0 deletions server/client/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { OpenSearchDashboardsClient } from "@opensearch-project/opensearch/api/opensearch_dashboards";
import {
CoreSetup,
ILegacyCustomClusterClient,
LegacyCallAPIOptions,
Logger,
OpenSearchDashboardsRequest,
RequestHandlerContext,
} from "opensearch-dashboards/server";

export interface IRequestHandlerContentWithDataSource extends RequestHandlerContext {
dataSource: {
opensearch: {
getClient: (dataSourceId: string) => OpenSearchDashboardsClient;
legacy: {
getClient: (
dataSourceId: string
) => {
callAPI: (endpoint: string, clientParams?: Record<string, any>, options?: LegacyCallAPIOptions) => Promise<unknown>;
};
};
};
};
}

export interface IGetClientProps {
core: CoreSetup;
/**
* We will rewrite the asScoped method of your client
* It would be better that create a new client before you pass in one
*/
client: ILegacyCustomClusterClient;
onExtendClient?: (client: OpenSearchDashboardsClient) => Record<string, any> | undefined;
getDataSourceId?: (context: RequestHandlerContext, request: OpenSearchDashboardsRequest) => string | undefined;
pluginId: string;
logger: Logger;
}

export type DashboardRequestEnhancedWithContext = OpenSearchDashboardsRequest & {
[contextKey: string]: IRequestHandlerContentWithDataSource;
};
Loading