Преглед изворни кода

[POR-1755] [POR-1564] Image Settings Revamp in Porter Yaml V2 (#3663)

Feroze Mohideen пре 2 година
родитељ
комит
b5aee30c51

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

@@ -17,7 +17,7 @@ import { useAppValidation } from "lib/hooks/useAppValidation";
 import api from "shared/api";
 import { useQueryClient } from "@tanstack/react-query";
 import Settings from "./tabs/Settings";
-import BuildSettings from "./tabs/BuildSettings";
+import BuildSettingsTab from "./tabs/BuildSettingsTab";
 import Environment from "./tabs/Environment";
 import AnimateHeight from "react-animate-height";
 import Banner from "components/porter/Banner";
@@ -33,6 +33,7 @@ import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
 import JobsTab from "./tabs/JobsTab";
 import ConfirmRedeployModal from "./ConfirmRedeployModal";
+import ImageSettingsTab from "./tabs/ImageSettingsTab";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
@@ -47,6 +48,7 @@ const validTabs = [
   // "debug",
   "environment",
   "build-settings",
+  "image-settings",
   "settings",
   // "helm-values",
   "job-history",
@@ -66,7 +68,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const { updateAppStep } = useAppAnalytics();
 
   const {
-    porterApp,
+    porterApp: porterAppRecord,
     latestProto,
     previewRevision,
     latestRevision,
@@ -92,25 +94,26 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   }, [tabParam]);
 
   const latestSource: SourceOptions = useMemo(() => {
-    if (porterApp.image_repo_uri) {
-      const [repository, tag] = porterApp.image_repo_uri.split(":");
+    // because we store the image info in the app proto, we can refer to that for repository/tag instead of the app record
+    if (porterAppRecord.image_repo_uri && latestProto.image) {
       return {
         type: "docker-registry",
         image: {
-          repository,
-          tag,
+          repository: latestProto.image.repository,
+          tag: latestProto.image.tag,
         },
       };
     }
 
+    // the app proto does not contain the fields below, so we must pull them from the app record
     return {
       type: "github",
-      git_repo_id: porterApp.git_repo_id ?? 0,
-      git_repo_name: porterApp.repo_name ?? "",
-      git_branch: porterApp.git_branch ?? "",
-      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+      git_repo_id: porterAppRecord.git_repo_id ?? 0,
+      git_repo_name: porterAppRecord.repo_name ?? "",
+      git_branch: porterAppRecord.git_branch ?? "",
+      porter_yaml_path: porterAppRecord.porter_yaml_path ?? "./porter.yaml",
     };
-  }, [porterApp]);
+  }, [porterAppRecord, latestProto]);
 
   const porterAppFormMethods = useForm<PorterAppFormData>({
     reValidateMode: "onSubmit",
@@ -202,7 +205,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         {
           id: projectId,
           cluster_id: clusterId,
-          app_name: porterApp.name,
+          app_name: porterAppRecord.name,
         }
       );
 
@@ -250,8 +253,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
             git_installation_id: latestSource.git_repo_id,
             owner: latestSource.git_repo_name.split("/")[0],
             name: latestSource.git_repo_name.split("/")[1],
-            branch: porterApp.git_branch,
-            filename: "porter_stack_" + porterApp.name + ".yml",
+            branch: porterAppRecord.git_branch,
+            filename: "porter_stack_" + porterAppRecord.name + ".yml",
           }
         );
 
@@ -264,19 +267,19 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         projectId,
         clusterId,
         deploymentTarget.id,
-        porterApp.name,
+        porterAppRecord.name,
       ]);
       setPreviewRevision(null);
 
       if (deploymentTarget.preview) {
         history.push(
-          `/preview-environments/apps/${porterApp.name}/${DEFAULT_TAB}?target=${deploymentTarget.id}`
+          `/preview-environments/apps/${porterAppRecord.name}/${DEFAULT_TAB}?target=${deploymentTarget.id}`
         );
         return;
       }
 
       // redirect to the default tab after save
-      history.push(`/apps/${porterApp.name}/${DEFAULT_TAB}`);
+      history.push(`/apps/${porterAppRecord.name}/${DEFAULT_TAB}`);
     } catch (err) {
       let message = "Unable to get error message";
       let stack = "Unable to get error stack";
@@ -294,18 +297,28 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   });
 
   const cancelRedeploy = useCallback(() => {
+    const resetProto = previewRevision ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
+      ignoreUnknownFields: true,
+    }) : latestProto;
+
+    // we don't store versions of build settings because they are stored in the db, so we just have to use the latest version
+    // however, for image settings, we can pull image repo and tag from the proto
+    const resetSource = porterAppRecord.image_repo_uri && resetProto.image ? {
+      type: "docker-registry" as const,
+      image: {
+        repository: resetProto.image.repository,
+        tag: resetProto.image.tag
+      }
+    } : latestSource;
+
     reset({
       app: clientAppFromProto({
-        proto: previewRevision
-          ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
-            ignoreUnknownFields: true,
-          })
-          : latestProto,
+        proto: resetProto,
         overrides: servicesFromYaml,
         variables: appEnv?.variables,
         secrets: appEnv?.secret_variables,
       }),
-      source: latestSource,
+      source: resetSource,
       deletions: {
         envGroupNames: [],
         serviceNames: [],
@@ -321,18 +334,30 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   }, [onSubmit, setConfirmDeployModalOpen]);
 
   useEffect(() => {
+    const newProto = previewRevision
+      ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
+        ignoreUnknownFields: true,
+      })
+      : latestProto;
+
+    // we don't store versions of build settings because they are stored in the db, so we just have to use the latest version
+    // however, for image settings, we can pull image repo and tag from the proto
+    const newSource = porterAppRecord.image_repo_uri && newProto.image ? {
+      type: "docker-registry" as const,
+      image: {
+        repository: newProto.image.repository,
+        tag: newProto.image.tag
+      }
+    } : latestSource;
+
     reset({
       app: clientAppFromProto({
-        proto: previewRevision
-          ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
-            ignoreUnknownFields: true,
-          })
-          : latestProto,
+        proto: newProto,
         overrides: servicesFromYaml,
         variables: appEnv?.variables,
         secrets: appEnv?.secret_variables,
       }),
-      source: latestSource,
+      source: newSource,
       deletions: {
         envGroupNames: [],
         serviceNames: [],
@@ -357,9 +382,10 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           deploymentTargetId={deploymentTarget.id}
           projectId={projectId}
           clusterId={clusterId}
-          appName={porterApp.name}
+          appName={porterAppRecord.name}
           latestSource={latestSource}
           onSubmit={onSubmit}
+          porterAppRecord={porterAppRecord}
         />
         <AnimateHeight height={isDirty && !onlyExpandedChanged ? "auto" : 0}>
           <Banner
@@ -405,25 +431,31 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                   value: "build-settings",
                 },
               ]
-              : []),
+              : [
+                {
+                  label: "Image Settings",
+                  value: "image-settings",
+                },
+              ]),
             { label: "Settings", value: "settings" },
           ]}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
             if (deploymentTarget.preview) {
               history.push(
-                `/preview-environments/apps/${porterApp.name}/${tab}?target=${deploymentTarget.id}`
+                `/preview-environments/apps/${porterAppRecord.name}/${tab}?target=${deploymentTarget.id}`
               );
               return;
             }
-            history.push(`/apps/${porterApp.name}/${tab}`);
+            history.push(`/apps/${porterAppRecord.name}/${tab}`);
           }}
         />
         <Spacer y={1} />
         {match(currentTab)
           .with("activity", () => <Activity />)
           .with("overview", () => <Overview maxCPU={maxCPU} maxRAM={maxRAM} />)
-          .with("build-settings", () => <BuildSettings />)
+          .with("build-settings", () => <BuildSettingsTab />)
+          .with("image-settings", () => <ImageSettingsTab />)
           .with("environment", () => (
             <Environment latestSource={latestSource} />
           ))

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -118,7 +118,7 @@ const AppHeader: React.FC = () => {
             </TagWrapper>
           </>
         )}
-        {!gitData && porterApp.image_repo_uri && (
+        {!gitData && latestProto.image && (
           <>
             <Spacer inline x={1} />
             <Container row>
@@ -127,7 +127,7 @@ const AppHeader: React.FC = () => {
                 src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
               />
               <Text size={13} color="helper">
-                {porterApp.image_repo_uri}
+                {`${latestProto.image.repository}`}
               </Text>
             </Container>
           </>

+ 29 - 25
dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx → dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettingsTab.tsx

@@ -6,8 +6,9 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import Spacer from "components/porter/Spacer";
 import Button from "components/porter/Button";
 import Error from "components/porter/Error";
+import { match } from "ts-pattern";
 
-const BuildSettings: React.FC = () => {
+const BuildSettingsTab: React.FC = () => {
   const {
     watch,
     formState: { isSubmitting, errors },
@@ -31,33 +32,36 @@ const BuildSettings: React.FC = () => {
     return "";
   }, [isSubmitting, errors]);
 
-  if (source.type !== "github") {
-    return null;
-  }
-
   return (
     <>
-      <RepoSettings
-        build={build}
-        source={source}
-        projectId={projectId}
-        appExists
-      />
-      <Spacer y={1} />
-      <Button
-        type="submit"
-        status={buttonStatus}
-        disabled={
-          isSubmitting ||
-          latestRevision.status === "CREATED" ||
-          latestRevision.status === "AWAITING_BUILD_ARTIFACT"
-        }
-        disabledTooltipMessage="Please wait for the build to complete before updating build settings"
-      >
-        Save build settings
-      </Button>
+      {match(source)
+        .with({ type: "github" }, (source) => (
+          <>
+            <RepoSettings
+              build={build}
+              source={source}
+              projectId={projectId}
+              appExists
+            />
+            <Spacer y={1} />
+            <Button
+              type="submit"
+              status={buttonStatus}
+              disabled={
+                isSubmitting ||
+                latestRevision.status === "CREATED" ||
+                latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+              }
+              disabledTooltipMessage="Please wait for the build to complete before updating build settings"
+            >
+              Save build settings
+            </Button>
+          </>
+        ))
+        .otherwise(() => null)
+      }
     </>
   );
 };
 
-export default BuildSettings;
+export default BuildSettingsTab;

+ 111 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx

@@ -0,0 +1,111 @@
+import React, { useMemo } from "react";
+import { useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+import { useLatestRevision } from "../LatestRevisionContext";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import { match } from "ts-pattern";
+import styled from "styled-components";
+import copy from "assets/copy-left.svg"
+import CopyToClipboard from "components/CopyToClipboard";
+import Link from "components/porter/Link";
+import Text from "components/porter/Text";
+import ImageSettings from "../../image-settings/ImageSettings";
+
+const ImageSettingsTab: React.FC = () => {
+    const {
+        watch,
+        formState: { isSubmitting, errors },
+    } = useFormContext<PorterAppFormData>();
+    const { projectId, latestRevision, latestProto } = useLatestRevision();
+
+    const source = watch("source");
+
+    const buttonStatus = useMemo(() => {
+        if (isSubmitting) {
+            return "loading";
+        }
+
+        if (Object.keys(errors).length > 0) {
+            return <Error message="Unable to update app" />;
+        }
+
+        return "";
+    }, [isSubmitting, errors]);
+
+    return (
+        <>
+            {match(source)
+                .with({ type: "docker-registry" }, (source) => (
+                    <>
+                        <ImageSettings
+                            projectId={projectId}
+                            source={source}
+                        />
+                        <Spacer y={1} />
+                        <Button
+                            type="submit"
+                            status={buttonStatus}
+                            disabled={
+                                isSubmitting ||
+                                latestRevision.status === "CREATED" ||
+                                latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+                            }
+                        >
+                            Save image settings
+                        </Button>
+                        <Spacer y={1} />
+                        <Text size={16}>Update command</Text>
+                        <Spacer y={0.5} />
+                        <Text color="helper">If you have the <Link to="https://docs.porter.run/standard/cli/command-reference/porter-update" target="_blank"><Text>Porter CLI</Text></Link> installed, you can update your application image tag by running the following command: </Text>
+                        <Spacer y={0.5} />
+                        <IdContainer>
+                            <Code>{`$ porter app update-tag ${latestProto.name} --tag latest`}</Code>
+                            <CopyContainer>
+                                <CopyToClipboard text={`porter app update-tag ${latestProto.name} --tag latest`}>
+                                    <CopyIcon src={copy} alt="copy" />
+                                </CopyToClipboard>
+                            </CopyContainer>
+                        </IdContainer>
+                    </>
+                ))
+                .otherwise(() => null)
+            }
+        </>
+    );
+};
+
+export default ImageSettingsTab;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const IdContainer = styled.div`
+    background: #000000;  
+    border-radius: 5px;
+    padding: 10px;
+    display: flex;
+    width: 100%;
+    border-radius: 5px;
+    border: 1px solid ${({ theme }) => theme.border};
+    align-items: center;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;

+ 2 - 1
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -31,7 +31,7 @@ import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 
 type Props = {};
 
-const Apps: React.FC<Props> = ({}) => {
+const Apps: React.FC<Props> = ({ }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
   const { currentDeploymentTarget } = useDeploymentTarget();
@@ -77,6 +77,7 @@ const Apps: React.FC<Props> = ({}) => {
       return apps.app_revisions;
     },
     {
+      refetchOnWindowFocus: false,
       enabled:
         !!currentCluster && !!currentProject && !!currentDeploymentTarget,
     }

+ 3 - 5
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -24,7 +24,6 @@ import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
 import RepoSettings from "./RepoSettings";
-import ImageSettings from "./ImageSettings";
 import Container from "components/porter/Container";
 import ServiceList from "../validate-apply/services-settings/ServiceList";
 import {
@@ -34,7 +33,7 @@ import {
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { valueExists } from "shared/util";
 import api from "shared/api";
-import { EnvGroup, PorterApp } from "@porter-dev/api-contracts";
+import { 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";
@@ -42,13 +41,12 @@ import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppValidation } from "lib/hooks/useAppValidation";
 import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
-import PorterYamlModal from "./PorterYamlModal";
-import EnvGroups from "../validate-apply/app-settings/EnvGroups";
 import {
   PopulatedEnvGroup,
   populatedEnvGroup,
 } from "../validate-apply/app-settings/types";
 import EnvSettings from "../validate-apply/app-settings/EnvSettings";
+import ImageSettings from "../image-settings/ImageSettings";
 import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
 type CreateAppProps = {} & RouteComponentProps;
@@ -584,7 +582,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                               )} */}
                           </>
                         ) : (
-                          <ImageSettings />
+                          <ImageSettings projectId={currentProject.id} source={source} />
                         )
                       ) : null}
                     </AnimateHeight>

+ 0 - 87
dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx

@@ -1,87 +0,0 @@
-import React from "react";
-import styled from "styled-components";
-
-import { pushFiltered } from "shared/routing";
-import Link from "components/porter/Link";
-import Spacer from "components/porter/Spacer";
-import ImageSelector from "components/image-selector/ImageSelector";
-import { Controller, useFormContext } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
-
-const ImageSettings: React.FC = ({}) => {
-  const { control } = useFormContext<PorterAppFormData>();
-
-  return (
-    <StyledSourceBox>
-      <Subtitle>
-        Specify the container image you would like to connect to this template.
-        <Spacer inline width="5px" />
-        <Link
-          hasunderline
-          onClick={() =>
-            pushFiltered({ location, history }, "/integrations/registry", [
-              "project_id",
-            ])
-          }
-        >
-          Manage Docker registries
-        </Link>
-      </Subtitle>
-      <DarkMatter antiHeight="-4px" />
-      {/* // todo(ianedwards): rewrite image selector to be more easily controllable by form */}
-      <Controller
-        name="source.image"
-        control={control}
-        render={({ field: { onChange, value } }) => (
-          <ImageSelector
-            selectedTag={value?.tag || "latest"}
-            selectedImageUrl={value?.repository || ""}
-            setSelectedImageUrl={(imageUrl) => {
-              onChange({
-                tag: value?.tag ?? "latest",
-                repository: imageUrl,
-              });
-            }}
-            setSelectedTag={(tag) => {
-              onChange({
-                ...value,
-                tag,
-              });
-            }}
-            forceExpanded={true}
-          />
-        )}
-      />
-
-      <br />
-    </StyledSourceBox>
-  );
-};
-
-export default ImageSettings;
-
-const DarkMatter = styled.div<{ antiHeight?: string }>`
-  width: 100%;
-  margin-top: ${(props) => props.antiHeight || "-15px"};
-`;
-
-const Subtitle = styled.div`
-  padding: 11px 0px 16px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-`;
-
-const StyledSourceBox = styled.div`
-  width: 100%;
-  color: #ffffff;
-  padding: 14px 35px 20px;
-  position: relative;
-  font-size: 13px;
-  margin-top: 6px;
-  margin-bottom: 25px;
-  border-radius: 5px;
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-`;

+ 160 - 0
dashboard/src/main/home/app-dashboard/image-settings/ImageList.tsx

@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import { integrationList } from "shared/common";
+import addCircle from "assets/add-circle.png";
+import Loading from "components/Loading";
+import { ImageType } from "./types";
+import SearchBar from "components/SearchBar";
+import Link from "components/porter/Link";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+  loading: boolean;
+  images: ImageType[];
+  setSelectedImage: (x: ImageType) => void;
+};
+
+const ImageList: React.FC<Props> = ({
+  setSelectedImage,
+  loading,
+  images,
+}) => {
+  const [error, setError] = useState<boolean>(false);
+  const [searchFilter, setSearchFilter] = useState<string>("");
+
+  const renderImageList = () => {
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error) {
+      return <LoadingWrapper>Error loading images</LoadingWrapper>;
+    } else if (images.length === 0 && !searchFilter) {
+      return <LoadingWrapper>
+        <Text color="helper">No linked images found.</Text>
+        <Spacer y={0.5} />
+        <div>
+          <Link to={"/integrations/registry"}>Configure linked image registries</Link>, or provide the URL of a public image (e.g. "nginx") to continue.
+        </div>
+      </LoadingWrapper>;
+    }
+
+    const sortedImages = searchFilter
+      ? images
+        .filter((img) =>
+          img.uri.toLowerCase().includes(searchFilter.toLowerCase())
+        )
+        .sort((a, b) => {
+          const aIndex = a.uri.toLowerCase().indexOf(searchFilter.toLowerCase());
+          const bIndex = b.uri.toLowerCase().indexOf(searchFilter.toLowerCase());
+          return aIndex - bIndex;
+        })
+      : images.sort((a, b) => {
+        return (
+          new Date(b.created_at ?? "").getTime() -
+          new Date(a.created_at ?? "").getTime()
+        );
+      });
+
+    const imageCards = sortedImages.map((image: ImageType, i: number) => {
+      return (
+        <ImageItem
+          key={i}
+          onClick={() => {
+            setSelectedImage(image);
+          }}
+        >
+          <img src={integrationList["dockerhub"].icon} />
+          {image.uri}
+        </ImageItem>
+      );
+    });
+    if (searchFilter !== "" && !images.some((image) => image.uri === searchFilter)) {
+      imageCards.push(
+        <ImageItem
+          key={images.length}
+          onClick={() => {
+            setSelectedImage({
+              uri: searchFilter,
+              name: searchFilter,
+              registry_id: 0,
+            });
+          }}
+        >
+          <img src={addCircle} />
+          {`Use image URL: \"${searchFilter}\"`}
+        </ImageItem>
+      );
+    }
+    return imageCards;
+  };
+
+  return (
+    <>
+      <SearchBar
+        setSearchFilter={setSearchFilter}
+        disabled={error || loading}
+        prompt={"Search images..."}
+      />
+      <ExpandedWrapper>
+        {renderImageList()}
+      </ExpandedWrapper>
+    </>
+  );
+};
+
+export default ImageList;
+
+const ImageItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid #606166;
+  color: #ffffff;
+  align-items: center;
+  padding: 10px 0px;
+  user-select: text;
+  cursor: text;
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  user-select: text;
+  color: #aaaabb;
+  cursor: text;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  background: #ffffff11;
+  overflow-y: auto;
+`;
+
+

+ 267 - 0
dashboard/src/main/home/app-dashboard/image-settings/ImageSettings.tsx

@@ -0,0 +1,267 @@
+import React, { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { Controller, useFormContext } from "react-hook-form";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import styled from "styled-components";
+import Input from "components/porter/Input";
+import { z } from "zod";
+import { PorterAppFormData, SourceOptions } from "lib/porter-apps";
+import ImageList from "./ImageList";
+import TagList from "./TagList";
+import { ImageType } from "./types";
+
+type Props = {
+    projectId: number;
+    source: SourceOptions & { type: "docker-registry" };
+};
+
+const ImageSettings: React.FC<Props> = ({
+    projectId,
+    source,
+}) => {
+    const { control, setValue } = useFormContext<PorterAppFormData>();
+    const [images, setImages] = useState<ImageType[]>([]);
+    const [selectedImage, setSelectedImage] = useState<ImageType | undefined>(undefined);
+    const { data: registries, isLoading: isLoadingRegistries } = useQuery(
+        ["getProjectRegistries", projectId],
+        async () => {
+            const res = await api.getProjectRegistries("<token>", {}, { id: projectId });
+            return await z.array(z.object({ id: z.number() })).parseAsync(res.data);
+        },
+        {
+            refetchOnWindowFocus: false,
+        }
+    )
+
+    const { data: imageResp, isLoading: isLoadingImages } = useQuery(
+        ["getImages", projectId, source],
+        async () => {
+            if (registries == null) {
+                return [];
+            }
+            return (await Promise.all(registries.map(async ({ id: registry_id }: { id: number }) => {
+                const res = await api.getImageRepos("<token>", {}, {
+                    project_id: projectId,
+                    registry_id,
+                });
+                const parsed = await z.array(z.object({
+                    uri: z.string(),
+                    name: z.string(),
+                })).parseAsync(res.data);
+                return parsed.map(p => ({ ...p, registry_id }))
+            }))).flat();
+        },
+        {
+            enabled: !!registries,
+            refetchOnWindowFocus: false,
+        }
+    );
+
+    useEffect(() => {
+        if (imageResp) {
+            setImages(imageResp);
+            if (source.image && source.image.repository) {
+                setSelectedImage(imageResp.find((image) => image.uri === source.image.repository));
+            }
+        }
+    }, [imageResp]);
+
+    return (
+        <div>
+            <Text size={16}>Image settings</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Specify your image URL.</Text>
+            <Spacer y={0.5} />
+
+            {(!source.image || !source.image.repository) && (
+                <Controller
+                    name="source.image"
+                    control={control}
+                    render={({ field: { onChange } }) => (
+                        <>
+                            <ExpandedWrapper>
+                                <ImageList
+                                    setSelectedImage={(image: ImageType) => {
+                                        setSelectedImage(image);
+                                        onChange({
+                                            repository: image.uri,
+                                        });
+                                    }}
+                                    images={images}
+                                    loading={isLoadingImages || isLoadingRegistries}
+                                />
+                            </ExpandedWrapper>
+                            <DarkMatter antiHeight="-4px" />
+                            <Spacer y={0.3} />
+                        </>
+                    )}
+                />
+            )}
+
+            {source.image && source.image.repository && (
+                <>
+                    <Input
+                        disabled={true}
+                        label="Image URL:"
+                        width="100%"
+                        value={selectedImage?.uri ?? source.image.repository}
+                        setValue={() => { }}
+                        placeholder=""
+                    />
+                    <BackButton
+                        width="170px"
+                        onClick={() => {
+                            setValue("source.image", {
+                                repository: "",
+                                tag: "",
+                            });
+                        }}
+                    >
+                        <i className="material-icons">keyboard_backspace</i>
+                        Select image URL
+                    </BackButton>
+                    <Spacer y={1} />
+                    <Text color="helper">Specify your image tag.</Text>
+                    <Spacer y={0.5} />
+                    {!source.image.tag && (
+                        <Controller
+                            name="source.image"
+                            control={control}
+                            render={({ field: { onChange } }) => (
+                                <ExpandedWrapper>
+                                    <TagList
+                                        selectedImage={selectedImage}
+                                        projectId={projectId}
+                                        setSelectedTag={
+                                            (tag: string) => {
+                                                onChange({
+                                                    repository: source.image.repository,
+                                                    tag,
+                                                });
+                                            }
+                                        }
+                                    />
+                                </ExpandedWrapper>
+                            )}
+                        />
+                    )}
+                    {source.image.tag && (
+                        <>
+                            <Input
+                                disabled={true}
+                                label="Image tag:"
+                                type="text"
+                                width="100%"
+                                value={source.image.tag}
+                                setValue={() => { }}
+                                placeholder=""
+                            />
+                            <BackButton
+                                width="170px"
+                                onClick={() => {
+                                    setValue("source.image", {
+                                        repository: source.image.repository,
+                                        tag: "",
+                                    });
+                                }}
+                            >
+                                <i className="material-icons">keyboard_backspace</i>
+                                Select image tag
+                            </BackButton>
+                        </>
+                    )}
+                </>
+            )}
+        </div>
+    );
+};
+
+export default ImageSettings;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  max-height: 275px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const StyledAdvancedBuildSettings = styled.div`
+  color: ${({ showSettings }) => (showSettings ? "white" : "#aaaabb")};
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  border-bottom-left-radius: ${({ showSettings }) => showSettings && "0px"};
+  border-bottom-right-radius: ${({ showSettings }) => showSettings && "0px"};
+  .dropdown {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showSettings: boolean; isCurrent: boolean }) =>
+        props.showSettings ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const AdvancedBuildTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 25px 35px 25px;
+  position: relative;
+  font-size: 13px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0px;
+  border-top-left-radius: 0px;
+  border-top-right-radius: 0px;
+`;

+ 174 - 0
dashboard/src/main/home/app-dashboard/image-settings/TagList.tsx

@@ -0,0 +1,174 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import tag_icon from "assets/tag.png";
+import addCircle from "assets/add-circle.png";
+
+import api from "shared/api";
+import Loading from "components/Loading";
+import { ImageType, TagType, tagValidator } from "./types";
+import { useQuery } from "@tanstack/react-query";
+import SearchBar from "components/SearchBar";
+import { z } from "zod";
+
+type Props = {
+  selectedImage?: ImageType;
+  projectId: number;
+  setSelectedTag: (x: string) => void;
+};
+
+const TagList: React.FC<Props> = ({
+  selectedImage,
+  projectId,
+  setSelectedTag,
+}) => {
+  const [tags, setTags] = useState<TagType[]>([]);
+  const [searchFilter, setSearchFilter] = useState<string>("");
+
+  const { data: tagResp, isLoading, error } = useQuery(
+    ["getImageTags", selectedImage],
+    async () => {
+      if (!selectedImage) {
+        return;
+      }
+
+      const res = await api.getImageTags(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          registry_id: selectedImage.registry_id,
+          repo_name: selectedImage.name,
+        }
+      );
+      return z.array(tagValidator).parseAsync(res.data);
+    },
+    {
+      enabled: !!selectedImage && selectedImage.registry_id !== 0,
+      refetchOnWindowFocus: false,
+    }
+  )
+
+  useEffect(() => {
+    if (tagResp) {
+      setTags(tagResp);
+    }
+  }, [tagResp])
+
+  const renderTagList = () => {
+    if (isLoading && selectedImage && selectedImage.registry_id !== 0) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error) {
+      return <LoadingWrapper>Error loading tags.</LoadingWrapper>;
+    } else if (tags.length === 0 && !searchFilter) {
+      return <LoadingWrapper>Please specify a tag.</LoadingWrapper>;
+    }
+
+    const sortedTags = searchFilter
+      ? tags
+        .filter((tag) => tag.tag.toLowerCase().includes(searchFilter.toLowerCase()))
+        .sort((a, b) => {
+          const aIndex = a.tag.toLowerCase().indexOf(searchFilter.toLowerCase());
+          const bIndex = b.tag.toLowerCase().indexOf(searchFilter.toLowerCase());
+          return aIndex - bIndex;
+        })
+      : tags.sort((a, b) => {
+        return (
+          new Date(b.pushed_at ?? "").getTime() -
+          new Date(a.pushed_at ?? "").getTime()
+        );
+      })
+
+    const tagCards = sortedTags.map((tag: TagType, i: number) => {
+      return (
+        <TagItem
+          key={i}
+          onClick={() => {
+            setSelectedTag(tag.tag);
+          }}
+        >
+          <img src={tag_icon} />
+          {tag.tag}
+        </TagItem>
+      );
+    });
+
+    if (searchFilter !== "" && !tags.some((tag) => tag.tag === searchFilter)) {
+      tagCards.push(
+        <TagItem
+          onClick={() => {
+            setSelectedTag(searchFilter);
+          }}
+        >
+          <img src={addCircle} />
+          {`Use tag \"${searchFilter}\"`}
+        </TagItem>
+      );
+    }
+
+    return tagCards;
+  };
+
+  return (
+    <>
+      <SearchBar
+        setSearchFilter={setSearchFilter}
+        disabled={error != null || isLoading}
+        prompt={"Search tags..."}
+      />
+      <ExpandedWrapper>
+        {renderTagList()}
+      </ExpandedWrapper>
+    </>
+  );
+};
+
+export default TagList;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  background: #ffffff11;
+  overflow-y: auto;
+`;
+
+const TagItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid #606166;
+  color: #ffffff;
+  align-items: center;
+  padding: 10px 0px;
+  user-select: text;
+  cursor: text;
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

+ 12 - 0
dashboard/src/main/home/app-dashboard/image-settings/types.ts

@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+export const imageValidator = z.object({
+    uri: z.string(),
+    name: z.string(),
+    created_at: z.string().optional(),
+    registry_id: z.number(),
+})
+export type ImageType = z.infer<typeof imageValidator>;
+
+export const tagValidator = z.object({ tag: z.string(), pushed_at: z.string() })
+export type TagType = z.infer<typeof tagValidator>;

+ 9 - 7
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -173,13 +173,15 @@ const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
                     selected={
                       previewRevision
                         ? revision.revision_number ===
-                          previewRevision.revision_number
+                        previewRevision.revision_number
                         : isLatestDeployedRevision
                     }
                     onClick={() => {
-                      setPreviewRevision(
-                        isLatestDeployedRevision ? null : revision
-                      );
+                      if (isLatestDeployedRevision) {
+                        setPreviewRevision(null);
+                      } else {
+                        setPreviewRevision(revision);
+                      }
                     }}
                   >
                     <Td>{revision.revision_number}</Td>
@@ -252,7 +254,7 @@ const RevisionHeader = styled.div`
     cursor: pointer;
     border-radius: 20px;
     transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "" : "rotate(-90deg)"};
+    props.showRevisions ? "" : "rotate(-90deg)"};
     transition: transform 0.1s ease;
   }
 `;
@@ -293,7 +295,7 @@ const Tr = styled.tr`
     props.selected ? "#ffffff11" : ""};
   :hover {
     background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-      props.disableHover ? "" : "#ffffff22"};
+    props.disableHover ? "" : "#ffffff22"};
   }
 `;
 
@@ -325,7 +327,7 @@ const RollbackButton = styled.div`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#405eddbb"};
+    props.disabled ? "" : "#405eddbb"};
   }
 `;
 

+ 5 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx

@@ -18,6 +18,7 @@ import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import RevisionTableContents from "./RevisionTableContents";
 import GHStatusBanner from "./GHStatusBanner";
 import Spacer from "components/porter/Spacer";
+import { PorterAppRecord } from "../../app-view/AppView";
 
 type Props = {
   deploymentTargetId: string;
@@ -27,6 +28,7 @@ type Props = {
   latestSource: SourceOptions;
   latestRevisionNumber: number;
   onSubmit: () => Promise<void>;
+  porterAppRecord: PorterAppRecord;
 };
 
 const RevisionsList: React.FC<Props> = ({
@@ -37,6 +39,7 @@ const RevisionsList: React.FC<Props> = ({
   appName,
   latestSource,
   onSubmit,
+  porterAppRecord,
 }) => {
   const { servicesFromYaml } = useLatestRevision();
   const { setValue } = useFormContext<PorterAppFormData>();
@@ -88,6 +91,7 @@ const RevisionsList: React.FC<Props> = ({
       }
     );
 
+    // hydrate revision with env variables only on revert
     const { app_revision } = await z
       .object({
         app_revision: appRevisionValidator.extend({
@@ -137,6 +141,7 @@ const RevisionsList: React.FC<Props> = ({
               expandRevisions={expandRevisions}
               setExpandRevisions={setExpandRevisions}
               setRevertData={setRevertData}
+              porterAppRecord={porterAppRecord}
             />
           ))
           .otherwise(() => null)}