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

Merge branch 'master' of github.com:porter-dev/porter into nico/implement-registry-listing-on-connect-registry

jnfrati пре 4 година
родитељ
комит
d5967555c3
28 измењених фајлова са 988 додато и 418 уклоњено
  1. 1 1
      api/server/handlers/provision/helpers.go
  2. 1 0
      api/server/shared/config/env/envconfs.go
  3. 13 5
      dashboard/package-lock.json
  4. 1 1
      dashboard/package.json
  5. BIN
      dashboard/src/assets/logo.png
  6. 13 13
      dashboard/src/components/ProvisionerStatus.tsx
  7. 3 6
      dashboard/src/components/image-selector/ImageList.tsx
  8. 3 5
      dashboard/src/components/image-selector/TagList.tsx
  9. 12 0
      dashboard/src/index.html
  10. 3 3
      dashboard/src/main/auth/Login.tsx
  11. 3 3
      dashboard/src/main/auth/Register.tsx
  12. 12 0
      dashboard/src/main/home/ModalHandler.tsx
  13. 11 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  14. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  15. 112 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  16. 3 3
      dashboard/src/main/home/dashboard/Dashboard.tsx
  17. 58 0
      dashboard/src/main/home/modals/SkipProvisioningModal.tsx
  18. 211 0
      dashboard/src/main/home/navbar/Help.tsx
  19. 17 3
      dashboard/src/main/home/navbar/Navbar.tsx
  20. 32 0
      dashboard/src/main/home/onboarding/Onboarding.tsx
  21. 1 1
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  22. 2 2
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  23. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  24. 0 359
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx
  25. 473 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx
  26. 0 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx
  27. 0 6
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx
  28. 1 3
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

+ 1 - 1
api/server/handlers/provision/helpers.go

@@ -61,7 +61,7 @@ func GetSharedProvisionerOpts(conf *config.Config, infra *models.Infra) (*provis
 		ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
 		TFHTTPBackendURL:    conf.ServerConf.ProvisionerBackendURL,
 		CredentialExchange: &provisioner.ProvisionCredentialExchange{
-			CredExchangeEndpoint: fmt.Sprintf("%s/api/internal/credentials", conf.ServerConf.ServerURL),
+			CredExchangeEndpoint: fmt.Sprintf("%s/api/internal/credentials", conf.ServerConf.ProvisionerCredExchangeURL),
 			CredExchangeToken:    rawToken,
 			CredExchangeID:       ceToken.ID,
 		},

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -61,6 +61,7 @@ type ServerConf struct {
 	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
 	ProvisionerJobNamespace    string `env:"PROV_JOB_NAMESPACE,default=default"`
 	ProvisionerBackendURL      string `env:"PROV_BACKEND_URL"`
+	ProvisionerCredExchangeURL string `env:"PROV_CRED_EXCHANGE_URL,default=http://porter:8080"`
 
 	SegmentClientKey string `env:"SEGMENT_CLIENT_KEY"`
 

+ 13 - 5
dashboard/package-lock.json

@@ -4267,11 +4267,18 @@
       "dev": true
     },
     "axios": {
-      "version": "0.20.0",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz",
-      "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==",
+      "version": "0.21.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
+      "integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
       "requires": {
-        "follow-redirects": "^1.10.0"
+        "follow-redirects": "^1.14.0"
+      },
+      "dependencies": {
+        "follow-redirects": {
+          "version": "1.14.5",
+          "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
+          "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
+        }
       }
     },
     "babel-loader": {
@@ -6714,7 +6721,8 @@
     "follow-redirects": {
       "version": "1.13.1",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
-      "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
+      "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==",
+      "dev": true
     },
     "for-in": {
       "version": "1.0.2",

+ 1 - 1
dashboard/package.json

@@ -19,7 +19,7 @@
     "@visx/tooltip": "^1.3.0",
     "ace-builds": "^1.4.12",
     "anser": "^2.0.1",
-    "axios": "^0.20.0",
+    "axios": "^0.21.2",
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",

BIN
dashboard/src/assets/logo.png


+ 13 - 13
dashboard/src/components/ProvisionerStatus.tsx

@@ -4,7 +4,7 @@ import { integrationList } from "shared/common";
 
 import loading from "assets/loading.gif";
 
-import styled from "styled-components";
+import styled, { keyframes } from "styled-components";
 
 type Props = {
   modules: TFModule[];
@@ -22,7 +22,7 @@ export interface TFModule {
 }
 
 export interface TFResourceError {
-  errored_out: boolean;
+  errored_out?: boolean;
   error_context?: string;
 }
 
@@ -199,6 +199,16 @@ const ExpandedError = styled.div`
   padding-bottom: 17px;
 `;
 
+const movingGradient = keyframes`
+  0% {
+      background-position: left bottom;
+  }
+
+  100% {
+      background-position: right bottom;
+  }
+`;
+
 const LoadingFill = styled.div<{ width: string; status: string }>`
   width: ${(props) => props.width};
   background: ${(props) =>
@@ -209,19 +219,9 @@ const LoadingFill = styled.div<{ width: string; status: string }>`
       : "linear-gradient(to right, #8ce1ff, #616FEE)"};
   height: 100%;
   background-size: 250% 100%;
-  animation: moving-gradient 2s infinite;
+  animation: ${movingGradient} 2s infinite;
   animation-timing-function: ease-in-out;
   animation-direction: alternate;
-
-  @keyframes moving-gradient {
-    0% {
-        background-position: left bottom;
-    }
-
-    100% {
-        background-position: right bottom;
-    }
-  }​
 `;
 
 const StatusIcon = styled.div<{ successful?: boolean }>`

+ 3 - 6
dashboard/src/components/image-selector/ImageList.tsx

@@ -280,20 +280,17 @@ const BackButton = styled.div`
   }
 `;
 
-const ImageItem = styled.div`
+const ImageItem = styled.div<{ lastItem: boolean, isSelected: boolean }>`
   display: flex;
   width: 100%;
   font-size: 13px;
-  border-bottom: 1px solid
-    ${(props: { lastItem: boolean; isSelected: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+  border-bottom: 1px solid ${props => props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   cursor: pointer;
-  background: ${(props: { isSelected: boolean; lastItem: boolean }) =>
-    props.isSelected ? "#ffffff11" : ""};
+  background: ${props => props.isSelected ? "#ffffff11" : ""};
   :hover {
     background: #ffffff22;
 

+ 3 - 5
dashboard/src/components/image-selector/TagList.tsx

@@ -169,20 +169,18 @@ const StyledTagList = styled.div`
   overflow: auto;
 `;
 
-const TagName = styled.div`
+const TagName = styled.div<{ lastItem?: boolean; isSelected?: boolean }>`
   display: flex;
   width: 100%;
   font-size: 13px;
   border-bottom: 1px solid
-    ${(props: { lastItem?: boolean; isSelected?: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    ${props => props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   cursor: pointer;
-  background: ${(props: { isSelected?: boolean; lastItem?: boolean }) =>
-    props.isSelected ? "#ffffff11" : ""};
+  background: ${props => props.isSelected ? "#ffffff11" : ""};
   :hover {
     background: #ffffff22;
 

+ 12 - 0
dashboard/src/index.html

@@ -39,6 +39,18 @@
       window.Cohere.init("_A-2HNgriISqaQq4yzTxM8V-");
     </script>
 
+    <script>
+      window.intercomSettings = {
+        app_id: "gq56g49i",
+        custom_launcher_selector: '#intercom_help'
+      };
+    </script>
+
+    <script>
+    // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+    (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/gq56g49i';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
+    </script>
+
     <script>
       !(function () {
         var analytics = (window.analytics = window.analytics || []);

+ 3 - 3
dashboard/src/main/auth/Login.tsx

@@ -436,9 +436,9 @@ const Prompt = styled.div`
 `;
 
 const Logo = styled.img`
-  width: 140px;
-  margin-top: 50px;
-  margin-bottom: 45px;
+  width: 110px;
+  margin-top: 55px;
+  margin-bottom: 40px;
   user-select: none;
 `;
 

+ 3 - 3
dashboard/src/main/auth/Register.tsx

@@ -434,9 +434,9 @@ const Prompt = styled.div`
 `;
 
 const Logo = styled.img`
-  width: 140px;
-  margin-top: 50px;
-  margin-bottom: 35px;
+  width: 110px;
+  margin-top: 45px;
+  margin-bottom: 30px;
   user-select: none;
 `;
 

+ 12 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -15,6 +15,7 @@ import RedirectToOnboardingModal from "./modals/RedirectToOnboardingModal";
 import UsageWarningModal from "./modals/UsageWarningModal";
 import api from "shared/api";
 import { AxiosError } from "axios";
+import SkipOnboardingModal from "./modals/SkipProvisioningModal";
 
 const ModalHandler: React.FC<{
   setRefreshClusters: (x: boolean) => void;
@@ -187,6 +188,17 @@ const ModalHandler: React.FC<{
           <UsageWarningModal />
         </Modal>
       )}
+
+      {modal === "SkipOnboardingModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="240px"
+          title="Would you like to skip project setup?"
+        >
+          <SkipOnboardingModal />
+        </Modal>
+      )}
     </>
   );
 };

+ 11 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -82,6 +82,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
+  const [fullScreenLogs, setFullScreenLogs] = useState<boolean>(false);
 
   const {
     newWebsocket,
@@ -383,7 +384,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </Placeholder>
           );
         } else {
-          return <StatusSection currentChart={chart} />;
+          return <StatusSection currentChart={chart} setFullScreenLogs={() => setFullScreenLogs(true)} />;
         }
       case "settings":
         return (
@@ -662,6 +663,14 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   return (
     <>
+      { 
+        fullScreenLogs ? (
+          <StatusSection
+            fullscreen={true} 
+            currentChart={currentChart} 
+            setFullScreenLogs={() => setFullScreenLogs(false)}
+          />
+        ) : (
       <StyledExpandedChart>
         <HeaderWrapper>
           <BackButton onClick={props.closeChart}>
@@ -758,6 +767,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
           </>
         )}
       </StyledExpandedChart>
+    )}
     </>
   );
 };

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -434,6 +434,7 @@ const LogStream = styled.div`
   flex: 1;
   float: right;
   height: 100%;
+  font-size: 13px;
   background: #121318;
   user-select: text;
   max-width: 65%;

+ 112 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -5,6 +5,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { ChartType, StorageType } from "shared/types";
 import Loading from "components/Loading";
+import backArrow from "assets/back_arrow.png";
 
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
@@ -12,10 +13,14 @@ import ControllerTab from "./ControllerTab";
 type Props = {
   selectors?: string[];
   currentChart: ChartType;
+  fullscreen?: boolean;
+  setFullScreenLogs?: any;
 };
 
 const StatusSectionFC: React.FunctionComponent<Props> = ({
   currentChart,
+  fullscreen,
+  setFullScreenLogs,
   selectors,
 }) => {
   const [selectedPod, setSelectedPod] = useState<any>({});
@@ -126,11 +131,108 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
     );
   };
 
-  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+  return (
+    <>
+      {
+        fullscreen ? (
+          <FullScreen>
+            <AbsoluteTitle>
+              <BackButton
+                onClick={setFullScreenLogs}
+              >
+                <i className="material-icons">navigate_before</i>
+              </BackButton>
+              Status ({currentChart.name})
+            </AbsoluteTitle>
+            <FullScreenButton top="70px" onClick={setFullScreenLogs}>
+              <i className="material-icons">close_fullscreen</i>
+            </FullScreenButton>
+            {renderStatusSection()}
+          </FullScreen>
+        ) : (
+          <StyledStatusSection>
+            <FullScreenButton onClick={setFullScreenLogs}>
+              <i className="material-icons">open_in_full</i>
+            </FullScreenButton>
+            {renderStatusSection()}
+          </StyledStatusSection>
+        )
+      }
+    </>
+  );
 };
 
 export default StatusSectionFC;
 
+const FullScreenButton = styled.div<{ top?: string }>`
+  position: absolute;
+  top: ${props => props.top || "10px"};
+  right: 10px;
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 5px;
+  background: #ffffff11;
+  border: 1px solid #aaaabb;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 30px;
+  z-index: 999;
+  cursor: pointer;
+  height: 30px;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  cursor: pointer;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 12px;
+  opacity: 0.75;
+`;
+
+
+const AbsoluteTitle = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+  font-size: 18px;
+  font-weight: 500;
+  user-select: text;
+`;
+
 const TabWrapper = styled.div`
   width: 35%;
   min-width: 250px;
@@ -163,6 +265,15 @@ const StyledStatusSection = styled.div`
   }
 `;
 
+const FullScreen = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  padding-top: 60px;
+`;
+
 const Wrapper = styled.div`
   width: 100%;
   height: 100%;

+ 3 - 3
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -16,7 +16,7 @@ import TitleSection from "components/TitleSection";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { SharedStatus } from "../onboarding/steps/ProvisionResources/forms/SharedStatus";
+import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -107,10 +107,10 @@ class Dashboard extends Component<PropsType, StateType> {
   renderTabContents = () => {
     if (this.currentTab() === "provisioner") {
       return (
-        <SharedStatus
+        <StatusPage
           filter={[]}
           project_id={this.props.projectId}
-          setInfraStatus={(val: string) => null}
+          setInfraStatus={() => null}
         />
       );
     } else if (this.currentTab() === "create-cluster") {

+ 58 - 0
dashboard/src/main/home/modals/SkipProvisioningModal.tsx

@@ -0,0 +1,58 @@
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+/**
+ * If user goes to /onboarding and has clusters, the Onboarding component
+ * will open this modal to let user skip onboarding and keep using porter.
+ */
+const SkipOnboardingModal = () => {
+  const { currentModalData, setCurrentModal } = useContext(Context);
+
+  return (
+    <>
+      <Subtitle>
+        Porter has detected an existing Kubernetes cluster that was connected
+        via the CLI. For custom setups, you can skip the project setup flow.
+      </Subtitle>
+      <Subtitle>Do you want to skip project setup?</Subtitle>
+      <ActionsWrapper>
+        <ActionButton
+          text="Yes, skip setup"
+          color="#616FEEcc"
+          onClick={() =>
+            typeof currentModalData?.skipOnboarding === "function" &&
+            currentModalData.skipOnboarding()
+          }
+          status={""}
+          clearPosition
+        />
+      </ActionsWrapper>
+    </>
+  );
+};
+
+export default SkipOnboardingModal;
+
+const ActionButton = styled(SaveButton)``;
+
+const ActionsWrapper = styled.div`
+  position: absolute;
+  bottom: 14px;
+  right: 14px;
+  display: flex;
+  ${ActionButton} {
+    margin-left: 5px;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  color: #aaaabb;
+  overflow: hidden;
+  margin-bottom: -10px;
+`;

+ 211 - 0
dashboard/src/main/home/navbar/Help.tsx

@@ -0,0 +1,211 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import discordLogo from "../../../assets/discord.svg";
+
+type PropsType = {
+};
+
+type StateType = {
+  showHelpDropdown: boolean;
+};
+
+export default class Help extends Component<PropsType, StateType> {
+  state = {
+      showHelpDropdown: false,
+  };
+
+  renderHelpDropdown = () => {
+    if (this.state.showHelpDropdown) {
+      return (
+        <>
+          <CloseOverlay
+            onClick={() =>
+              this.setState({
+                showHelpDropdown: false,
+              })
+            }
+          />
+          <Dropdown
+            dropdownWidth="155px"
+            dropdownMaxHeight="300px"
+          >
+            <Option onClick={()=> {
+                window.open('https://docs.porter.run', '_blank').focus();}
+            }>
+            <i className="material-icons-outlined">book</i>
+                Documentation
+            </Option>
+            <Line/>
+            <Option onClick={() => {
+              window.open('https://discord.gg/Vbse9vJtPU', '_blank').focus();
+            }}>
+            <Icon src={discordLogo} />
+              Community
+            </Option>
+            <Line/>
+            <Option id={'intercom_help'}>
+            <i className="material-icons-outlined">message</i>
+                Message us
+            </Option>            
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  render() {
+    return (
+      <FeedbackButton selected={this.state.showHelpDropdown === true}>
+        <Flex
+          onClick={() =>
+            this.setState({
+              showHelpDropdown: !this.state.showHelpDropdown,
+            })
+          }
+        >
+          <i className="material-icons-outlined">help_outline</i>
+          Help
+        </Flex>
+        {this.renderHelpDropdown()}
+      </FeedbackButton>
+    );
+  }
+}
+
+Help.contextType = Context;
+
+const Option = styled.div`
+    margin-left: 15px;
+    font-size: 13px;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+    width: 120px;
+    height: 40px;
+    color: #ffffff88;
+    cursor: pointer;
+    > i {
+        opacity: 50%;
+        color: white;
+        margin-right: 7px;
+        font-size: 20px;
+        cursor: pointer;
+    }
+`
+
+const Line = styled.div`
+    height: 1px;
+    z-index: 0;
+    left: 0;
+    background: #aaaabb55;
+    width: 100%;
+`
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  width: 100vw;
+  height: 100vh;
+  z-index: 100;
+  top: 0;
+  left: 0;
+  cursor: default;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  right: 0;
+  top: calc(100% + 5px);
+  background: #26282f;
+  width: ${(props: {
+    dropdownWidth: string;
+    dropdownMaxHeight: string;
+    feedbackSent?: boolean;
+  }) => props.dropdownWidth};
+  max-height: ${(props: {
+    dropdownWidth: string;
+    dropdownMaxHeight: string;
+    feedbackSent?: boolean;
+  }) => (props.dropdownMaxHeight ? props.dropdownMaxHeight : "300px")};
+  border-radius: 10px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+  animation: ${(props: {
+    dropdownWidth: string;
+    dropdownMaxHeight: string;
+    feedbackSent?: boolean;
+  }) => (props.feedbackSent ? "flyOff 0.3s 0.05s" : "")};
+  animation-fill-mode: forwards;
+  @keyframes flyOff {
+    from {
+      opacity: 1;
+      transform: translateX(0px);
+    }
+    to {
+      opacity: 0;
+      transform: translateX(100px);
+    }
+  }
+`;
+
+const NavButton = styled.a`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-right: 15px;
+  :hover {
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    cursor: pointer;
+    color: ${(props: { selected?: boolean }) =>
+      props.selected ? "#ffffff" : "#ffffff88"};
+    font-size: 24px;
+  }
+`;
+
+const FeedbackButton = styled(NavButton)`
+  color: ${(props: { selected?: boolean }) =>
+    props.selected ? "#ffffff" : "#ffffff88"};
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-right: 20px;
+  :hover {
+    color: #ffffff;
+    > div {
+      > i {
+        color: #ffffff;
+      }
+    }
+  }
+
+  > div {
+    > i {
+      color: ${(props: { selected?: boolean }) =>
+        props.selected ? "#ffffff" : "#ffffff88"};
+      font-size: 22px;
+      margin-right: 6px;
+    }
+  }
+`;
+
+const Icon = styled.img`
+    margin-left: -2px;
+    height: 25px;
+    width: 25px;
+    opacity: 50%;
+    margin-right: 5px;
+`

+ 17 - 3
dashboard/src/main/home/navbar/Navbar.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 
 import Feedback from "./Feedback";
+import Help from "./Help";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { Select } from "@material-ui/core";
 
@@ -55,14 +56,13 @@ class Navbar extends Component<PropsType, StateType> {
   };
 
   renderFeedbackButton = () => {
-    if (this.context?.capabilities?.provisioner) {
-      return <Feedback currentView={this.props.currentView} />;
-    }
+    return <Feedback currentView={this.props.currentView} />;
   };
 
   render() {
     return (
       <StyledNavbar>
+        <Help/>
         {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
@@ -244,6 +244,20 @@ const StyledNavbar = styled.div`
   z-index: 1;
 `;
 
+const HelpIcon = styled.div`
+> a {
+  > i {
+    font-size: 18px;
+    margin-left: 8px;
+    margin-top: 2px;
+    color: #8590ff;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+}
+`
+
 const NavButton = styled.a`
   display: flex;
   position: relative;

+ 32 - 0
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -115,6 +115,38 @@ const Onboarding = () => {
     }
   }, [context.user]);
 
+  const skipOnboarding = () => {
+    OFState.actions.goTo("clean_up");
+  };
+
+  const checkIfUserHasClusters = async () => {
+    const { setCurrentModal, currentProject } = context;
+
+    const project_id = currentProject?.id;
+
+    try {
+      if (typeof project_id !== "number") {
+        return;
+      }
+
+      const clusters = await api
+        .getClusters("<token>", {}, { id: project_id })
+        .then((res) => res?.data);
+
+      const hasClusters = Array.isArray(clusters) && clusters.length;
+
+      if (hasClusters) {
+        setCurrentModal("SkipOnboardingModal", { skipOnboarding });
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  useEffect(() => {
+    checkIfUserHasClusters();
+  }, [context?.currentProject?.id]);
+
   return (
     <StyledOnboarding>{isLoading ? <Loading /> : <Routes />}</StyledOnboarding>
   );

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -96,7 +96,7 @@ const ConnectSource: React.FC<{
           </Helper>
         </>
       )}
-      {!isLoading && accountData?.accounts.length && (
+      {!isLoading && accountData?.accounts?.length && (
         <>
           <Helper>Porter currently has access to:</Helper>
           <List>

+ 2 - 2
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -12,7 +12,7 @@ import ProviderSelector, {
 import FormFlowWrapper from "./forms/FormFlow";
 import ConnectExternalCluster from "./forms/_ConnectExternalCluster";
 import backArrow from "assets/back_arrow.png";
-import { SharedStatus } from "./forms/SharedStatus";
+import { StatusPage } from "./forms/StatusPage";
 import { useSnapshot } from "valtio";
 import { OFState } from "../../state";
 
@@ -108,7 +108,7 @@ const ProvisionResources: React.FC<Props> = () => {
       case "status":
         return (
           <>
-            <SharedStatus
+            <StatusPage
               project_id={project?.id}
               filter={getFilterOpts()}
               setInfraStatus={setInfraStatus}

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx

@@ -125,7 +125,7 @@ const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
           {FormTitle[provider] && <img src={FormTitle[provider].icon} />}
           {FormTitle[provider] && FormTitle[provider].label}
         </FormHeader>
-        <GuideButton href={FormTitle[provider].doc} target="_blank">
+        <GuideButton href={FormTitle[provider]?.doc} target="_blank">
           <i className="material-icons-outlined">help</i>
           Guide
         </GuideButton>

+ 0 - 359
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx

@@ -1,359 +0,0 @@
-import ProvisionerStatus, {
-  TFModule,
-  TFResource,
-  TFResourceError,
-} from "components/ProvisionerStatus";
-import React, { useEffect, useState } from "react";
-import api from "shared/api";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-export const SharedStatus: React.FC<{
-  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
-  project_id: number;
-  filter: string[];
-}> = ({ setInfraStatus, project_id, filter }) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-
-  const [tfModules, setTFModules] = useState<TFModule[]>([]);
-  const [isLoadingState, setIsLoadingState] = useState(true);
-
-  const updateTFModules = (
-    index: number,
-    addedResources: TFResource[],
-    erroredResources: TFResource[],
-    globalErrors: TFResourceError[],
-    gotDesired?: boolean
-  ) => {
-    if (!tfModules[index]?.resources) {
-      tfModules[index].resources = [];
-    }
-
-    if (!tfModules[index]?.global_errors) {
-      tfModules[index].global_errors = [];
-    }
-
-    if (gotDesired) {
-      tfModules[index].got_desired = true;
-    }
-
-    let resources = tfModules[index].resources;
-
-    // construct map of tf resources addresses to indices
-    let resourceAddrMap = new Map<string, number>();
-
-    tfModules[index].resources.forEach((resource, index) => {
-      resourceAddrMap.set(resource.addr, index);
-    });
-
-    for (let addedResource of addedResources) {
-      // if exists, update state to provisioned
-      if (resourceAddrMap.has(addedResource.addr)) {
-        let currResource = resources[resourceAddrMap.get(addedResource.addr)];
-        addedResource.errored = currResource.errored;
-        resources[resourceAddrMap.get(addedResource.addr)] = addedResource;
-      } else {
-        resources.push(addedResource);
-        resourceAddrMap.set(addedResource.addr, resources.length - 1);
-
-        // if the resource is being added but there's not a desired state, re-query for the
-        // desired state
-        if (!tfModules[index].got_desired) {
-          updateDesiredState(index, tfModules[index]);
-        }
-      }
-    }
-
-    for (let erroredResource of erroredResources) {
-      // if exists, update state to provisioned
-      if (resourceAddrMap.has(erroredResource.addr)) {
-        resources[resourceAddrMap.get(erroredResource.addr)] = erroredResource;
-      } else {
-        resources.push(erroredResource);
-        resourceAddrMap.set(erroredResource.addr, resources.length - 1);
-      }
-    }
-
-    tfModules[index].global_errors = [
-      ...tfModules[index].global_errors,
-      ...globalErrors,
-    ];
-
-    setTFModules([...tfModules]);
-  };
-
-  useEffect(() => {
-    if (isLoadingState) {
-      return;
-    }
-    // recompute tf module state each time, to see if infra is ready
-    if (tfModules.length > 0) {
-      // see if all tf modules are in a "created" state
-      if (
-        tfModules.filter((val) => val.status == "created").length ==
-        tfModules.length
-      ) {
-        setInfraStatus({
-          hasError: false,
-        });
-        return;
-      }
-
-      if (
-        tfModules.filter((val) => val.status == "error").length ==
-        tfModules.length
-      ) {
-        setInfraStatus({
-          hasError: true,
-          description: "Encountered error while provisioning",
-        });
-        return;
-      }
-
-      // otherwise, check that all resources in each module are provisioned. Each module
-      // must have more than one resource
-      let numModulesSuccessful = 0;
-      let numModulesErrored = 0;
-
-      for (let tfModule of tfModules) {
-        if (tfModule.status == "created") {
-          numModulesSuccessful++;
-        } else if (tfModule.status == "error") {
-          numModulesErrored++;
-        } else {
-          let resLength = tfModule.resources?.length;
-          if (resLength > 0) {
-            numModulesSuccessful +=
-              tfModule.resources.filter((resource) => resource.provisioned)
-                .length == resLength
-                ? 1
-                : 0;
-
-            // if there's a global error, or the number of resources that errored_out is
-            // greater than 0, this resource is in an error state
-            numModulesErrored +=
-              tfModule.global_errors?.length > 0 ||
-              tfModule.resources.filter(
-                (resource) => resource.errored?.errored_out
-              ).length > 0
-                ? 1
-                : 0;
-          } else if (tfModule.global_errors?.length > 0) {
-            numModulesErrored += 1;
-          }
-        }
-      }
-
-      if (numModulesSuccessful == tfModules.length) {
-        setInfraStatus({
-          hasError: false,
-        });
-      } else if (numModulesErrored + numModulesSuccessful == tfModules.length) {
-        // otherwise, if all modules are either in an error state or successful,
-        // set the status to error
-        setInfraStatus({
-          hasError: true,
-        });
-      }
-    } else {
-      setInfraStatus(null);
-    }
-  }, [tfModules, isLoadingState]);
-
-  const setupInfraWebsocket = (
-    websocketID: string,
-    module: TFModule,
-    index: number
-  ) => {
-    let apiPath = `/api/projects/${project_id}/infras/${module.id}/logs`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log(`connected to websocket: ${websocketID}`);
-      },
-      onmessage: (evt: MessageEvent) => {
-        // parse the data
-        let parsedData = JSON.parse(evt.data);
-
-        let addedResources: TFResource[] = [];
-        let erroredResources: TFResource[] = [];
-        let globalErrors: TFResourceError[] = [];
-
-        for (let streamVal of parsedData) {
-          let streamValData = JSON.parse(streamVal?.Values?.data);
-
-          switch (streamValData?.type) {
-            case "apply_complete":
-              addedResources.push({
-                addr: streamValData?.hook?.resource?.addr,
-                provisioned: true,
-                errored: {
-                  errored_out: false,
-                },
-              });
-
-              break;
-            case "diagnostic":
-              if (streamValData["@level"] == "error") {
-                if (streamValData?.hook?.resource?.addr != "") {
-                  erroredResources.push({
-                    addr: streamValData?.hook?.resource?.addr,
-                    provisioned: false,
-                    errored: {
-                      errored_out: true,
-                      error_context: streamValData["@message"],
-                    },
-                  });
-                } else {
-                  globalErrors.push({
-                    errored_out: true,
-                    error_context: streamValData["@message"],
-                  });
-                }
-              }
-            case "change_summary":
-              if (streamValData.changes.add != 0) {
-                updateDesiredState(index, module);
-              }
-            default:
-          }
-        }
-
-        updateTFModules(index, addedResources, erroredResources, globalErrors);
-      },
-
-      onclose: () => {
-        console.log(`closing websocket: ${websocketID}`);
-      },
-
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(websocketID);
-      },
-    };
-
-    newWebsocket(websocketID, apiPath, wsConfig);
-    openWebsocket(websocketID);
-  };
-
-  const mergeCurrentAndDesired = (
-    index: number,
-    desired: any,
-    currentMap: Map<string, string>
-  ) => {
-    // map desired state to list of resources
-    var addedResources: TFResource[] = desired?.map((val: any) => {
-      return {
-        addr: val?.addr,
-        provisioned: currentMap.has(val?.addr),
-        errored: {
-          errored_out: val?.errored?.errored_out,
-          error_context: val?.errored?.error_context,
-        },
-      };
-    });
-
-    updateTFModules(index, addedResources, [], [], true);
-  };
-
-  const updateDesiredState = (index: number, val: TFModule) => {
-    setIsLoadingState(true);
-    api
-      .getInfraDesired(
-        "<token>",
-        {},
-        { project_id: project_id, infra_id: val?.id }
-      )
-      .then((resDesired) => {
-        api
-          .getInfraCurrent(
-            "<token>",
-            {},
-            { project_id: project_id, infra_id: val?.id }
-          )
-          .then((resCurrent) => {
-            var desired = resDesired.data;
-            var current = resCurrent.data;
-
-            // convert current state to a lookup table
-            var currentMap: Map<string, string> = new Map();
-
-            current?.resources?.forEach((val: any) => {
-              currentMap.set(val?.type + "." + val?.name, "");
-            });
-
-            mergeCurrentAndDesired(index, desired, currentMap);
-          })
-          .catch((err) => {
-            var desired = resDesired.data;
-            var currentMap: Map<string, string> = new Map();
-
-            // merge with empty current map
-            mergeCurrentAndDesired(index, desired, currentMap);
-          })
-          .finally(() => {
-            setIsLoadingState(true);
-          });
-      })
-      .catch((err) => {
-        console.log(err);
-        setIsLoadingState(true);
-      });
-  };
-
-  useEffect(() => {
-    api.getInfra("<token>", {}, { project_id: project_id }).then((res) => {
-      var matchedInfras: Map<string, any> = new Map();
-
-      res.data.forEach((infra: any) => {
-        // if filter list is empty, add infra automatically
-        if (filter.length == 0) {
-          matchedInfras.set(infra.kind + "-" + infra.id, infra);
-        } else if (
-          filter.includes(infra.kind) &&
-          (matchedInfras.get(infra.Kind)?.id || 0 < infra.id)
-        ) {
-          matchedInfras.set(infra.kind, infra);
-        }
-      });
-
-      // query for desired and current state, and convert to tf module
-      matchedInfras.forEach((infra: any) => {
-        var module: TFModule = {
-          id: infra.id,
-          kind: infra.kind,
-          status: infra.status,
-          got_desired: false,
-          created_at: infra.created_at,
-        };
-
-        tfModules.push(module);
-      });
-
-      setTFModules([...tfModules]);
-
-      tfModules.forEach((val, index) => {
-        if (val?.status != "created") {
-          updateDesiredState(index, val);
-          setupInfraWebsocket(val.id + "", val, index);
-        }
-      });
-    });
-
-    return closeAllWebsockets;
-  }, []);
-
-  let sortedModules = tfModules.sort((a, b) =>
-    b.id < a.id ? -1 : b.id > a.id ? 1 : 0
-  );
-
-  return (
-    <>
-      <ProvisionerStatus modules={sortedModules} />
-    </>
-  );
-};

+ 473 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx

@@ -0,0 +1,473 @@
+import ProvisionerStatus, {
+  TFModule,
+  TFResource,
+  TFResourceError,
+} from "components/ProvisionerStatus";
+import React, { useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
+  project_id: number;
+  filter: string[];
+};
+
+type Infra = {
+  id: number;
+  created_at: string;
+  updated_at: string;
+  project_id: number;
+  kind: string;
+  status: string;
+  last_applied: any;
+};
+
+type Desired = {
+  addr: string;
+  errored:
+    | { errored_out: false }
+    | { errored_out: true; error_context: string };
+  implied_provider: string;
+  resource: string;
+  resource_name: string;
+  resource_type: string;
+};
+
+type InfraCurrentResponse = {
+  version: number;
+  terraform_version: string;
+  serial: number;
+  lineage: string;
+  outputs: any;
+  resources: {
+    instances: any[];
+    mode: string;
+    name: string;
+    provider: string;
+    type: string;
+  }[];
+};
+
+export const StatusPage = ({
+  filter: selectedFilters,
+  project_id,
+  setInfraStatus,
+}: Props) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const {
+    tfModules,
+    initModule,
+    updateDesired,
+    updateModuleResources,
+    updateGlobalErrorsForModule,
+  } = useTFModules();
+
+  const filterBySelectedInfras = (currentInfra: Infra) => {
+    if (!Array.isArray(selectedFilters) || !selectedFilters?.length) {
+      return true;
+    }
+
+    if (selectedFilters.includes(currentInfra.kind)) {
+      return true;
+    }
+    return false;
+  };
+
+  const getLatestInfras = (infras: Infra[]) => {
+    // Create a map with the relation infra.kind => infra
+    // This will allow us to keep only one infra per kind.
+    const infraMap = new Map<string, Infra>();
+
+    infras.forEach((infra) => {
+      // Get last infra from that kind, kind being gke, ecr, etc.
+      const latestSavedInfra = infraMap.get(infra.kind);
+
+      // If infra doesn't exists, it means its the first one appearing so we save it
+      if (!latestSavedInfra) {
+        infraMap.set(infra.kind, infra);
+        return;
+      }
+
+      // Check if the latest saved infra was recent than the one we're currently iterating
+      // If the current one iterating is newer, then we update the map!
+      if (
+        new Date(infra.created_at).getTime() >
+        new Date(latestSavedInfra.created_at).getTime()
+      ) {
+        infraMap.set(infra.kind, infra);
+        return;
+      }
+    });
+
+    // Get the array from the values of the array.
+    return Array.from(infraMap.values());
+  };
+
+  const getInfras = async () => {
+    try {
+      const res = await api.getInfra<Infra[]>(
+        "<token>",
+        {},
+        { project_id: project_id }
+      );
+      // Filter infras based on what we care only, usually on the onboarding we'll want only the ones
+      // currently being provisioned
+      const matchedInfras = res.data.filter(filterBySelectedInfras);
+
+      // Get latest infras for each kind of infra on the array.
+      const latestMatchedInfras = getLatestInfras(matchedInfras);
+
+      // Check if all infras are created then enable continue button
+      if (latestMatchedInfras.every((infra) => infra.status === "created")) {
+        setInfraStatus({
+          hasError: false,
+        });
+      }
+
+      // Init tf modules based on matched infras
+      latestMatchedInfras.forEach((infra) => {
+        // Init the module for the hook
+        initModule(infra);
+
+        // Update all the resources needed for the current infra
+        getDesiredState(infra.id);
+      });
+    } catch (error) {}
+  };
+
+  const getDesiredState = async (infra_id: number) => {
+    try {
+      const desired = await api
+        .getInfraDesired("<token>", {}, { project_id, infra_id })
+        .then((res) => res?.data);
+
+      updateDesired(infra_id, desired);
+      // Check if we have some modules already provisioned
+      await getProvisionedModules(infra_id);
+
+      // Connect to websocket that will provide live info of the provisioning for this infra
+      connectToLiveUpdateModule(infra_id);
+    } catch (error) {
+      console.error(error);
+      setTimeout(() => {
+        getDesiredState(infra_id);
+      }, 500);
+    }
+  };
+
+  const getProvisionedModules = async (infra_id: number) => {
+    try {
+      const current = await api
+        .getInfraCurrent<InfraCurrentResponse>(
+          "<token>",
+          {},
+          { project_id, infra_id }
+        )
+        .then((res) => res?.data);
+
+      const provisionedResources: TFResource[] = current?.resources?.map(
+        (resource: any) => {
+          return {
+            addr: `${resource?.type}.${resource?.name}`,
+            provisioned: true,
+            errored: {
+              errored_out: false,
+            },
+          } as TFResource;
+        }
+      );
+
+      updateModuleResources(infra_id, provisionedResources);
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  const connectToLiveUpdateModule = (infra_id: number) => {
+    const websocketId = `${infra_id}`;
+    const apiPath = `/api/projects/${project_id}/infras/${infra_id}/logs`;
+
+    const wsConfig: NewWebsocketOptions = {
+      onopen: () => {
+        console.log(`connected to websocket for infra_id: ${websocketId}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // parse the data
+        const parsedData = JSON.parse(evt.data);
+
+        const addedResources: TFResource[] = [];
+        const erroredResources: TFResource[] = [];
+        const globalErrors: TFResourceError[] = [];
+
+        for (const streamVal of parsedData) {
+          const streamValData = JSON.parse(streamVal?.Values?.data);
+
+          switch (streamValData?.type) {
+            case "apply_complete":
+              addedResources.push({
+                addr: streamValData?.hook?.resource?.addr,
+                provisioned: true,
+                errored: {
+                  errored_out: false,
+                },
+              });
+
+              break;
+            case "diagnostic":
+              if (streamValData["@level"] == "error") {
+                if (streamValData?.hook?.resource?.addr !== "") {
+                  erroredResources.push({
+                    addr: streamValData?.hook?.resource?.addr,
+                    provisioned: false,
+                    errored: {
+                      errored_out: true,
+                      error_context: streamValData["@message"],
+                    },
+                  });
+                } else {
+                  globalErrors.push({
+                    errored_out: true,
+                    error_context: streamValData["@message"],
+                  });
+                }
+              }
+            default:
+          }
+        }
+
+        updateModuleResources(infra_id, [
+          ...addedResources,
+          ...erroredResources,
+        ]);
+
+        updateGlobalErrorsForModule(infra_id, globalErrors);
+      },
+
+      onclose: () => {
+        console.log(`closing websocket for infra_id: ${websocketId}`);
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(`${websocketId}`);
+      },
+    };
+
+    newWebsocket(websocketId, apiPath, wsConfig);
+    openWebsocket(websocketId);
+  };
+
+  useEffect(() => {
+    getInfras();
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!tfModules?.length) {
+      setInfraStatus(null);
+      return;
+    }
+    const hasModuleWithError = tfModules.find(
+      (module) => module.status === "error"
+    );
+    const hasModuleInCreatingState = tfModules.find(
+      (module) => module.status === "creating"
+    );
+
+    if (hasModuleInCreatingState) {
+      setInfraStatus(null);
+      return;
+    }
+
+    if (!hasModuleInCreatingState && !hasModuleWithError) {
+      setInfraStatus({ hasError: false });
+      return;
+    }
+
+    if (!hasModuleInCreatingState && hasModuleWithError) {
+      setInfraStatus({ hasError: true });
+      return;
+    }
+  }, [tfModules]);
+
+  const sortedModules = tfModules.sort((a, b) =>
+    b.id < a.id ? -1 : b.id > a.id ? 1 : 0
+  );
+
+  return <ProvisionerStatus modules={sortedModules} />;
+};
+
+type TFModulesState = {
+  [infraId: number]: TFModule;
+};
+
+const useTFModules = () => {
+  // Use a ref to keep track of all the Terraform modules
+  const modules = useRef<TFModulesState>({});
+
+  // Use state to keep the reactive array of terraform modules
+  const [tfModules, setTfModules] = useState<TFModule[]>([]);
+
+  /**
+   * This will map out the ref containing all the terraform modules and return a sorted array.
+   */
+  const updateTFModules = (): void => {
+    if (typeof modules.current !== "object") {
+      setTfModules([]);
+    }
+
+    const sortedModules = Object.values(modules.current).sort((a, b) =>
+      b.id < a.id ? -1 : b.id > a.id ? 1 : 0
+    );
+    setTfModules(sortedModules);
+  };
+
+  /**
+   * Init a TFModule based on a Infra, this infra is usually more basic
+   * and doesn't contain all the resources that it actually needs.
+   * The initialized TFModule will be used to keep track if the infra
+   * changed from creating status to another one.
+   *
+   * @param infra Infra object used to initialize the terraform module used to track provisioning status
+   */
+  const initModule = (infra: Infra) => {
+    const module: TFModule = {
+      id: infra.id,
+      kind: infra.kind,
+      status: infra.status,
+      got_desired: false,
+      created_at: infra.created_at,
+    };
+    setModule(infra.id, module);
+  };
+
+  /**
+   * Add or replace if existed, this function will set the module into the ref
+   * and call the updateTFModules to update the array used to show the infras
+   *
+   * @param infraId Infra ID to be updated
+   * @param module New updated module
+   */
+  const setModule = (infraId: number, module: TFModule) => {
+    modules.current = {
+      ...modules.current,
+      [infraId]: module,
+    };
+    updateTFModules();
+  };
+
+  const getModule = (infraId: number) => {
+    return { ...modules.current[infraId] };
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param desired All the desired resources that are going to be needed to complete provisioning
+   */
+  const updateDesired = (infraId: number, desired: Desired[]) => {
+    const selectedModule = getModule(infraId);
+
+    if (!Array.isArray(selectedModule?.resources)) {
+      selectedModule.resources = [];
+    }
+
+    selectedModule.resources = desired.map((d) => {
+      return {
+        addr: d.addr,
+        errored: d.errored,
+        provisioned: false,
+      };
+    });
+
+    setModule(infraId, selectedModule);
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param updatedResources Updated resources array, this may contain one or more objects with some status updates.
+   */
+  const updateModuleResources = (
+    infraId: number,
+    updatedResources: TFResource[]
+  ) => {
+    const selectedModule = getModule(infraId);
+
+    const updatedModuleResources = selectedModule.resources.map((resource) => {
+      const correspondedResource: TFResource = updatedResources.find(
+        (updatedResource) => updatedResource.addr === resource.addr
+      );
+      if (!correspondedResource) {
+        return resource;
+      }
+      let errored = undefined;
+
+      if (correspondedResource?.errored) {
+        errored = {
+          ...(correspondedResource?.errored || {}),
+        };
+      }
+
+      return {
+        ...resource,
+        provisioned: correspondedResource.provisioned,
+        errored,
+      };
+    });
+
+    selectedModule.resources = updatedModuleResources;
+
+    const isModuleCreated =
+      selectedModule.resources.every((resource) => {
+        return resource.provisioned;
+      }) && !selectedModule.global_errors?.length;
+
+    const isModuleOnError =
+      selectedModule.resources.find((resource) => {
+        return resource.errored?.errored_out;
+      }) || selectedModule.global_errors?.length;
+
+    if (isModuleCreated) {
+      selectedModule.status = "created";
+    } else if (isModuleOnError) {
+      selectedModule.status = "error";
+    } else {
+      selectedModule.status = selectedModule.status;
+    }
+
+    setModule(infraId, selectedModule);
+  };
+
+  /**
+   * @param infraId Module to be updated
+   * @param globalErrors Errors that may not belong to a resource but appeared during provisioning
+   */
+  const updateGlobalErrorsForModule = (
+    infraId: number,
+    globalErrors: TFResourceError[]
+  ) => {
+    const module = getModule(infraId);
+
+    module.global_errors = [...(module.global_errors || []), ...globalErrors];
+    if (globalErrors.length) {
+      module.status = "error";
+    }
+    setModule(infraId, module);
+  };
+
+  return {
+    tfModules,
+    initModule,
+    updateDesired,
+    updateModuleResources,
+    updateGlobalErrorsForModule,
+  };
+};

+ 0 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -10,7 +10,6 @@ import {
 import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import { useSnapshot } from "valtio";
-import { SharedStatus } from "./SharedStatus";
 import Loading from "components/Loading";
 import Helper from "components/form-components/Helper";
 

+ 0 - 6
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

@@ -1,10 +1,6 @@
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
-import ProvisionerStatus, {
-  TFModule,
-  TFResource,
-} from "components/ProvisionerStatus";
 import SaveButton from "components/SaveButton";
 import { OFState } from "main/home/onboarding/state";
 import { DOProvisionerConfig } from "main/home/onboarding/types";
@@ -12,8 +8,6 @@ import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-import { SharedStatus } from "./SharedStatus";
 import Loading from "components/Loading";
 
 const tierOptions = [

+ 1 - 3
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -13,7 +13,6 @@ import React, { useEffect, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
-import { SharedStatus } from "./SharedStatus";
 
 const regionOptions = [
   { value: "asia-east1", label: "asia-east1" },
@@ -231,8 +230,7 @@ export const CredentialsForm: React.FC<{
           {lastConnectedAccount?.gcp_sa_email || "n/a"}
         </Flex>
         <Right>
-          Connected at{" "}
-          {readableDate(lastConnectedAccount.created_at)}
+          Connected at {readableDate(lastConnectedAccount.created_at)}
         </Right>
       </PreviewRow>
       <Helper>