Jelajahi Sumber

Merge pull request #2077 from porter-dev/staging

Frontend Fixes -> prod
Nicolas Frati 4 tahun lalu
induk
melakukan
f0170eb213

+ 35 - 0
api/server/handlers/release/create.go

@@ -23,6 +23,7 @@ import (
 	"github.com/porter-dev/porter/internal/registry"
 	"gopkg.in/yaml.v2"
 	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/v1"
 )
 
 type CreateReleaseHandler struct {
@@ -110,6 +111,29 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	k8sAgent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps := make([]*v1.ConfigMap, 0)
+
+	if request.SyncedEnvGroups != nil && len(request.SyncedEnvGroups) > 0 {
+		for _, envGroupName := range request.SyncedEnvGroups {
+			// read the attached configmap
+			cm, _, err := k8sAgent.GetLatestVersionedConfigMap(envGroupName, namespace)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Couldn't find the env group"), http.StatusNotFound))
+				return
+			}
+
+			configMaps = append(configMaps, cm)
+		}
+	}
+
 	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
 
 	if err != nil {
@@ -117,6 +141,17 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	if len(configMaps) > 0 {
+		for _, cm := range configMaps {
+
+			_, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, release.Name)
+
+			if err != nil {
+				c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf("Couldn't add %s to the config map %s", release.Name, cm.Name)))
+			}
+		}
+	}
+
 	if request.Tags != nil {
 		tags, err := c.Repo().Tag().LinkTagsToRelease(request.Tags, release)
 

+ 1 - 0
api/types/release.go

@@ -49,6 +49,7 @@ type CreateReleaseRequest struct {
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
 	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
 	Tags               []string                      `json:"tags,omitempty"`
+	SyncedEnvGroups    []string                      `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {

+ 31 - 67
dashboard/src/components/form-components/KeyValueArray.tsx

@@ -3,9 +3,11 @@ import styled from "styled-components";
 import Modal from "../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../main/home/modals/EnvEditorModal";
+import { dotenv_parse } from "shared/string_utils";
 
 import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
 
 export type KeyValue = {
   key: string;
@@ -136,23 +138,34 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                 spellCheck={false}
               />
               <Spacer />
-              <Input
-                placeholder="ex: value"
-                width="270px"
-                value={value}
-                onChange={(e: any) => {
-                  this.state.values[i].value = e.target.value;
-                  this.setState({ values: this.state.values });
-
-                  let obj = this.valuesToObject();
-                  this.props.setValues(obj);
-                }}
-                disabled={
-                  this.props.disabled || value?.includes("PORTERSECRET")
-                }
-                type={value?.includes("PORTERSECRET") ? "password" : "text"}
-                spellCheck={false}
-              />
+              {value?.includes("PORTERSECRET") ? (
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  disabled
+                  type={"password"}
+                  spellCheck={false}
+                />
+              ) : (
+                <MultiLineInput
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  onChange={(e: any) => {
+                    this.state.values[i].value = e.target.value;
+                    this.setState({ values: this.state.values });
+
+                    let obj = this.valuesToObject();
+                    this.props.setValues(obj);
+                  }}
+                  disabled={
+                    this.props.disabled || value?.includes("PORTERSECRET")
+                  }
+                  spellCheck={false}
+                  rows={value?.split("\n").length}
+                />
+              )}
               {this.renderDeleteButton(i)}
               {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
@@ -204,57 +217,8 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
-  // Parses src into an Object
-  parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug);
-    const obj = {} as Record<string, string>;
-    const NEWLINE = "\n";
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-    const RE_NEWLINES = /\\n/g;
-    const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-    // convert Buffers before splitting into lines and processing
-    src
-      .toString()
-      .split(NEWLINES_MATCH)
-      .forEach(function (line: any, idx: any) {
-        // matching "KEY' and 'VAL' in 'KEY=VAL'
-        const keyValueArr = line.match(RE_INI_KEY_VAL);
-        // matched?
-        if (keyValueArr != null) {
-          const key = keyValueArr[1];
-          // default undefined or missing values to empty string
-          let val = keyValueArr[2] || "";
-          const end = val.length - 1;
-          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-          const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-          // if single or double quoted, remove quotes
-          if (isSingleQuoted || isDoubleQuoted) {
-            val = val.substring(1, end);
-
-            // if double quoted, expand newlines
-            if (isDoubleQuoted) {
-              val = val.replace(RE_NEWLINES, NEWLINE);
-            }
-          } else {
-            // remove surrounding whitespace
-            val = val.trim();
-          }
-
-          obj[key] = val;
-        } else if (debug) {
-          console.log(
-            `did not match key and value when parsing line ${idx + 1}: ${line}`
-          );
-        }
-      });
-
-    return obj;
-  };
-
   readFile = (env: string) => {
-    let envObj = this.parseEnv(env, null);
+    let envObj = dotenv_parse(env);
     let push = true;
 
     for (let key in envObj) {

+ 123 - 110
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -20,6 +20,7 @@ import Heading from "components/form-components/Heading";
 import Loading from "components/Loading";
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { dotenv_parse } from "shared/string_utils";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -102,51 +103,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
   }
 
   const parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug);
-    const obj = {} as Record<string, string>;
-    const NEWLINE = "\n";
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-    const RE_NEWLINES = /\\n/g;
-    const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-    // convert Buffers before splitting into lines and processing
-    src
-      .toString()
-      .split(NEWLINES_MATCH)
-      .forEach(function (line: any, idx: any) {
-        // matching "KEY' and 'VAL' in 'KEY=VAL'
-        const keyValueArr = line.match(RE_INI_KEY_VAL);
-        // matched?
-        if (keyValueArr != null) {
-          const key = keyValueArr[1];
-          // default undefined or missing values to empty string
-          let val = keyValueArr[2] || "";
-          const end = val.length - 1;
-          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-          const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-          // if single or double quoted, remove quotes
-          if (isSingleQuoted || isDoubleQuoted) {
-            val = val.substring(1, end);
-
-            // if double quoted, expand newlines
-            if (isDoubleQuoted) {
-              val = val.replace(RE_NEWLINES, NEWLINE);
-            }
-          } else {
-            // remove surrounding whitespace
-            val = val.trim();
-          }
-
-          obj[key] = val;
-        } else if (debug) {
-          console.log(
-            `did not match key and value when parsing line ${idx + 1}: ${line}`
-          );
-        }
-      });
-
-    return obj;
+    return dotenv_parse(src);
   };
 
   const readFile = (env: string) => {
@@ -333,7 +290,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
 
           return (
             <InputWrapper key={i}>
-              <Input
+              <KeyInput
                 placeholder="ex: key"
                 width="270px"
                 value={entry.key}
@@ -360,30 +317,41 @@ const KeyValueArray: React.FC<Props> = (props) => {
                 }
               />
               <Spacer />
-              <Input
-                placeholder="ex: value"
-                width="270px"
-                value={value}
-                onChange={(e: any) => {
-                  e.persist();
-                  setState((prev) => {
-                    return {
-                      values: prev.values?.map((t, j) => {
-                        if (j == i) {
-                          return {
-                            ...t,
-                            value: e.target.value,
-                          };
-                        }
-                        return t;
-                      }),
-                    };
-                  });
-                }}
-                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
-                type={value.includes("PORTERSECRET") ? "password" : "text"}
-                spellCheck={false}
-              />
+              {value?.includes("PORTERSECRET") ? (
+                <KeyInput
+                  placeholder="ex: value"
+                  width="270px"
+                  disabled
+                  type={"password"}
+                  spellCheck={false}
+                  value={value}
+                />
+              ) : (
+                <MultiLineInput
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  onChange={(e: any) => {
+                    e.persist();
+                    setState((prev) => {
+                      return {
+                        values: prev.values?.map((t, j) => {
+                          if (j == i) {
+                            return {
+                              ...t,
+                              value: e.target.value,
+                            };
+                          }
+                          return t;
+                        }),
+                      };
+                    });
+                  }}
+                  disabled={props.isReadOnly}
+                  spellCheck={false}
+                  rows={value?.split("\n").length}
+                />
+              )}
               {renderDeleteButton(i)}
               {renderHiddenOption(value.includes("PORTERSECRET"), i)}
               {checkOverridedKey(entry.key)}
@@ -486,24 +454,27 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   }
 
+  const isNumber = (s: string) => {
+    return !isNaN(!s ? NaN : Number(String(s).trim()));
+  };
+
+  const rg = /(?:^|[^\\])(\\n)/g;
+  const fixNewlines = (s: string) => {
+    while (rg.test(s)) {
+      s = s.replace(rg, (str) => {
+        if (str.length == 2) return "\n";
+        if (str[0] != "\\") return str[0] + "\n";
+        return "\\n";
+      });
+    }
+    return s;
+  };
+
   if (props.variable.includes("env")) {
     let obj = {
       normal: {},
     } as any;
-    const rg = /(?:^|[^\\])(\\n)/g;
-    const fixNewlines = (s: string) => {
-      while (rg.test(s)) {
-        s = s.replace(rg, (str) => {
-          if (str.length == 2) return "\n";
-          if (str[0] != "\\") return str[0] + "\n";
-          return "\\n";
-        });
-      }
-      return s;
-    };
-    const isNumber = (s: string) => {
-      return !isNaN(!s ? NaN : Number(String(s).trim()));
-    };
+
     state.values.forEach((entry: any, i: number) => {
       if (isNumber(entry.value)) {
         obj.normal[entry.key] = entry.value;
@@ -539,20 +510,7 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   } else {
     let obj = {} as any;
-    const rg = /(?:^|[^\\])(\\n)/g;
-    const fixNewlines = (s: string) => {
-      while (rg.test(s)) {
-        s = s.replace(rg, (str) => {
-          if (str.length == 2) return "\n";
-          if (str[0] != "\\") return str[0] + "\n";
-          return "\\n";
-        });
-      }
-      return s;
-    };
-    const isNumber = (s: string) => {
-      return !isNaN(!s ? NaN : Number(String(s).trim()));
-    };
+
     state.values.forEach((entry: any, i: number) => {
       if (isNumber(entry.value)) {
         obj[entry.key] = entry.value;
@@ -579,7 +537,7 @@ export const getMetadata: GetMetadataFunction<KeyValueArrayMetadata> = (
   state: KeyValueArrayFieldState
 ) => {
   // We don't need any metadata for other key-value-array fields yet so we return null for that variable
-  if (!props?.variable?.includes("env")) {
+  if (!state || !props?.variable?.includes("env")) {
     return {
       [props.variable]: null,
     };
@@ -654,22 +612,35 @@ const ExpandableEnvGroup: React.FC<{
 
                     return (
                       <InputWrapper key={i}>
-                        <Input
+                        <KeyInput
                           placeholder="ex: key"
                           width="270px"
                           value={key}
                           disabled
                         />
                         <Spacer />
-                        <Input
-                          placeholder="ex: value"
-                          width="270px"
-                          value={value}
-                          disabled
-                          type={
-                            value.includes("PORTERSECRET") ? "password" : "text"
-                          }
-                        />
+                        {value?.includes("PORTERSECRET") ? (
+                          <KeyInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            type={
+                              value.includes("PORTERSECRET")
+                                ? "password"
+                                : "text"
+                            }
+                          />
+                        ) : (
+                          <MultiLineInput
+                            placeholder="ex: value"
+                            width="270px"
+                            value={value}
+                            disabled
+                            rows={value?.split("\n").length}
+                            spellCheck={false}
+                          ></MultiLineInput>
+                        )}
                       </InputWrapper>
                     );
                   }
@@ -822,7 +793,7 @@ type InputProps = {
   borderColor?: string;
 };
 
-const Input = styled.input<InputProps>`
+const KeyInput = styled.input<InputProps>`
   outline: none;
   border: none;
   margin-bottom: 5px;
@@ -837,6 +808,48 @@ const Input = styled.input<InputProps>`
   height: 35px;
 `;
 
+export const MultiLineInput = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#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;
+  }
+`;
+
 const Label = styled.div`
   color: #ffffff;
   margin-bottom: 10px;

+ 33 - 34
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -4,7 +4,8 @@ import Modal from "main/home/modals/Modal";
 import EnvEditorModal from "main/home/modals/EnvEditorModal";
 
 import upload from "assets/upload.svg";
-import { parseStringToEnvObject } from "./utils";
+import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
+import { dotenv_parse } from "shared/string_utils";
 
 export type KeyValueType = {
   key: string;
@@ -44,7 +45,7 @@ const EnvGroupArray = ({
   }, [values]);
 
   const readFile = (env: string) => {
-    const envObj = parseStringToEnvObject(env, null);
+    const envObj = dotenv_parse(env);
     const _values = values;
 
     for (const key in envObj) {
@@ -99,19 +100,36 @@ const EnvGroupArray = ({
                     spellCheck={false}
                   />
                   <Spacer />
-                  <Input
-                    placeholder="ex: value"
-                    width="270px"
-                    value={entry.value}
-                    onChange={(e: any) => {
-                      let _values = values;
-                      _values[i].value = e.target.value;
-                      setValues(_values);
-                    }}
-                    disabled={disabled || entry.locked}
-                    type={entry.hidden ? "password" : "text"}
-                    spellCheck={false}
-                  />
+
+                  {entry.hidden ? (
+                    <Input
+                      placeholder="ex: value"
+                      width="270px"
+                      value={entry.value}
+                      onChange={(e: any) => {
+                        let _values = values;
+                        _values[i].value = e.target.value;
+                        setValues(_values);
+                      }}
+                      disabled={disabled || entry.locked}
+                      type={entry.hidden ? "password" : "text"}
+                      spellCheck={false}
+                    />
+                  ) : (
+                    <MultiLineInput
+                      placeholder="ex: value"
+                      width="270px"
+                      value={entry.value}
+                      onChange={(e: any) => {
+                        let _values = values;
+                        _values[i].value = e.target.value;
+                        setValues(_values);
+                      }}
+                      rows={entry.value?.split("\n").length}
+                      disabled={disabled || entry.locked}
+                      spellCheck={false}
+                    />
+                  )}
 
                   {secretOption && (
                     <HideButton
@@ -227,25 +245,6 @@ const AddRowButton = styled.div`
   }
 `;
 
-const LoadButton = styled(AddRowButton)`
-  background: none;
-  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 UploadButton = styled(AddRowButton)`
   background: none;
   position: relative;

+ 0 - 49
dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts

@@ -1,49 +0,0 @@
-export const parseStringToEnvObject = (src: any, options: any) => {
-  const debug = Boolean(options && options.debug);
-  const obj = {} as Record<string, string>;
-  const NEWLINE = "\n";
-  const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-  const RE_NEWLINES = /\\n/g;
-  const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-  // convert Buffers before splitting into lines and processing
-  src
-    .toString()
-    .split(NEWLINES_MATCH)
-    .forEach(function (line: any, idx: any) {
-      // matching "KEY' and 'VAL' in 'KEY=VAL'
-      const keyValueArr = line.match(RE_INI_KEY_VAL);
-      // matched?
-      if (keyValueArr != null) {
-        const key = keyValueArr[1];
-        // default undefined or missing values to empty string
-        let val = keyValueArr[2] || "";
-        const end = val.length - 1;
-        const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-        const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-        // if single or double quoted, remove quotes
-        if (isSingleQuoted || isDoubleQuoted) {
-          val = val.substring(1, end);
-
-          // if double quoted, expand newlines
-          if (isDoubleQuoted) {
-            val = val.replace(RE_NEWLINES, NEWLINE);
-          }
-        } else {
-          // remove surrounding whitespace
-          val = val.trim();
-        }
-
-        obj[key] = val;
-      } else if (debug) {
-        /*
-        console.log(
-          `did not match key and value when parsing line ${idx + 1}: ${line}`
-        );
-        */
-      }
-    });
-
-  return obj;
-};

+ 34 - 29
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -231,8 +231,8 @@ export const ExpandedJobChartFC: React.FC<{
                 }}
                 isDeployedFromGithub={!!chart?.git_action_config?.git_repo}
                 repositoryUrl={chart?.git_action_config?.git_repo}
-                currentChartVersion={Number(chart.version)}
-                latestChartVersion={Number(chart.latest_version)}
+                currentChartVersion={Number(chart?.version)}
+                latestChartVersion={Number(chart?.latest_version)}
               />
             </>
           )}
@@ -265,7 +265,7 @@ export const ExpandedJobChartFC: React.FC<{
           setShowDeleteOverlay={(showOverlay: boolean) => {
             if (showOverlay) {
               setCurrentOverlay({
-                message: `Are you sure you want to delete ${chart.name}?`,
+                message: `Are you sure you want to delete ${chart?.name}?`,
                 onYes: handleDeleteChart,
                 onNo: () => setCurrentOverlay(null),
               });
@@ -293,6 +293,7 @@ export const ExpandedJobChartFC: React.FC<{
         <ExpandedJobHeader
           chart={chart}
           jobs={jobs}
+          disableRevisions
           closeChart={closeChart}
           refreshChart={refreshChart}
           upgradeChart={upgradeChart}
@@ -303,7 +304,7 @@ export const ExpandedJobChartFC: React.FC<{
         <Placeholder>
           <TextWrap>
             <Header>
-              <Spinner src={loading} /> Deleting "{chart.name}"
+              <Spinner src={loading} /> Deleting "{chart?.name}"
             </Header>
             You will be automatically redirected after deletion is complete.
           </TextWrap>
@@ -346,7 +347,7 @@ export const ExpandedJobChartFC: React.FC<{
             <PorterFormWrapper
               formData={formData}
               valuesToOverride={{
-                namespace: chart.namespace,
+                namespace: chart?.namespace,
                 clusterId: currentCluster?.id,
               }}
               renderTabContents={renderTabContents}
@@ -393,6 +394,7 @@ const ExpandedJobHeader: React.FC<{
   upgradeChart: () => Promise<void>;
   loadChartWithSpecificRevision: (revision: number) => void;
   setDisableForm: (disable: boolean) => void;
+  disableRevisions?: boolean;
 }> = ({
   chart,
   closeChart,
@@ -401,13 +403,14 @@ const ExpandedJobHeader: React.FC<{
   upgradeChart,
   loadChartWithSpecificRevision,
   setDisableForm,
+  disableRevisions,
 }) => (
   <HeaderWrapper>
     <BackButton onClick={closeChart}>
       <BackButtonImg src={backArrow} />
     </BackButton>
-    <TitleSection icon={chart.chart.metadata.icon} iconWidth="33px">
-      {chart.name}
+    <TitleSection icon={chart?.chart.metadata.icon} iconWidth="33px">
+      {chart?.name}
       <DeploymentType currentChart={chart} />
       <TagWrapper>
         Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
@@ -423,28 +426,30 @@ const ExpandedJobHeader: React.FC<{
         {" " + readableDate(chart.info.last_deployed)}
       </LastDeployed>
     </InfoWrapper>
-    <RevisionSection
-      chart={chart}
-      refreshChart={() => refreshChart()}
-      setRevision={(chart, isCurrent) => {
-        loadChartWithSpecificRevision(chart?.version);
-        setDisableForm(!isCurrent);
-      }}
-      forceRefreshRevisions={false}
-      refreshRevisionsOff={() => {}}
-      shouldUpdate={
-        chart.latest_version &&
-        chart.latest_version !== chart.chart.metadata.version
-      }
-      latestVersion={chart.latest_version}
-      upgradeVersion={(_version, cb) => {
-        upgradeChart().then(() => {
-          if (typeof cb === "function") {
-            cb();
-          }
-        });
-      }}
-    />
+    {!disableRevisions ? (
+      <RevisionSection
+        chart={chart}
+        refreshChart={() => refreshChart()}
+        setRevision={(chart, isCurrent) => {
+          loadChartWithSpecificRevision(chart?.version);
+          setDisableForm(!isCurrent);
+        }}
+        forceRefreshRevisions={false}
+        refreshRevisionsOff={() => {}}
+        shouldUpdate={
+          chart?.latest_version &&
+          chart?.latest_version !== chart?.chart.metadata.version
+        }
+        latestVersion={chart?.latest_version}
+        upgradeVersion={(_version, cb) => {
+          upgradeChart().then(() => {
+            if (typeof cb === "function") {
+              cb();
+            }
+          });
+        }}
+      />
+    ) : null}
   </HeaderWrapper>
 );
 

+ 23 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -3,7 +3,7 @@ import { useContext, useEffect, useRef, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
-import { ChartType } from "shared/types";
+import { ChartType, ChartTypeWithExtendedConfig } from "shared/types";
 import yaml from "js-yaml";
 import { usePrevious } from "shared/hooks/usePrevious";
 import { useRouting } from "shared/routing";
@@ -41,6 +41,27 @@ export const useJobs = (chart: ChartType) => {
     closeWebsocket,
   } = useWebsockets();
 
+  const isBeingDeployed = (latestJob: any) => {
+    const currentChart: ChartTypeWithExtendedConfig = chart;
+    const chartImage = currentChart.config.image.repository;
+
+    let latestImageDetected =
+      latestJob?.spec?.template?.spec?.containers[0]?.image;
+
+    if (!PORTER_IMAGE_TEMPLATES.includes(chartImage)) {
+      return false;
+    }
+
+    if (
+      latestImageDetected &&
+      !PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)
+    ) {
+      return false;
+    }
+
+    return true;
+  };
+
   const sortJobsAndSave = (newJobs: any[]) => {
     // Set job run from URL if needed
     const urlParams = new URLSearchParams(location.search);
@@ -51,10 +72,7 @@ export const useJobs = (chart: ChartType) => {
 
     newJobs.sort((job1, job2) => getTime(job2) - getTime(job1));
 
-    let latestImageDetected =
-      newJobs[0]?.spec?.template?.spec?.containers[0]?.image;
-    if (!PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)) {
-      // this.setState({ jobs, newestImage, imageIsPlaceholder: false });
+    if (!isBeingDeployed(newJobs[0])) {
       setHasPorterImageTemplate(false);
     }
     jobsRef.current = newJobs;

+ 0 - 195
dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx

@@ -1,195 +0,0 @@
-import Loading from "components/Loading";
-import React, { useCallback, useContext, useEffect, useState } from "react";
-import { useHistory, useLocation } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { useRouting } from "shared/routing";
-import styled from "styled-components";
-import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
-import DashboardHeader from "../DashboardHeader";
-import PullRequestIcon from "assets/pull_request_icon.svg";
-import DeploymentList from "./deployments/DeploymentList";
-import EnvironmentsList from "./environments/EnvironmentsList";
-import { environments } from "./mocks";
-import { PreviewEnvironmentsHeader } from "./components/PreviewEnvironmentsHeader";
-
-const PreviewEnvironmentsHome = () => {
-  const { currentCluster, currentProject } = useContext(Context);
-
-  const [hasGHAccountsLinked, setHasGHAccountsLinked] = useState(false);
-  const [hasEnvironments, setHasEnvironments] = useState(false);
-  const [isLoading, setIsLoading] = useState(true);
-  const [environments, setEnvironments] = useState([]);
-  const [selectedRepo, setSelectedRepo] = useState("");
-
-  const { getQueryParam } = useRouting();
-  const location = useLocation();
-  const history = useHistory();
-
-  const getAccounts = async () => {
-    try {
-      const res = await api.getGithubAccounts("<token>", {}, {});
-      if (res.status !== 200) {
-        throw new Error("Not authorized");
-      }
-
-      return res.data;
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const getEnvironments = async () => {
-    try {
-      const { data } = await api.listEnvironments(
-        "<token>",
-        {},
-        {
-          project_id: currentProject?.id,
-          cluster_id: currentCluster?.id,
-        }
-      );
-
-      return data;
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const checkPreviewEnvironmentsEnabling = async (subscribeStauts: {
-    subscribed: boolean;
-  }) => {
-    try {
-      await getAccounts();
-
-      const envs = await getEnvironments();
-      // const envs = await mockRequest();
-
-      if (!subscribeStauts.subscribed) {
-        return;
-      }
-
-      if (!Array.isArray(envs)) {
-        setHasGHAccountsLinked(true);
-        return;
-      }
-
-      setHasGHAccountsLinked(true);
-      setHasEnvironments(true);
-      setEnvironments(envs);
-    } catch (error) {
-      setHasGHAccountsLinked(false);
-    }
-  };
-
-  useEffect(() => {
-    let subscribedStatus = { subscribed: true };
-
-    setIsLoading(true);
-
-    checkPreviewEnvironmentsEnabling(subscribedStatus).finally(() => {
-      if (subscribedStatus.subscribed) {
-        setIsLoading(false);
-      }
-    });
-
-    return () => {
-      subscribedStatus.subscribed = false;
-    };
-  }, [currentCluster, currentProject]);
-
-  useEffect(() => {
-    const current_repo = getQueryParam("repository");
-    setSelectedRepo(current_repo);
-  }, [location.search, history]);
-
-  if (isLoading) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-        <Placeholder>
-          <Loading />
-        </Placeholder>
-      </>
-    );
-  }
-
-  if (!hasGHAccountsLinked) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-        <Placeholder>
-          <Title>There are no repositories linked</Title>
-          <Subtitle>
-            In order to use preview environments, you must install the porter
-            app in at least one repository.
-          </Subtitle>
-          <ButtonEnablePREnvironments />
-        </Placeholder>
-      </>
-    );
-  }
-
-  if (!hasEnvironments) {
-    return (
-      <>
-        <PreviewEnvironmentsHeader />
-
-        <Placeholder>
-          <Title>Preview environments are not enabled on this cluster</Title>
-          <Subtitle>
-            In order to use preview environments, you must enable preview
-            environments on this cluster.
-          </Subtitle>
-          <ButtonEnablePREnvironments />
-        </Placeholder>
-      </>
-    );
-  }
-
-  return (
-    <>
-      <PreviewEnvironmentsHeader />
-      <EnvironmentsList
-        environments={environments}
-        setEnvironments={setEnvironments}
-      />
-    </>
-  );
-};
-
-export default PreviewEnvironmentsHome;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Title = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subtitle = styled.div`
-  width: 50%;
-`;

+ 53 - 18
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -7,8 +7,12 @@ import { Link } from "react-router-dom";
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
 
+type Props = {
+  setIsReady: (status: boolean) => void;
+};
+
 // TODO: Billing is still not capable to show if a user can use or not PR environments, add that instead of "hasBillingEnabled"
-const ButtonEnablePREnvironments = () => {
+const ButtonEnablePREnvironments = ({ setIsReady }: Props) => {
   // const { hasBillingEnabled } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [hasGHAccountConnected, setHasGHAccountConnected] = useState(false);
@@ -46,6 +50,10 @@ const ButtonEnablePREnvironments = () => {
     };
   }, []);
 
+  useEffect(() => {
+    setIsReady(!isLoading);
+  }, [isLoading]);
+
   const getButtonProps = () => {
     const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
 
@@ -87,7 +95,7 @@ const ButtonEnablePREnvironments = () => {
         <Container>
           <Button {...getButtonProps()}>
             <img src={pr_icon} alt="Pull request icon" />
-            Connect repositories
+            Connect GitHub account
           </Button>
         </Container>
       </>
@@ -98,8 +106,7 @@ const ButtonEnablePREnvironments = () => {
     <>
       <Container>
         <Button {...getButtonProps()}>
-          <img src={pr_icon} alt="Pull request icon" />
-          Enable Preview Environments
+          <i className="material-icons">add</i> Add Repository
         </Button>
       </Container>
     </>
@@ -109,29 +116,57 @@ const ButtonEnablePREnvironments = () => {
 export default ButtonEnablePREnvironments;
 
 const Button = styled(DynamicLink)`
-  background-color: #616feecc;
-  border: none;
-  border-radius: 6px;
-  color: white;
   display: flex;
+  flex-direction: row;
   align-items: center;
-  justify-content: center;
-  padding: 8px 12px;
-  font-size: 14px;
+  justify-content: space-between;
+  font-size: 13px;
   cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
   img {
-    margin-right: 10px;
-    width: 20px;
-    height: 20px;
+    margin-left: 2px;
+    margin-right: 5px;
+    width: 18px;
+    height: 18px;
   }
-  transition: background-color 150ms ease-out;
-  :hover {
-    background-color: #616feefb;
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
   }
 `;
 
 const Container = styled.div`
   width: 50%;
   display: flex;
-  margin-top: 20px;
 `;

+ 101 - 27
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -4,51 +4,117 @@ import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import styled from "styled-components";
-import { deployments, environments } from "../mocks";
+import ButtonEnablePREnvironments from "../components/ButtonEnablePREnvironments";
+import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import { Environment } from "../types";
 import EnvironmentCard from "./EnvironmentCard";
 
-type Props = {
-  environments: Environment[];
-  setEnvironments: (
-    setFunction: (prev: Environment[]) => Environment[]
-  ) => void;
-};
+const EnvironmentsList = () => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [buttonIsReady, setButtonIsReady] = useState(false);
+
+  const [environments, setEnvironments] = useState<Environment[]>([]);
 
-const EnvironmentsList = ({ environments, setEnvironments }: Props) => {
   const removeEnvironmentFromList = (deletedEnv: Environment) => {
     setEnvironments((prev) => {
       return prev.filter((env) => env.id !== deletedEnv.id);
     });
   };
 
+  const getEnvironments = async () => {
+    try {
+      const { data } = await api.listEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      return data;
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const checkPreviewEnvironmentsEnabling = async (subscribeStauts: {
+    subscribed: boolean;
+  }) => {
+    try {
+      const envs = await getEnvironments();
+      // const envs = await mockRequest();
+
+      if (!subscribeStauts.subscribed) {
+        return;
+      }
+
+      if (!Array.isArray(envs)) {
+        return;
+      }
+
+      setEnvironments(envs);
+    } catch (error) {
+      setEnvironments([]);
+    }
+  };
+
+  useEffect(() => {
+    let subscribedStatus = { subscribed: true };
+
+    setIsLoading(true);
+
+    checkPreviewEnvironmentsEnabling(subscribedStatus).finally(() => {
+      if (subscribedStatus.subscribed) {
+        setIsLoading(false);
+      }
+    });
+
+    return () => {
+      subscribedStatus.subscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
   return (
     <>
-      <ControlRow>
-        <Button to={`/preview-environments/connect-repo`}>
-          <i className="material-icons">add</i> Add Repository
-        </Button>
-      </ControlRow>
-      {environments.length === 0 && (
-        <Placeholder>
-          No repositories found with Preview Environments enabled.
-        </Placeholder>
-      )}
-      <EnvironmentsGrid>
-        {environments.map((env) => (
-          <EnvironmentCard
-            key={env.id}
-            environment={env}
-            onDelete={removeEnvironmentFromList}
-          />
-        ))}
-      </EnvironmentsGrid>
+      <PreviewEnvironmentsHeader />
+      <Relative>
+        {isLoading || !buttonIsReady ? (
+          <FloatingPlaceholder>
+            <Loading />
+          </FloatingPlaceholder>
+        ) : null}
+
+        <ControlRow>
+          <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
+        </ControlRow>
+        {environments.length === 0 ? (
+          <Placeholder>
+            No repositories found with Preview Environments enabled.
+          </Placeholder>
+        ) : (
+          <EnvironmentsGrid>
+            {environments.map((env) => (
+              <EnvironmentCard
+                key={env.id}
+                environment={env}
+                onDelete={removeEnvironmentFromList}
+              />
+            ))}
+          </EnvironmentsGrid>
+        )}
+      </Relative>
     </>
   );
 };
 
 export default EnvironmentsList;
 
+const Relative = styled.div`
+  position: relative;
+`;
+
 const Placeholder = styled.div`
   padding: 30px;
   margin-top: 35px;
@@ -71,6 +137,14 @@ const Placeholder = styled.div`
   }
 `;
 
+const FloatingPlaceholder = styled(Placeholder)`
+  position: absolute;
+  background: #3d3f42;
+  width: 100%;
+  height: 100%;
+  margin-top: 0px;
+`;
+
 const EnvironmentsGrid = styled.div`
   margin-top: 32px;
   padding-bottom: 150px;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -4,7 +4,7 @@ import { Context } from "shared/Context";
 import ConnectNewRepo from "./ConnectNewRepo";
 import DeploymentDetail from "./deployments/DeploymentDetail";
 import DeploymentList from "./deployments/DeploymentList";
-import PreviewEnvironmentsHome from "./PreviewEnvironmentsHome";
+import EnvironmentsList from "./environments/EnvironmentsList";
 
 export const Routes = () => {
   const { path } = useRouteMatch();
@@ -29,7 +29,7 @@ export const Routes = () => {
           <DeploymentList />
         </Route>
         <Route path={`${path}/`}>
-          <PreviewEnvironmentsHome />
+          <EnvironmentsList />
         </Route>
       </Switch>
     </>

+ 3 - 26
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -301,6 +301,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       }
     }
 
+    const synced = values?.container?.env?.synced || [];
+
     try {
       await api.deployTemplate(
         "<token>",
@@ -312,6 +314,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           name: release_name,
           github_action_config: githubActionConfig,
           build_config: buildConfig,
+          synced_env_groups: synced.map((s: any) => s.name),
         },
         {
           id: currentProject.id,
@@ -329,32 +332,6 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       return;
     }
 
-    // Save application into synced groups
-    const synced = values?.container?.env?.synced || [];
-
-    const addApplicationToEnvGroupPromises = synced.map((envGroup: any) => {
-      return api.addApplicationToEnvGroup(
-        "<token>",
-        {
-          name: envGroup?.name,
-          app_name: release_name,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: selectedNamespace,
-        }
-      );
-    });
-
-    try {
-      await Promise.all(addApplicationToEnvGroupPromises);
-    } catch (error) {
-      setCurrentError(
-        "We coudln't sync the env group to the application, please go to your recently deployed application and try again through the environment tab."
-      );
-    }
-
     setSaveValuesStatus("successful");
     // redirect to dashboard with namespace
     setTimeout(() => {

+ 1 - 0
dashboard/src/shared/api.tsx

@@ -430,6 +430,7 @@ const deployTemplate = baseApi<
     name: string;
     github_action_config?: FullActionConfigType;
     build_config?: any;
+    synced_env_groups?: string[];
   },
   {
     id: number;

+ 1 - 0
dashboard/src/shared/hooks/useChart.ts

@@ -100,6 +100,7 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
    * Delete/Uninstall chart
    */
   const deleteChart = async () => {
+    setStatus("deleting");
     try {
       const syncedEnvGroups = chart.config?.container?.env?.synced || [];
       const removeApplicationToEnvGroupPromises = syncedEnvGroups.map(

+ 42 - 0
dashboard/src/shared/string_utils.ts

@@ -11,3 +11,45 @@ export const readableDate = (s: string) => {
 export const capitalize = (s: string) => {
   return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
 };
+
+const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
+
+export const dotenv_parse = (src: string): Record<string, string> => {
+  // Parser src into an Object
+
+  const obj = {} as Record<string, string>;
+
+  // Convert buffer to string
+  let lines = src.toString();
+
+  // Convert line breaks to same format
+  lines = lines.replace(/\r\n?/gm, "\n");
+
+  let match;
+  while ((match = LINE.exec(lines)) != null) {
+    const key = match[1];
+
+    // Default undefined or null to empty string
+    let value = match[2] || "";
+
+    // Remove whitespace
+    value = value.trim();
+
+    // Check if double quoted
+    const maybeQuote = value[0];
+
+    // Remove surrounding quotes
+    value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
+
+    // Expand newlines if double quoted
+    if (maybeQuote === '"') {
+      value = value.replace(/\\n/g, "\n");
+      value = value.replace(/\\r/g, "\r");
+    }
+
+    // Add to object
+    obj[key] = value;
+  }
+
+  return obj;
+};