diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index dba7225274..329d0ca0d5 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/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 = ({ tabParam }) => { const { updateAppStep } = useAppAnalytics(); const { - porterApp, + porterApp: porterAppRecord, latestProto, previewRevision, latestRevision, @@ -92,25 +94,26 @@ const AppDataContainer: React.FC = ({ 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({ reValidateMode: "onSubmit", @@ -202,7 +205,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { { id: projectId, cluster_id: clusterId, - app_name: porterApp.name, + app_name: porterAppRecord.name, } ); @@ -250,8 +253,8 @@ const AppDataContainer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ tabParam }) => { deploymentTargetId={deploymentTarget.id} projectId={projectId} clusterId={clusterId} - appName={porterApp.name} + appName={porterAppRecord.name} latestSource={latestSource} onSubmit={onSubmit} + porterAppRecord={porterAppRecord} /> = ({ 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}`); }} /> {match(currentTab) .with("activity", () => ) .with("overview", () => ) - .with("build-settings", () => ) + .with("build-settings", () => ) + .with("image-settings", () => ) .with("environment", () => ( )) diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx index ce5951ec0e..13c8892692 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx @@ -118,7 +118,7 @@ const AppHeader: React.FC = () => { )} - {!gitData && porterApp.image_repo_uri && ( + {!gitData && latestProto.image && ( <> @@ -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" /> - {porterApp.image_repo_uri} + {`${latestProto.image.repository}`} diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettingsTab.tsx similarity index 52% rename from dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx rename to dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettingsTab.tsx index 8399d1445f..975d4f66af 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx +++ b/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 ( <> - - - + {match(source) + .with({ type: "github" }, (source) => ( + <> + + + + + )) + .otherwise(() => null) + } ); }; -export default BuildSettings; +export default BuildSettingsTab; \ No newline at end of file diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx new file mode 100644 index 0000000000..85bbccdc9f --- /dev/null +++ b/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(); + const { projectId, latestRevision, latestProto } = useLatestRevision(); + + const source = watch("source"); + + const buttonStatus = useMemo(() => { + if (isSubmitting) { + return "loading"; + } + + if (Object.keys(errors).length > 0) { + return ; + } + + return ""; + }, [isSubmitting, errors]); + + return ( + <> + {match(source) + .with({ type: "docker-registry" }, (source) => ( + <> + + + + + Update command + + If you have the Porter CLI installed, you can update your application image tag by running the following command: + + + {`$ porter app update-tag ${latestProto.name} --tag latest`} + + + + + + + + )) + .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; + } +`; \ No newline at end of file diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index 13933d76f3..a276940f39 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx @@ -31,7 +31,7 @@ import { useDeploymentTarget } from "shared/DeploymentTargetContext"; type Props = {}; -const Apps: React.FC = ({}) => { +const Apps: React.FC = ({ }) => { const { currentProject, currentCluster } = useContext(Context); const { updateAppStep } = useAppAnalytics(); const { currentDeploymentTarget } = useDeploymentTarget(); @@ -77,6 +77,7 @@ const Apps: React.FC = ({}) => { return apps.app_revisions; }, { + refetchOnWindowFocus: false, enabled: !!currentCluster && !!currentProject && !!currentDeploymentTarget, } diff --git a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx index 7f703de9e6..70120fb2e3 100644 --- a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx +++ b/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 = ({ history }) => { )} */} ) : ( - + ) ) : null} diff --git a/dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx b/dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx deleted file mode 100644 index 867ff98355..0000000000 --- a/dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx +++ /dev/null @@ -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(); - - return ( - - - Specify the container image you would like to connect to this template. - - - pushFiltered({ location, history }, "/integrations/registry", [ - "project_id", - ]) - } - > - Manage Docker registries - - - - {/* // todo(ianedwards): rewrite image selector to be more easily controllable by form */} - ( - { - onChange({ - tag: value?.tag ?? "latest", - repository: imageUrl, - }); - }} - setSelectedTag={(tag) => { - onChange({ - ...value, - tag, - }); - }} - forceExpanded={true} - /> - )} - /> - -
-
- ); -}; - -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; -`; diff --git a/dashboard/src/main/home/app-dashboard/image-settings/ImageList.tsx b/dashboard/src/main/home/app-dashboard/image-settings/ImageList.tsx new file mode 100644 index 0000000000..5825e9107d --- /dev/null +++ b/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 = ({ + setSelectedImage, + loading, + images, +}) => { + const [error, setError] = useState(false); + const [searchFilter, setSearchFilter] = useState(""); + + const renderImageList = () => { + if (loading) { + return ( + + + + ); + } else if (error) { + return Error loading images; + } else if (images.length === 0 && !searchFilter) { + return + No linked images found. + +
+ Configure linked image registries, or provide the URL of a public image (e.g. "nginx") to continue. +
+
; + } + + 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 ( + { + setSelectedImage(image); + }} + > + + {image.uri} + + ); + }); + if (searchFilter !== "" && !images.some((image) => image.uri === searchFilter)) { + imageCards.push( + { + setSelectedImage({ + uri: searchFilter, + name: searchFilter, + registry_id: 0, + }); + }} + > + + {`Use image URL: \"${searchFilter}\"`} + + ); + } + return imageCards; + }; + + return ( + <> + + + {renderImageList()} + + + ); +}; + +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; +`; + + diff --git a/dashboard/src/main/home/app-dashboard/image-settings/ImageSettings.tsx b/dashboard/src/main/home/app-dashboard/image-settings/ImageSettings.tsx new file mode 100644 index 0000000000..b3af881b94 --- /dev/null +++ b/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 = ({ + projectId, + source, +}) => { + const { control, setValue } = useFormContext(); + const [images, setImages] = useState([]); + const [selectedImage, setSelectedImage] = useState(undefined); + const { data: registries, isLoading: isLoadingRegistries } = useQuery( + ["getProjectRegistries", projectId], + async () => { + const res = await api.getProjectRegistries("", {}, { 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("", {}, { + 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 ( +
+ Image settings + + Specify your image URL. + + + {(!source.image || !source.image.repository) && ( + ( + <> + + { + setSelectedImage(image); + onChange({ + repository: image.uri, + }); + }} + images={images} + loading={isLoadingImages || isLoadingRegistries} + /> + + + + + )} + /> + )} + + {source.image && source.image.repository && ( + <> + { }} + placeholder="" + /> + { + setValue("source.image", { + repository: "", + tag: "", + }); + }} + > + keyboard_backspace + Select image URL + + + Specify your image tag. + + {!source.image.tag && ( + ( + + { + onChange({ + repository: source.image.repository, + tag, + }); + } + } + /> + + )} + /> + )} + {source.image.tag && ( + <> + { }} + placeholder="" + /> + { + setValue("source.image", { + repository: source.image.repository, + tag: "", + }); + }} + > + keyboard_backspace + Select image tag + + + )} + + )} +
+ ); +}; + +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; +`; diff --git a/dashboard/src/main/home/app-dashboard/image-settings/TagList.tsx b/dashboard/src/main/home/app-dashboard/image-settings/TagList.tsx new file mode 100644 index 0000000000..efb564e1a7 --- /dev/null +++ b/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 = ({ + selectedImage, + projectId, + setSelectedTag, +}) => { + const [tags, setTags] = useState([]); + const [searchFilter, setSearchFilter] = useState(""); + + const { data: tagResp, isLoading, error } = useQuery( + ["getImageTags", selectedImage], + async () => { + if (!selectedImage) { + return; + } + + const res = await api.getImageTags( + "", + {}, + { + 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 ( + + + + ); + } else if (error) { + return Error loading tags.; + } else if (tags.length === 0 && !searchFilter) { + return Please specify a tag.; + } + + 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 ( + { + setSelectedTag(tag.tag); + }} + > + + {tag.tag} + + ); + }); + + if (searchFilter !== "" && !tags.some((tag) => tag.tag === searchFilter)) { + tagCards.push( + { + setSelectedTag(searchFilter); + }} + > + + {`Use tag \"${searchFilter}\"`} + + ); + } + + return tagCards; + }; + + return ( + <> + + + {renderTagList()} + + + ); +}; + +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; +`; diff --git a/dashboard/src/main/home/app-dashboard/image-settings/types.ts b/dashboard/src/main/home/app-dashboard/image-settings/types.ts new file mode 100644 index 0000000000..5cbcf27005 --- /dev/null +++ b/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; + +export const tagValidator = z.object({ tag: z.string(), pushed_at: z.string() }) +export type TagType = z.infer; \ No newline at end of file diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx index e56e727cfa..be745f9abe 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx @@ -173,13 +173,15 @@ const RevisionTableContents: React.FC = ({ selected={ previewRevision ? revision.revision_number === - previewRevision.revision_number + previewRevision.revision_number : isLatestDeployedRevision } onClick={() => { - setPreviewRevision( - isLatestDeployedRevision ? null : revision - ); + if (isLatestDeployedRevision) { + setPreviewRevision(null); + } else { + setPreviewRevision(revision); + } }} > {revision.revision_number} @@ -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"}; } `; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx index cb11d1e364..a9ea4a374e 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx +++ b/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; + porterAppRecord: PorterAppRecord; }; const RevisionsList: React.FC = ({ @@ -37,6 +39,7 @@ const RevisionsList: React.FC = ({ appName, latestSource, onSubmit, + porterAppRecord, }) => { const { servicesFromYaml } = useLatestRevision(); const { setValue } = useFormContext(); @@ -88,6 +91,7 @@ const RevisionsList: React.FC = ({ } ); + // 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 = ({ expandRevisions={expandRevisions} setExpandRevisions={setExpandRevisions} setRevertData={setRevertData} + porterAppRecord={porterAppRecord} /> )) .otherwise(() => null)}