Browse Source

Porter v2 env (#3582)

Co-authored-by: Ian Edwards <ianedwards559@gmail.com>
sdess09 2 years ago
parent
commit
ab844dbe1e

+ 18 - 6
api/server/handlers/porter_app/update_app_environment_group.go

@@ -45,18 +45,18 @@ func NewUpdateAppEnvironmentHandler(
 
 // UpdateAppEnvironmentRequest represents the accepted fields on a request to the /apps/{porter_app_name}/environment-group endpoint
 type UpdateAppEnvironmentRequest struct {
-	DeploymentTargetID string            `schema:"deployment_target_id"`
-	Variables          map[string]string `schema:"variables"`
-	Secrets            map[string]string `schema:"secrets"`
+	DeploymentTargetID string            `json:"deployment_target_id"`
+	Variables          map[string]string `json:"variables"`
+	Secrets            map[string]string `json:"secrets"`
 	// HardUpdate is used to remove any variables that are not specified in the request.  If false, the request will only update the variables specified in the request,
 	// and leave all other variables untouched.
-	HardUpdate bool `schema:"remove_missing"`
+	HardUpdate bool `json:"remove_missing"`
 }
 
 // UpdateAppEnvironmentResponse represents the fields on the response object from the /apps/{porter_app_name}/environment-group endpoint
 type UpdateAppEnvironmentResponse struct {
-	EnvGroupName    string `schema:"env_group_name"`
-	EnvGroupVersion int    `schema:"env_group_version"`
+	EnvGroupName    string `json:"env_group_name"`
+	EnvGroupVersion int    `json:"env_group_version"`
 }
 
 // ServeHTTP updates or creates the environment group for an app
@@ -81,6 +81,18 @@ func (c *UpdateAppEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
+	porterApp, err := c.Config().Repo.PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error getting porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if porterApp.ID == 0 {
+		err := telemetry.Error(ctx, span, nil, "porter app not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})
 
 	if request.DeploymentTargetID == "" {
 		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.100",
+        "@porter-dev/api-contracts": "^0.1.4",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.100",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.100.tgz",
-      "integrity": "sha512-Y17fzm6HHmClFnMEWgwr178wZBTOuF17903/2icG/u4CA9JhtVgH6QvSzYcJ/Eu0kX+7pXm6pw24bxagFIeivA==",
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.1.4.tgz",
+      "integrity": "sha512-bLsjDmIxrLwZNWrPJFe7p0MC5BPVNTzwFMn8RIS4eniRIwfNWyi/MDZGq/RaWftvqEg8/G1KlI6ixCvRjoKrSg==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.100",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.100.tgz",
-      "integrity": "sha512-Y17fzm6HHmClFnMEWgwr178wZBTOuF17903/2icG/u4CA9JhtVgH6QvSzYcJ/Eu0kX+7pXm6pw24bxagFIeivA==",
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.1.4.tgz",
+      "integrity": "sha512-bLsjDmIxrLwZNWrPJFe7p0MC5BPVNTzwFMn8RIS4eniRIwfNWyi/MDZGq/RaWftvqEg8/G1KlI6ixCvRjoKrSg==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 2 - 2
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.100",
+    "@porter-dev/api-contracts": "^0.1.4",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
@@ -147,4 +147,4 @@
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"
   }
-}
+}

+ 8 - 4
dashboard/src/lib/hooks/useAppValidation.ts

@@ -70,12 +70,16 @@ export const useAppValidation = ({
       if (!deploymentTargetID) {
         throw new Error("No deployment target selected");
       }
-
+      const variables = data.app.env
+        .filter((e) => !e.deleted)
+        .reduce((acc: Record<string, string>, curr) => {
+          acc[curr.key] = curr.value;
+          return acc;
+        }, {});
       const envVariableDeletions = removedEnvKeys(
-        data.app.env,
+        variables,
         prevRevision?.env || {}
       );
-
       const proto = clientAppToProto(data);
       const commit_sha = await match(data.source)
         .with({ type: "github" }, async (src) => {
@@ -125,7 +129,7 @@ export const useAppValidation = ({
         atob(validAppData.validate_b64_app_proto)
       );
 
-      return validatedAppProto;
+      return { validatedAppProto: validatedAppProto, env: data.app.env };
     },
     [deploymentTargetID, currentProject, currentCluster]
   );

+ 20 - 5
dashboard/src/lib/porter-apps/index.ts

@@ -70,9 +70,16 @@ export const clientAppValidator = z.object({
     readOnly: z.boolean(),
     value: z.string(),
   }),
+  envGroups: z.object({ name: z.string(), version: z.bigint() }).array().default([]),
   services: serviceValidator.array(),
   predeploy: serviceValidator.array().optional(),
-  env: z.record(z.string(), z.string()).default({}),
+  env: z.object({
+    key: z.string(),
+    value: z.string(),
+    hidden: z.boolean(),
+    locked: z.boolean(),
+    deleted: z.boolean(),
+  }).array().default([]),
   build: buildValidator,
 });
 export type ClientPorterApp = z.infer<typeof clientAppValidator>;
@@ -181,7 +188,10 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
         new PorterApp({
           name: app.name.value,
           services,
-          env: app.env,
+          envGroups: app.envGroups.map((eg) => ({
+            name: eg.name,
+            version: eg.version,
+          })),
           build: clientBuildToProto(app.build),
           ...(predeploy && {
             predeploy: serviceProto(serializeService(predeploy)),
@@ -194,7 +204,10 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
         new PorterApp({
           name: app.name.value,
           services,
-          env: app.env,
+          envGroups: app.envGroups.map((eg) => ({
+            name: eg.name,
+            version: eg.version,
+          })),
           image: {
             repository: src.image.repository,
             tag: src.image.tag,
@@ -295,7 +308,8 @@ export function clientAppFromProto(
       },
       services,
       predeploy: predeployList,
-      env: proto.env,
+      env: [],
+      envGroups: proto.envGroups.map((eg) => ({ name: eg.name, version: eg.version })),
       build: clientBuildFromProto(proto.build) ?? {
         method: "pack",
         context: "./",
@@ -326,7 +340,8 @@ export function clientAppFromProto(
     },
     services,
     predeploy,
-    env: proto.env,
+    env: [],
+    envGroups: proto.envGroups.map((eg) => ({ name: eg.name, version: eg.version })),
     build: clientBuildFromProto(proto.build) ?? {
       method: "pack",
       context: "./",

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -151,7 +151,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
   const onSubmit = handleSubmit(async (data) => {
     try {
-      const validatedAppProto = await validateApp(data, latestProto);
+      const { validatedAppProto } = await validateApp(data, latestProto);
       await api.applyApp(
         "<token>",
         {

+ 85 - 21
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -35,7 +35,7 @@ import EnvVariables from "../validate-apply/app-settings/EnvVariables";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { valueExists } from "shared/util";
 import api from "shared/api";
-import { PorterApp } from "@porter-dev/api-contracts";
+import { EnvGroup, PorterApp } from "@porter-dev/api-contracts";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
 import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import Error from "components/porter/Error";
@@ -44,6 +44,7 @@ import { useAppValidation } from "lib/hooks/useAppValidation";
 import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
 import PorterYamlModal from "./PorterYamlModal";
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -55,7 +56,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     count: number;
   }>({ detected: false, count: 0 });
   const [showGHAModal, setShowGHAModal] = React.useState(false);
-  const [userHasSeenNoPorterYamlFoundModal, setUserHasSeenNoPorterYamlFoundModal] = React.useState(false);
+  const [
+    userHasSeenNoPorterYamlFoundModal,
+    setUserHasSeenNoPorterYamlFoundModal,
+  ] = React.useState(false);
 
   const [
     validatedAppProto,
@@ -135,7 +139,12 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const build = watch("app.build");
   const image = watch("source.image");
   const services = watch("app.services");
-  const { detectedServices: servicesFromYaml, porterYamlFound, detectedName, loading: isLoadingPorterYaml } = usePorterYaml({ source: source?.type === "github" ? source : null });
+  const {
+    detectedServices: servicesFromYaml,
+    porterYamlFound,
+    detectedName,
+    loading: isLoadingPorterYaml,
+  } = usePorterYaml({ source: source?.type === "github" ? source : null });
   const deploymentTarget = useDefaultDeploymentTarget();
   const { updateAppStep } = useAppAnalytics(name.value);
   const { validateApp } = useAppValidation({
@@ -146,7 +155,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const onSubmit = handleSubmit(async (data) => {
     try {
       setDeployError("");
-      const validatedAppProto = await validateApp(data);
+      const { validatedAppProto, env } = await validateApp(data);
       setValidatedAppProto(validatedAppProto);
 
       if (source.type === "github") {
@@ -154,7 +163,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         return;
       }
 
-      await createAndApply({ app: validatedAppProto, source });
+      await createAndApply({ app: validatedAppProto, source, env });
     } catch (err) {
       if (axios.isAxiosError(err) && err.response?.data?.error) {
         setDeployError(err.response?.data?.error);
@@ -170,9 +179,11 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     async ({
       app,
       source,
+      env,
     }: {
       app: PorterApp | null;
       source: SourceOptions;
+      env: KeyValueType[];
     }) => {
       setIsDeploying(true);
       // log analytics event that we started form submission
@@ -199,10 +210,57 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }
         );
 
+         const variables = env
+          .filter((e) => !e.hidden && !e.deleted)
+          .reduce((acc: Record<string, string>, item) => {
+            acc[item.key] = item.value;
+            return acc;
+          }, {});
+        const secrets = env
+          .filter((e) => !e.deleted)
+          .reduce((acc: Record<string, string>, item) => {
+            if (item.hidden) {
+              acc[item.key] = item.value;
+            }
+            return acc;
+          }, {});
+        const envGroupResponse = await api.updateEnvironmentGroupV2(
+          "<token>",
+          {
+            deployment_target_id: deploymentTarget.deployment_target_id,
+            variables: variables,
+            secrets: secrets,
+          },
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            app_name: app.name,
+          }
+        );
+
+        const addedEnvGroup = await z
+          .object({
+            env_group_name: z.string(),
+            env_group_version: z.number(),
+          })
+          .parseAsync(envGroupResponse.data);
+        const envGroups = [
+          ...app.envGroups,
+          {
+            name: addedEnvGroup.env_group_name,
+            version: addedEnvGroup.env_group_version,
+          },
+        ];
+
+        const appWithSeededEnv = new PorterApp({
+          ...app,
+          envGroups,
+        });
+
         await api.applyApp(
           "<token>",
           {
-            b64_app_proto: btoa(app.toJsonString()),
+            b64_app_proto: btoa(appWithSeededEnv.toJsonString()),
             deployment_target_id: deploymentTarget.deployment_target_id,
           },
           {
@@ -440,21 +498,27 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                               source={source}
                               projectId={currentProject.id}
                             />
-                            {!userHasSeenNoPorterYamlFoundModal && !porterYamlFound && !isLoadingPorterYaml &&
-                              <Controller
-                                name="source.porter_yaml_path"
-                                control={control}
-                                render={({ field: { onChange, value } }) => (
-                                  <PorterYamlModal
-                                    close={() => setUserHasSeenNoPorterYamlFoundModal(true)}
-                                    setPorterYamlPath={(porterYamlPath) => {
-                                      onChange(porterYamlPath);
-                                    }}
-                                    porterYamlPath={value}
-                                  />
-                                )}
-                              />
-                            }
+                            {!userHasSeenNoPorterYamlFoundModal &&
+                              !porterYamlFound &&
+                              !isLoadingPorterYaml && (
+                                <Controller
+                                  name="source.porter_yaml_path"
+                                  control={control}
+                                  render={({ field: { onChange, value } }) => (
+                                    <PorterYamlModal
+                                      close={() =>
+                                        setUserHasSeenNoPorterYamlFoundModal(
+                                          true
+                                        )
+                                      }
+                                      setPorterYamlPath={(porterYamlPath) => {
+                                        onChange(porterYamlPath);
+                                      }}
+                                      porterYamlPath={value}
+                                    />
+                                  )}
+                                />
+                              )}
                           </>
                         ) : (
                           <ImageSettings />

+ 7 - 21
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx

@@ -1,36 +1,21 @@
-import React, { useCallback } from "react";
+import React, { useCallback, useEffect } from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
 import { PorterAppFormData } from "lib/porter-apps";
-import EnvGroupArrayStacks, {
-  KeyValueType,
-} from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
-
+import EnvGroupArrayV2 from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2";
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2";
 const EnvVariables: React.FC = () => {
   const { control } = useFormContext<PorterAppFormData>();
 
-  const recordToKVType = useCallback((env?: Record<string, string>) => {
-    return Object.entries(env ?? []).map(([key, value]) => {
-      return { key, value, hidden: false, locked: false, deleted: false };
-    });
-  }, []);
-
-  const kvTypeToRecord = useCallback((env: KeyValueType[]) => {
-    return env.reduce((acc, { key, value }) => {
-      acc[key] = value;
-      return acc;
-    }, {} as Record<string, string>);
-  }, []);
-
   return (
     <Controller
       name={`app.env`}
       control={control}
       render={({ field: { value, onChange } }) => (
-        <EnvGroupArrayStacks
-          values={recordToKVType(value)}
+        <EnvGroupArrayV2
+          values={value ? value : []}
           setValues={(x: KeyValueType[]) => {
-            onChange(kvTypeToRecord(x));
+            onChange(x);
           }}
           fileUpload={true}
           syncedEnvGroups={[]}
@@ -40,4 +25,5 @@ const EnvVariables: React.FC = () => {
   );
 };
 
+
 export default EnvVariables;

+ 403 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArrayV2.tsx

@@ -0,0 +1,403 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import Modal from "main/home/modals/Modal";
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+
+import upload from "assets/upload.svg";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
+import { dotenv_parse } from "shared/string_utils";
+import { NewPopulatedEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+type PropsType = {
+  label?: string;
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+  disabled?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
+  syncedEnvGroups?: NewPopulatedEnvGroup[];
+};
+
+const EnvGroupArrayV2 = ({
+  label,
+  values,
+  setValues,
+  disabled,
+  fileUpload,
+  secretOption,
+  syncedEnvGroups
+}: PropsType) => {
+  const [showEditorModal, setShowEditorModal] = useState(false);
+
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
+    }
+  }, [values]);
+  const isKeyOverriding = (key: string) => {
+    if (!syncedEnvGroups) return false;
+    return syncedEnvGroups.some(envGroup =>
+      key in envGroup.variables || key in envGroup?.secret_variables
+    );
+  };
+
+  const readFile = (env: string) => {
+    const envObj = dotenv_parse(env);
+    const _values = [...values];
+
+    for (const key in envObj) {
+      let push = true;
+
+      for (let i = 0; i < values.length; i++) {
+        const existingKey = values[i]["key"];
+        const isExistingKeyDeleted = values[i]["deleted"];
+        if (key === existingKey && !isExistingKeyDeleted) {
+          _values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        _values.push({
+          key,
+          value: envObj[key],
+          hidden: false,
+          locked: false,
+          deleted: false,
+        });
+      }
+    }
+
+    setValues(_values);
+  };
+
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{label}</Label>
+        {!!values?.length &&
+          values.map((entry: KeyValueType, i: number) => {
+            if (!entry.deleted) {
+              return (
+                <InputWrapper key={i}>
+                  <Input
+                    placeholder="ex: key"
+                    width="270px"
+                    value={entry.key}
+                    onChange={(e: any) => {
+                      const _values = [...values];
+                      _values[i].key = e.target.value;
+                      setValues(_values);
+                    }}
+                    disabled={disabled || entry.locked}
+                    spellCheck={false}
+                    override={isKeyOverriding(entry.key)}
+                  />
+                  < Spacer x={.5} inline />
+                  {entry.hidden ? (
+                    entry.value?.includes("PORTERSECRET") ? (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        disabled
+                        type={"password"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />) : (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        onChange={(e: any) => {
+                          const _values = [...values];
+                          _values[i].value = e.target.value;
+                          setValues(_values);
+                        }}
+                        disabled={disabled || entry.locked}
+                        type={entry.hidden ? "password" : "text"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+
+                      />)
+                  ) : (
+                    entry.value?.includes("PORTERSECRET") ? (
+                      <Input
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        disabled
+                        type={"password"}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />) : (
+                      <MultiLineInputer
+                        placeholder="ex: value"
+                        width="270px"
+                        value={entry.value}
+                        onChange={(e: any) => {
+                          const _values = [...values];
+                          _values[i].value = e.target.value;
+                          setValues(_values);
+                        }}
+                        rows={entry.value?.split("\n").length}
+                        disabled={disabled || entry.locked}
+                        spellCheck={false}
+                        override={isKeyOverriding(entry.key)}
+                      />
+                    ))
+                  }
+                  {(
+                    <HideButton
+                      onClick={() => {
+                        if (!entry.locked) {
+                          const values1 = [...values];
+                          values1[i].hidden = !values1[i].hidden;
+                          setValues(values1);
+                        }
+                      }}
+                      disabled={entry.locked}
+                    >
+                      {entry.hidden ? (
+                        <i className="material-icons">lock</i>
+                      ) : (
+                        <i className="material-icons">lock_open</i>
+                      )}
+                    </HideButton>
+                  )}
+
+                  {!disabled && (
+                    <DeleteButton
+                      onClick={() => {
+                        setValues(values.filter((val, index) => index !== i));
+                      }}
+                    >
+                      <i className="material-icons">cancel</i>
+                    </DeleteButton>
+                  )}
+
+                  {isKeyOverriding(entry.key) && <><Spacer x={1} inline /> <Text color={'#6b74d6'} >Key is overriding value in a environment group</Text></>}
+                </InputWrapper>
+              );
+            }
+          })}
+        {!disabled && (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                const _values = [
+                  ...values,
+                  {
+                    key: "",
+                    value: "",
+                    hidden: false,
+                    locked: false,
+                    deleted: false,
+                  },
+                ];
+                setValues(_values);
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer x={.5} inline />
+            {fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setShowEditorModal(true);
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
+            )}
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {showEditorModal && (
+        <Modal
+          onRequestClose={() => setShowEditorModal(false)}
+          width="60%"
+          height="650px"
+        >
+          <EnvEditorModal
+            closeModal={() => setShowEditorModal(false)}
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default EnvGroupArrayV2;
+
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "default" : "pointer"};
+    :hover {
+      color: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#ffffff44" : "#ffffff88"};
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+
+`;
+
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  override?: boolean;
+};
+
+const Input = styled.input<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: ${(props) => (props.override ? '2px solid #6b74d6' : ' 1px solid #ffffff55')};
+  border-radius: 3px;
+  width: ${(props) => props.width ? props.width : "270px"};
+  color: ${(props) => props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;
+
+export const MultiLineInputer = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: ${(props) => (props.override ? '2px solid #6b74d6' : ' 1px solid #ffffff55')};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

+ 21 - 4
dashboard/src/shared/api.tsx

@@ -932,12 +932,12 @@ const listAppRevisions = baseApi<
 });
 
 const getLatestAppRevisions = baseApi<
-{},{
+  {}, {
     project_id: number;
     cluster_id: number;
-}>("GET", ({ project_id, cluster_id }) => {
-  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`;
-})
+  }>("GET", ({ project_id, cluster_id }) => {
+    return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`;
+  })
 
 const getGitlabProcfileContents = baseApi<
   {
@@ -1794,6 +1794,22 @@ const getAllEnvGroups = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
 });
 
+const updateEnvironmentGroupV2 = baseApi<
+  {
+    deployment_target_id: string;
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+    remove_missing?: boolean;
+  },
+  {
+    id: number;
+    cluster_id: number;
+    app_name: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/apps/${pathParams.app_name}/update-environment `;
+});
+
 const listEnvGroups = baseApi<
   {},
   {
@@ -3108,6 +3124,7 @@ export default {
   updateStacksEnvGroup,
   listEnvGroups,
   getAllEnvGroups,
+  updateEnvironmentGroupV2,
   getEnvGroup,
   deleteEnvGroup,
   deleteNewEnvGroup,