diff --git a/docs/how-to/create-a-router/configure-ensembler.md b/docs/how-to/create-a-router/configure-ensembler.md index 794ce9b2a..2d366caf7 100644 --- a/docs/how-to/create-a-router/configure-ensembler.md +++ b/docs/how-to/create-a-router/configure-ensembler.md @@ -15,7 +15,11 @@ It is not possible to select as the final response a route that has traffic rule ## Standard Ensembler -For experiment engines configured to work with standard ensemblers (i.e., Standard Experiment Engines with experiment selection enabled), the router will return a response from one of the routes based on the configured mapping between routes and experiment treatments. At run time, the treatment returned by the engine will be used to select the corresponding route’s response. +There are two types of Standard Ensemblers available, one that is available with Standard Experiment Engines that +have experiment selection enabled, and the other with Custom Experiment Engines. + +### Standard Experiment Engines +For routers configured with Standard Experiment Engines that have experiment selection enabled, the router will return a response from one of the routes based on the configured mapping between routes and experiment treatments. At run time, the treatment returned by the engine will be used to select the corresponding route’s response. In addition, a fallback route may be configured whose results will be used at runtime when the call to the experiment engine fails or if a route mapping for the treatment generated by the experiment engine does not exist. @@ -25,6 +29,16 @@ In addition, a fallback route may be configured whose results will be used at ru It is not possible to select as the fallback response a route that has traffic rules associated to it. {% endhint %} +### Custom Experiment Engines +For routers configured with Custom Experiment Engines, the router will return a response from one of the routes +corresponding to the route name that is found within the treatment configuration returned by the experiment +engine. At run time, the router will attempt to access the route name within the treatment configuration received via a +user-configured path. + +A fallback route also has to be configured in order to capture cases whereby the route name found in the treatment +configuration does not correspond to any of the routes configured, or when the user-configured path is invalid with +respect to the treatment configuration received. + ## Docker Turing will deploy the specified image as a post-processor and will send in the request payload the following, for diff --git a/engines/experiment/docs/developer_guide.md b/engines/experiment/docs/developer_guide.md index 5fb314179..c62b9ab44 100644 --- a/engines/experiment/docs/developer_guide.md +++ b/engines/experiment/docs/developer_guide.md @@ -79,6 +79,30 @@ type IExperimentConfig = { [key: string]: IJsonValue }; }) => React.ReactElement ``` +In addition, custom experiment engines should also provide the following UI components to support the edit form and view +configuration components for Standard Ensemblers (see +[this](../../../docs/how-to/create-a-router/configure-ensembler.md) for more details). The `routeNamePath` field +should be configured to allow the Turing Router to access the route name from a treatment configuration received +from the experiment engine. + +```javascript +// EditStandardEnsemblerConfig component signature +({ + projectId: int, + routes: Array, + routeNamePath: string, + onChangeHandler: (React.ChangeEvent) => void, + errors: yup.ValidationError, +}) => React.ReactElement + +// StandardEnsemblerConfigDetails component signature. +({ + projectId: int, + routes: Array, + routeNamePath: string +}) => React.ReactElement +``` + ### Experiment Runner Experiment runners are required to implement the methods in the `ExperimentRunner` interface. This interface contains a single method to retrieve the treatment for a given request. diff --git a/ui/src/components/experiments/ExperimentEngineLoaderComponent.js b/ui/src/components/remote_component/ExperimentEngineComponentLoader.js similarity index 68% rename from ui/src/components/experiments/ExperimentEngineLoaderComponent.js rename to ui/src/components/remote_component/ExperimentEngineComponentLoader.js index 8e042845e..bf98690a7 100644 --- a/ui/src/components/experiments/ExperimentEngineLoaderComponent.js +++ b/ui/src/components/remote_component/ExperimentEngineComponentLoader.js @@ -17,9 +17,10 @@ const LoadDynamicScript = ({ url, setReady, setFailed }) => { }; // Dynamic Script Loading component wrapper -export const ExperimentEngineLoaderComponent = ({ +export const ExperimentEngineComponentLoader = ({ FallbackView, - experimentEngine, + remoteUi, + componentName, children, }) => { const [urlReady, setUrlReady] = useState(false); @@ -28,30 +29,30 @@ export const ExperimentEngineLoaderComponent = ({ const [configFailed, setConfigFailed] = useState(false); return urlFailed ? ( - + ) : configFailed ? ( - + ) : !urlReady || !configReady ? ( <> - {!!experimentEngine.url && !urlReady && ( + {!!remoteUi.url && !urlReady && ( )} - {!!experimentEngine.config && !configReady && ( + {!!remoteUi.config && !configReady && ( )} - + ) : ( children ); }; -export default ExperimentEngineLoaderComponent; +export default ExperimentEngineComponentLoader; diff --git a/ui/src/experiment/ExperimentsRouter.js b/ui/src/experiment/ExperimentsRouter.js index bc0a9034d..1d3187d84 100644 --- a/ui/src/experiment/ExperimentsRouter.js +++ b/ui/src/experiment/ExperimentsRouter.js @@ -3,7 +3,7 @@ import { EuiPageTemplate, } from "@elastic/eui"; import { RemoteComponent } from "../components/remote_component/RemoteComponent"; -import { ExperimentEngineLoaderComponent } from "../components/experiments/ExperimentEngineLoaderComponent"; +import ExperimentEngineComponentLoader from "../components/remote_component/ExperimentEngineComponentLoader"; import { useConfig } from "../config"; @@ -32,16 +32,18 @@ const RemoteRouter = ({ projectId }) => { return ( }> - + remoteUi={defaultExperimentEngine} + componentName="Experiment Engine" + > } projectId={projectId} /> - + ); }; diff --git a/ui/src/router/components/configuration/components/EnsemblerConfigSection.js b/ui/src/router/components/configuration/components/EnsemblerConfigSection.js index 9d165bdf1..4e17e7581 100644 --- a/ui/src/router/components/configuration/components/EnsemblerConfigSection.js +++ b/ui/src/router/components/configuration/components/EnsemblerConfigSection.js @@ -1,16 +1,15 @@ import React, { Fragment } from "react"; import { DockerConfigViewGroup } from "./docker_config_section/DockerConfigViewGroup"; -import { FallbackRouteConfigSection } from "./standard_config_section/FallbackRouteConfigSection"; -import { TreatmentMappingConfigSection } from "./standard_config_section/TreatmentMappingConfigSection"; import { ExperimentEngineContextProvider } from "../../../../providers/experiments/ExperimentEngineContextProvider"; import { NopConfigViewGroup } from "./nop_config_section/NopConfigViewGroup"; import { PyFuncConfigViewGroup } from "./pyfunc_config_section/PyFuncConfigViewGroup"; +import { StandardConfigViewGroup } from "./standard_config_section/StandardConfigViewGroup" import { EnsemblersContextContextProvider } from "../../../../providers/ensemblers/context"; -import { EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; export const EnsemblerConfigSection = ({ projectId, config: { + routes, ensembler, experiment_engine: { type, config: experimentConfig }, }, @@ -38,22 +37,13 @@ export const EnsemblerConfigSection = ({ )} {ensembler.type === "standard" && ( - - - - - - - - + )} diff --git a/ui/src/router/components/configuration/components/ExperimentConfigSection.js b/ui/src/router/components/configuration/components/ExperimentConfigSection.js index de7ee7464..1858ba870 100644 --- a/ui/src/router/components/configuration/components/ExperimentConfigSection.js +++ b/ui/src/router/components/configuration/components/ExperimentConfigSection.js @@ -3,7 +3,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from "@elastic/eui"; import { ConfigSectionPanel } from "../../../../components/config_section"; import { RemoteComponent } from "../../../../components/remote_component/RemoteComponent"; -import { ExperimentEngineLoaderComponent } from "../../../../components/experiments/ExperimentEngineLoaderComponent"; +import ExperimentEngineComponentLoader from "../../../../components/remote_component/ExperimentEngineComponentLoader"; import ExperimentEngineContext from "../../../../providers/experiments/context"; import { StandardExperimentConfigGroup } from "./experiment_config_section/StandardExperimentConfigGroup"; @@ -29,9 +29,11 @@ const CustomExperimentConfigView = ({ projectId, remoteUi, config }) => { return ( }> - + remoteUi={remoteUi} + componentName="Experiment Engine" + > { projectId={projectId} config={config} /> - + ); }; diff --git a/ui/src/router/components/configuration/components/standard_config_section/StandardConfigViewGroup.js b/ui/src/router/components/configuration/components/standard_config_section/StandardConfigViewGroup.js new file mode 100644 index 000000000..3aa23cd75 --- /dev/null +++ b/ui/src/router/components/configuration/components/standard_config_section/StandardConfigViewGroup.js @@ -0,0 +1,95 @@ +import React, { useContext } from "react"; + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from "@elastic/eui"; +import { TreatmentMappingConfigSection } from "./TreatmentMappingConfigSection"; +import { FallbackRouteConfigSection } from "./FallbackRouteConfigSection"; +import ExperimentEngineContext from "../../../../../providers/experiments/context"; +import { Panel } from "../../../form/components/Panel"; +import ExperimentEngineComponentLoader from "../../../../../components/remote_component/ExperimentEngineComponentLoader"; +import { RemoteComponent } from "../../../../../components/remote_component/RemoteComponent"; + +const FallbackView = ({ text }) => ( + + + + {text} + + +); + +const StandardEnsemblerWithCustomExperimentEngineConfigView = ({ + remoteUi, + projectId, + routes, + routeNamePath +}) => { + // Load component from remote host + return ( + }> + + } + projectId={projectId} + routes={routes} + routeNamePath={routeNamePath} + /> + + + ); +}; + +export const StandardConfigViewGroup = ({ + projectId, + routes, + standardConfig, + experimentConfig, + type +}) => { + const { getEngineProperties, isLoaded } = useContext(ExperimentEngineContext); + + const engineProps = getEngineProperties(type); + + return ( + !!standardConfig && ( + + {engineProps?.type === "standard" ? ( + + + + ) : isLoaded ? ( + + + + ) : ( + + + + )} + + + + + + ) + ); +}; \ No newline at end of file diff --git a/ui/src/router/components/form/components/ensembler_config/standard_ensembler/StandardEnsemblerFormGroup.js b/ui/src/router/components/form/components/ensembler_config/standard_ensembler/StandardEnsemblerFormGroup.js index 4118c84c4..3be98dd4c 100644 --- a/ui/src/router/components/form/components/ensembler_config/standard_ensembler/StandardEnsemblerFormGroup.js +++ b/ui/src/router/components/form/components/ensembler_config/standard_ensembler/StandardEnsemblerFormGroup.js @@ -1,5 +1,5 @@ -import React, { useEffect } from "react"; -import { EuiFlexItem, EuiText } from "@elastic/eui"; +import React, { useContext, useEffect } from "react"; +import { EuiFlexItem, EuiSpacer, EuiText } from "@elastic/eui"; import { get } from "../../../../../../components/form/utils"; import { StandardEnsembler } from "../../../../../../services/ensembler"; @@ -7,9 +7,55 @@ import { StandardEnsemblerPanel } from "./StandardEnsemblerPanel"; import { RouteSelectionPanel } from "../RouteSelectionPanel"; import { FormLabelWithToolTip } from "../../../../../../components/form/label_with_tooltip/FormLabelWithToolTip"; import { useOnChangeHandler } from "../../../../../../components/form/hooks/useOnChangeHandler"; +import ExperimentEngineContext from "../../../../../../providers/experiments/context"; +import { Panel } from "../../Panel"; +import { RemoteComponent } from "../../../../../../components/remote_component/RemoteComponent"; +import ExperimentEngineComponentLoader from "../../../../../../components/remote_component/ExperimentEngineComponentLoader"; + +const FallbackView = ({ text }) => ( + + + + {text} + + +); + +const StandardEnsemblerWithCustomExperimentEnginePanel = ({ + remoteUi, + projectId, + routes, + routeNamePath, + onChange, + errors, +}) => { + // Load component from remote host + return ( + }> + + } + projectId={projectId} + routes={routes} + routeNamePath={routeNamePath} + onChange={onChange} + errors={errors} + /> + + + ); +}; export const StandardEnsemblerFormGroup = ({ - experimentConfig = {}, + projectId, + experimentEngine = {}, routes, rules, default_traffic_rule, @@ -19,10 +65,14 @@ export const StandardEnsemblerFormGroup = ({ }) => { const { onChange } = useOnChangeHandler(onChangeHandler); + const { getEngineProperties, isLoaded } = useContext(ExperimentEngineContext); + useEffect(() => { !standardConfig && onChangeHandler(StandardEnsembler.newConfig()); }, [standardConfig, onChangeHandler]); + const engineProps = getEngineProperties(experimentEngine.type); + const routeOptions = [ { value: "nop", @@ -47,15 +97,33 @@ export const StandardEnsemblerFormGroup = ({ return ( !!standardConfig && ( <> - - - + {engineProps?.type === "standard" ? ( + + + + ) : isLoaded ? ( + + + + ) : ( + + + + )} + { } // Ensembler must be selected when there is an experiment engine const ensemblerOptions = typeOptions.filter((o) => o.value !== "nop"); - if ( - !engineProps?.standard_experiment_manager_config - ?.experiment_selection_enabled - ) { + if (engineProps?.standard_experiment_manager_config?.experiment_selection_enabled === false) { // Standard Ensembler is not available when experiment selection is disabled return ensemblerOptions.filter((o) => o.value !== "standard"); } diff --git a/ui/src/router/components/form/components/experiment_config/ExperimentConfigPanel.js b/ui/src/router/components/form/components/experiment_config/ExperimentConfigPanel.js index d44025fdb..af7ff7083 100644 --- a/ui/src/router/components/form/components/experiment_config/ExperimentConfigPanel.js +++ b/ui/src/router/components/form/components/experiment_config/ExperimentConfigPanel.js @@ -3,7 +3,7 @@ import { EuiFlexItem, EuiSpacer } from "@elastic/eui"; import { RemoteComponent } from "../../../../../components/remote_component/RemoteComponent"; import ExperimentEngineContext from "../../../../../providers/experiments/context"; -import { ExperimentEngineLoaderComponent } from "../../../../../components/experiments/ExperimentEngineLoaderComponent"; +import ExperimentEngineComponentLoader from "../../../../../components/remote_component/ExperimentEngineComponentLoader"; import { Panel } from "../Panel"; import { StandardExperimentConfigGroup } from "./StandardExperimentConfigGroup"; @@ -28,9 +28,11 @@ const CustomExperimentEngineConfigGroup = ({ return ( }> - + remoteUi={remoteUi} + componentName="Experiment Engine" + > - + ); }; diff --git a/ui/src/router/components/form/steps/EnsemblerStep.js b/ui/src/router/components/form/steps/EnsemblerStep.js index 9e7ced29c..0e748f57c 100644 --- a/ui/src/router/components/form/steps/EnsemblerStep.js +++ b/ui/src/router/components/form/steps/EnsemblerStep.js @@ -71,7 +71,8 @@ export const EnsemblerStep = ({ projectId }) => { {ensembler.type === "standard" && (