Przeglądaj źródła

Merge pull request #2202 from porter-dev/staging

Preview env + frontend + EKS form fixes -> production
abelanger5 3 lat temu
rodzic
commit
9d2297b5b3

+ 20 - 0
api/client/environment.go

@@ -22,6 +22,26 @@ func (c *Client) ListEnvironments(
 	return resp, err
 	return resp, err
 }
 }
 
 
+func (c *Client) CreateDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.CreateDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) GetDeployment(
 func (c *Client) GetDeployment(
 	ctx context.Context,
 	ctx context.Context,
 	projID, clusterID, envID uint,
 	projID, clusterID, envID uint,

+ 5 - 0
api/server/handlers/infra/forms.go

@@ -557,6 +557,11 @@ tabs:
       placeholder: "ex: 10.99"
       placeholder: "ex: 10.99"
       settings:
       settings:
         default: "10.99"
         default: "10.99"
+    - type: checkbox
+      label: "Add additional private subnets to the cluster in each AZ."
+      variable: additional_private_subnets
+      settings:
+        default: false
   - name: nginx_settings
   - name: nginx_settings
     contents:
     contents:
     - type: heading
     - type: heading

+ 23 - 1
cli/cmd/apply.go

@@ -743,7 +743,29 @@ func (t *DeploymentHook) PreApply() error {
 		},
 		},
 	)
 	)
 
 
-	if err == nil {
+	if err != nil && strings.Contains(err.Error(), "not found") {
+		// in this case, create the deployment
+		_, err = t.client.CreateDeployment(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			&types.CreateDeploymentRequest{
+				Namespace:     t.namespace,
+				PullRequestID: t.prID,
+				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+					ActionID: t.actionID,
+				},
+				GitHubMetadata: &types.GitHubMetadata{
+					PRName:       t.prName,
+					RepoName:     t.repoName,
+					RepoOwner:    t.repoOwner,
+					CommitSHA:    t.commitSHA,
+					PRBranchFrom: t.branchFrom,
+					PRBranchInto: t.branchInto,
+				},
+			},
+		)
+	} else if err == nil {
 		_, err = t.client.UpdateDeployment(
 		_, err = t.client.UpdateDeployment(
 			context.Background(),
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
 			t.projectID, t.gitInstallationID, t.clusterID,

+ 51 - 16
cli/cmd/list.go

@@ -10,8 +10,11 @@ import (
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
+	"helm.sh/helm/v3/pkg/release"
 )
 )
 
 
+var allNamespaces bool
+
 // listCmd represents the "porter list" base command and "porter list all" subcommand
 // listCmd represents the "porter list" base command and "porter list all" subcommand
 var listCmd = &cobra.Command{
 var listCmd = &cobra.Command{
 	Use:   "list",
 	Use:   "list",
@@ -76,6 +79,13 @@ func init() {
 		"the namespace of the release",
 		"the namespace of the release",
 	)
 	)
 
 
+	listCmd.PersistentFlags().BoolVar(
+		&allNamespaces,
+		"all-namespaces",
+		false,
+		"list resources for all namespaces",
+	)
+
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listJobsCmd)
 	listCmd.AddCommand(listJobsCmd)
 	listCmd.AddCommand(listAddonsCmd)
 	listCmd.AddCommand(listAddonsCmd)
@@ -124,24 +134,49 @@ func listAddons(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 }
 }
 
 
 func writeReleases(client *api.Client, kind string) error {
 func writeReleases(client *api.Client, kind string) error {
-	releases, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, namespace, &types.ListReleasesRequest{
-		ReleaseListFilter: &types.ReleaseListFilter{
-			Limit: 50,
-			Skip:  0,
-			StatusFilter: []string{
-				"deployed",
-				"uninstalled",
-				"pending",
-				"pending-install",
-				"pending-upgrade",
-				"pending-rollback",
-				"failed",
+	var namespaces []string
+	var releases []*release.Release
+
+	if allNamespaces {
+		resp, err := client.GetK8sNamespaces(context.Background(), cliConf.Project, cliConf.Cluster)
+
+		if err != nil {
+			return err
+		}
+
+		namespaceResp := *resp
+
+		for _, ns := range namespaceResp {
+			namespaces = append(namespaces, ns.Name)
+		}
+	} else {
+		namespaces = append(namespaces, namespace)
+	}
+
+	for _, ns := range namespaces {
+		resp, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, ns,
+			&types.ListReleasesRequest{
+				ReleaseListFilter: &types.ReleaseListFilter{
+					Limit: 50,
+					Skip:  0,
+					StatusFilter: []string{
+						"deployed",
+						"uninstalled",
+						"pending",
+						"pending-install",
+						"pending-upgrade",
+						"pending-rollback",
+						"failed",
+					},
+				},
 			},
 			},
-		},
-	})
+		)
 
 
-	if err != nil {
-		return err
+		if err != nil {
+			return err
+		}
+
+		releases = append(releases, resp...)
 	}
 	}
 
 
 	w := new(tabwriter.Writer)
 	w := new(tabwriter.Writer)

+ 39 - 2
dashboard/src/components/repo-selector/RepoList.tsx

@@ -19,6 +19,42 @@ type Props = {
   filteredRepos?: string[];
   filteredRepos?: string[];
 };
 };
 
 
+type Provider =
+  | {
+      provider: "github";
+      name: string;
+      installation_id: number;
+    }
+  | {
+      provider: "gitlab";
+      instance_url: string;
+      integration_id: number;
+    };
+
+// Sort provider by name if it's github or instance url if it's gitlab
+const sortProviders = (providers: Provider[]) => {
+  const githubProviders = providers.filter(
+    (provider) => provider.provider === "github"
+  );
+
+  const gitlabProviders = providers.filter(
+    (provider) => provider.provider === "gitlab"
+  );
+
+  const githubSortedProviders = githubProviders.sort((a, b) => {
+    if (a.provider === "github" && b.provider === "github") {
+      return a.name.localeCompare(b.name);
+    }
+  });
+
+  const gitlabSortedProviders = gitlabProviders.sort((a, b) => {
+    if (a.provider === "gitlab" && b.provider === "gitlab") {
+      return a.instance_url.localeCompare(b.instance_url);
+    }
+  });
+  return [...gitlabSortedProviders, ...githubSortedProviders];
+};
+
 const RepoList: React.FC<Props> = ({
 const RepoList: React.FC<Props> = ({
   actionConfig,
   actionConfig,
   setActionConfig,
   setActionConfig,
@@ -51,8 +87,9 @@ const RepoList: React.FC<Props> = ({
           return;
           return;
         }
         }
 
 
-        setProviders(data);
-        setCurrentProvider(data[0]);
+        const sortedProviders = sortProviders(data);
+        setProviders(sortedProviders);
+        setCurrentProvider(sortedProviders[0]);
       })
       })
       .catch((err) => {
       .catch((err) => {
         setHasProviders(false);
         setHasProviders(false);

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -174,7 +174,12 @@ const ExpandedStack = () => {
             value: "source_config",
             value: "source_config",
             component: (
             component: (
               <>
               <>
-                <SourceConfig revision={currentRevision}></SourceConfig>
+                <SourceConfig
+                  namespace={namespace}
+                  revision={currentRevision}
+                  readOnly={stack.latest_revision.id !== currentRevision.id}
+                  onSourceConfigUpdate={() => getStack()}
+                ></SourceConfig>
               </>
               </>
             ),
             ),
           },
           },

+ 82 - 9
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -1,17 +1,72 @@
 import { Tooltip } from "@material-ui/core";
 import { Tooltip } from "@material-ui/core";
 import ImageSelector from "components/image-selector/ImageSelector";
 import ImageSelector from "components/image-selector/ImageSelector";
-import React from "react";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
 import styled from "styled-components";
 import styled from "styled-components";
-import { AppResource, FullStackRevision, SourceConfig } from "../types";
+import { AppResource, FullStackRevision, SourceConfig, Stack } from "../types";
+import SourceEditorDocker from "./components/SourceEditorDocker";
+
+const _SourceConfig = ({
+  namespace,
+  revision,
+  readOnly,
+  onSourceConfigUpdate,
+}: {
+  namespace: string;
+  revision: FullStackRevision;
+  readOnly: boolean;
+  onSourceConfigUpdate: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [sourceConfigArrayCopy, setSourceConfigArrayCopy] = useState<
+    SourceConfig[]
+  >(() => revision.source_configs);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const handleChange = (sourceConfig: SourceConfig) => {
+    const newSourceConfigArray = [...sourceConfigArrayCopy];
+    const index = newSourceConfigArray.findIndex(
+      (sc) => sc.id === sourceConfig.id
+    );
+    newSourceConfigArray[index] = sourceConfig;
+    setSourceConfigArrayCopy(newSourceConfigArray);
+  };
+
+  const handleSave = () => {
+    setButtonStatus("loading");
+    api
+      .updateStackSourceConfig(
+        "<token>",
+        {
+          source_configs: sourceConfigArrayCopy,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: namespace,
+          stack_id: revision.stack_id,
+        }
+      )
+      .then(() => {
+        setButtonStatus("successful");
+        onSourceConfigUpdate();
+      })
+      .catch((err) => {
+        setButtonStatus("Something went wrong");
+        setCurrentError(err);
+      });
+  };
 
 
-const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
   return (
   return (
     <SourceConfigStyles.Wrapper>
     <SourceConfigStyles.Wrapper>
       {revision.source_configs.map((sourceConfig) => {
       {revision.source_configs.map((sourceConfig) => {
         const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
         const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
 
 
         const appList = formatAppList(apps, 2);
         const appList = formatAppList(apps, 2);
-        console.log({ appList });
         return (
         return (
           <SourceConfigStyles.ItemContainer>
           <SourceConfigStyles.ItemContainer>
             {appList.hiddenApps?.length ? (
             {appList.hiddenApps?.length ? (
@@ -36,15 +91,24 @@ const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
                 Used by {appList.value}
                 Used by {appList.value}
               </SourceConfigStyles.ItemTitle>
               </SourceConfigStyles.ItemTitle>
             )}
             )}
-            <ImageSelector
-              selectedImageUrl={sourceConfig.image_repo_uri}
-              selectedTag={sourceConfig.image_tag}
-              forceExpanded
-              readOnly
+            <SourceEditorDocker
+              sourceConfig={sourceConfig}
+              onChange={handleChange}
+              readOnly={readOnly || buttonStatus === "loading"}
             />
             />
           </SourceConfigStyles.ItemContainer>
           </SourceConfigStyles.ItemContainer>
         );
         );
       })}
       })}
+      <SourceConfigStyles.SaveButtonRow>
+        <SourceConfigStyles.SaveButton
+          onClick={handleSave}
+          text="Save"
+          clearPosition={true}
+          makeFlush={true}
+          status={buttonStatus}
+          statusPosition="left"
+        />
+      </SourceConfigStyles.SaveButtonRow>
     </SourceConfigStyles.Wrapper>
     </SourceConfigStyles.Wrapper>
   );
   );
 };
 };
@@ -89,6 +153,7 @@ const formatAppList = (apps: AppResource[], limit: number = 3) => {
 const SourceConfigStyles = {
 const SourceConfigStyles = {
   Wrapper: styled.div`
   Wrapper: styled.div`
     margin-top: 30px;
     margin-top: 30px;
+    position: relative;
   `,
   `,
   ItemContainer: styled.div`
   ItemContainer: styled.div`
     background: #ffffff11;
     background: #ffffff11;
@@ -102,4 +167,12 @@ const SourceConfigStyles = {
   TooltipItem: styled.div`
   TooltipItem: styled.div`
     font-size: 14px;
     font-size: 14px;
   `,
   `,
+  SaveButtonRow: styled.div`
+    margin-top: 15px;
+    display: flex;
+    justify-content: flex-end;
+  `,
+  SaveButton: styled(SaveButton)`
+    z-index: unset;
+  `,
 };
 };

+ 258 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -0,0 +1,258 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+
+export type SelectProps<T> = {
+  value: T;
+  options: T[];
+  accessor: (option: T) => string | React.ReactNode;
+  onChange: (value: T) => void;
+  isOptionEqualToValue?: (option: T, value: T) => boolean;
+  label: string;
+  isLoading?: boolean;
+  dropdown?: {
+    maxH?: string;
+    width?: string;
+    label?: string;
+    option?: {
+      height?: string;
+    };
+  };
+  placeholder: string;
+  className?: string;
+  readOnly?: boolean;
+};
+
+const Select = <T extends unknown>({
+  value,
+  options,
+  accessor,
+  onChange,
+  isOptionEqualToValue,
+  label,
+  isLoading,
+  placeholder,
+  dropdown,
+  className,
+  readOnly,
+}: SelectProps<T>) => {
+  const wrapperRef = useRef();
+  const [expanded, setExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => {
+    setExpanded(false);
+  });
+
+  const handleOptionClick = (value: T) => {
+    setExpanded(false);
+    onChange(value);
+  };
+
+  const getLabel = () => {
+    if (label) {
+      return <SelectStyles.Label> {label} </SelectStyles.Label>;
+    }
+    return null;
+  };
+
+  if (isLoading) {
+    return (
+      <div>
+        {getLabel()}
+        <SelectStyles.Wrapper>
+          <SelectStyles.Selector
+            className={className}
+            expanded={false}
+            readOnly={readOnly}
+          >
+            <SelectStyles.Loading>
+              <Loading />
+            </SelectStyles.Loading>
+          </SelectStyles.Selector>
+        </SelectStyles.Wrapper>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      {getLabel()}
+      <SelectStyles.Wrapper ref={wrapperRef}>
+        <SelectStyles.Selector
+          className={className}
+          onClick={() => setExpanded(!expanded)}
+          expanded={expanded}
+          readOnly={readOnly}
+        >
+          <SelectStyles.CurrentValue>
+            <span>{value ? accessor(value) : placeholder}</span>
+          </SelectStyles.CurrentValue>
+          {readOnly ? null : <i className="material-icons">arrow_drop_down</i>}
+        </SelectStyles.Selector>
+        {expanded && !readOnly ? (
+          <SelectStyles.Dropdown.Wrapper
+            width={dropdown?.width}
+            maxH={dropdown?.maxH}
+          >
+            {dropdown?.label && (
+              <SelectStyles.Dropdown.Label>
+                {dropdown?.label}
+              </SelectStyles.Dropdown.Label>
+            )}
+            {options.length > 0 ? (
+              <>
+                {options.map((option, i) => (
+                  <SelectStyles.Dropdown.Option
+                    key={i}
+                    onClick={() => !readOnly && handleOptionClick(option)}
+                    lastItem={i === options.length - 1}
+                    selected={
+                      isOptionEqualToValue
+                        ? isOptionEqualToValue(option, value)
+                        : option === value
+                    }
+                    height={dropdown?.option?.height}
+                  >
+                    {accessor(option)}
+                  </SelectStyles.Dropdown.Option>
+                ))}
+              </>
+            ) : (
+              <SelectStyles.Dropdown.NoOptions>
+                No options available
+              </SelectStyles.Dropdown.NoOptions>
+            )}
+          </SelectStyles.Dropdown.Wrapper>
+        ) : null}
+      </SelectStyles.Wrapper>
+    </div>
+  );
+};
+
+export default Select;
+
+export const SelectStyles = {
+  Wrapper: styled.div`
+    position: relative;
+  `,
+  Label: styled.div`
+    color: #ffffff;
+    margin-bottom: 10px;
+    margin-top: 20px;
+    font-size: 13px;
+  `,
+
+  Selector: styled.div<{ expanded: boolean; readOnly: boolean }>`
+    height: 35px;
+    border: 1px solid #ffffff55;
+    font-size: 13px;
+    padding: 5px 10px;
+    padding-left: 15px;
+    border-radius: 3px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    cursor: ${(props) => (props.readOnly ? "normal" : "pointer")};
+    background: ${(props) => {
+      if (props.readOnly) {
+        return "#ffffff55";
+      }
+
+      if (props.expanded) {
+        return "#ffffff33";
+      }
+      return "#ffffff11";
+    }};
+
+    :hover {
+      background: ${(props) => {
+        if (props.readOnly) {
+          return "#ffffff55";
+        }
+
+        if (props.expanded) {
+          return "#ffffff33";
+        }
+        return "#ffffff22";
+      }};
+    }
+
+    > i {
+      font-size: 20px;
+      transform: ${(props) => (props.expanded ? "rotate(180deg)" : "")};
+    }
+  `,
+
+  Loading: styled.div`
+    width: 100%;
+  `,
+
+  CurrentValue: styled.div`
+    display: flex;
+    align-items: center;
+    width: 85%;
+
+    > span {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      z-index: 0;
+    }
+  `,
+
+  Dropdown: {
+    Wrapper: styled.div<{ width: string; maxH?: string }>`
+      background: #26282f;
+      width: ${(props) => props.width || "100%"};
+      max-height: ${(props) => props.maxH || "300px"};
+      border-radius: 3px;
+      z-index: 999;
+      overflow-y: auto;
+      margin-bottom: 20px;
+      box-shadow: 0 8px 20px 0px #00000088;
+      position: absolute;
+    `,
+    Option: styled.div<{
+      selected: boolean;
+      lastItem: boolean;
+      height?: string;
+    }>`
+      width: 100%;
+      border-top: 1px solid #00000000;
+      border-bottom: 1px solid
+        ${(props) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+      height: ${(props) => props.height || "37px"};
+      font-size: 13px;
+      align-items: center;
+      display: flex;
+      align-items: center;
+      padding-left: 15px;
+      cursor: pointer;
+      padding-right: 10px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+      :hover {
+        background: #ffffff22;
+      }
+    `,
+    Label: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+    `,
+    NoOptions: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+      :not(:first-child) {
+        border-top: 1px solid #ffffff15;
+      }
+    `,
+  },
+};

+ 321 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/SourceEditorDocker.tsx

@@ -0,0 +1,321 @@
+import SelectRow from "components/form-components/SelectRow";
+import SearchSelector from "components/SearchSelector";
+import Selector from "components/Selector";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+import { proxy, useSnapshot } from "valtio";
+import { SourceConfig } from "../../types";
+import Select from "./Select";
+
+const SourceEditorDocker = ({
+  sourceConfig,
+  onChange,
+  readOnly = false,
+}: {
+  readOnly: boolean;
+  sourceConfig: SourceConfig;
+  onChange: (sourceConfig: SourceConfig) => void;
+}) => {
+  const [registry, setRegistry] = useState<DockerRegistry | null>(null);
+  const [image, setImage] = useState<string | null>(
+    () => sourceConfig.image_repo_uri
+  );
+  const [tag, setTag] = useState<string | null>(() => sourceConfig.image_tag);
+
+  const imageName = useMemo(() => {
+    if (!registry) {
+      return "";
+    }
+
+    if (!image) {
+      return "";
+    }
+
+    return image.replace(registry.url + "/", "");
+  }, [image, registry]);
+
+  useEffect(() => {
+    const newSourceConfig: SourceConfig = {
+      ...sourceConfig,
+      image_repo_uri: image,
+      image_tag: tag,
+    };
+
+    onChange(newSourceConfig);
+  }, [image, tag]);
+
+  return (
+    <>
+      <SourceEditorDockerStlyes.RegistryWrapper>
+        <_DockerRepositorySelector
+          currentImageUrl={sourceConfig.image_repo_uri}
+          value={registry}
+          onChange={setRegistry}
+          readOnly={readOnly}
+        />
+      </SourceEditorDockerStlyes.RegistryWrapper>
+      {registry && (
+        <SourceEditorDockerStlyes.ImageAndTagWrapper>
+          <_ImageSelector
+            registry={registry}
+            value={image}
+            onChange={setImage}
+            readOnly={readOnly}
+          />
+
+          {registry && imageName && (
+            <_TagSelector
+              registry={registry}
+              imageName={imageName}
+              value={tag}
+              onChange={setTag}
+              readOnly={readOnly}
+            />
+          )}
+        </SourceEditorDockerStlyes.ImageAndTagWrapper>
+      )}
+    </>
+  );
+};
+
+type DockerRegistry = {
+  id: number;
+  project_id: number;
+  name: string;
+  url: string;
+  service: string;
+  infra_id: number;
+  aws_integration_id: number;
+};
+
+const _DockerRepositorySelector = ({
+  currentImageUrl,
+  value,
+  onChange,
+  readOnly,
+}: {
+  currentImageUrl: string;
+  value: DockerRegistry;
+  onChange: (newRegistry: DockerRegistry) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [registries, setRegistries] = useState<DockerRegistry[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .getProjectRegistries<DockerRegistry[]>(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setRegistries(data);
+        if (!value) {
+          const currentRegistry = data.find((r) =>
+            currentImageUrl.includes(r.url)
+          );
+          onChange(currentRegistry);
+        }
+        setIsLoading(false);
+      });
+  }, []);
+
+  const handleChange = (newRegistry: DockerRegistry) => {
+    onChange(newRegistry);
+  };
+
+  return (
+    <>
+      <Select
+        value={value}
+        options={registries}
+        onChange={handleChange}
+        accessor={(val) => val.name}
+        label="Docker Registry"
+        placeholder="Select a registry"
+        isOptionEqualToValue={(a, b) => a.url === b.url}
+        readOnly={readOnly}
+        isLoading={isLoading}
+        dropdown={{
+          maxH: "200px",
+        }}
+      />
+    </>
+  );
+};
+
+type ImageRepo = {
+  name: string;
+  created_at: string;
+  uri: string;
+};
+
+const _ImageSelector = ({
+  registry,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  value: string;
+  onChange: (newValue: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [images, setImages] = useState<ImageRepo[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageRepos<ImageRepo[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry.id,
+        }
+      )
+      .then(({ data }) => {
+        setImages(data);
+
+        if (!value) {
+          onChange(data[0].uri);
+        }
+        setIsLoading(false);
+      });
+  }, []);
+
+  const handleChange = (image: string) => {
+    onChange(image);
+  };
+
+  const displayName = (imageUrl: string) => {
+    const image = images.find((i) => i.uri === imageUrl);
+    if (!image) {
+      return imageUrl;
+    }
+    return image.name;
+  };
+
+  return (
+    <Select
+      value={value}
+      options={images.map((image) => image.uri)}
+      accessor={displayName}
+      label="Image"
+      placeholder="Select an image"
+      onChange={handleChange}
+      isOptionEqualToValue={(a, b) => a === b}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+type DockerImageTag = {
+  digest: string;
+  tag: string;
+  manifest: string;
+  repository_name: string;
+  pushed_at: string;
+};
+
+const _TagSelector = ({
+  registry,
+  imageName,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  imageName: string;
+  value: string;
+  onChange: (newTag: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+  const [imageTags, setImageTags] = useState<string[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageTags<DockerImageTag[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry?.id,
+          repo_name: imageName,
+        }
+      )
+      .then(({ data }) => {
+        if (!data?.length) {
+          setImageTags([]);
+          onChange("");
+          setIsLoading(false);
+          return;
+        }
+
+        const sortedTags = data.sort((a, b) => {
+          const aDate = new Date(a.pushed_at);
+          const bDate = new Date(b.pushed_at);
+          return bDate.getTime() - aDate.getTime();
+        });
+        setImageTags(sortedTags.map((tag) => tag.tag));
+
+        if (sortedTags.map((tag) => tag.tag).includes(value)) {
+          onChange(value);
+        } else {
+          onChange(sortedTags[0].tag);
+        }
+
+        setIsLoading(false);
+      });
+  }, [registry, imageName]);
+
+  const handleChange = (tag: string) => {
+    onChange(tag);
+  };
+
+  return (
+    <Select
+      value={value}
+      options={imageTags}
+      accessor={(tag) => tag}
+      label="Tag"
+      placeholder="Select a tag"
+      onChange={handleChange}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+export default SourceEditorDocker;
+
+const SourceEditorDockerStlyes = {
+  RegistryWrapper: styled.div``,
+  ImageAndTagWrapper: styled.div`
+    display: grid;
+    grid-template-columns: 3fr 1fr;
+    grid-gap: 10px;
+    align-items: center;
+  `,
+};

+ 34 - 62
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -103,6 +103,34 @@ class Sidebar extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  /**
+   * Helper function that will keep the query params before redirect the user to a new page
+   *
+   * @param location
+   * @param path Path to redirect to
+   * @returns React router `to` object
+   */
+  withQueryParams = (location: any, path: string) => {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathNamespace = params.namespace;
+    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
+
+    if (!pathNamespace) {
+      pathNamespace = getQueryParam(this.props, "namespace");
+    }
+
+    if (pathNamespace) {
+      search = search.concat(`&namespace=${pathNamespace}`);
+    }
+
+    return {
+      ...location,
+      pathname: path,
+      search,
+    };
+  };
+
   renderClusterContent = () => {
   renderClusterContent = () => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
 
 
@@ -110,74 +138,16 @@ class Sidebar extends Component<PropsType, StateType> {
       return (
       return (
         <>
         <>
           <NavButton
           <NavButton
-            to={(location) => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/applications",
-                search,
-              };
-            }}
+            to={(location) => this.withQueryParams(location, "/applications")}
           >
           >
             <Img src={monoweb} />
             <Img src={monoweb} />
             Applications
             Applications
           </NavButton>
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/jobs",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/jobs")}>
             <Img src={monojob} />
             <Img src={monojob} />
             Jobs
             Jobs
           </NavButton>
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/env-groups",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/env-groups")}>
             <Img src={sliders} />
             <Img src={sliders} />
             Env Groups
             Env Groups
           </NavButton>
           </NavButton>
@@ -224,7 +194,9 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             </NavButton>
           )}
           )}
           {currentProject?.stacks_enabled ? (
           {currentProject?.stacks_enabled ? (
-            <NavButton to="/stacks">
+            <NavButton
+              to={(location) => this.withQueryParams(location, "/stacks")}
+            >
               <Icon className="material-icons-outlined">lan</Icon>
               <Icon className="material-icons-outlined">lan</Icon>
               Stacks
               Stacks
             </NavButton>
             </NavButton>

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

@@ -3,7 +3,10 @@ import { PullRequest } from "main/home/cluster-dashboard/preview-environments/ty
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
 import { BuildConfig, FullActionConfigType } from "./types";
 import { BuildConfig, FullActionConfigType } from "./types";
-import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
+import {
+  CreateStackBody,
+  SourceConfig,
+} from "main/home/cluster-dashboard/stacks/types";
 
 
 /**
 /**
  * Generic api call format
  * Generic api call format
@@ -2029,6 +2032,22 @@ const deleteStack = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
 );
 );
 
 
+const updateStackSourceConfig = baseApi<
+  {
+    source_configs: SourceConfig[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PUT",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -2220,4 +2239,5 @@ export default {
   createStack,
   createStack,
   rollbackStack,
   rollbackStack,
   deleteStack,
   deleteStack,
+  updateStackSourceConfig,
 };
 };

+ 62 - 19
internal/repository/gorm/environment.go

@@ -28,13 +28,24 @@ func (repo *EnvironmentRepository) CreateEnvironment(env *models.Environment) (*
 
 
 func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
 func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
 	env := &models.Environment{}
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where(
-		"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner = LOWER(?) AND git_repo_name = LOWER(?)",
-		projectID, clusterID, gitInstallationID,
-		strings.ToLower(gitRepoOwner), strings.ToLower(gitRepoName),
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where(
+			"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			projectID, clusterID, gitInstallationID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where(
+			"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			projectID, clusterID, gitInstallationID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
 	}
+
 	return env, nil
 	return env, nil
 }
 }
 
 
@@ -56,11 +67,22 @@ func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
 	gitRepoOwner, gitRepoName string,
 	gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 ) (*models.Environment, error) {
 	env := &models.Environment{}
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner = LOWER(?) AND git_repo_name = LOWER(?)",
-		projectID, clusterID, strings.ToLower(gitRepoOwner), strings.ToLower(gitRepoName),
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			projectID, clusterID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			projectID, clusterID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
 	}
+
 	return env, nil
 	return env, nil
 }
 }
 
 
@@ -68,11 +90,22 @@ func (repo *EnvironmentRepository) ReadEnvironmentByWebhookIDOwnerRepoName(
 	webhookID, gitRepoOwner, gitRepoName string,
 	webhookID, gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 ) (*models.Environment, error) {
 	env := &models.Environment{}
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner = LOWER(?) AND git_repo_name = LOWER(?)",
-		webhookID, strings.ToLower(gitRepoOwner), strings.ToLower(gitRepoName),
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			webhookID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			webhookID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
 	}
+
 	return env, nil
 	return env, nil
 }
 }
 
 
@@ -148,11 +181,21 @@ func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
 ) (*models.Deployment, error) {
 ) (*models.Deployment, error) {
 	depl := &models.Deployment{}
 	depl := &models.Deployment{}
 
 
-	if err := repo.db.Order("id asc").
-		Where("environment_id = ? AND repo_owner = LOWER(?) AND repo_name = LOWER(?) AND pull_request_id = ?",
-			environmentID, strings.ToLower(gitRepoOwner), strings.ToLower(gitRepoName), prNumber).
-		First(&depl).Error; err != nil {
-		return nil, err
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id asc").
+			Where("environment_id = ? AND repo_owner LIKE ? AND repo_name LIKE ? AND pull_request_id = ?",
+				environmentID, gitRepoOwner, gitRepoName, prNumber).
+			First(&depl).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id asc").
+			Where("environment_id = ? AND repo_owner iLIKE ? AND repo_name iLIKE ? AND pull_request_id = ?",
+				environmentID, gitRepoOwner, gitRepoName, prNumber).
+			First(&depl).Error; err != nil {
+			return nil, err
+		}
 	}
 	}
 
 
 	return depl, nil
 	return depl, nil