diff --git a/apps/deploy-web/next.config.js b/apps/deploy-web/next.config.js index 93ca1d679..ab9e9d421 100644 --- a/apps/deploy-web/next.config.js +++ b/apps/deploy-web/next.config.js @@ -30,7 +30,7 @@ const moduleExports = { styledComponents: true }, images: { - domains: ["raw.githubusercontent.com"] + domains: ["raw.githubusercontent.com", "avatars.githubusercontent.com"] }, output: "standalone", typescript: { diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx index 9191df96a..1dbb5c6e6 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx @@ -3,6 +3,7 @@ import { createRef, useEffect, useState } from "react"; import { Alert, Button, buttonVariants, Spinner, Tabs, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; import { ArrowLeft } from "iconoir-react"; +import yaml from "js-yaml"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { NextSeo } from "next-seo"; @@ -16,10 +17,12 @@ import { useDeploymentLeaseList } from "@src/queries/useLeaseQuery"; import { useProviderList } from "@src/queries/useProvidersQuery"; import { RouteStep } from "@src/types/route-steps.type"; import { AnalyticsEvents } from "@src/utils/analytics"; +import { deploymentData } from "@src/utils/deploymentData"; import { getDeploymentLocalData } from "@src/utils/deploymentLocalDataUtils"; import { cn } from "@src/utils/styleUtils"; import { UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; +import { getRepoUrl, isRedeployImage } from "../remote-deploy/utils"; import { Title } from "../shared/Title"; import { DeploymentDetailTopBar } from "./DeploymentDetailTopBar"; import { DeploymentLeaseShell } from "./DeploymentLeaseShell"; @@ -31,10 +34,16 @@ import { ManifestUpdate } from "./ManifestUpdate"; export function DeploymentDetail({ dseq }: React.PropsWithChildren<{ dseq: string }>) { const router = useRouter(); const [activeTab, setActiveTab] = useState("LEASES"); + const [editedManifest, setEditedManifest] = useState(null); + const [deploymentVersion, setDeploymentVersion] = useState(null); + const [showOutsideDeploymentMessage, setShowOutsideDeploymentMessage] = useState(false); const { address, isWalletLoaded } = useWallet(); const { isSettingsInit } = useSettings(); const [leaseRefs, setLeaseRefs] = useState>([]); const [deploymentManifest, setDeploymentManifest] = useState(null); + const remoteDeploy: boolean = !!editedManifest && isRedeployImage(editedManifest); + const repo: string | null = remoteDeploy ? getRepoUrl(editedManifest) : null; + const { data: deployment, isFetching: isLoadingDeployment, @@ -95,6 +104,25 @@ export function DeploymentDetail({ dseq }: React.PropsWithChildren<{ dseq: strin // eslint-disable-next-line react-hooks/exhaustive-deps }, [isWalletLoaded, isSettingsInit]); + useEffect(() => { + const init = async () => { + const localDeploymentData = getDeploymentLocalData(deployment?.dseq || ""); + + if (localDeploymentData?.manifest) { + setShowOutsideDeploymentMessage(false); + setEditedManifest(localDeploymentData?.manifest); + const yamlVersion = yaml.load(localDeploymentData?.manifest); + const version = await deploymentData.getManifestVersion(yamlVersion); + + setDeploymentVersion(version); + } else { + setShowOutsideDeploymentMessage(true); + } + }; + + init(); + }, [deployment]); + useEffect(() => { if (leases && leases.some(l => l.state === "active")) { if (tabQuery) { @@ -173,6 +201,13 @@ export function DeploymentDetail({ dseq }: React.PropsWithChildren<{ dseq: strin {activeTab === "EDIT" && deployment && leases && ( { @@ -199,6 +234,7 @@ export function DeploymentDetail({ dseq }: React.PropsWithChildren<{ dseq: strin {leases && leases.map((lease, i) => ( ))} diff --git a/apps/deploy-web/src/components/deployments/LeaseRow.tsx b/apps/deploy-web/src/components/deployments/LeaseRow.tsx index b584ae893..073b43d45 100644 --- a/apps/deploy-web/src/components/deployments/LeaseRow.tsx +++ b/apps/deploy-web/src/components/deployments/LeaseRow.tsx @@ -41,421 +41,441 @@ type Props = { dseq: string; providers: ApiProviderList[]; loadDeploymentDetail: () => void; + remoteDeploy?: boolean; + repo?: string | null; }; export type AcceptRefType = { getLeaseStatus: () => void; }; -export const LeaseRow = React.forwardRef(({ lease, setActiveTab, deploymentManifest, dseq, providers, loadDeploymentDetail }, ref) => { - const provider = providers?.find(p => p.owner === lease?.provider); - const { localCert } = useCertificate(); - const isLeaseActive = lease.state === "active"; - const [isServicesAvailable, setIsServicesAvailable] = useState(false); - const { favoriteProviders, updateFavoriteProviders } = useLocalNotes(); - const isFavorite = favoriteProviders.some(x => lease?.provider === x); - const { - data: leaseStatus, - error, - refetch: getLeaseStatus, - isLoading: isLoadingLeaseStatus - } = useLeaseStatus(provider?.hostUri || "", lease, { - enabled: isLeaseActive && !isServicesAvailable && !!provider?.hostUri && !!localCert, - refetchInterval: 10_000, - onSuccess: leaseStatus => { - if (leaseStatus) { - checkIfServicesAreAvailable(leaseStatus); +export const LeaseRow = React.forwardRef( + ({ lease, setActiveTab, deploymentManifest, dseq, providers, loadDeploymentDetail, remoteDeploy, repo }, ref) => { + const provider = providers?.find(p => p.owner === lease?.provider); + const { localCert } = useCertificate(); + const isLeaseActive = lease.state === "active"; + const [isServicesAvailable, setIsServicesAvailable] = useState(false); + const { favoriteProviders, updateFavoriteProviders } = useLocalNotes(); + const isFavorite = favoriteProviders.some(x => lease?.provider === x); + const { + data: leaseStatus, + error, + refetch: getLeaseStatus, + isLoading: isLoadingLeaseStatus + } = useLeaseStatus(provider?.hostUri || "", lease, { + enabled: isLeaseActive && !isServicesAvailable && !!provider?.hostUri && !!localCert, + refetchInterval: 10_000, + onSuccess: leaseStatus => { + if (leaseStatus) { + checkIfServicesAreAvailable(leaseStatus); + } } + }); + const { isLoading: isLoadingProviderStatus, refetch: getProviderStatus } = useProviderStatus(provider?.hostUri || "", { + enabled: false, + retry: false + }); + const isLeaseNotFound = error && (error as string).includes && (error as string).includes("lease not found") && isLeaseActive; + const servicesNames = useMemo(() => (leaseStatus ? Object.keys(leaseStatus.services) : []), [leaseStatus]); + const [isSendingManifest, setIsSendingManifest] = useState(false); + const { data: bid } = useBidInfo(lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.provider); + const { enqueueSnackbar } = useSnackbar(); + + React.useImperativeHandle(ref, () => ({ + getLeaseStatus: loadLeaseStatus + })); + + const loadLeaseStatus = useCallback(() => { + if (isLeaseActive && provider && localCert) { + getLeaseStatus(); + getProviderStatus(); + } + }, [isLeaseActive, provider, localCert, getLeaseStatus, getProviderStatus]); + + const parsedManifest = useMemo(() => yaml.load(deploymentManifest), [deploymentManifest]); + + const checkIfServicesAreAvailable = leaseStatus => { + const servicesNames = leaseStatus ? Object.keys(leaseStatus.services) : []; + const isServicesAvailable = + servicesNames.length > 0 + ? servicesNames + .map(n => leaseStatus.services[n]) + .every(service => { + return service.available > 0; + }) + : false; + setIsServicesAvailable(isServicesAvailable); + }; + + useEffect(() => { + loadLeaseStatus(); + }, [lease, provider, localCert, loadLeaseStatus]); + + function handleEditManifestClick(ev) { + ev.preventDefault(); + setActiveTab("EDIT"); } - }); - const { isLoading: isLoadingProviderStatus, refetch: getProviderStatus } = useProviderStatus(provider?.hostUri || "", { - enabled: false, - retry: false - }); - const isLeaseNotFound = error && (error as string).includes && (error as string).includes("lease not found") && isLeaseActive; - const servicesNames = useMemo(() => (leaseStatus ? Object.keys(leaseStatus.services) : []), [leaseStatus]); - const [isSendingManifest, setIsSendingManifest] = useState(false); - const { data: bid } = useBidInfo(lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.provider); - const { enqueueSnackbar } = useSnackbar(); - - React.useImperativeHandle(ref, () => ({ - getLeaseStatus: loadLeaseStatus - })); - - const loadLeaseStatus = useCallback(() => { - if (isLeaseActive && provider && localCert) { - getLeaseStatus(); - getProviderStatus(); - } - }, [isLeaseActive, provider, localCert, getLeaseStatus, getProviderStatus]); - - const parsedManifest = useMemo(() => yaml.load(deploymentManifest), [deploymentManifest]); - - const checkIfServicesAreAvailable = leaseStatus => { - const servicesNames = leaseStatus ? Object.keys(leaseStatus.services) : []; - const isServicesAvailable = - servicesNames.length > 0 - ? servicesNames - .map(n => leaseStatus.services[n]) - .every(service => { - return service.available > 0; - }) - : false; - setIsServicesAvailable(isServicesAvailable); - }; - - useEffect(() => { - loadLeaseStatus(); - }, [lease, provider, localCert, loadLeaseStatus]); - - function handleEditManifestClick(ev) { - ev.preventDefault(); - setActiveTab("EDIT"); - } - async function sendManifest() { - setIsSendingManifest(true); - try { - const manifest = deploymentData.getManifest(parsedManifest, true); + async function sendManifest() { + setIsSendingManifest(true); + try { + const manifest = deploymentData.getManifest(parsedManifest, true); - await sendManifestToProvider(provider as ApiProviderList, manifest, dseq, localCert as LocalCert); + await sendManifestToProvider(provider as ApiProviderList, manifest, dseq, localCert as LocalCert); - enqueueSnackbar(, { variant: "success", autoHideDuration: 10_000 }); + enqueueSnackbar(, { variant: "success", autoHideDuration: 10_000 }); - loadDeploymentDetail(); - } catch (err) { - enqueueSnackbar(, { variant: "error", autoHideDuration: null }); + loadDeploymentDetail(); + } catch (err) { + enqueueSnackbar(, { variant: "error", autoHideDuration: null }); + } + setIsSendingManifest(false); } - setIsSendingManifest(false); - } - const onStarClick = event => { - event.preventDefault(); - event.stopPropagation(); + const onStarClick = event => { + event.preventDefault(); + event.stopPropagation(); - const newFavorites = isFavorite ? favoriteProviders.filter(x => x !== lease.provider) : favoriteProviders.concat([lease.provider]); + const newFavorites = isFavorite ? favoriteProviders.filter(x => x !== lease.provider) : favoriteProviders.concat([lease.provider]); - updateFavoriteProviders(newFavorites); - }; + updateFavoriteProviders(newFavorites); + }; - const gpuModels = bid && bid.bid.resources_offer.flatMap(x => getGpusFromAttributes(x.resources.gpu.attributes)); + const gpuModels = bid && bid.bid.resources_offer.flatMap(x => getGpusFromAttributes(x.resources.gpu.attributes)); - const sshInstructions = useMemo(() => { - return servicesNames.reduce((acc, serviceName) => { - if (!sshVmImages.has(get(parsedManifest, ["services", serviceName, "image"]))) { - return acc; - } + const sshInstructions = useMemo(() => { + return servicesNames.reduce((acc, serviceName) => { + if (!sshVmImages.has(get(parsedManifest, ["services", serviceName, "image"]))) { + return acc; + } - const exposes = leaseStatus.forwarded_ports[serviceName]; + const exposes = leaseStatus.forwarded_ports[serviceName]; - return exposes.reduce((exposesAcc, expose) => { - if (expose.port !== 22) { - return exposesAcc; - } + return exposes.reduce((exposesAcc, expose) => { + if (expose.port !== 22) { + return exposesAcc; + } - if (exposesAcc) { - exposesAcc += "\n"; - } + if (exposesAcc) { + exposesAcc += "\n"; + } - return exposesAcc.concat(`ssh root@${expose.host} -p ${expose.externalPort} -i ~/.ssh/id_rsa`); - }, acc); - }, ""); - }, [parsedManifest, servicesNames, leaseStatus]); + return exposesAcc.concat(`ssh root@${expose.host} -p ${expose.externalPort} -i ~/.ssh/id_rsa`); + }, acc); + }, ""); + }, [parsedManifest, servicesNames, leaseStatus]); - return ( - - -
-
- {lease.state} - + return ( + + +
+
+ {lease.state} + - GSEQ: - {lease.gseq} + GSEQ: + {lease.gseq} - OSEQ: - {lease.oseq} -
+ OSEQ: + {lease.oseq} +
- {isLeaseActive && ( -
- setActiveTab("LOGS")}> - View logs - + {isLeaseActive && ( +
+ setActiveTab("LOGS")}> + View logs + +
+ )} +
+
+ +
+
+
- )} -
- - -
-
- + + +
+ } /> -
- - - -
- } - /> - - - {isLeaseActive && isLoadingProviderStatus && } - {provider && ( -
- - {provider.name?.length > 25 ? getSplitText(provider.name, 10, 10) : provider.name} - + + {isLeaseActive && isLoadingProviderStatus && } + {provider && (
- - - {provider?.isAudited && ( -
- -
- )} -
-
- )} - - } - /> -
- - {isLeaseNotFound && ( - - The lease was not found on this provider. This can happen if no manifest was sent to the provider. To send one you can update your deployment in the{" "} - VIEW / EDIT MANIFEST tab. - {deploymentManifest && ( - <> -
- OR -
- - - )} -
- )} - - {!leaseStatus && isLoadingLeaseStatus && } - - {isLeaseActive && - leaseStatus && - leaseStatus.services && - servicesNames - .map(n => leaseStatus.services[n]) - .map((service, i) => ( -
1 && i !== servicesNames.length - 1 - })} - key={`${service.name}_${i}`} - > -
- - {isLoadingLeaseStatus || !isServicesAvailable ? ( -
- -
- ) : ( -
- - Workloads can take some time to spin up. If you see an error when browsing the uri, it is recommended to refresh and wait a bit. - Check the{" "} - setActiveTab("LOGS")} className="text-white"> - logs - {" "} - for more information. - - } - > - - + + {provider.name?.length > 25 ? getSplitText(provider.name, 10, 10) : provider.name} + + +
+ + + {provider?.isAudited && ( +
+ +
+ )} +
)} + + } + /> +
- {isServicesAvailable && ( -
- -
- )} -
+ {isLeaseNotFound && ( + + The lease was not found on this provider. This can happen if no manifest was sent to the provider. To send one you can update your deployment in + the VIEW / EDIT MANIFEST tab. + {deploymentManifest && ( + <> +
+ OR +
+ + + )} +
+ )} + {!leaseStatus && isLoadingLeaseStatus && } + + {isLeaseActive && + leaseStatus && + leaseStatus.services && + servicesNames + .map(n => leaseStatus.services[n]) + .map((service, i) => (
0 || (leaseStatus.forwarded_ports && leaseStatus.forwarded_ports[service.name]?.length > 0) + className={cn("mt-2", { + ["border-b pb-2"]: servicesNames.length > 1 && i !== servicesNames.length - 1 })} + key={`${service.name}_${i}`} > -
- Available:  - 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> - {service.available} - -
-
- Ready Replicas:  - 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> - {service.ready_replicas} - +
+ + {isLoadingLeaseStatus || !isServicesAvailable ? ( +
+ +
+ ) : ( +
+ + Workloads can take some time to spin up. If you see an error when browsing the uri, it is recommended to refresh and wait a bit. + Check the{" "} + setActiveTab("LOGS")} className="text-white"> + logs + {" "} + for more information. + + } + > + + +
+ )} + + {isServicesAvailable && ( +
+ +
+ )}
-
- Total:  - 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> - {service.total} - + +
0 || (leaseStatus.forwarded_ports && leaseStatus.forwarded_ports[service.name]?.length > 0) + })} + > +
+ Available:  + 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> + {service.available} + +
+
+ Ready Replicas:  + 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> + {service.ready_replicas} + +
+
+ Total:  + 0 ? "success" : "destructive"} className="h-3 px-1 text-xs leading-3"> + {service.total} + +
-
- {leaseStatus.forwarded_ports && leaseStatus.forwarded_ports[service.name]?.length > 0 && ( -
0 })}> - - {leaseStatus.forwarded_ports[service.name].map(p => ( -
- {p.host ? ( - - - {p.port}:{p.externalPort} - + {leaseStatus.forwarded_ports && leaseStatus.forwarded_ports[service.name]?.length > 0 && !remoteDeploy && ( +
0 })}> + + {leaseStatus.forwarded_ports[service.name].map(p => ( +
+ {p.host ? ( + + + {p.port}:{p.externalPort} + + + + ) : ( + {`${p.port}:${p.externalPort}`} + )} +
+ ))} +
+ } + /> +
+ )} + + {remoteDeploy && repo && ( + <> +
+ +
    +
  • + + {repo?.replace("https://github.com/", "")?.replace("https://gitlab.com/", "")} + + + +
  • +
+
+ + )} + {service.uris?.length > 0 && ( + <> +
+ +
    + {service.uris.map(uri => { + return ( +
  • + + {uri} - ) : ( - {`${p.port}:${p.externalPort}`} - )} -
- ))} -
- } - /> -
- )} - - {service.uris?.length > 0 && ( - <> -
- -
    - {service.uris.map(uri => { - return ( -
  • - - {uri} - - -    - -
  • - ); - })} -
-
- - )} -
- ))} - - {isLeaseActive && leaseStatus && leaseStatus.ips && ( -
- -
    - {servicesNames - .flatMap(service => leaseStatus.ips[service]) - .filter(Boolean) - .map(ip => ( -
  • - - - {ip.IP}:{ip.ExternalPort} - - - -    - -
    IP: {ip.IP}
    -
    External Port: {ip.ExternalPort}
    -
    Port: {ip.Port}
    -
    Protocol: {ip.Protocol}
    - - } - > - -
    - -
  • - ))} -
-
- )} - - {sshInstructions && ( -
-
SSH Instructions:
-
    -
  • - Open a command terminal on your machine and copy this command into it: - -
  • -
  • - Replace ~/.ssh/id_rsa with the path to the private key (stored on your local machine) corresponding to the public key you provided earlier -
  • -
  • Run the command
  • -
-
- )} - -
- ); -}); +    + + + ); + })} + + + + )} + + ))} + + {isLeaseActive && leaseStatus && leaseStatus.ips && ( +
+ +
    + {servicesNames + .flatMap(service => leaseStatus.ips[service]) + .filter(Boolean) + .map(ip => ( +
  • + + + {ip.IP}:{ip.ExternalPort} + + + +    + +
    IP: {ip.IP}
    +
    External Port: {ip.ExternalPort}
    +
    Port: {ip.Port}
    +
    Protocol: {ip.Protocol}
    + + } + > + +
    + +
  • + ))} +
+
+ )} + + {sshInstructions && ( +
+
SSH Instructions:
+
    +
  • + Open a command terminal on your machine and copy this command into it: + +
  • +
  • + Replace ~/.ssh/id_rsa with the path to the private key (stored on your local machine) corresponding to the public key you provided earlier +
  • +
  • Run the command
  • +
+
+ )} + + + ); + } +); diff --git a/apps/deploy-web/src/components/deployments/ManifestUpdate.tsx b/apps/deploy-web/src/components/deployments/ManifestUpdate.tsx index a14b02e81..49128c862 100644 --- a/apps/deploy-web/src/components/deployments/ManifestUpdate.tsx +++ b/apps/deploy-web/src/components/deployments/ManifestUpdate.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; +import { Dispatch, useEffect, useState } from "react"; import { Alert, Button, CustomTooltip, Snackbar, Spinner } from "@akashnetwork/ui/components"; import { InfoCircle, WarningCircle } from "iconoir-react"; import yaml from "js-yaml"; @@ -19,9 +19,10 @@ import { DeploymentDto, LeaseDto } from "@src/types/deployment"; import { ApiProviderList } from "@src/types/provider"; import { AnalyticsEvents } from "@src/utils/analytics"; import { deploymentData } from "@src/utils/deploymentData"; -import { getDeploymentLocalData, saveDeploymentManifest } from "@src/utils/deploymentLocalDataUtils"; +import { saveDeploymentManifest } from "@src/utils/deploymentLocalDataUtils"; import { sendManifestToProvider } from "@src/utils/deploymentUtils"; import { TransactionMessageData } from "@src/utils/TransactionMessageData"; +import RemoteDeployUpdate from "../remote-deploy/update/RemoteDeployUpdate"; import { ManifestErrorSnackbar } from "../shared/ManifestErrorSnackbar"; import { Title } from "../shared/Title"; @@ -29,39 +30,37 @@ type Props = { deployment: DeploymentDto; leases: LeaseDto[]; closeManifestEditor: () => void; + remoteDeploy: boolean; + showOutsideDeploymentMessage: boolean; + editedManifest: string; + deploymentVersion: string | null; + setDeploymentVersion: Dispatch>; + setEditedManifest: Dispatch>; + setShowOutsideDeploymentMessage: Dispatch>; }; -export const ManifestUpdate: React.FunctionComponent = ({ deployment, leases, closeManifestEditor }) => { +export const ManifestUpdate: React.FunctionComponent = ({ + deployment, + leases, + closeManifestEditor, + remoteDeploy, + showOutsideDeploymentMessage, + editedManifest, + deploymentVersion, + setDeploymentVersion, + setEditedManifest, + setShowOutsideDeploymentMessage +}) => { const [parsingError, setParsingError] = useState(null); - const [deploymentVersion, setDeploymentVersion] = useState(null); - const [editedManifest, setEditedManifest] = useState(""); + const [isSendingManifest, setIsSendingManifest] = useState(false); - const [showOutsideDeploymentMessage, setShowOutsideDeploymentMessage] = useState(false); + const { settings } = useSettings(); const { address, signAndBroadcastTx, isManaged: isManagedWallet } = useWallet(); const { data: providers } = useProviderList(); const { localCert, isLocalCertMatching, createCertificate, isCreatingCert } = useCertificate(); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); - useEffect(() => { - const init = async () => { - const localDeploymentData = getDeploymentLocalData(deployment.dseq); - - if (localDeploymentData && localDeploymentData.manifest) { - setEditedManifest(localDeploymentData?.manifest); - - const yamlVersion = yaml.load(localDeploymentData?.manifest); - const version = await deploymentData.getManifestVersion(yamlVersion); - - setDeploymentVersion(version); - } else { - setShowOutsideDeploymentMessage(true); - } - }; - - init(); - }, [deployment]); - /** * Validate the manifest periodically */ @@ -240,6 +239,7 @@ export const ManifestUpdate: React.FunctionComponent = ({ deployment, lea disabled={!!parsingError || !editedManifest || !providers || isSendingManifest || deployment.state !== "active"} onClick={() => handleUpdateClick()} size="sm" + type="button" > Update Deployment @@ -251,8 +251,12 @@ export const ManifestUpdate: React.FunctionComponent = ({ deployment, lea - - + + {remoteDeploy ? ( + + ) : ( + + )} diff --git a/apps/deploy-web/src/components/new-deployment/ManifestEdit.tsx b/apps/deploy-web/src/components/new-deployment/ManifestEdit.tsx index cf4a65fa2..995535663 100644 --- a/apps/deploy-web/src/components/new-deployment/ManifestEdit.tsx +++ b/apps/deploy-web/src/components/new-deployment/ManifestEdit.tsx @@ -1,7 +1,7 @@ "use client"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { certificateManager } from "@akashnetwork/akashjs/build/certificates/certificate-manager"; -import { Alert, Button, CustomTooltip, Input, Spinner } from "@akashnetwork/ui/components"; +import { Alert, Button, CustomTooltip, Input, Snackbar, Spinner } from "@akashnetwork/ui/components"; import { EncodeObject } from "@cosmjs/proto-signing"; import { useTheme as useMuiTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -9,6 +9,7 @@ import { ArrowRight, InfoCircle } from "iconoir-react"; import { useAtom } from "jotai"; import { useRouter, useSearchParams } from "next/navigation"; import { event } from "nextjs-google-analytics"; +import { useSnackbar } from "notistack"; import { browserEnvConfig } from "@src/config/browser-env.config"; import { useCertificate } from "@src/context/CertificateProvider"; @@ -47,15 +48,18 @@ type Props = { selectedTemplate: TemplateCreation | null; editedManifest: string | null; setEditedManifest: Dispatch>; + github?: boolean; + setGithub?: Dispatch; }; -export const ManifestEdit: React.FunctionComponent = ({ editedManifest, setEditedManifest, onTemplateSelected, selectedTemplate }) => { +export const ManifestEdit: React.FunctionComponent = ({ editedManifest, setEditedManifest, onTemplateSelected, selectedTemplate, github }) => { const [parsingError, setParsingError] = useState(null); const [deploymentName, setDeploymentName] = useState(""); const [isCreatingDeployment, setIsCreatingDeployment] = useState(false); const [isDepositingDeployment, setIsDepositingDeployment] = useState(false); const [isCheckingPrerequisites, setIsCheckingPrerequisites] = useState(false); const [selectedSdlEditMode, setSelectedSdlEditMode] = useAtom(sdlStore.selectedSdlEditMode); + const [isRepoDataValidated, setIsRepoDataValidated] = useState(false); const [sdlDenom, setSdlDenom] = useState("uakt"); const { settings } = useSettings(); const { address, signAndBroadcastTx, isManaged } = useWallet(); @@ -75,6 +79,7 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s const wallet = useWallet(); const managedDenom = useManagedWalletDenom(); const { createDeploymentConfirm } = useManagedDeploymentConfirm(); + const { enqueueSnackbar } = useSnackbar(); useWhen(wallet.isManaged && sdlDenom === "uakt", () => { setSdlDenom(managedDenom); @@ -87,7 +92,6 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s }, [editedManifest] ); - useWhen(hasComponent("ssh"), () => { setSelectedSdlEditMode("builder"); }); @@ -122,6 +126,13 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s } }; + useEffect(() => { + if (github) { + setSelectedSdlEditMode("builder"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [github]); + useEffect(() => { if (selectedTemplate?.name) { setDeploymentName(selectedTemplate.name); @@ -179,6 +190,13 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s } const handleCreateDeployment = async () => { + if (github && !isRepoDataValidated) { + enqueueSnackbar(, { + variant: "error" + }); + return; + } + if (selectedSdlEditMode === "builder") { const valid = await sdlBuilderRef.current?.validate(); if (!valid) return; @@ -353,44 +371,46 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s -
- {hasComponent("yml-editor") && ( -
- - -
- )} - {hasComponent("yml-uploader") && !templateId && ( - <> - - - - )} -
+ {!github && ( +
+ {hasComponent("yml-editor") && ( +
+ + +
+ )} + {hasComponent("yml-uploader") && !templateId && ( + <> + + + + )} +
+ )} {parsingError && {parsingError}} @@ -400,7 +420,15 @@ export const ManifestEdit: React.FunctionComponent = ({ editedManifest, s
)} {(hasComponent("ssh") || selectedSdlEditMode === "builder") && ( - + )} {isDepositingDeployment && ( diff --git a/apps/deploy-web/src/components/new-deployment/NewDeploymentContainer.tsx b/apps/deploy-web/src/components/new-deployment/NewDeploymentContainer.tsx index c941e1a26..8f68bad2c 100644 --- a/apps/deploy-web/src/components/new-deployment/NewDeploymentContainer.tsx +++ b/apps/deploy-web/src/components/new-deployment/NewDeploymentContainer.tsx @@ -12,12 +12,14 @@ import { RouteStep } from "@src/types/route-steps.type"; import { hardcodedTemplates } from "@src/utils/templates"; import { UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; +import { isRedeployImage } from "../remote-deploy/utils"; import { CreateLease } from "./CreateLease"; import { ManifestEdit } from "./ManifestEdit"; import { CustomizedSteppers } from "./Stepper"; import { TemplateList } from "./TemplateList"; export const NewDeploymentContainer: FC = () => { + const [github, setGithub] = useState(false); const { isLoading: isLoadingTemplates, templates } = useTemplates(); const [activeStep, setActiveStep] = useState(null); const [selectedTemplate, setSelectedTemplate] = useState(null); @@ -39,6 +41,7 @@ export const NewDeploymentContainer: FC = () => { if (redeployTemplate) { // If it's a redeployment, set the template from local storage setSelectedTemplate(redeployTemplate as TemplateCreation); + setEditedManifest(redeployTemplate.content as string); } else if (galleryTemplate) { // If it's a deployment from the template gallery, load from template data @@ -50,12 +53,39 @@ export const NewDeploymentContainer: FC = () => { } } + const code = searchParams?.get("code"); + const type = searchParams?.get("type"); + const state = searchParams?.get("state"); + const redeploy = searchParams?.get("redeploy"); + + if (type === "github" || code || state === "gitlab") { + if (!redeploy) { + if (state === "gitlab") { + router.replace( + UrlService.newDeployment({ + step: RouteStep.editDeployment, + type: "gitlab", + code + }) + ); + } + } + + setGithub(true); + } else { + setGithub(false); + } + const queryStep = searchParams?.get("step"); const _activeStep = getStepIndexByParam(queryStep); setActiveStep(_activeStep); if ((redeployTemplate || galleryTemplate) && queryStep !== RouteStep.editDeployment) { - router.replace(UrlService.newDeployment({ ...searchParams, step: RouteStep.editDeployment })); + if (isRedeployImage(redeployTemplate?.content as string)) { + router.replace(UrlService.newDeployment({ ...searchParams, step: RouteStep.editDeployment, type: "github", redeploy: redeploy ?? "redeploy" })); + } else { + router.replace(UrlService.newDeployment({ ...searchParams, step: RouteStep.editDeployment })); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, templates]); @@ -128,13 +158,15 @@ export const NewDeploymentContainer: FC = () => {
{activeStep !== null && }
- {activeStep === 0 && } + {activeStep === 0 && } {activeStep === 1 && ( )} {activeStep === 2 && } diff --git a/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx b/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx index 16610f3aa..00585b733 100644 --- a/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx +++ b/apps/deploy-web/src/components/new-deployment/SdlBuilder.tsx @@ -16,11 +16,16 @@ import { defaultService, defaultSshVMService } from "@src/utils/sdl/data"; import { generateSdl } from "@src/utils/sdl/sdlGenerator"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import { transformCustomSdlFields, TransformError } from "@src/utils/sdl/transformCustomSdlFields"; +import GithubDeploy from "../remote-deploy/GithubDeploy"; import { SimpleServiceFormControl } from "../sdl/SimpleServiceFormControl"; interface Props { sdlString: string | null; setEditedManifest: Dispatch; + github?: boolean; + setDeploymentName: Dispatch; + deploymentName: string; + setIsRepoDataValidated?: Dispatch; } export type SdlBuilderRefType = { @@ -28,161 +33,176 @@ export type SdlBuilderRefType = { validate: () => Promise; }; -export const SdlBuilder = React.forwardRef(({ sdlString, setEditedManifest }, ref) => { - const [error, setError] = useState(null); - const formRef = useRef(null); - const [isInit, setIsInit] = useState(false); - const { hasComponent, imageList } = useSdlBuilder(); - const form = useForm({ - defaultValues: { - services: [cloneDeep(hasComponent("ssh") ? defaultSshVMService : defaultService)], - imageList: imageList, - hasSSHKey: hasComponent("ssh") - }, - resolver: zodResolver(SdlBuilderFormValuesSchema) - }); - const { control, trigger, watch, setValue } = form; - const { - fields: services, - remove: removeService, - append: appendService - } = useFieldArray({ - control, - name: "services", - keyName: "id" - }); - const { services: formServices = [] } = watch(); - const { data: gpuModels } = useGpuModels(); - const [serviceCollapsed, setServiceCollapsed] = useState([]); - const wallet = useWallet(); - const managedDenom = useManagedWalletDenom(); - - useWhen( - wallet.isManaged, - () => { - formServices.forEach((service, index) => { - const { denom } = service.placement.pricing; - - if (denom !== managedDenom) { - setValue(`services.${index}.placement.pricing.denom`, managedDenom); - } - }); - }, - [formServices, sdlString] - ); - - React.useImperativeHandle(ref, () => ({ - getSdl: getSdl, - validate: async () => { - return await trigger(); - } - })); - - useEffect(() => { - const { unsubscribe } = watch(data => { - const sdl = generateSdl(data.services as ServiceType[]); - setEditedManifest(sdl); +export const SdlBuilder = React.forwardRef( + ({ sdlString, setEditedManifest, github, setDeploymentName, deploymentName, setIsRepoDataValidated }, ref) => { + const [error, setError] = useState(null); + const formRef = useRef(null); + const [isInit, setIsInit] = useState(false); + const { hasComponent, imageList } = useSdlBuilder(); + const form = useForm({ + defaultValues: { + services: [cloneDeep(hasComponent("ssh") ? defaultSshVMService : defaultService)], + imageList: imageList, + hasSSHKey: hasComponent("ssh") + }, + resolver: zodResolver(SdlBuilderFormValuesSchema) + }); + const { control, trigger, watch, setValue } = form; + const { + fields: services, + remove: removeService, + append: appendService + } = useFieldArray({ + control, + name: "services", + keyName: "id" }); + const { services: formServices = [] } = watch(); + const { data: gpuModels } = useGpuModels(); + const [serviceCollapsed, setServiceCollapsed] = useState(github ? [0] : []); + + const wallet = useWallet(); + const managedDenom = useManagedWalletDenom(); + + useWhen( + wallet.isManaged, + () => { + formServices.forEach((service, index) => { + const { denom } = service.placement.pricing; + + if (denom !== managedDenom) { + setValue(`services.${index}.placement.pricing.denom`, managedDenom); + } + }); + }, + [formServices, sdlString] + ); + + React.useImperativeHandle(ref, () => ({ + getSdl: getSdl, + validate: async () => { + return await trigger(); + } + })); + + useEffect(() => { + const { unsubscribe } = watch(data => { + const sdl = generateSdl(data.services as ServiceType[]); + setEditedManifest(sdl); + }); - try { - if (sdlString) { - const services = createAndValidateSdl(sdlString); - setValue("services", services as ServiceType[]); + try { + if (sdlString) { + const services = createAndValidateSdl(sdlString); + setValue("services", services as ServiceType[]); + } + } catch (error) { + setError("Error importing SDL"); } - } catch (error) { - setError("Error importing SDL"); - } - setIsInit(true); + setIsInit(true); - return () => { - unsubscribe(); - }; - }, [watch]); - - const getSdl = () => { - try { - return generateSdl(transformCustomSdlFields(formServices, { withSSH: hasComponent("ssh") })); - } catch (err) { - if (err instanceof TransformError) { - setError(err.message); + return () => { + unsubscribe(); + }; + }, [watch]); + + const getSdl = () => { + try { + return generateSdl(transformCustomSdlFields(formServices, { withSSH: hasComponent("ssh") })); + } catch (err) { + if (err instanceof TransformError) { + setError(err.message); + } } - } - }; - - const createAndValidateSdl = (yamlStr: string) => { - try { - if (!yamlStr) return []; - - const services = importSimpleSdl(yamlStr); - - setError(null); - - return services; - } catch (err) { - if (err.name === "YAMLException" || err.name === "CustomValidationError") { - setError(err.message); - } else if (err.name === "TemplateValidation") { - setError(err.message); - } else { - setError("Error while parsing SDL file"); - console.error(err); + }; + + const createAndValidateSdl = (yamlStr: string) => { + try { + if (!yamlStr) return []; + + const services = importSimpleSdl(yamlStr); + + setError(null); + + return services; + } catch (err) { + if (err.name === "YAMLException" || err.name === "CustomValidationError") { + setError(err.message); + } else if (err.name === "TemplateValidation") { + setError(err.message); + } else { + setError("Error while parsing SDL file"); + } } - } - }; - - const onAddService = () => { - appendService({ ...defaultService, id: nanoid(), title: `service-${services.length + 1}` }); - }; - - const onRemoveService = (index: number) => { - removeService(index); - }; - - return ( -
- {!isInit ? ( -
- -
- ) : ( -
- - {formServices && - services.map((service, serviceIndex) => ( - - ))} - - {error && ( - - {error} - - )} + }; + + const onAddService = () => { + appendService({ ...defaultService, id: nanoid(), title: `service-${services.length + 1}` }); + }; + + const onRemoveService = (index: number) => { + removeService(index); + }; - {!hasComponent("ssh") && ( -
-
- -
-
+ return ( +
+ {!isInit ? ( +
+ +
+ ) : ( + <> + {github && ( + )} - - - )} -
- ); -}); +
+ + {formServices && + services.map((service, serviceIndex) => ( + + ))} + + {error && ( + + {error} + + )} + + {!hasComponent("ssh") && !github && ( +
+
+ +
+
+ )} + + + + )} +
+ ); + } +); diff --git a/apps/deploy-web/src/components/new-deployment/TemplateList.tsx b/apps/deploy-web/src/components/new-deployment/TemplateList.tsx index 2144bbe84..1b35c2a28 100644 --- a/apps/deploy-web/src/components/new-deployment/TemplateList.tsx +++ b/apps/deploy-web/src/components/new-deployment/TemplateList.tsx @@ -1,8 +1,7 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { Dispatch, useEffect, useState } from "react"; import { Button, buttonVariants } from "@akashnetwork/ui/components"; -import { ArrowRight, Cpu, Linux, Rocket, Wrench } from "iconoir-react"; -import { NavArrowLeft } from "iconoir-react"; +import { ArrowRight, Cpu, Linux, NavArrowLeft, Rocket, Wrench } from "iconoir-react"; import { useAtom } from "jotai"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -13,7 +12,6 @@ import sdlStore from "@src/store/sdlStore"; import { ApiTemplate } from "@src/types"; import { RouteStep } from "@src/types/route-steps.type"; import { cn } from "@src/utils/styleUtils"; -import { helloWorldTemplate } from "@src/utils/templates"; import { domainName, NewDeploymentParams, UrlService } from "@src/utils/urlUtils"; import { CustomNextSeo } from "../shared/CustomNextSeo"; import { TemplateBox } from "../templates/TemplateBox"; @@ -33,13 +31,21 @@ const previewTemplateIds = [ "akash-network-awesome-akash-grok", "akash-network-awesome-akash-FastChat" ]; - -export const TemplateList: React.FunctionComponent = () => { +type Props = { + setGithub: Dispatch; +}; +export const TemplateList: React.FunctionComponent = ({ setGithub }) => { const { templates } = useTemplates(); const router = useRouter(); const [previewTemplates, setPreviewTemplates] = useState([]); const [, setSdlEditMode] = useAtom(sdlStore.selectedSdlEditMode); const previousRoute = usePreviousRoute(); + const handleGithubTemplate = async () => { + setGithub(true); + router.push( + UrlService.newDeployment({ step: RouteStep.editDeployment, type: "github", templateId: "akash-network-awesome-akash-automatic-deployment-CICD-template" }) + ); + }; useEffect(() => { if (templates) { @@ -76,11 +82,17 @@ export const TemplateList: React.FunctionComponent = () => {
- } - onClick={() => router.push(UrlService.newDeployment({ step: RouteStep.editDeployment, templateId: helloWorldTemplate.code }))} + onClick={() => router.push(UrlService.newDeployment({ step: RouteStepKeys.editDeployment, templateId: helloWorldTemplate.code }))} + /> */} + } + onClick={handleGithubTemplate} /> { + const [token, setToken] = useAtom(tokens); + return ( + + + + + + {userProfile?.login || userProfileBit?.username || userProfileGitLab?.name} + + { + setToken({ + access_token: null, + refresh_token: null, + type: "github", + alreadyLoggedIn: token?.alreadyLoggedIn?.includes(token.type) + ? token.alreadyLoggedIn + : token?.alreadyLoggedIn && token?.alreadyLoggedIn?.length > 0 + ? [...token.alreadyLoggedIn, token.type] + : [token.type] + }); + }} + className="flex cursor-pointer items-center gap-2" + > + Switch Git Provider + + + setToken({ + access_token: null, + refresh_token: null, + type: "github", + alreadyLoggedIn: [] + }) + } + className="flex cursor-pointer items-center gap-2" + > + Logout + + + + ); +}; + +export default AccountDropDown; diff --git a/apps/deploy-web/src/components/remote-deploy/Advanced.tsx b/apps/deploy-web/src/components/remote-deploy/Advanced.tsx new file mode 100644 index 000000000..551cfd06e --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/Advanced.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { Control } from "react-hook-form"; +import { Card, CardContent, Collapsible, CollapsibleContent, CollapsibleTrigger, Separator } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; +import { NavArrowDown } from "iconoir-react"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { EnvFormModal } from "./EnvFormModal"; +import { EnvVarList } from "./EnvList"; + +const Advanced = ({ services, control }: { services: ServiceType[]; control: Control }) => { + const serviceIndex = 0; + const [expanded, setExpanded] = useState(false); + const currentService = services[serviceIndex]; + const [isEditingEnv, setIsEditingEnv] = useState(null); + return ( + { + setExpanded(value); + }} + > + + + +
+

{!expanded && "Environment Variables"}

+ +
+
+ {expanded && } + +
+ {isEditingEnv === serviceIndex && ( + setIsEditingEnv(null)} serviceIndex={serviceIndex} envs={currentService.env || []} /> + )} +
+ +
+
+
+
+
+
+ ); +}; + +export default Advanced; diff --git a/apps/deploy-web/src/components/remote-deploy/CustomInput.tsx b/apps/deploy-web/src/components/remote-deploy/CustomInput.tsx new file mode 100644 index 000000000..47a62ebeb --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/CustomInput.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Input } from "@akashnetwork/ui/components"; +const CustomInput = ({ + label, + description, + placeholder, + onChange +}: { + label: string; + description?: string; + placeholder: string; + onChange: (e: React.ChangeEvent) => void; +}) => { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ +
+ ); +}; + +export default CustomInput; diff --git a/apps/deploy-web/src/components/remote-deploy/Details.tsx b/apps/deploy-web/src/components/remote-deploy/Details.tsx new file mode 100644 index 000000000..d020cbfc1 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/Details.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Card, CardContent, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger, Label, Separator } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; +import { NavArrowDown } from "iconoir-react"; + +import { ServiceType } from "@src/types"; +import CustomInput from "./CustomInput"; +import { appendEnv, ServiceSetValue } from "./utils"; + +const Details = ({ services, setValue }: { services: ServiceType[]; setValue: ServiceSetValue }) => { + const [expanded, setExpanded] = useState(false); + const currentService = services[0]; + return ( + { + setExpanded(value); + }} + > + + + +
+

Build & Install Configurations

+ +
+
+ {expanded && } + +
+ appendEnv("INSTALL_COMMAND", e.target.value, false, setValue, services)} + label="Install Command" + placeholder="npm install" + /> + appendEnv("BUILD_DIRECTORY", e.target.value, false, setValue, services)} label="Build Directory" placeholder="dist" /> + appendEnv("BUILD_COMMAND", e.target.value, false, setValue, services)} + label="Build Command" + placeholder="npm run build" + /> + appendEnv("CUSTOM_SRC", e.target.value, false, setValue, services)} label="Start Command" placeholder="npm start" /> + appendEnv("NODE_VERSION", e.target.value, false, setValue, services)} label="Node Version" placeholder="21" /> +
+
+ + + env.key === "DISABLE_PULL")?.value !== "yes"} + id="disable-pull" + defaultChecked={false} + onCheckedChange={value => { + const pull = !value ? "yes" : "no"; + appendEnv("DISABLE_PULL", pull, false, setValue, services); + }} + /> +
+

If checked, Console will automatically re-deploy your app on any code commits

+
+
+
+
+
+
+ ); +}; +export default Details; diff --git a/apps/deploy-web/src/components/remote-deploy/EnvFormModal.tsx b/apps/deploy-web/src/components/remote-deploy/EnvFormModal.tsx new file mode 100644 index 000000000..358dab073 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/EnvFormModal.tsx @@ -0,0 +1,171 @@ +"use client"; +import { ReactNode, useEffect, useState } from "react"; +import { Control, Controller, useFieldArray } from "react-hook-form"; +import { Button, CustomNoDivTooltip, FormField, FormInput, Popup, Switch } from "@akashnetwork/ui/components"; +import { Bin } from "iconoir-react"; +import { nanoid } from "nanoid"; + +import { EnvironmentVariableType, RentGpusFormValuesType, SdlBuilderFormValuesType } from "@src/types"; +import { cn } from "@src/utils/styleUtils"; +import { FormPaper } from "../sdl/FormPaper"; +import { hiddenEnv } from "./utils"; + +type Props = { + serviceIndex: number; + onClose: () => void; + envs: EnvironmentVariableType[]; + control: Control; + hasSecretOption?: boolean; + children?: ReactNode; + update?: boolean; +}; + +export const EnvFormModal: React.FunctionComponent = ({ update, control, serviceIndex, envs: _envs, onClose, hasSecretOption = true }) => { + const [currentEnvs, setCurrentEnvs] = useState(); + const { + fields: envs, + remove: removeEnv, + append: appendEnv + } = useFieldArray({ + control, + name: `services.${serviceIndex}.env`, + keyName: "id" + }); + + const onAddEnv = () => { + appendEnv({ id: nanoid(), key: "", value: "", isSecret: false }); + }; + + const _onClose = () => { + const _envToRemove: number[] = []; + + _envs.forEach((e, i) => { + if (!e.key.trim()) { + _envToRemove.push(i); + } + }); + + removeEnv(_envToRemove); + + onClose(); + }; + + useEffect(() => { + const CurEnvs = envs?.filter(e => !hiddenEnv.includes(e?.key?.trim())); + setCurrentEnvs(CurEnvs); + + if (CurEnvs.length === 0 && !update) { + onAddEnv(); + } + }, [envs]); + + return ( + + + {currentEnvs?.map((env, envIndex) => { + const index = envs?.findIndex(e => e.id === env.id); + + return ( +
+
+ ( +
+ field.onChange(event.target.value)} + className="w-full" + /> +
+ )} + /> + + ( +
+ field.onChange(event.target.value)} + className="w-full" + /> +
+ )} + /> +
+ +
0, + ["justify-end"]: index === 0 || !hasSecretOption + })} + > + {envIndex > 0 && ( + + )} + + {hasSecretOption && ( + ( + +

+ Secret +

+

+ This is for secret variables containing sensitive information you don't want to be saved in your template. +

+ + } + > + +
+ )} + /> + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/deploy-web/src/components/remote-deploy/EnvList.tsx b/apps/deploy-web/src/components/remote-deploy/EnvList.tsx new file mode 100644 index 000000000..2e298478b --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/EnvList.tsx @@ -0,0 +1,58 @@ +"use client"; +import { Dispatch, ReactNode, SetStateAction } from "react"; +import { CustomTooltip } from "@akashnetwork/ui/components"; +import { InfoCircle } from "iconoir-react"; + +import { ServiceType } from "@src/types"; +import { FormPaper } from "../sdl/FormPaper"; +import { hiddenEnv } from "./utils"; + +type Props = { + currentService: ServiceType; + serviceIndex?: number; + children?: ReactNode; + setIsEditingEnv: Dispatch>; +}; + +export const EnvVarList: React.FunctionComponent = ({ currentService, setIsEditingEnv, serviceIndex }) => { + const envs = currentService.env?.filter(e => !hiddenEnv.includes(e.key)); + return ( + +
+ Environment Variables + + + A list of environment variables to expose to the running container. +
+
+ + View official documentation. + + + } + > + +
+ + setIsEditingEnv(serviceIndex !== undefined ? serviceIndex : true)} + > + Edit + +
+ + {(envs?.length || 0) > 0 ? ( + envs?.map((e, i) => ( +
+ {e.key}={e.value} +
+ )) + ) : ( +

None

+ )} +
+ ); +}; diff --git a/apps/deploy-web/src/components/remote-deploy/GithubDeploy.tsx b/apps/deploy-web/src/components/remote-deploy/GithubDeploy.tsx new file mode 100644 index 000000000..f77fff750 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/GithubDeploy.tsx @@ -0,0 +1,229 @@ +import { Dispatch, useEffect, useState } from "react"; +import { Control, UseFormSetValue } from "react-hook-form"; +import { Button, Spinner, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; +import { Bitbucket, Github as GitIcon, GitlabFull } from "iconoir-react"; +import { useAtom } from "jotai"; + +import { useWhen } from "@src/hooks/useWhen"; +import { tokens } from "@src/store/remoteDeployStore"; +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { handleLogin, handleReLogin, useFetchAccessToken, useUserProfile } from "./api/api"; +import { handleLoginBit, useBitFetchAccessToken, useBitUserProfile } from "./api/bitbucket-api"; +import { handleGitLabLogin, useGitLabFetchAccessToken, useGitLabUserProfile } from "./api/gitlab-api"; +import Bit from "./bitbucket/Bit"; +import Github from "./github/Github"; +import GitLab from "./gitlab/Gitlab"; +import AccountDropDown from "./AccountDropdown"; +import Advanced from "./Advanced"; +import CustomInput from "./CustomInput"; +import Details from "./Details"; +import { appendEnv } from "./utils"; + +const GithubDeploy = ({ + setValue, + services, + control, + deploymentName, + setDeploymentName, + setIsRepoDataValidated +}: { + setValue: UseFormSetValue; + services: ServiceType[]; + control: Control; + setDeploymentName: Dispatch; + deploymentName: string; + setIsRepoDataValidated?: Dispatch; +}) => { + const [token, setToken] = useAtom(tokens); + const [selectedTab, setSelectedTab] = useState("git"); + + const [hydrated, setHydrated] = useState(false); + + const { data: userProfile, isLoading: fetchingProfile } = useUserProfile(); + const { data: userProfileBit, isLoading: fetchingProfileBit } = useBitUserProfile(); + const { data: userProfileGitLab, isLoading: fetchingProfileGitLab } = useGitLabUserProfile(); + + const { mutate: fetchAccessToken, isLoading: fetchingToken } = useFetchAccessToken(); + const { mutate: fetchAccessTokenBit, isLoading: fetchingTokenBit } = useBitFetchAccessToken(); + const { mutate: fetchAccessTokenGitLab, isLoading: fetchingTokenGitLab } = useGitLabFetchAccessToken(); + + useWhen( + services?.[0]?.env?.find(e => e.key === "REPO_URL" && services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")), + () => { + setIsRepoDataValidated?.(true); + } + ); + useWhen(services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value === "https://github.com/onwidget/astrowind", () => { + setValue("services.0.env", []); + }); + useWhen(!services?.[0]?.env?.find(e => e.key === "REPO_URL" && services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")), () => { + setIsRepoDataValidated?.(false); + }); + + useEffect(() => { + setHydrated(true); + }, []); + + useEffect(() => { + const url = new URL(window.location.href); + + const code = url.searchParams.get("code"); + + if (code && !token?.access_token && hydrated) { + if (token?.type === "github") fetchAccessToken(code); + if (token?.type === "bitbucket") fetchAccessTokenBit(code); + if (token?.type === "gitlab") fetchAccessTokenGitLab(code); + } + }, [hydrated]); + + return ( + <> +
+
+

Import Repository

+ + {token?.access_token && ( +
+ +
+ )} +
+ + { + { + setSelectedTab(value); + setValue("services.0.env", []); + }} + defaultValue="git" + className="mt-6" + > +
+ + + Git Provider + + + Third-Party Git Repository + + + {token?.access_token && ( +
+ +
+ )} +
+ + {fetchingToken || fetchingProfile || fetchingTokenBit || fetchingProfileBit || fetchingTokenGitLab || fetchingProfileGitLab ? ( +
+ +

Loading...

+
+ ) : ( + !token?.access_token && ( +
+
+

Connect Account

+

Connect a git provider to access your repositories.

+
+
+ + + +
+
+ ) + )} +
+ + appendEnv("REPO_URL", e.target.value, false, setValue, services)} + /> + appendEnv("BRANCH_NAME", e.target.value, false, setValue, services)} + /> + +
+ } + + {selectedTab === "git" && token?.access_token && ( +
+ {token?.type === "github" ? ( + <> + + + ) : token?.type === "bitbucket" ? ( + + ) : ( + + )} +
+ )} +
+
+ + + ); +}; + +export default GithubDeploy; diff --git a/apps/deploy-web/src/components/remote-deploy/Repos.tsx b/apps/deploy-web/src/components/remote-deploy/Repos.tsx new file mode 100644 index 000000000..924e830d9 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/Repos.tsx @@ -0,0 +1,325 @@ +import { Dispatch, useEffect, useState } from "react"; +import { UseFormSetValue } from "react-hook-form"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + RadioGroup, + RadioGroupItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner +} from "@akashnetwork/ui/components"; +import { Folder, GithubCircle, Lock } from "iconoir-react"; +import { useAtom } from "jotai"; +import { Globe2 } from "lucide-react"; +import { nanoid } from "nanoid"; +import Image from "next/image"; + +import useRemoteDeployFramework from "@src/hooks/useRemoteDeployFramework"; +import { tokens } from "@src/store/remoteDeployStore"; +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { IGithubDirectoryItem } from "@src/types/remotedeploy"; +import { useSrcFolders } from "./api/api"; +import { useBitSrcFolders } from "./api/bitbucket-api"; +import { useGitlabSrcFolders } from "./api/gitlab-api"; +import CustomInput from "./CustomInput"; +import { appendEnv, removeEnv, removeInitialUrl, RepoType } from "./utils"; + +const Repos = ({ + repos, + setValue, + isLoading, + services, + setDeploymentName, + profile, + type = "github" +}: { + repos?: RepoType[]; + setValue: UseFormSetValue; + services: ServiceType[]; + isLoading: boolean; + setDeploymentName: Dispatch; + deploymentName: string; + profile?: { + name: string; + email: string; + avatar_url: string; + login: string; + html_url: string; + }; + type?: "github" | "gitlab" | "bitbucket"; +}) => { + const [token] = useAtom(tokens); + const [search, setSearch] = useState(""); + const [filteredRepos, setFilteredRepos] = useState(repos); + const [currentAccount, setCurrentAccount] = useState(""); + const [directory, setDirectory] = useState(null); + const [open, setOpen] = useState(false); + const [accounts, setAccounts] = useState([]); + const rootFolder = "akash-root-folder-repo-path"; + const currentRepo = services?.[0]?.env?.find(e => e.key === "REPO_URL"); + const repo = repos?.find(r => r.html_url === currentRepo?.value); + const currentFolder = services?.[0]?.env?.find(e => e.key === "FRONTEND_FOLDER"); + const { currentFramework, isLoading: frameworkLoading } = useRemoteDeployFramework({ + services, + setValue, + subFolder: currentFolder?.value + }); + + const { isLoading: isGettingDirectory, isFetching: isGithubLoading } = useSrcFolders(setFolders, removeInitialUrl(currentRepo?.value)); + const { isLoading: isGettingDirectoryBit, isFetching: isBitLoading } = useBitSrcFolders( + setFolders, + removeInitialUrl(currentRepo?.value), + services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")?.value + ); + + const { isLoading: isGettingDirectoryGitlab, isFetching: isGitlabLoading } = useGitlabSrcFolders( + setFolders, + services?.[0]?.env?.find(e => e.key === "GITLAB_PROJECT_ID")?.value + ); + + const isLoadingDirectories = isGithubLoading || isGitlabLoading || isBitLoading || isGettingDirectory || isGettingDirectoryBit || isGettingDirectoryGitlab; + + useEffect(() => { + if (type === "github") { + const differentOwnersArray = repos?.map(repo => repo?.owner?.login || ""); + const uniqueOwners = Array.from(new Set(differentOwnersArray)); + setAccounts(uniqueOwners); + setCurrentAccount( + repos?.find(repo => currentRepo?.value?.includes(repo?.html_url?.replace("https://github.com/", "")))?.owner?.login || + uniqueOwners?.find(account => profile?.login === account) || + uniqueOwners?.[0] + ); + } + setFilteredRepos(repos); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repos, type, profile]); + + function setFolders(data: IGithubDirectoryItem[]) { + if (data?.length > 0) { + setDirectory(data); + } else { + setDirectory(null); + } + } + + return ( +
+
+

Select Repository

+

Select a Repo to be deployed

+
+ + + + + + + + Search Repository +
+ {type === "github" && ( + + )} + { + setSearch(e.target.value); + setFilteredRepos(repos?.filter(repo => repo.name.toLowerCase().includes(e.target.value.toLowerCase()))); + }} + /> +
+
+
+ {filteredRepos + ?.filter(repo => repo?.owner?.login === currentAccount || type !== "github") + ?.map(repo => ( +
+
+
+
+ {currentFramework && !frameworkLoading && currentRepo?.value === repo.html_url ? ( + currentFramework?.image ? ( + // eslint-disable-next-line @next/next/no-img-element + {currentFramework.title} + ) : ( + + ) + ) : ( + + )} +

{repo.name}

+ {repo.private && } +
+
+ {currentRepo?.value === repo?.html_url ? ( + + ) : ( + + )} +
+ {isLoadingDirectories && currentRepo?.value === repo.html_url && ( +
+

Fetching Directory

+ +
+ )} + {currentRepo?.value === repo.html_url && + (directory && directory?.filter(item => item.type === "dir" || item.type === "commit_directory" || item.type === "tree")?.length > 0 ? ( +
+
+

Select Directory

+
+ + { + if (value === rootFolder) { + removeEnv("FRONTEND_FOLDER", setValue, services); + } else { + appendEnv("FRONTEND_FOLDER", value, false, setValue, services); + } + }} + value={currentFolder?.value || rootFolder} + > +
+ + +
+ {directory + ?.filter(item => item.type === "dir" || item.type === "commit_directory" || item.type === "tree") + .map(item => ( +
+ + +
+ ))} +
+
+ ) : ( + appendEnv("FRONTEND_FOLDER", e.target.value, false, setValue, services)} + label="Frontend Folder" + description="By default we use ./, Change the version if needed" + placeholder="eg. app" + /> + ))} +
+ ))} + {isLoading && ( +
+ +
+ )} + {filteredRepos?.filter(repo => repo?.owner?.login === currentAccount || type !== "github")?.length === 0 && ( +
No Repository Found
+ )} +
+
+
+
+ ); +}; + +export default Repos; diff --git a/apps/deploy-web/src/components/remote-deploy/SelectBranches.tsx b/apps/deploy-web/src/components/remote-deploy/SelectBranches.tsx new file mode 100644 index 000000000..d6899b277 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/SelectBranches.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Control, useFieldArray } from "react-hook-form"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Spinner } from "@akashnetwork/ui/components"; +import { nanoid } from "nanoid"; + +import { SdlBuilderFormValuesType } from "@src/types"; +const SelectBranches = ({ + control, + + loading, + branches, + selected +}: { + control: Control; + loading: boolean; + branches?: { + name: string; + }[]; + selected?: string; +}) => { + const { fields, append, update } = useFieldArray({ + control, + name: "services.0.env", + keyName: "id" + }); + return ( +
+
+

Select Branch

+

Select a branch to use for deployment

+
+ + +
+ ); +}; + +export default SelectBranches; diff --git a/apps/deploy-web/src/components/remote-deploy/api/api.ts b/apps/deploy-web/src/components/remote-deploy/api/api.ts new file mode 100644 index 000000000..61effa327 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/api/api.ts @@ -0,0 +1,168 @@ +import { useMutation, useQuery } from "react-query"; +import axios, { AxiosError } from "axios"; +import { useAtom } from "jotai"; +import { usePathname, useRouter } from "next/navigation"; + +import { tokens } from "@src/store/remoteDeployStore"; +import { GitCommit } from "@src/types/remoteCommits"; +import { GithubRepository, IGithubDirectoryItem, PackageJson } from "@src/types/remotedeploy"; +import { GitHubProfile } from "@src/types/remoteProfile"; +import { REDIRECT_URL } from "../utils"; + +const GITHUB_API_URL = "https://api.github.com"; + +export const handleLogin = () => { + window.location.href = process.env.NEXT_PUBLIC_GITHUB_APP_INSTALLATION_URL as string; +}; + +export const handleReLogin = () => { + window.location.href = `https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID}&redirect_uri=${REDIRECT_URL}`; +}; + +const axiosInstance = axios.create({ + baseURL: GITHUB_API_URL, + headers: { + "Content-Type": "application/json", + Accept: "application/json" + } +}); + +export const useUserProfile = () => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["userProfile", token?.access_token], + queryFn: async () => { + const response = await axiosInstance.get("/user", { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "github" + }); +}; + +export const useRepos = () => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["repos", token?.access_token], + queryFn: async () => { + const response = await axiosInstance.get( + "/user/repos?per_page=150", + + { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + } + ); + return response.data; + }, + onError: (error: AxiosError<{ message: string }>) => { + if (error?.response?.data?.message === "Bad credentials") { + console.log("bad credentials"); + } + }, + + enabled: !!token?.access_token && token.type === "github" + }); +}; + +export const useFetchAccessToken = () => { + const [, setToken] = useAtom(tokens); + const pathname = usePathname(); + const router = useRouter(); + return useMutation({ + mutationFn: async (code: string) => { + const response = await axios.post(`/api/github/authenticate`, { + code + }); + + return response.data; + }, + onSuccess: data => { + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + type: "github" + }); + router.replace(pathname.split("?")[0] + "?step=edit-deployment&type=github"); + } + }); +}; + +export const useBranches = (repo?: string) => { + const [token] = useAtom(tokens); + + return useQuery({ + queryKey: ["branches", repo, token?.access_token], + queryFn: async () => { + const response = await axiosInstance.get(`/repos/${repo}/branches`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + + enabled: !!token?.access_token && token.type === "github" && !!repo + }); +}; + +export const useCommits = (repo: string, branch: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["commits", repo, branch, token?.access_token, repo, branch], + queryFn: async () => { + const response = await axiosInstance.get(`/repos/${repo}/commits?sha=${branch}`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + + enabled: !!token?.access_token && token.type === "github" && !!repo && !!branch + }); +}; + +export const usePackageJson = (onSettled: (data: PackageJson) => void, repo?: string, subFolder?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["packageJson", repo, subFolder], + queryFn: async () => { + const response = await axiosInstance.get(`/repos/${repo}/contents/${subFolder ? `${subFolder}/` : ""}package.json`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "github" && !!repo, + onSettled: data => { + if (data?.content === undefined) return; + const content = atob(data.content); + const parsed = JSON.parse(content); + onSettled(parsed); + } + }); +}; +export const useSrcFolders = (onSettled: (data: IGithubDirectoryItem[]) => void, repo?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["srcFolders", repo], + queryFn: async () => { + const response = await axiosInstance.get(`/repos/${repo}/contents`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "github" && !!repo, + onSettled: data => { + onSettled(data); + } + }); +}; diff --git a/apps/deploy-web/src/components/remote-deploy/api/bitbucket-api.ts b/apps/deploy-web/src/components/remote-deploy/api/bitbucket-api.ts new file mode 100644 index 000000000..c30af32cf --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/api/bitbucket-api.ts @@ -0,0 +1,199 @@ +import { useMutation, useQuery } from "react-query"; +import axios, { AxiosError } from "axios"; +import { useAtom } from "jotai"; +import { usePathname, useRouter } from "next/navigation"; + +import { tokens } from "@src/store/remoteDeployStore"; +import { BitBucketCommit } from "@src/types/remoteCommits"; +import { IGithubDirectoryItem, PackageJson } from "@src/types/remotedeploy"; +import { BitProfile } from "@src/types/remoteProfile"; +import { BitRepository, BitWorkspace } from "@src/types/remoteRepos"; + +const Bitbucket_API_URL = "https://api.bitbucket.org/2.0"; + +export const handleLoginBit = () => { + window.location.href = `https://bitbucket.org/site/oauth2/authorize?client_id=${process.env.NEXT_PUBLIC_BITBUCKET_CLIENT_ID}&response_type=code`; +}; +const axiosInstance = axios.create({ + baseURL: Bitbucket_API_URL, + headers: { + "Content-Type": "application/json", + Accept: "application/json" + } +}); + +export const useFetchRefreshBitToken = () => { + const [token, setToken] = useAtom(tokens); + + return useMutation({ + mutationFn: async () => { + const response = await axios.post(`/api/bitbucket/refresh`, { + refreshToken: token?.refresh_token + }); + + return response.data; + }, + onSuccess: data => { + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + type: "bitbucket" + }); + } + }); +}; + +export const useBitFetchAccessToken = () => { + const [, setToken] = useAtom(tokens); + const pathname = usePathname(); + const router = useRouter(); + return useMutation({ + mutationFn: async (code: string) => { + const response = await axios.post(`/api/bitbucket/authenticate`, { + code + }); + + return response.data; + }, + onSuccess: data => { + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + type: "bitbucket" + }); + + router.replace(pathname.split("?")[0] + "?step=edit-deployment&type=github"); + } + }); +}; + +export const useBitUserProfile = () => { + const [token] = useAtom(tokens); + const { mutate } = useFetchRefreshBitToken(); + return useQuery({ + queryKey: ["userProfile", token.access_token], + queryFn: async () => { + const response = await axiosInstance.get("/user", { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket", + onError: (error: AxiosError) => { + if (error.response?.status === 401) { + mutate(); + } + } + }); +}; + +export const useBitBucketCommits = (repo?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["commits", repo, token.access_token, repo], + queryFn: async () => { + const response = await axiosInstance.get(`/repositories/${repo}/commits`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket" && !!repo + }); +}; + +export const useWorkspaces = () => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["workspaces", token.access_token], + queryFn: async () => { + const response = await axiosInstance.get<{ + values: BitWorkspace[]; + }>("/workspaces", { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket" + }); +}; + +export const useBitReposByWorkspace = (workspace: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["repos", token.access_token, workspace], + queryFn: async () => { + const response = await axiosInstance.get<{ + values: BitRepository[]; + }>(`/repositories/${workspace}`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket" && !!workspace + }); +}; + +export const useBitBranches = (repo?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["branches", repo], + queryFn: async () => { + const response = await axiosInstance.get(`/repositories/${repo}/refs/branches`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!repo && !!token?.access_token && token.type === "bitbucket" + }); +}; + +export const useBitPackageJson = (onSettled: (data: PackageJson) => void, repo?: string, branch?: string, subFolder?: string) => { + const [token] = useAtom(tokens); + + return useQuery({ + queryKey: ["packageJson", repo, branch, subFolder], + queryFn: async () => { + const response = await axiosInstance.get(`/repositories/${repo}/src/${branch}/${subFolder ? `${subFolder}/` : ""}package.json`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket" && !!repo && !!branch, + onSettled: data => { + onSettled(data); + } + }); +}; + +export const useBitSrcFolders = (onSettled: (data: IGithubDirectoryItem[]) => void, repo?: string, branch?: string) => { + const [token] = useAtom(tokens); + + return useQuery({ + queryKey: ["src-folders-bit", repo, branch], + // root folder + queryFn: async () => { + const response = await axiosInstance.get(`/repositories/${repo}/src/${branch}/.`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "bitbucket" && !!repo && !!branch, + onSettled: data => { + onSettled(data?.values); + } + }); +}; diff --git a/apps/deploy-web/src/components/remote-deploy/api/gitlab-api.ts b/apps/deploy-web/src/components/remote-deploy/api/gitlab-api.ts new file mode 100644 index 000000000..52025299d --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/api/gitlab-api.ts @@ -0,0 +1,200 @@ +import { useMutation, useQuery } from "react-query"; +import axios, { AxiosError } from "axios"; +import { useAtom } from "jotai"; +import { usePathname, useRouter } from "next/navigation"; + +import { tokens } from "@src/store/remoteDeployStore"; +import { GitLabCommit } from "@src/types/remoteCommits"; +import { IGithubDirectoryItem, PackageJson } from "@src/types/remotedeploy"; +import { GitLabProfile } from "@src/types/remoteProfile"; +import { GitlabGroup, GitlabRepo } from "@src/types/remoteRepos"; + +export const handleGitLabLogin = () => { + window.location.href = `https://gitlab.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITLAB_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URI}&response_type=code&scope=read_user+read_repository+read_api+api&state=gitlab`; +}; + +const axiosInstance = axios.create({ + baseURL: "https://gitlab.com/api/v4", + headers: { + "Content-Type": "application/json", + Accept: "application/json" + } +}); + +export const useGitLabFetchAccessToken = () => { + const [, setToken] = useAtom(tokens); + const pathname = usePathname(); + const router = useRouter(); + return useMutation({ + mutationFn: async (code: string) => { + const response = await axios.post(`/api/gitlab/authenticate`, { + code + }); + + return response.data; + }, + onSuccess: data => { + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + type: "gitlab" + }); + + router.replace(pathname.split("?")[0] + "?step=edit-deployment&type=github"); + } + }); +}; + +export const useFetchRefreshGitlabToken = () => { + const [token, setToken] = useAtom(tokens); + + return useMutation({ + mutationFn: async () => { + const response = await axios.post(`/api/gitlab/refresh`, { + refreshToken: token?.refresh_token + }); + + return response.data; + }, + onSuccess: data => { + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + type: "gitlab" + }); + } + }); +}; + +export const useGitLabUserProfile = () => { + const [token] = useAtom(tokens); + + const { mutate } = useFetchRefreshGitlabToken(); + + return useQuery({ + queryKey: ["gitlab-user-Profile", token?.access_token], + queryFn: async () => { + const response = await axiosInstance.get("/user", { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab", + onError: (error: AxiosError) => { + if (error.response?.status === 401) { + mutate(); + } + } + }); +}; + +export const useGitLabGroups = () => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["gitlab-repos", token?.access_token], + queryFn: async () => { + const response = await axiosInstance.get(`/groups`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab" + }); +}; + +export const useGitLabReposByGroup = (group: string | undefined) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["repos", token?.access_token, group], + queryFn: async () => { + const response = await axiosInstance.get(`/groups/${group}/projects`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab" && !!group + }); +}; + +export const useGitLabBranches = (repo?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["branches", repo], + queryFn: async () => { + const response = await axiosInstance.get(`/projects/${repo}/repository/branches`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab" && !!repo + }); +}; + +export const useGitLabCommits = (repo?: string, branch?: string) => { + const [token] = useAtom(tokens); + return useQuery({ + queryKey: ["commits", repo, branch, token?.access_token, repo, branch], + queryFn: async () => { + const response = await axiosInstance.get(`/projects/${repo}/repository/commits?ref_name=${branch}`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + + enabled: !!token?.access_token && token.type === "gitlab" && !!repo && !!branch + }); +}; + +export const useGitlabPackageJson = (onSettled: (data: PackageJson) => void, repo?: string, subFolder?: string) => { + const [token] = useAtom(tokens); + + return useQuery({ + queryKey: ["packageJson-gitlab", repo, subFolder], + queryFn: async () => { + const response = await axiosInstance.get(`/projects/${repo}/repository/files/${subFolder ? `${subFolder}%2F` : ""}package.json?ref=main`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab" && !!repo, + onSettled: data => { + if (data?.content === undefined) return; + const content = atob(data.content); + const parsed = JSON.parse(content); + onSettled(parsed); + } + }); +}; + +export const useGitlabSrcFolders = (onSettled: (data: IGithubDirectoryItem[]) => void, repo?: string) => { + const [token] = useAtom(tokens); + + return useQuery({ + queryKey: ["src-folders-gitlab", repo], + + queryFn: async () => { + const response = await axiosInstance.get(`/projects/${repo}/repository/tree`, { + headers: { + Authorization: `Bearer ${token?.access_token}` + } + }); + return response.data; + }, + enabled: !!token?.access_token && token.type === "gitlab" && !!repo, + onSettled: data => { + onSettled(data); + } + }); +}; diff --git a/apps/deploy-web/src/components/remote-deploy/bitbucket/Bit.tsx b/apps/deploy-web/src/components/remote-deploy/bitbucket/Bit.tsx new file mode 100644 index 000000000..9c188df72 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/bitbucket/Bit.tsx @@ -0,0 +1,57 @@ +import { Dispatch, useState } from "react"; + +import { ServiceType } from "@src/types"; +import { BitProfile } from "@src/types/remoteProfile"; +import { useBitReposByWorkspace } from "../api/bitbucket-api"; +import Repos from "../Repos"; +import { ServiceControl, ServiceSetValue } from "../utils"; +import Branches from "./Branches"; +import WorkSpaces from "./Workspaces"; + +const Bit = ({ + loading, + setValue, + services, + control, + setDeploymentName, + deploymentName, + profile +}: { + setDeploymentName: Dispatch; + deploymentName: string; + loading: boolean; + setValue: ServiceSetValue; + services: ServiceType[]; + control: ServiceControl; + profile?: BitProfile; +}) => { + const [workSpace, setWorkSpace] = useState(""); + + const { data: repos, isLoading } = useBitReposByWorkspace(workSpace); + + return ( + <> + + ({ + name: repo.name, + default_branch: repo?.mainbranch?.name, + html_url: repo?.links?.html?.href, + userName: profile?.username, + private: repo?.is_private + })) ?? [] + } + type="bitbucket" + setValue={setValue} + setDeploymentName={setDeploymentName} + deploymentName={deploymentName} + services={services} + /> + + + ); +}; + +export default Bit; diff --git a/apps/deploy-web/src/components/remote-deploy/bitbucket/Branches.tsx b/apps/deploy-web/src/components/remote-deploy/bitbucket/Branches.tsx new file mode 100644 index 000000000..bf901a602 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/bitbucket/Branches.tsx @@ -0,0 +1,16 @@ +import { Control } from "react-hook-form"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { useBitBranches } from "../api/bitbucket-api"; +import SelectBranches from "../SelectBranches"; +import { removeInitialUrl } from "../utils"; + +const Branches = ({ services, control }: { services: ServiceType[]; control: Control }) => { + const selected = removeInitialUrl(services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value); + + const { data: branches, isLoading: branchesLoading } = useBitBranches(selected); + + return ; +}; + +export default Branches; diff --git a/apps/deploy-web/src/components/remote-deploy/bitbucket/Workspaces.tsx b/apps/deploy-web/src/components/remote-deploy/bitbucket/Workspaces.tsx new file mode 100644 index 000000000..493952deb --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/bitbucket/Workspaces.tsx @@ -0,0 +1,51 @@ +import { Dispatch, useState } from "react"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Spinner } from "@akashnetwork/ui/components"; +import { Bitbucket } from "iconoir-react"; + +import { useWorkspaces } from "../api/bitbucket-api"; + +const WorkSpaces = ({ isLoading, setWorkSpaces }: { isLoading: boolean; workSpaces: string; setWorkSpaces: Dispatch }) => { + const [open, setOpen] = useState(false); + + const { data, isLoading: loadingWorkSpaces } = useWorkspaces(); + + return ( +
+
+

Select WorkSpace

+

Select a Work-Space to use for deployment

+
+ + +
+ ); +}; + +export default WorkSpaces; diff --git a/apps/deploy-web/src/components/remote-deploy/github/Branches.tsx b/apps/deploy-web/src/components/remote-deploy/github/Branches.tsx new file mode 100644 index 000000000..2f66fd2ed --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/github/Branches.tsx @@ -0,0 +1,16 @@ +import { Control } from "react-hook-form"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { useBranches } from "../api/api"; +import SelectBranches from "../SelectBranches"; +import { removeInitialUrl } from "../utils"; + +const Branches = ({ services, control }: { services: ServiceType[]; control: Control }) => { + const selected = removeInitialUrl(services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value); + + const { data: branches, isLoading: branchesLoading } = useBranches(selected); + + return ; +}; + +export default Branches; diff --git a/apps/deploy-web/src/components/remote-deploy/github/Github.tsx b/apps/deploy-web/src/components/remote-deploy/github/Github.tsx new file mode 100644 index 000000000..be6bedb69 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/github/Github.tsx @@ -0,0 +1,54 @@ +import { Dispatch } from "react"; +import { Control } from "react-hook-form"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { GitHubProfile } from "@src/types/remoteProfile"; +import { useRepos } from "../api/api"; +import Repos from "../Repos"; +import { ServiceSetValue } from "../utils"; +import Branches from "./Branches"; + +const Github = ({ + control, + setValue, + services, + setDeploymentName, + deploymentName, + profile +}: { + setDeploymentName: Dispatch; + deploymentName: string; + control: Control; + + setValue: ServiceSetValue; + services: ServiceType[]; + profile?: GitHubProfile; +}) => { + const { data: repos, isLoading } = useRepos(); + + return ( + <> + repo.owner?.login === profile?.login || repo?.owner?.type === "Organization") + ?.map(repo => ({ + name: repo.name, + default_branch: repo?.default_branch, + html_url: repo?.html_url, + private: repo?.private, + id: repo.id?.toString(), + owner: repo?.owner + }))} + setValue={setValue} + isLoading={isLoading} + services={services} + setDeploymentName={setDeploymentName} + deploymentName={deploymentName} + profile={profile} + /> + + + ); +}; + +export default Github; diff --git a/apps/deploy-web/src/components/remote-deploy/gitlab/Branches.tsx b/apps/deploy-web/src/components/remote-deploy/gitlab/Branches.tsx new file mode 100644 index 000000000..807f1eb39 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/gitlab/Branches.tsx @@ -0,0 +1,19 @@ +import { Control } from "react-hook-form"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { GitlabRepo } from "@src/types/remoteRepos"; +import { useGitLabBranches } from "../api/gitlab-api"; +import SelectBranches from "../SelectBranches"; + +const Branches = ({ repos, services, control }: { repos?: GitlabRepo[]; services: ServiceType[]; control: Control }) => { + const selected = + repos && repos?.length > 0 + ? repos?.find(e => e.web_url === services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value)?.id?.toString() + : services?.[0]?.env?.find(e => e.key === "GITLAB_PROJECT_ID")?.value; + + const { data: branches, isLoading: branchesLoading } = useGitLabBranches(selected); + + return ; +}; + +export default Branches; diff --git a/apps/deploy-web/src/components/remote-deploy/gitlab/Gitlab.tsx b/apps/deploy-web/src/components/remote-deploy/gitlab/Gitlab.tsx new file mode 100644 index 000000000..bfb42c43e --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/gitlab/Gitlab.tsx @@ -0,0 +1,54 @@ +import React, { Dispatch, useState } from "react"; + +import { ServiceType } from "@src/types"; +import { useGitLabReposByGroup } from "../api/gitlab-api"; +import Repos from "../Repos"; +import { ServiceControl, ServiceSetValue } from "../utils"; +import Branches from "./Branches"; +import Groups from "./Groups"; + +const GitLab = ({ + loading, + setValue, + services, + control, + setDeploymentName, + deploymentName +}: { + setDeploymentName: Dispatch; + deploymentName: string; + loading: boolean; + setValue: ServiceSetValue; + services: ServiceType[]; + control: ServiceControl; +}) => { + const [group, setGroup] = useState(""); + const { data: repos, isLoading } = useGitLabReposByGroup(group); + + return ( + <> + + ({ + name: repo.name, + id: repo.id?.toString(), + default_branch: repo?.default_branch, + html_url: repo?.web_url, + userName: "gitlab", + private: repo?.visibility === "private" + })) ?? [] + } + setValue={setValue} + setDeploymentName={setDeploymentName} + deploymentName={deploymentName} + type="gitlab" + /> + + + ); +}; + +export default GitLab; diff --git a/apps/deploy-web/src/components/remote-deploy/gitlab/Groups.tsx b/apps/deploy-web/src/components/remote-deploy/gitlab/Groups.tsx new file mode 100644 index 000000000..5eca6c21a --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/gitlab/Groups.tsx @@ -0,0 +1,50 @@ +import { Dispatch, useState } from "react"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Spinner } from "@akashnetwork/ui/components"; +import { GitlabFull } from "iconoir-react"; + +import { useGitLabGroups } from "../api/gitlab-api"; +const Groups = ({ isLoading, setGroup }: { isLoading: boolean; setGroup: Dispatch }) => { + const [open, setOpen] = useState(false); + + const { data, isLoading: loadingWorkSpaces } = useGitLabGroups(); + + return ( +
+
+

Select Group

+

Select a Group to use for deployment

+
+ + +
+ ); +}; + +export default Groups; diff --git a/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx b/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx new file mode 100644 index 000000000..79d00948b --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/update/RemoteDeployUpdate.tsx @@ -0,0 +1,123 @@ +import React, { Dispatch, useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { Checkbox, Label, Snackbar } from "@akashnetwork/ui/components"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; + +import { tokens } from "@src/store/remoteDeployStore"; +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { defaultService } from "@src/utils/sdl/data"; +import { generateSdl } from "@src/utils/sdl/sdlGenerator"; +import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; +import { github } from "@src/utils/templates"; +import BitBranches from "../bitbucket/Branches"; +import { EnvFormModal } from "../EnvFormModal"; +import { EnvVarList } from "../EnvList"; +import Branches from "../github/Branches"; +import GitBranches from "../gitlab/Branches"; +import { appendEnv } from "../utils"; +import Rollback from "./Rollback"; + +const RemoteDeployUpdate = ({ sdlString, setEditedManifest }: { sdlString: string; setEditedManifest: Dispatch> }) => { + const [token] = useAtom(tokens); + const [, setIsInit] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + const [, setError] = useState(null); + const [isEditingEnv, setIsEditingEnv] = useState(false); + const { control, watch, setValue } = useForm({ defaultValues: { services: [defaultService] } }); + const { fields: services } = useFieldArray({ control, name: "services", keyName: "id" }); + + useEffect(() => { + const { unsubscribe }: any = watch(data => { + const sdl = generateSdl(data.services as ServiceType[]); + setEditedManifest(sdl); + }); + try { + if (sdlString) { + const services = createAndValidateSdl(sdlString); + setValue("services", services as ServiceType[]); + } + } catch (error) { + setError("Error importing SDL"); + } + setIsInit(true); + return () => { + unsubscribe(); + }; + }, [watch, sdlString]); + + const createAndValidateSdl = (yamlStr: string) => { + try { + if (!yamlStr) return []; + const services = importSimpleSdl(yamlStr); + setError(null); + return services; + } catch (err) { + if (err.name === "YAMLException" || err.name === "CustomValidationError") { + setError(err.message); + } else if (err.name === "TemplateValidation") { + setError(err.message); + } else { + setError("Error while parsing SDL file"); + console.error(err); + } + } + }; + return github.content.includes(services?.[0]?.image) && services?.[0]?.env && services?.[0]?.env?.length > 0 ? ( +
+
+
+ + + e.key === "DISABLE_PULL")?.value !== "yes"} + onCheckedChange={value => { + const pull = !value ? "yes" : "no"; + appendEnv("DISABLE_PULL", pull, false, setValue, services); + enqueueSnackbar(, { + variant: "info" + }); + }} + /> +
+

If checked, Console will automatically re-deploy your app on any code commits

+
+ + + {isEditingEnv && ( + { + setIsEditingEnv(false); + }} + /> + )} + + {token.access_token && services[0]?.env?.find(e => e.key === "REPO_URL")?.value?.includes(token.type) && ( + <> +
+
+

Rollback

Rollback to a specific commit

+
+ + +
+ {token?.type === "github" ? ( + + ) : token?.type === "gitlab" ? ( + + ) : ( + + )} + + )} +
+ ) : null; +}; +export default RemoteDeployUpdate; diff --git a/apps/deploy-web/src/components/remote-deploy/update/Rollback.tsx b/apps/deploy-web/src/components/remote-deploy/update/Rollback.tsx new file mode 100644 index 000000000..29c43c62d --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/update/Rollback.tsx @@ -0,0 +1,32 @@ +import { Control } from "react-hook-form"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { useCommits } from "../api/api"; +import { useBitBucketCommits } from "../api/bitbucket-api"; +import { useGitLabCommits } from "../api/gitlab-api"; +import { removeInitialUrl } from "../utils"; +import RollbackModal from "./RollbackModal"; + +const Rollback = ({ services, control }: { services: ServiceType[]; control: Control }) => { + const { data } = useCommits( + services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value?.replace("https://github.com/", "") ?? "", + services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")?.value ?? "" + ); + const { data: labCommits } = useGitLabCommits( + services?.[0]?.env?.find(e => e.key === "GITLAB_PROJECT_ID")?.value, + services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")?.value + ); + const { data: bitbucketCommits } = useBitBucketCommits(removeInitialUrl(services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value ?? "")); + + const commits = + data && data?.length > 0 + ? data.map(commit => ({ name: commit.commit.message, value: commit.sha, date: new Date(commit.commit.author.date) })) + : labCommits && labCommits?.length > 0 + ? labCommits?.map(commit => ({ name: commit.title, value: commit.id, date: new Date(commit.authored_date) })) + : bitbucketCommits && bitbucketCommits?.values?.length > 0 + ? bitbucketCommits?.values?.map(commit => ({ name: commit.message, value: commit.hash, date: new Date(commit.date) })) + : null; + + return ; +}; +export default Rollback; diff --git a/apps/deploy-web/src/components/remote-deploy/update/RollbackModal.tsx b/apps/deploy-web/src/components/remote-deploy/update/RollbackModal.tsx new file mode 100644 index 000000000..733b1fa5f --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/update/RollbackModal.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { Control, useFieldArray } from "react-hook-form"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + RadioGroup, + RadioGroupItem, + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@akashnetwork/ui/components"; +import { GitCommitVertical, GitGraph, Info } from "lucide-react"; +import { nanoid } from "nanoid"; + +import { SdlBuilderFormValuesType } from "@src/types"; +import { RollBackType } from "@src/types/remotedeploy"; + +const RollbackModal = ({ data, control }: { data?: RollBackType[] | null; control: Control }) => { + const [filteredData, setFilteredData] = useState([]); + const [value, setValue] = useState(""); + const { fields: services } = useFieldArray({ control, name: "services", keyName: "id" }); + const { append, update } = useFieldArray({ control, name: "services.0.env", keyName: "id" }); + const currentHash = services[0]?.env?.find(e => e.key === "COMMIT_HASH"); + useEffect(() => { + if (data) { + setFilteredData( + data?.filter(item => { + return item.name.toLowerCase().includes(value.toLowerCase()); + }) + ); + } + }, [data, value]); + + return ( +
+ + + + + + + Rollbacks + + + You need to click update deployment button to apply changes + + + + + Commit Name + Commit Hash + + +
+
+ { + setValue(e.target.value); + }} + /> +
+ {filteredData?.length > 0 ? ( + { + const hash = { id: nanoid(), key: "COMMIT_HASH", value: value, isSecret: false }; + + if (currentHash) { + update(services[0]?.env?.findIndex(e => e.key === "COMMIT_HASH") as number, hash); + } else { + append(hash); + } + }} + > + {filteredData?.map(item => ( +
+ + +
+ ))} +
+ ) : ( + <> + )} +
+
+ +
+ + { + const hash = { id: nanoid(), key: "COMMIT_HASH", value: e.target.value, isSecret: false }; + if (currentHash) { + update(services[0]?.env?.findIndex(e => e.key === "COMMIT_HASH") as number, hash); + } else { + append(hash); + } + }} + /> +
+
+
+
+
+
+ ); +}; + +export default RollbackModal; diff --git a/apps/deploy-web/src/components/remote-deploy/utils.ts b/apps/deploy-web/src/components/remote-deploy/utils.ts new file mode 100644 index 000000000..ba75105c3 --- /dev/null +++ b/apps/deploy-web/src/components/remote-deploy/utils.ts @@ -0,0 +1,135 @@ +import { Control, UseFormSetValue } from "react-hook-form"; +import { nanoid } from "nanoid"; + +import { SdlBuilderFormValuesType, ServiceType } from "@src/types"; +import { Owner } from "@src/types/remotedeploy"; +import { github } from "@src/utils/templates"; + +export type ServiceControl = Control; +export type ServiceSetValue = UseFormSetValue; +export type OAuth = "github" | "gitlab" | "bitbucket"; +export const PROXY_API_URL_AUTH = "https://proxy-console-github.vercel.app"; +export const hiddenEnv = [ + "REPO_URL", + "BRANCH_NAME", + "ACCESS_TOKEN", + "BUILD_DIRECTORY", + "BUILD_COMMAND", + "NODE_VERSION", + "CUSTOM_SRC", + "COMMIT_HASH", + "GITLAB_PROJECT_ID", + "GITLAB_ACCESS_TOKEN", + "BITBUCKET_ACCESS_TOKEN", + "BITBUCKET_USER", + "DISABLE_PULL", + "GITHUB_ACCESS_TOKEN", + "FRONTEND_FOLDER" +]; +export const REDIRECT_URL = `${process.env.NEXT_PUBLIC_REDIRECT_URI}?step=edit-deployment&type=github`; +export function appendEnv(key: string, value: string, isSecret: boolean, setValue: ServiceSetValue, services: ServiceType[]) { + const previousEnv = services[0]?.env || []; + if (previousEnv.find(e => e.key === key)) { + previousEnv.map(e => { + if (e.key === key) { + e.value = value; + e.isSecret = isSecret; + + return e; + } + return e; + }); + } else { + previousEnv.push({ id: nanoid(), key, value, isSecret }); + } + setValue("services.0.env", previousEnv); +} + +export function removeEnv(key: string, setValue: ServiceSetValue, services: ServiceType[]) { + const previousEnv = services[0]?.env || []; + const newEnv = previousEnv.filter(e => e.key !== key); + setValue("services.0.env", newEnv); +} + +export const removeInitialUrl = (url?: string) => { + return url?.split("/").slice(-2).join("/"); +}; + +export interface RepoType { + name: string; + id?: string; + default_branch: string; + html_url: string; + userName?: string; + private: boolean; + owner?: Owner; +} + +export const isRedeployImage = (yml: string) => { + return github.content.includes(yml?.split("service-1:")?.[1]?.split("expose:")?.[0]?.split("image: ")?.[1]); +}; + +export const getRepoUrl = (yml?: string | null) => { + if (!yml) return null; + const list = yml?.split("\n"); + const envIndex = list?.findIndex(item => item?.includes("env:")); + const profileIndex = list?.findIndex(item => item?.includes("profiles:")); + const env = list?.slice(envIndex + 1, profileIndex); + const repo = env?.find(item => item?.includes("REPO_URL")); + + return repo ? repo?.split("=")[1] : null; +}; + +export const supportedFrameworks = [ + { + title: "React", + value: "react", + image: "https://static-00.iconduck.com/assets.00/react-icon-512x456-2ynx529a.png" + }, + { + title: "Vue", + value: "vue", + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Vue.js_Logo.svg/1200px-Vue.js_Logo.svg.png" + }, + { + title: "Angular", + value: "angular", + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Angular_full_color_logo.svg/1200px-Angular_full_color_logo.svg.png" + }, + { + title: "Svelte", + value: "svelte", + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Svelte_Logo.svg/1200px-Svelte_Logo.svg.png" + }, + { + title: "Next.js", + value: "next", + image: "https://uxwing.com/wp-content/themes/uxwing/download/brands-and-social-media/nextjs-icon.png" + }, + + { + title: "Astro", + value: "astro", + image: "https://icon.icepanel.io/Technology/png-shadow-512/Astro.png" + }, + { + title: "Nuxt.js", + value: "nuxt", + image: "https://v2.nuxt.com/_nuxt/icons/icon_64x64.6dcbd4.png" + }, + + { + title: "Gridsome ", + value: "gridsome", + image: "https://gridsome.org/assets/static/favicon.b9532cc.c6d52b979318cc0b0524324281174df2.png" + }, + { + title: "Vite", + value: "vite", + image: "https://vitejs.dev/logo.svg" + }, + { + title: "Other", + value: "other" + } +]; diff --git a/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx b/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx index 668d54d7c..f53ea986f 100644 --- a/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx +++ b/apps/deploy-web/src/components/sdl/SimpleServiceFormControl.tsx @@ -66,6 +66,7 @@ type Props = { setValue: UseFormSetValue; gpuModels: GpuVendor[] | undefined; hasSecretOption?: boolean; + github?: boolean; }; export const SimpleServiceFormControl: React.FunctionComponent = ({ @@ -78,7 +79,8 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ setServiceCollapsed, setValue, gpuModels, - hasSecretOption + hasSecretOption, + github }) => { const [isEditingCommands, setIsEditingCommands] = useState(null); const [isEditingEnv, setIsEditingEnv] = useState(null); @@ -94,7 +96,6 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ const _isEditingPlacement = serviceIndex === isEditingPlacement; const { imageList, hasComponent, toggleCmp } = useSdlBuilder(); const wallet = useWallet(); - const onExpandClick = () => { setServiceCollapsed(prev => { if (expanded) { @@ -144,39 +145,42 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({ /> )} -
- ( - - Service Name - - The service name serves as a identifier for the workload to be ran on the Akash Network. -
-
- - View official documentation. - - - } - > - -
-
- } - value={field.value} - className="flex-grow" - onChange={event => field.onChange((event.target.value || "").toLowerCase())} - /> - )} - /> - +
+ {github ? ( +

Build Server Specs

+ ) : ( + ( + + Service Name + + The service name serves as a identifier for the workload to be ran on the Akash Network. +
+
+ + View official documentation. + + + } + > + +
+
+ } + value={field.value} + className="flex-grow" + onChange={event => field.onChange((event.target.value || "").toLowerCase())} + /> + )} + /> + )}
{!expanded && isDesktop && (
@@ -208,84 +212,86 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
-
- ( - - {imageList?.length ? ( -
- + + Docker Logo +
+ +
+
+ + + {imageList.map(image => { + return ( + + {image} + + ); + })} + + + +
+ ) : ( + + Docker Image / OS + + Docker image of the container. +
+
+ Best practices: avoid using :latest image tags as Akash Providers heavily cache images. + + } + > + +
- - - - {imageList.map(image => { - return ( - - {image} - - ); - })} - - - -
- ) : ( - - Docker Image / OS - - Docker image of the container. -
-
- Best practices: avoid using :latest image tags as Akash Providers heavily cache images. - - } + } + placeholder="Example: mydockerimage:1.01" + value={field.value} + error={!!fieldState.error} + onChange={event => field.onChange((event.target.value || "").toLowerCase())} + startIconClassName="pl-2" + startIcon={Docker Logo} + endIcon={ + - -
-
- } - placeholder="Example: mydockerimage:1.01" - value={field.value} - error={!!fieldState.error} - onChange={event => field.onChange((event.target.value || "").toLowerCase())} - startIconClassName="pl-2" - startIcon={Docker Logo} - endIcon={ - - - - } - data-testid="image-name-input" - /> - )} + + + } + data-testid="image-name-input" + /> + )} - - - )} - /> -
+ + + )} + /> +
+ )}
@@ -317,39 +323,43 @@ export const SimpleServiceFormControl: React.FunctionComponent = ({
-
- {(hasComponent("ssh") || hasComponent("ssh-toggle")) && ( - - {hasComponent("ssh-toggle") && ( - { - toggleCmp("ssh"); - setValue("hasSSHKey", !!checked); - }} - className="ml-4" - label="Expose SSH" - data-testid="ssh-toggle" - /> + {!github && ( + <> +
+ {(hasComponent("ssh") || hasComponent("ssh-toggle")) && ( + + {hasComponent("ssh-toggle") && ( + { + toggleCmp("ssh"); + setValue("hasSSHKey", !!checked); + }} + className="ml-4" + label="Expose SSH" + data-testid="ssh-toggle" + /> + )} + {hasComponent("ssh") && } + )} - {hasComponent("ssh") && } - - )} -
- -
+
+ +
- {hasComponent("command") && ( -
- + {hasComponent("command") && ( +
+ +
+ )}
- )} -
-
- -
+
+ +
+ + )} {hasComponent("service-count") && (
diff --git a/apps/deploy-web/src/config/browser-env.config.ts b/apps/deploy-web/src/config/browser-env.config.ts index b3efd136e..bb621a3c4 100644 --- a/apps/deploy-web/src/config/browser-env.config.ts +++ b/apps/deploy-web/src/config/browser-env.config.ts @@ -14,5 +14,9 @@ export const browserEnvConfig = validateStaticEnvVars({ NEXT_PUBLIC_NODE_ENV: process.env.NEXT_PUBLIC_NODE_ENV, NEXT_PUBLIC_BASE_API_MAINNET_URL: process.env.NEXT_PUBLIC_BASE_API_MAINNET_URL, NEXT_PUBLIC_BASE_API_TESTNET_URL: process.env.NEXT_PUBLIC_BASE_API_TESTNET_URL, - NEXT_PUBLIC_BASE_API_SANDBOX_URL: process.env.NEXT_PUBLIC_BASE_API_SANDBOX_URL + NEXT_PUBLIC_BASE_API_SANDBOX_URL: process.env.NEXT_PUBLIC_BASE_API_SANDBOX_URL, + NEXT_PUBLIC_REDIRECT_URI: process.env.NEXT_PUBLIC_REDIRECT_URI, + NEXT_PUBLIC_GITHUB_APP_INSTALLATION_URL: process.env.NEXT_PUBLIC_GITHUB_APP_INSTALLATION_URL, + NEXT_PUBLIC_BITBUCKET_CLIENT_ID: process.env.NEXT_PUBLIC_BITBUCKET_CLIENT_ID, + NEXT_PUBLIC_GITLAB_CLIENT_ID: process.env.NEXT_PUBLIC_GITLAB_CLIENT_ID }); diff --git a/apps/deploy-web/src/config/env-config.schema.ts b/apps/deploy-web/src/config/env-config.schema.ts index a7338a3c2..597d2f73f 100644 --- a/apps/deploy-web/src/config/env-config.schema.ts +++ b/apps/deploy-web/src/config/env-config.schema.ts @@ -18,7 +18,11 @@ export const browserEnvSchema = z.object({ NEXT_PUBLIC_BASE_API_MAINNET_URL: z.string().url(), NEXT_PUBLIC_BASE_API_TESTNET_URL: z.string().url(), NEXT_PUBLIC_BASE_API_SANDBOX_URL: z.string().url(), - NEXT_PUBLIC_GA_MEASUREMENT_ID: z.string().optional() + NEXT_PUBLIC_GA_MEASUREMENT_ID: z.string().optional(), + NEXT_PUBLIC_REDIRECT_URI: z.string().url(), + NEXT_PUBLIC_GITHUB_APP_INSTALLATION_URL: z.string().url(), + NEXT_PUBLIC_BITBUCKET_CLIENT_ID: z.string().optional(), + NEXT_PUBLIC_GITLAB_CLIENT_ID: z.string().optional() }); export const serverEnvSchema = browserEnvSchema.extend({ @@ -32,7 +36,10 @@ export const serverEnvSchema = browserEnvSchema.extend({ AUTH0_SCOPE: z.string(), BASE_API_MAINNET_URL: z.string().url(), BASE_API_TESTNET_URL: z.string().url(), - BASE_API_SANDBOX_URL: z.string().url() + BASE_API_SANDBOX_URL: z.string().url(), + GITHUB_CLIENT_SECRET: z.string(), + BITBUCKET_CLIENT_SECRET: z.string(), + GITLAB_CLIENT_SECRET: z.string() }); export type BrowserEnvConfig = z.infer; diff --git a/apps/deploy-web/src/hooks/useRemoteDeployFramework.tsx b/apps/deploy-web/src/hooks/useRemoteDeployFramework.tsx new file mode 100644 index 000000000..a932d8303 --- /dev/null +++ b/apps/deploy-web/src/hooks/useRemoteDeployFramework.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; + +import { usePackageJson } from "@src/components/remote-deploy/api/api"; +import { useBitPackageJson } from "@src/components/remote-deploy/api/bitbucket-api"; +import { useGitlabPackageJson } from "@src/components/remote-deploy/api/gitlab-api"; +import { removeInitialUrl, ServiceSetValue, supportedFrameworks } from "@src/components/remote-deploy/utils"; +import { ServiceType } from "@src/types"; +import { PackageJson } from "@src/types/remotedeploy"; + +const useRemoteDeployFramework = ({ + services, + setValue, + subFolder +}: { + services: ServiceType[]; + setValue: ServiceSetValue; + + subFolder?: string; +}) => { + const [data, setData] = useState(null); + const selected = services?.[0]?.env?.find(e => e.key === "REPO_URL")?.value; + + const setValueHandler = (data: PackageJson) => { + if (data?.dependencies) { + setData(data); + const cpus = (Object.keys(data?.dependencies ?? {})?.length / 10 / 2)?.toFixed(1); + + setValue("services.0.profile.cpu", +cpus > 2 ? +cpus : 2); + } else { + setData(null); + } + }; + + const { isLoading } = usePackageJson(setValueHandler, removeInitialUrl(selected), subFolder); + const { isLoading: gitlabLoading, isFetching } = useGitlabPackageJson( + setValueHandler, + services?.[0]?.env?.find(e => e.key === "GITLAB_PROJECT_ID")?.value, + subFolder + ); + + const { isLoading: bitbucketLoading } = useBitPackageJson( + setValueHandler, + removeInitialUrl(selected), + services?.[0]?.env?.find(e => e.key === "BRANCH_NAME")?.value, + subFolder + ); + + return { + currentFramework: supportedFrameworks.find(f => data?.scripts?.dev?.includes(f.value)) ?? { + title: "Other", + value: "other" + }, + isLoading: isLoading || gitlabLoading || bitbucketLoading || isFetching + }; +}; + +export default useRemoteDeployFramework; diff --git a/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts new file mode 100644 index 000000000..6de359776 --- /dev/null +++ b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import BitbucketAuth from "@src/services/auth/bitbucket.service"; + +const NEXT_PUBLIC_BITBUCKET_CLIENT_ID: string = process.env.NEXT_PUBLIC_BITBUCKET_CLIENT_ID as string; +const BITBUCKET_CLIENT_SECRET: string = process.env.BITBUCKET_CLIENT_SECRET as string; + +export default async function exchangeBitBucketCodeForTokensHandler(req: NextApiRequest, res: NextApiResponse): Promise { + const { code }: { code: string } = req.body; + + if (!code) { + return res.status(400).send("No authorization code provided"); + } + + const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET); + + try { + const { access_token, refresh_token } = await bitbucketAuth.exchangeAuthorizationCodeForTokens(code); + res.status(200).json({ access_token, refresh_token }); + } catch (error) { + res.status(500).send("Something went wrong"); + } +} diff --git a/apps/deploy-web/src/pages/api/bitbucket/refresh.ts b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts new file mode 100644 index 000000000..3d56cb3b7 --- /dev/null +++ b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import BitbucketAuth from "@src/services/auth/bitbucket.service"; + +const NEXT_PUBLIC_BITBUCKET_CLIENT_ID: string = process.env.NEXT_PUBLIC_BITBUCKET_CLIENT_ID as string; +const BITBUCKET_CLIENT_SECRET: string = process.env.BITBUCKET_CLIENT_SECRET as string; + +export default async function refreshTokensHandler(req: NextApiRequest, res: NextApiResponse): Promise { + const { refreshToken }: { refreshToken: string } = req.body; + + if (!refreshToken) { + return res.status(400).send("No refresh token provided"); + } + + const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET); + + try { + const { access_token, refresh_token } = await bitbucketAuth.refreshTokensUsingRefreshToken(refreshToken); + res.status(200).json({ access_token, refresh_token }); + } catch (error) { + res.status(500).send("Something went wrong"); + } +} diff --git a/apps/deploy-web/src/pages/api/github/authenticate.ts b/apps/deploy-web/src/pages/api/github/authenticate.ts new file mode 100644 index 000000000..531a05442 --- /dev/null +++ b/apps/deploy-web/src/pages/api/github/authenticate.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import GitHubAuth from "@src/services/auth/github.service"; + +const { NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = process.env; + +export default async function exchangeGitHubCodeForTokenHandler(req: NextApiRequest, res: NextApiResponse): Promise { + const { code }: { code: string } = req.body; + + if (!code) { + return res.status(400).send("No authorization code provided"); + } + + const gitHubAuth = new GitHubAuth(NEXT_PUBLIC_GITHUB_CLIENT_ID as string, GITHUB_CLIENT_SECRET as string, NEXT_PUBLIC_REDIRECT_URI as string); + + try { + const access_token = await gitHubAuth.exchangeAuthorizationCodeForToken(code); + res.status(200).json({ access_token }); + } catch (error) { + res.status(500).send("Something went wrong"); + } +} diff --git a/apps/deploy-web/src/pages/api/gitlab/authenticate.ts b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts new file mode 100644 index 000000000..2a291581e --- /dev/null +++ b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import GitlabAuth from "@src/services/auth/gitlab.service"; + +const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = process.env; + +export default async function exchangeGitLabCodeForTokensHandler(req: NextApiRequest, res: NextApiResponse): Promise { + const { code }: { code: string } = req.body; + + if (!code) { + return res.status(400).send("No authorization code provided"); + } + + const gitlabAuth = new GitlabAuth(NEXT_PUBLIC_GITLAB_CLIENT_ID as string, GITLAB_CLIENT_SECRET as string, NEXT_PUBLIC_REDIRECT_URI as string); + + try { + const { access_token, refresh_token } = await gitlabAuth.exchangeAuthorizationCodeForTokens(code); + res.status(200).json({ access_token, refresh_token }); + } catch (error) { + res.status(500).send("Something went wrong"); + } +} diff --git a/apps/deploy-web/src/pages/api/gitlab/refresh.ts b/apps/deploy-web/src/pages/api/gitlab/refresh.ts new file mode 100644 index 000000000..310a88804 --- /dev/null +++ b/apps/deploy-web/src/pages/api/gitlab/refresh.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import GitlabAuth from "@src/services/auth/gitlab.service"; + +const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET } = process.env; + +export default async function refreshGitLabTokensHandler(req: NextApiRequest, res: NextApiResponse): Promise { + const { refreshToken }: { refreshToken: string } = req.body; + + if (!refreshToken) { + return res.status(400).send("No refresh token provided"); + } + + const gitlabAuth = new GitlabAuth(NEXT_PUBLIC_GITLAB_CLIENT_ID as string, GITLAB_CLIENT_SECRET as string); + + try { + const { access_token, refresh_token } = await gitlabAuth.refreshTokensUsingRefreshToken(refreshToken); + res.status(200).json({ access_token, refresh_token }); + } catch (error) { + res.status(500).send("Something went wrong"); + } +} diff --git a/apps/deploy-web/src/services/auth/bitbucket.service.ts b/apps/deploy-web/src/services/auth/bitbucket.service.ts new file mode 100644 index 000000000..173f06324 --- /dev/null +++ b/apps/deploy-web/src/services/auth/bitbucket.service.ts @@ -0,0 +1,59 @@ +import axios, { AxiosResponse } from "axios"; +import { URLSearchParams } from "url"; + +interface Tokens { + access_token: string; + refresh_token: string; +} + +class BitbucketAuth { + private tokenUrl: string; + private clientId: string; + private clientSecret: string; + + constructor(clientId: string, clientSecret: string) { + this.tokenUrl = "https://bitbucket.org/site/oauth2/access_token"; + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + async exchangeAuthorizationCodeForTokens(authorizationCode: string): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "authorization_code"); + params.append("code", authorizationCode); + + const headers = { + Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }; + + try { + const response: AxiosResponse = await axios.post(this.tokenUrl, params.toString(), { headers }); + const { access_token, refresh_token }: Tokens = response.data; + return { access_token, refresh_token }; + } catch (error) { + throw new Error("Failed to exchange authorization code for tokens"); + } + } + + async refreshTokensUsingRefreshToken(refreshToken: string): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("refresh_token", refreshToken); + + const headers = { + Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }; + + try { + const response: AxiosResponse = await axios.post(this.tokenUrl, params.toString(), { headers }); + const { access_token, refresh_token }: Tokens = response.data; + return { access_token, refresh_token }; + } catch (error) { + throw new Error("Failed to refresh tokens using refresh token"); + } + } +} + +export default BitbucketAuth; diff --git a/apps/deploy-web/src/services/auth/github.service.ts b/apps/deploy-web/src/services/auth/github.service.ts new file mode 100644 index 000000000..1d155ec05 --- /dev/null +++ b/apps/deploy-web/src/services/auth/github.service.ts @@ -0,0 +1,39 @@ +import axios, { AxiosResponse } from "axios"; + +class GitHubAuth { + private tokenUrl: string; + private clientId: string; + private clientSecret: string; + private redirectUri: string | undefined; + + constructor(clientId: string, clientSecret: string, redirectUri?: string) { + this.tokenUrl = "https://github.com/login/oauth/access_token"; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + async exchangeAuthorizationCodeForToken(authorizationCode: string): Promise { + try { + const response: AxiosResponse = await axios.post(this.tokenUrl, { + client_id: this.clientId, + client_secret: this.clientSecret, + code: authorizationCode, + redirect_uri: this.redirectUri + }); + + const params = new URLSearchParams(response.data); + const access_token = params.get("access_token"); + + if (!access_token) { + throw new Error("No access token returned from GitHub"); + } + + return access_token; + } catch (error) { + throw new Error("Failed to exchange authorization code for access token"); + } + } +} + +export default GitHubAuth; diff --git a/apps/deploy-web/src/services/auth/gitlab.service.ts b/apps/deploy-web/src/services/auth/gitlab.service.ts new file mode 100644 index 000000000..66535e0ea --- /dev/null +++ b/apps/deploy-web/src/services/auth/gitlab.service.ts @@ -0,0 +1,55 @@ +import axios, { AxiosResponse } from "axios"; + +interface Tokens { + access_token: string; + refresh_token: string; +} + +class GitlabAuth { + private tokenUrl: string; + private clientId: string; + private clientSecret: string; + private redirectUri: string | undefined; + + constructor(clientId: string, clientSecret: string, redirectUri?: string) { + this.tokenUrl = "https://gitlab.com/oauth/token"; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + async exchangeAuthorizationCodeForTokens(authorizationCode: string): Promise { + try { + const response: AxiosResponse = await axios.post(this.tokenUrl, { + client_id: this.clientId, + client_secret: this.clientSecret, + code: authorizationCode, + redirect_uri: this.redirectUri, + grant_type: "authorization_code" + }); + + const { access_token, refresh_token }: Tokens = response.data; + return { access_token, refresh_token }; + } catch (error) { + throw new Error("Failed to exchange authorization code for tokens"); + } + } + + async refreshTokensUsingRefreshToken(refreshToken: string): Promise { + try { + const response: AxiosResponse = await axios.post(this.tokenUrl, { + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token" + }); + + const { access_token, refresh_token }: Tokens = response.data; + return { access_token, refresh_token }; + } catch (error) { + throw new Error("Failed to refresh tokens using refresh token"); + } + } +} + +export default GitlabAuth; diff --git a/apps/deploy-web/src/store/remoteDeployStore.ts b/apps/deploy-web/src/store/remoteDeployStore.ts new file mode 100644 index 000000000..eaec6363e --- /dev/null +++ b/apps/deploy-web/src/store/remoteDeployStore.ts @@ -0,0 +1,15 @@ +import { atomWithStorage } from "jotai/utils"; + +import { OAuth } from "@src/components/remote-deploy/utils"; + +export const tokens = atomWithStorage<{ + access_token: string | null; + refresh_token: string | null; + type: OAuth; + alreadyLoggedIn?: string[]; +}>("remote-deploy-tokens", { + access_token: null, + refresh_token: null, + type: "github", + alreadyLoggedIn: [] +}); diff --git a/apps/deploy-web/src/types/remoteCommits.ts b/apps/deploy-web/src/types/remoteCommits.ts new file mode 100644 index 000000000..04dd5245a --- /dev/null +++ b/apps/deploy-web/src/types/remoteCommits.ts @@ -0,0 +1,164 @@ +export interface GitCommit { + sha: string; + node_id: string; + commit: { + author: { + name: string; + email: string; + date: string; + }; + committer: { + name: string; + email: string; + date: string; + }; + message: string; + tree: { + sha: string; + url: string; + }; + url: string; + comment_count: number; + verification: { + verified: boolean; + reason: string; + signature: string | null; + payload: string | null; + }; + }; + url: string; + html_url: string; + comments_url: string; + author: { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + }; + committer: { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + }; + parents: { + sha: string; + url: string; + html_url: string; + }[]; +} + +export interface GitLabCommit { + id: string; + short_id: string; + created_at: string; + parent_ids: string[]; + title: string; + message: string; + author_name: string; + author_email: string; + authored_date: string; + committer_name: string; + committer_email: string; + committed_date: string; + web_url: string; +} + +export interface BitBucketCommit { + values: { + type: string; + hash: string; + date: string; + author: Author; + message: string; + summary: Summary; + links: CommitLinks; + parents: Parent[]; + rendered: RenderedMessage; + repository: Repository; + }[]; + pagelen: number; +} + +interface Author { + type: string; + raw: string; +} + +interface Summary { + type: string; + raw: string; + markup: string; + html: string; +} + +interface CommitLinks { + self: Link; + html: Link; + diff: Link; + approve: Link; + comments: Link; + statuses: Link; + patch: Link; +} + +interface Link { + href: string; +} + +interface Parent { + hash: string; + links: ParentLinks; + type: string; +} + +interface ParentLinks { + self: Link; + html: Link; +} + +interface RenderedMessage { + message: Summary; +} + +interface Repository { + type: string; + full_name: string; + links: RepositoryLinks; + name: string; + uuid: string; +} + +interface RepositoryLinks { + self: Link; + html: Link; + avatar: Link; +} diff --git a/apps/deploy-web/src/types/remoteProfile.ts b/apps/deploy-web/src/types/remoteProfile.ts new file mode 100644 index 000000000..6d1f01107 --- /dev/null +++ b/apps/deploy-web/src/types/remoteProfile.ts @@ -0,0 +1,115 @@ +export interface BitProfile { + display_name: string; + links: { + self: { + href: string; + }; + avatar: { + href: string; + }; + repositories: { + href: string; + }; + snippets: { + href: string; + }; + html: { + href: string; + }; + hooks: { + href: string; + }; + }; + created_on: string; + type: string; + uuid: string; + has_2fa_enabled: null; + username: string; + is_staff: boolean; + account_id: string; + nickname: string; + account_status: string; + location: null; +} + +export interface GitLabProfile { + id: number; + username: string; + name: string; + state: string; + locked: boolean; + avatar_url: string; + web_url: string; + created_at: string; + bio: string; + location: string; + public_email: string; + skype: string; + linkedin: string; + twitter: string; + discord: string; + website_url: string; + organization: string; + job_title: string; + pronouns: string; + bot: boolean; + work_information: string; + local_time: string; + last_sign_in_at: string; + confirmed_at: string; + last_activity_on: string; + email: string; + theme_id: number; + color_scheme_id: number; + projects_limit: number; + current_sign_in_at: string; + identities: { + provider: string; + extern_uid: string; + saml_provider_id: string; + }[]; + can_create_group: boolean; + can_create_project: boolean; + two_factor_enabled: boolean; + external: boolean; + private_profile: boolean; + commit_email: string; + shared_runners_minutes_limit: string; + extra_shared_runners_minutes_limit: string; + scim_identities: any[]; +} + +export interface GitHubProfile { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + name: string; + company: string; + blog: string; + location: string; + email: string; + hireable: string; + bio: string; + twitter_username: string; + public_repos: number; + public_gists: number; + followers: number; + following: number; + created_at: string; + updated_at: string; +} diff --git a/apps/deploy-web/src/types/remoteRepos.ts b/apps/deploy-web/src/types/remoteRepos.ts new file mode 100644 index 000000000..284d58360 --- /dev/null +++ b/apps/deploy-web/src/types/remoteRepos.ts @@ -0,0 +1,278 @@ +interface RepositoryLinks { + self: Link; + html: Link; + avatar: Link; + pullrequests: Link; + commits: Link; + forks: Link; + watchers: Link; + branches: Link; + tags: Link; + downloads: Link; + source: Link; + clone: CloneLink[]; + hooks: Link; +} + +interface Link { + href: string; +} + +interface CloneLink extends Link { + name: string; +} + +interface RepositoryOwner { + display_name: string; + links: { + self: Link; + avatar: Link; + html: Link; + }; + type: string; + uuid: string; + username: string; +} + +export interface BitWorkspace { + type: string; + uuid: string; + name: string; + slug: string; + links: { + avatar: Link; + html: Link; + self: Link; + }; +} + +interface Project { + type: string; + key: string; + uuid: string; + name: string; + links: { + self: Link; + html: Link; + avatar: Link; + }; +} + +interface MainBranch { + name: string; + type: string; +} + +interface OverrideSettings { + default_merge_strategy: boolean; + branching_model: boolean; +} + +export interface BitRepository { + type: string; + full_name: string; + links: RepositoryLinks; + name: string; + slug: string; + description: string; + scm: string; + website: string | null; + owner: RepositoryOwner; + workspace: BitWorkspace; + is_private: boolean; + project: Project; + fork_policy: string; + created_on: string; + updated_on: string; + size: number; + language: string; + uuid: string; + mainbranch: MainBranch; + override_settings: OverrideSettings; + parent: null | string; +} + +interface Namespace { + id: number; + name: string; + path: string; + kind: string; + full_path: string; + parent_id: number | null; + avatar_url: string | null; + web_url: string; +} + +interface Links { + self: string; + issues: string; + merge_requests: string; + repo_branches: string; + labels: string; + events: string; + members: string; + cluster_agents: string; +} + +interface ContainerExpirationPolicy { + cadence: string; + enabled: boolean; + keep_n: number; + older_than: string; + name_regex: string; + name_regex_keep: string | null; + next_run_at: string; +} + +export interface GitlabRepo { + id: number; + description: string; + name: string; + name_with_namespace: string; + path: string; + path_with_namespace: string; + created_at: string; + default_branch: string; + tag_list: string[]; + topics: string[]; + ssh_url_to_repo: string; + http_url_to_repo: string; + web_url: string; + readme_url: string; + forks_count: number; + avatar_url: string | null; + star_count: number; + last_activity_at: string; + namespace: Namespace; + container_registry_image_prefix: string; + _links: Links; + packages_enabled: boolean; + empty_repo: boolean; + archived: boolean; + visibility: string; + resolve_outdated_diff_discussions: boolean; + container_expiration_policy: ContainerExpirationPolicy; + repository_object_format: string; + issues_enabled: boolean; + merge_requests_enabled: boolean; + wiki_enabled: boolean; + jobs_enabled: boolean; + snippets_enabled: boolean; + container_registry_enabled: boolean; + service_desk_enabled: boolean; + service_desk_address: string; + can_create_merge_request_in: boolean; + issues_access_level: string; + repository_access_level: string; + merge_requests_access_level: string; + forking_access_level: string; + wiki_access_level: string; + builds_access_level: string; + snippets_access_level: string; + pages_access_level: string; + analytics_access_level: string; + container_registry_access_level: string; + security_and_compliance_access_level: string; + releases_access_level: string; + environments_access_level: string; + feature_flags_access_level: string; + infrastructure_access_level: string; + monitor_access_level: string; + model_experiments_access_level: string; + model_registry_access_level: string; + emails_disabled: boolean; + emails_enabled: boolean; + shared_runners_enabled: boolean; + lfs_enabled: boolean; + creator_id: number; + import_url: string; + import_type: string; + import_status: string; + open_issues_count: number; + description_html: string; + updated_at: string; + ci_default_git_depth: number; + ci_forward_deployment_enabled: boolean; + ci_forward_deployment_rollback_allowed: boolean; + ci_job_token_scope_enabled: boolean; + ci_separated_caches: boolean; + ci_allow_fork_pipelines_to_run_in_parent_project: boolean; + ci_id_token_sub_claim_components: string[]; + build_git_strategy: string; + keep_latest_artifact: boolean; + restrict_user_defined_variables: boolean; + ci_pipeline_variables_minimum_override_role: string; + runners_token: string | null; + runner_token_expiration_interval: string | null; + group_runners_enabled: boolean; + auto_cancel_pending_pipelines: string; + build_timeout: number; + auto_devops_enabled: boolean; + auto_devops_deploy_strategy: string; + ci_push_repository_for_job_token_allowed: boolean; + ci_config_path: string; + public_jobs: boolean; + shared_with_groups: string[]; + only_allow_merge_if_pipeline_succeeds: boolean; + allow_merge_on_skipped_pipeline: string | null; + request_access_enabled: boolean; + only_allow_merge_if_all_discussions_are_resolved: boolean; + remove_source_branch_after_merge: boolean; + printing_merge_request_link_enabled: boolean; + merge_method: string; + squash_option: string; + enforce_auth_checks_on_uploads: boolean; + suggestion_commit_message: string | null; + merge_commit_template: string | null; + squash_commit_template: string | null; + issue_branch_template: string | null; + warn_about_potentially_unwanted_characters: boolean; + autoclose_referenced_issues: boolean; + external_authorization_classification_label: string; + requirements_enabled: boolean; + requirements_access_level: string; + security_and_compliance_enabled: boolean; + pre_receive_secret_detection_enabled: boolean; + compliance_frameworks: string[]; +} + +interface DefaultBranchProtectionDefaults { + allowed_to_push: Array<{ access_level: number }>; + allow_force_push: boolean; + allowed_to_merge: Array<{ access_level: number }>; +} + +export interface GitlabGroup { + id: number; + web_url: string; + name: string; + path: string; + description: string; + visibility: string; + share_with_group_lock: boolean; + require_two_factor_authentication: boolean; + two_factor_grace_period: number; + project_creation_level: string; + auto_devops_enabled: boolean | null; + subgroup_creation_level: string; + emails_disabled: boolean; + emails_enabled: boolean; + mentions_disabled: boolean | null; + lfs_enabled: boolean; + math_rendering_limits_enabled: boolean; + lock_math_rendering_limits_enabled: boolean; + default_branch: string | null; + default_branch_protection: number; + default_branch_protection_defaults: DefaultBranchProtectionDefaults; + avatar_url: string | null; + request_access_enabled: boolean; + full_name: string; + full_path: string; + created_at: string; + parent_id: number | null; + organization_id: number; + shared_runners_setting: string; + ldap_cn: string | null; + ldap_access: string | null; + wiki_access_level: string; +} diff --git a/apps/deploy-web/src/types/remotedeploy.ts b/apps/deploy-web/src/types/remotedeploy.ts new file mode 100644 index 000000000..51680af17 --- /dev/null +++ b/apps/deploy-web/src/types/remotedeploy.ts @@ -0,0 +1,149 @@ +export interface IGithubDirectoryItem { + type: "file" | "dir" | "commit_directory" | "tree"; + size: number; + name: string; + path: string; + content?: string; + sha: string; + url: string; + git_url: string; + html_url: string; + download_url: string; + _links: { + git: string; + html: string; + self: string; + }; +} +export interface RollBackType { + name: string; + value: string; + date: Date; +} + +export interface Owner { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; +} + +interface License { + key: string; + name: string; + spdx_id: string; + url: string | null; + node_id: string; +} + +interface Permissions { + admin: boolean; + maintain: boolean; + push: boolean; + triage: boolean; + pull: boolean; +} + +export interface GithubRepository { + id: number; + node_id: string; + name: string; + full_name: string; + private: boolean; + owner: Owner; + html_url: string; + description: string | null; + fork: boolean; + url: string; + forks_url: string; + keys_url: string; + collaborators_url: string; + teams_url: string; + hooks_url: string; + issue_events_url: string; + events_url: string; + assignees_url: string; + branches_url: string; + tags_url: string; + blobs_url: string; + git_tags_url: string; + git_refs_url: string; + trees_url: string; + statuses_url: string; + languages_url: string; + stargazers_url: string; + contributors_url: string; + subscribers_url: string; + subscription_url: string; + commits_url: string; + git_commits_url: string; + comments_url: string; + issue_comment_url: string; + contents_url: string; + compare_url: string; + merges_url: string; + archive_url: string; + downloads_url: string; + issues_url: string; + pulls_url: string; + milestones_url: string; + notifications_url: string; + labels_url: string; + releases_url: string; + deployments_url: string; + created_at: string; + updated_at: string; + pushed_at: string; + git_url: string; + ssh_url: string; + clone_url: string; + svn_url: string; + homepage: string | null; + size: number; + stargazers_count: number; + watchers_count: number; + language: string | null; + has_issues: boolean; + has_projects: boolean; + has_downloads: boolean; + has_wiki: boolean; + has_pages: boolean; + has_discussions: boolean; + forks_count: number; + mirror_url: string | null; + archived: boolean; + disabled: boolean; + open_issues_count: number; + license: License; + allow_forking: boolean; + is_template: boolean; + web_commit_signoff_required: boolean; + topics: string[]; + visibility: string; + forks: number; + open_issues: number; + watchers: number; + default_branch: string; + permissions: Permissions; +} + +export interface PackageJson { + // this should any as we cannot know what dependencies are available + dependencies?: any; + devDependencies?: any; + scripts?: any; +} diff --git a/apps/deploy-web/src/utils/templates.ts b/apps/deploy-web/src/utils/templates.ts index 284f3e352..de7b9862b 100644 --- a/apps/deploy-web/src/utils/templates.ts +++ b/apps/deploy-web/src/utils/templates.ts @@ -74,4 +74,50 @@ deployment: ` }; -export const hardcodedTemplates = [sdlBuilderTemplate, helloWorldTemplate]; +export const github = { + title: "GitHub", + name: "GitHub", + code: "github", + category: "General", + description: "Get started with a simple linux Ubuntu server!", + githubUrl: "", + valuesToChange: [], + content: `--- +version: "2.0" +services: + service-1: + image: hoomanhq/automation:0.421 + expose: + - port: 3000 + as: 80 + to: + - global: true + - port: 8080 + as: 8080 + to: + - global: true +profiles: + compute: + service-1: + resources: + cpu: + units: 2 + memory: + size: 12GB + storage: + - size: 8Gi + placement: + dcloud: + pricing: + service-1: + denom: uakt + amount: 1000 +deployment: + service-1: + dcloud: + profile: service-1 + count: 1 +` +}; + +export const hardcodedTemplates = [sdlBuilderTemplate, helloWorldTemplate, github]; diff --git a/apps/deploy-web/src/utils/urlUtils.ts b/apps/deploy-web/src/utils/urlUtils.ts index 3ceb03c8b..9fb0b0b43 100644 --- a/apps/deploy-web/src/utils/urlUtils.ts +++ b/apps/deploy-web/src/utils/urlUtils.ts @@ -7,6 +7,8 @@ export type NewDeploymentParams = { redeploy?: string | number; templateId?: string; page?: "new-deployment" | "deploy-linux"; + type?: string; + code?: string | null; }; export const domainName = "https://console.akash.network"; @@ -63,9 +65,9 @@ export class UrlService { // New deployment static newDeployment = (params: NewDeploymentParams = {}) => { - const { step, dseq, redeploy, templateId } = params; + const { step, dseq, redeploy, templateId, type, code } = params; const page = params.page || "new-deployment"; - return `/${page}${appendSearchParams({ dseq, step, templateId, redeploy })}`; + return `/${page}${appendSearchParams({ dseq, step, templateId, redeploy, type, code })}`; }; } diff --git a/package-lock.json b/package-lock.json index c5e6da37a..ddf9d032d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ }, "apps/api": { "name": "console-api", - "version": "2.23.1", + "version": "2.23.2", "license": "Apache-2.0", "dependencies": { "@akashnetwork/akash-api": "^1.3.0",