Quellcode durchsuchen

Merge branch 'porter-dev:master' into master

jimcru21 vor 5 Jahren
Ursprung
Commit
c7c0282218

+ 1 - 1
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -215,7 +215,7 @@ const BackButton = styled.div`
   margin-bottom: -7px;
   padding-right: 15px;
   border: 1px solid #ffffff55;
-  border-radius: 3px;
+  border-radius: 100px;
   width: ${(props: { width: string }) => props.width};
   color: white;
   background: #ffffff11;

+ 4 - 3
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -3,8 +3,8 @@ import React, { Component } from "react";
 import styled from "styled-components";
 
 import { integrationList } from "shared/common";
-import { Context } from "../../shared/Context";
-import api from "../../shared/api";
+import { Context } from "shared/Context";
+import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../values-form/InputRow";
@@ -164,6 +164,7 @@ export default class ActionDetails extends Component<PropsType, StateType> {
               this.props.setFolderPath(null);
               this.props.setProcfilePath(null);
               this.props.setProcfileProcess(null);
+              this.props.setSelectedRegistry(null);
             }}
           >
             <i className="material-icons">keyboard_backspace</i>
@@ -330,7 +331,7 @@ const BackButton = styled.div`
   margin-bottom: -7px;
   padding-right: 15px;
   border: 1px solid #ffffff55;
-  border-radius: 3px;
+  border-radius: 100px;
   width: ${(props: { width: string }) => props.width};
   color: white;
   background: #ffffff11;

+ 2 - 2
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -332,7 +332,7 @@ ContentsList.contextType = Context;
 const FlexWrapper = styled.div`
   position: absolute;
   bottom: 28px;
-  left: 185px;
+  left: 195px;
   display: flex;
   align-items: center;
 `;
@@ -482,7 +482,7 @@ const UseButton = styled.div`
   background: #616feecc;
   font-weight: 500;
   padding: 10px 15px;
-  border-radius: 3px;
+  border-radius: 100px;
   box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   :hover {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -48,7 +48,7 @@ export default class ChartList extends Component<PropsType, StateType> {
           namespace: this.props.namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
-          limit: 20,
+          limit: 50,
           skip: 0,
           byDate: false,
           statusFilter: [

+ 18 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -93,15 +93,19 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               currentChart: res.data,
               loading: false,
               imageIsPlaceholder: true,
+              newestImage: image,
             },
             () => {
               this.updateTabs();
             }
           );
         } else {
-          this.setState({ currentChart: res.data, loading: false }, () => {
-            this.updateTabs();
-          });
+          this.setState(
+            { currentChart: res.data, loading: false, newestImage: image },
+            () => {
+              this.updateTabs();
+            }
+          );
         }
       })
       .catch(console.log);
@@ -201,8 +205,16 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         let newestImage =
           event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
             ?.image;
-
-        this.setState({ newestImage, imageIsPlaceholder: false });
+        if (
+          newestImage &&
+          newestImage !== "porterdev/hello-porter-job" &&
+          newestImage !== "porterdev/hello-porter-job:latest" &&
+          newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
+          newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
+        ) {
+          console.log("set to false on crons", newestImage)
+          this.setState({ newestImage, imageIsPlaceholder: false });
+        }
       }
     };
 
@@ -340,6 +352,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job" &&
       newestImage !== "public.ecr.aws/o1j4x7p4/hello-porter-job:latest"
     ) {
+      console.log("set to false on sorting")
       this.setState({ jobs, newestImage, imageIsPlaceholder: false });
     } else {
       this.setState({ jobs });

+ 75 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -2,6 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 import * as Anser from "anser";
+import api from "shared/api";
 
 type PropsType = {
   selectedPod: any;
@@ -91,6 +92,41 @@ export default class Logs extends Component<PropsType, StateType> {
     });
   };
 
+  getPodStatus = (status: any) => {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
+      return status.containerStatuses[0].state.waiting.reason;
+    } else if (status?.phase === "Pending") {
+      return "pending";
+    }
+
+    if (status?.phase === "Succeeded") {
+      return "succeeded";
+    }
+
+    if (status?.phase === "Failed") {
+      return "failed";
+    }
+
+    if (status?.phase === "Running") {
+      let collatedStatus = "running";
+
+      status?.containerStatuses?.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus =
+            s.state?.waiting.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
+        } else if (s.state?.terminated) {
+          collatedStatus = "failed";
+        }
+      });
+      return collatedStatus;
+    }
+  };
+
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
@@ -130,8 +166,45 @@ export default class Logs extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    this.setupWebsocket();
-    this.scrollToBottom(false);
+    let { selectedPod } = this.props;
+    let status = this.getPodStatus(selectedPod?.status);
+    console.log("STATUS", selectedPod?.status, status);
+    if (status == "running" || status == "succeeded") {
+      this.setupWebsocket();
+      this.scrollToBottom(false);
+      return;
+    }
+
+    api
+      .getPodEvents(
+        "<token>",
+        {
+          cluster_id: this.context.currentCluster.id,
+        },
+        {
+          name: selectedPod?.metadata?.name,
+          namespace: selectedPod?.metadata?.namespace,
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        let logs = [] as Anser.AnserJsonEntry[][];
+        // TODO: column view
+        // logs.push(Anser.ansiToJson("\u001b[33;5;196mEvent Type\u001b[0m \t || \t \u001b[43m\u001b[34m\tReason\t\u001b[0m \t ||\tMessage"))
+
+        res.data.items.forEach((evt: any) => {
+          let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+          let ansiLog = Anser.ansiToJson(
+            `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+          );
+          logs.push(ansiLog);
+        });
+        this.setState({ logs: logs });
+        console.log(res);
+      })
+      .catch((err) => {
+        console.log(err);
+      });
   }
 
   componentWillUnmount() {

+ 0 - 16
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -77,22 +77,6 @@ const Highlight = styled.div`
   margin-right: 10px;
 `;
 
-const Banner = styled.div`
-  height: 40px;
-  width: 100%;
-  margin: 15px 0;
-  font-size: 13px;
-  display: flex;
-  border-radius: 5px;
-  padding-left: 15px;
-  align-items: center;
-  background: #ffffff11;
-  > i {
-    margin-right: 10px;
-    font-size: 18px;
-  }
-`;
-
 const StyledStatusPlaceholder = styled.div`
   width: 100%;
   height: calc(100vh - 470px);

+ 116 - 125
dashboard/src/main/home/launch/Launch.tsx

@@ -8,14 +8,13 @@ import { PorterTemplate } from "shared/types";
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
+import LaunchFlow from "./launch-flow/LaunchFlow";
 
 import hardcodedNames from "./hardcodedNameDict";
-import { Link } from "react-router-dom";
 import semver from "semver";
-import { version } from "html-webpack-plugin";
 
 const tabOptions = [
-  { label: "New Application", value: "docker" },
+  { label: "New Application", value: "porter" },
   { label: "Community Add-ons", value: "community" },
 ];
 
@@ -23,21 +22,25 @@ type PropsType = {};
 
 type StateType = {
   currentTemplate: PorterTemplate | null;
+  form: any;
   currentTab: string;
   addonTemplates: PorterTemplate[];
   applicationTemplates: PorterTemplate[];
   loading: boolean;
   error: boolean;
+  isOnLaunchFlow: boolean;
 };
 
 export default class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
-    currentTab: "docker",
+    form: null as any,
+    currentTab: "porter",
     addonTemplates: [] as PorterTemplate[],
     applicationTemplates: [] as PorterTemplate[],
     loading: true,
     error: false,
+    isOnLaunchFlow: false,
   };
 
   componentDidMount() {
@@ -128,8 +131,8 @@ export default class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderApplicationList = () => {
-    let { loading, error, applicationTemplates } = this.state;
+  renderTemplateList = (templates: any) => {
+    let { loading, error } = this.state;
 
     if (loading) {
       return (
@@ -143,7 +146,7 @@ export default class Templates extends Component<PropsType, StateType> {
           <i className="material-icons">error</i> Error retrieving templates.
         </Placeholder>
       );
-    } else if (applicationTemplates.length === 0) {
+    } else if (templates.length === 0) {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No templates found.
@@ -151,146 +154,109 @@ export default class Templates extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.applicationTemplates.map(
-      (template: PorterTemplate, i: number) => {
-        let { name, icon, description } = template;
-        if (hardcodedNames[name]) {
-          name = hardcodedNames[name];
-        }
-        return (
-          <TemplateBlock
-            key={i}
-            onClick={() => this.setState({ currentTemplate: template })}
-          >
-            {this.renderIcon(icon)}
-            <TemplateTitle>{name}</TemplateTitle>
-            <TemplateDescription>{description}</TemplateDescription>
-          </TemplateBlock>
-        );
-      }
-    );
-  };
-
-  renderAddonList = () => {
-    let { loading, error, addonTemplates } = this.state;
-
-    if (loading) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (error) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error retrieving templates.
-        </Placeholder>
-      );
-    } else if (addonTemplates.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No templates found.
-        </Placeholder>
-      );
-    }
-
-    return this.state.addonTemplates.map(
-      (template: PorterTemplate, i: number) => {
-        let { name, icon, description } = template;
-        if (hardcodedNames[name]) {
-          name = hardcodedNames[name];
-        }
-        return (
-          <TemplateBlock
-            key={i}
-            onClick={() => this.setState({ currentTemplate: template })}
-          >
-            {this.renderIcon(icon)}
-            <TemplateTitle>{name}</TemplateTitle>
-            <TemplateDescription>{description}</TemplateDescription>
-          </TemplateBlock>
-        );
-      }
+    return (
+      <TemplateList>
+        {templates.map((template: PorterTemplate, i: number) => {
+          let { name, icon, description } = template;
+          if (hardcodedNames[name]) {
+            name = hardcodedNames[name];
+          }
+          return (
+            <TemplateBlock
+              key={name}
+              onClick={() => this.setState({ currentTemplate: template })}
+            >
+              {this.renderIcon(icon)}
+              <TemplateTitle>{name}</TemplateTitle>
+              <TemplateDescription>{description}</TemplateDescription>
+            </TemplateBlock>
+          );
+        })}
+      </TemplateList>
     );
   };
 
-  renderApplicationTemplates = () => {
-    if (!this.context.currentCluster) {
-      return (
-        <>
-          <Banner>
-            <i className="material-icons">error_outline</i>
-            <Link to="dashboard">Provision</Link> &nbsp;or&nbsp;
-            <Link
-              to="#"
-              onClick={() =>
-                this.context.setCurrentModal("ClusterInstructionsModal")
-              }
-            >
-              connect
-            </Link>
-            &nbsp;to a cluster
-          </Banner>
-        </>
-      );
-    }
+  renderTabContents = () => {
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
+          setForm={(x: any) => this.setState({ form: x })}
+          showLaunchFlow={() => this.setState({ isOnLaunchFlow: true })}
           currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
           setCurrentTemplate={(currentTemplate: PorterTemplate) => {
             this.setState({ currentTemplate });
           }}
-          skipDescription={false}
         />
       );
     }
-    return <TemplateList>{this.renderApplicationList()}</TemplateList>;
+    if (this.state.currentTab === "porter") {
+      return this.renderTemplateList(this.state.applicationTemplates);
+    } else {
+      return this.renderTemplateList(this.state.addonTemplates);
+    }
   };
 
-  renderAddonTemplates = () => {
-    if (this.state.currentTemplate) {
+  render() {
+    if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
       return (
-        <ExpandedTemplate
+        <TemplatesWrapper>
+          <TitleSection>
+            <Title>Launch</Title>
+            <a
+              href="https://docs.getporter.dev/docs/porter-templates"
+              target="_blank"
+            >
+              <i className="material-icons">help_outline</i>
+            </a>
+          </TitleSection>
+          {this.context.currentCluster ? (
+            <>
+              <TabSelector
+                options={tabOptions}
+                currentTab={this.state.currentTab}
+                setCurrentTab={(value: string) =>
+                  this.setState({
+                    currentTab: value,
+                    currentTemplate: null,
+                  })
+                }
+              />
+              {this.renderTabContents()}
+            </>
+          ) : (
+            <>
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                No cluster connected to this project.
+              </Banner>
+              <StyledStatusPlaceholder>
+                You need to connect a cluster to use Porter.
+                <Highlight
+                  onClick={() => {
+                    this.context.setCurrentModal(
+                      "ClusterInstructionsModal",
+                      {}
+                    );
+                  }}
+                >
+                  + Connect an existing cluster
+                </Highlight>
+              </StyledStatusPlaceholder>
+            </>
+          )}
+        </TemplatesWrapper>
+      );
+    } else {
+      return (
+        <LaunchFlow
+          form={this.state.form}
           currentTab={this.state.currentTab}
           currentTemplate={this.state.currentTemplate}
-          setCurrentTemplate={(currentTemplate: PorterTemplate) => {
-            this.setState({ currentTemplate });
-          }}
+          hideLaunchFlow={() => this.setState({ isOnLaunchFlow: false })}
         />
       );
     }
-    return <TemplateList>{this.renderAddonList()}</TemplateList>;
-  };
-
-  render() {
-    return (
-      <TemplatesWrapper>
-        <TitleSection>
-          <Title>Launch</Title>
-          <a
-            href="https://docs.getporter.dev/docs/porter-templates"
-            target="_blank"
-          >
-            <i className="material-icons">help_outline</i>
-          </a>
-        </TitleSection>
-        <TabSelector
-          options={tabOptions}
-          currentTab={this.state.currentTab}
-          setCurrentTab={(value: string) =>
-            this.setState({
-              currentTab: value,
-              currentTemplate: null,
-            })
-          }
-        />
-        {this.state.currentTab === "docker"
-          ? this.renderApplicationTemplates()
-          : this.renderAddonTemplates()}
-      </TemplatesWrapper>
-    );
   }
 }
 
@@ -327,6 +293,31 @@ const Banner = styled.div`
   }
 `;
 
+const Highlight = styled.div`
+  color: #8590ff;
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 10px;
+`;
+
+const StyledStatusPlaceholder = styled.div`
+  width: 100%;
+  height: calc(100vh - 365px);
+  margin-top: 20px;
+  display: flex;
+  color: #aaaabb;
+  border-radius: 5px;
+  padding-bottom: 20px;
+  text-align: center;
+  font-size: 13px;
+  background: #ffffff09;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-family: "Work Sans", sans-serif;
+  user-select: text;
+`;
+
 const LoadingWrapper = styled.div`
   padding-top: 300px;
 `;

+ 5 - 3
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -14,6 +14,8 @@ type PropsType = {
   currentTab: string;
   setCurrentTemplate: (x: PorterTemplate) => void;
   skipDescription?: boolean;
+  showLaunchFlow: () => void;
+  setForm: (x: any) => void;
 };
 
 type StateType = {
@@ -44,7 +46,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   fetchTemplateInfo = () => {
     this.setState({ loading: true });
     let params =
-      this.props.currentTab == "docker"
+      this.props.currentTab == "porter"
         ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
         : { repo_url: process.env.ADDON_CHART_REPO_URL };
 
@@ -56,8 +58,8 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
       .then((res) => {
         let { form, values, markdown, metadata } = res.data;
         let keywords = metadata.keywords;
+        this.props.setForm(form);
         this.setState({
-          form,
           values,
           markdown,
           keywords,
@@ -113,7 +115,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 
             this.props.setCurrentTemplate(template);
           }}
-          launchTemplate={() => this.setState({ showLaunchTemplate: true })}
+          launchTemplate={this.props.showLaunchFlow}
           markdown={this.state.markdown}
           keywords={this.state.keywords}
         />

+ 0 - 1
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -243,7 +243,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     _.set(values, "ingress.provider", provider);
     var url: string;
-    console.log("ok here", values);
     // check if template is docker and create external domain if necessary
     if (this.props.currentTemplate.name == "web") {
       if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {

+ 3 - 4
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -82,7 +82,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
           </Banner>
         </>
       );
-    } else if (this.props.currentTab == "docker") {
+    } else if (this.props.currentTab == "porter") {
       return (
         <>
           <Br />
@@ -113,8 +113,8 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
               href="https://docs.getporter.dev/docs/https-and-custom-domains"
             >
               Porter's HTTPS setup guide
-            </Link>{" "}
-            (5 minutes).
+            </Link>
+            &nbsp;(5 minutes).
           </Banner>
         </>
       );
@@ -189,7 +189,6 @@ TemplateInfo.contextType = Context;
 
 const Link = styled.a`
   color: #8590ff;
-  margin-right: 5px;
   cursor: pointer;
   margin-left: 5px;
 `;

+ 5 - 0
dashboard/src/main/home/launch/hardcodedNameDict.tsx

@@ -10,6 +10,11 @@ const hardcodedNames: { [key: string]: string } = {
   web: "Web Service",
   worker: "Worker",
   job: "Job",
+  "cert-manager": "Cert Manager",
+  elasticsearch: "Elasticsearch",
+  prometheus: "Prometheus",
+  rabbitmq: "RabbitMQ",
+  logdna: "LogDNA",
 };
 
 export default hardcodedNames;

+ 550 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -0,0 +1,550 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+import randomWords from "random-words";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import hardcodedNames from "../hardcodedNameDict";
+import SourcePage from "./SourcePage";
+import SettingsPage from "./SettingsPage";
+
+import {
+  PorterTemplate,
+  ActionConfigType,
+  ChoiceType,
+  ClusterType,
+  StorageType,
+} from "shared/types";
+
+type PropsType = RouteComponentProps & {
+  currentTab?: string;
+  currentTemplate: PorterTemplate;
+  hideLaunchFlow: () => void;
+  form: any;
+};
+
+type StateType = {
+  currentPage: string;
+  templateName: string;
+  sourceType: string;
+  valuesToOverride: any;
+
+  imageUrl: string;
+  imageTag: string;
+
+  actionConfig: ActionConfigType;
+  procfileProcess: string;
+  branch: string;
+  repoType: string;
+  dockerfilePath: string | null;
+  procfilePath: string | null;
+  folderPath: string | null;
+  selectedRegistry: any;
+
+  selectedNamespace: string;
+  saveValuesStatus: string;
+};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  branch: "",
+  git_repo_id: 0,
+};
+
+class LaunchFlow extends Component<PropsType, StateType> {
+  state = {
+    currentPage: "source",
+    templateName: "",
+    saveValuesStatus: "",
+    sourceType: "",
+    selectedNamespace: "default",
+    valuesToOverride: {} as any,
+
+    imageUrl: "",
+    imageTag: "",
+
+    actionConfig: { ...defaultActionConfig },
+    procfileProcess: "",
+    branch: "",
+    repoType: "",
+    dockerfilePath: null as string | null,
+    procfilePath: null as string | null,
+    folderPath: null as string | null,
+    selectedRegistry: null as any,
+  };
+
+  createGHAction = (chartName: string, chartNamespace: string, env?: any) => {
+    let { currentProject, currentCluster } = this.context;
+    let {
+      actionConfig,
+      branch,
+      selectedRegistry,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+    let imageRepoUri = `${selectedRegistry.url}/${chartName}-${chartNamespace}`;
+
+    // DockerHub registry integration is per repo
+    if (selectedRegistry.service === "dockerhub") {
+      imageRepoUri = selectedRegistry.url;
+    }
+
+    api
+      .createGHAction(
+        "<token>",
+        {
+          git_repo: actionConfig.git_repo,
+          git_branch: branch,
+          registry_id: selectedRegistry.id,
+          dockerfile_path: dockerfilePath,
+          folder_path: folderPath,
+          image_repo_uri: imageRepoUri,
+          git_repo_id: actionConfig.git_repo_id,
+          env: env,
+        },
+        {
+          project_id: currentProject.id,
+          CLUSTER_ID: currentCluster.id,
+          RELEASE_NAME: chartName,
+          RELEASE_NAMESPACE: chartNamespace,
+        }
+      )
+      .then((res) => console.log(""))
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: `Could not create GitHub Action: ${err}`,
+        });
+      });
+  };
+
+  onSubmitAddon = (wildcard?: any) => {
+    let { selectedNamespace } = this.state;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let name =
+      this.state.templateName || randomWords({ exactly: 3, join: "-" });
+    this.setState({ saveValuesStatus: "loading" });
+
+    let values = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+
+    api
+      .deployTemplate(
+        "<token>",
+        {
+          templateName: this.props.currentTemplate.name,
+          storage: StorageType.Secret,
+          formValues: values,
+          namespace: selectedNamespace,
+          name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate?.currentVersion || "latest",
+          repo_url: process.env.ADDON_CHART_REPO_URL,
+        }
+      )
+      .then((_) => {
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: "successful" }, () => {
+          // redirect to dashboard
+          let dst =
+            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+          setTimeout(() => {
+            this.props.history.push(dst);
+          }, 500);
+          window.analytics.track("Deployed Add-on", {
+            name: this.props.currentTemplate.name,
+            namespace: selectedNamespace,
+            values: values,
+          });
+        });
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: `Could not deploy template: ${err}`,
+        });
+        setCurrentError(err.response.data.errors[0]);
+        window.analytics.track("Failed to Deploy Add-on", {
+          name: this.props.currentTemplate.name,
+          namespace: selectedNamespace,
+          values: values,
+          error: err,
+        });
+      });
+  };
+
+  onSubmit = async (rawValues: any) => {
+    let { currentCluster, currentProject } = this.context;
+    let {
+      selectedNamespace,
+      templateName,
+      imageUrl,
+      imageTag,
+      sourceType,
+    } = this.state;
+    let name = templateName || randomWords({ exactly: 3, join: "-" });
+    this.setState({ saveValuesStatus: "loading" });
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    let tag = imageTag;
+    if (imageUrl.includes(":")) {
+      let splits = imageUrl.split(":");
+      imageUrl = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (sourceType === "repo") {
+      if (this.props.currentTemplate?.name == "job") {
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      default:
+        provider = "";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (imageUrl && tag) {
+      _.set(values, "image.repository", imageUrl);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+    var url: string;
+    // check if template is docker and create external domain if necessary
+    if (this.props.currentTemplate.name == "web") {
+      if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
+        url = await new Promise((resolve, reject) => {
+          api
+            .createSubdomain(
+              "<token>",
+              {
+                release_name: name,
+              },
+              {
+                id: currentProject.id,
+                cluster_id: currentCluster.id,
+              }
+            )
+            .then((res) => {
+              resolve(res.data?.external_url);
+            })
+            .catch((err) => {
+              let parsedErr =
+                err?.response?.data?.errors && err.response.data.errors[0];
+              if (parsedErr) {
+                err = parsedErr;
+              }
+              this.setState({
+                saveValuesStatus: `Could not create subdomain: ${err}`,
+              });
+            });
+        });
+
+        values.ingress.porter_hosts = [url];
+      }
+    }
+
+    api
+      .deployTemplate(
+        "<token>",
+        {
+          templateName: this.props.currentTemplate.name,
+          imageURL: imageUrl,
+          storage: StorageType.Secret,
+          formValues: values,
+          namespace: selectedNamespace,
+          name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate?.currentVersion || "latest",
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        }
+      )
+      .then((res: any) => {
+        if (sourceType === "repo") {
+          let env = rawValues["container.env.normal"];
+          console.log(env);
+          this.createGHAction(name, selectedNamespace, env);
+        }
+        // this.props.setCurrentView('cluster-dashboard');
+        this.setState({ saveValuesStatus: "successful" }, () => {
+          // redirect to dashboard with namespace
+          setTimeout(() => {
+            let dst =
+              this.props.currentTemplate.name === "job"
+                ? "jobs"
+                : "applications";
+            this.props.history.push(dst);
+          }, 1000);
+        });
+      })
+      .catch((err: any) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        console.log(parsedErr);
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        this.setState({
+          saveValuesStatus: `Could not deploy template: ${err}`,
+        });
+      });
+  };
+
+  renderCurrentPage = () => {
+    let { form, currentTab } = this.props;
+    let {
+      currentPage,
+      valuesToOverride,
+      templateName,
+      imageUrl,
+      imageTag,
+      actionConfig,
+      branch,
+      repoType,
+      dockerfilePath,
+      procfileProcess,
+      procfilePath,
+      folderPath,
+      selectedNamespace,
+      selectedRegistry,
+      saveValuesStatus,
+      sourceType,
+    } = this.state;
+
+    if (currentPage === "source" && currentTab === "porter") {
+      return (
+        <SourcePage
+          sourceType={sourceType}
+          setSourceType={(x: string) => this.setState({ sourceType: x })}
+          templateName={templateName}
+          setPage={(x: string) => {
+            this.setState({ currentPage: x });
+          }}
+          setTemplateName={(x: string) => this.setState({ templateName: x })}
+          setValuesToOverride={(x: any) =>
+            this.setState({ valuesToOverride: x })
+          }
+          imageUrl={imageUrl}
+          setImageUrl={(x: string) => this.setState({ imageUrl: x })}
+          imageTag={imageTag}
+          setImageTag={(x: string) => this.setState({ imageTag: x })}
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) =>
+            this.setState({ actionConfig: x })
+          }
+          branch={branch}
+          setBranch={(x: string) => this.setState({ branch: x })}
+          procfileProcess={procfileProcess}
+          setProcfileProcess={(x: string) =>
+            this.setState({ procfileProcess: x })
+          }
+          repoType={repoType}
+          setRepoType={(x: string) => this.setState({ repoType: x })}
+          dockerfilePath={dockerfilePath}
+          setDockerfilePath={(x: string) =>
+            this.setState({ dockerfilePath: x })
+          }
+          folderPath={folderPath}
+          setFolderPath={(x: string) => this.setState({ folderPath: x })}
+          procfilePath={procfilePath}
+          setProcfilePath={(x: string) => this.setState({ procfilePath: x })}
+          selectedRegistry={selectedRegistry}
+          setSelectedRegistry={(x: string) =>
+            this.setState({ selectedRegistry: x })
+          }
+        />
+      );
+    }
+
+    // Display main (non-source) settings page
+    return (
+      <SettingsPage
+        onSubmit={currentTab === "porter" ? this.onSubmit : this.onSubmitAddon}
+        saveValuesStatus={saveValuesStatus}
+        selectedNamespace={selectedNamespace}
+        setSelectedNamespace={(x: string) =>
+          this.setState({ selectedNamespace: x })
+        }
+        templateName={templateName}
+        setTemplateName={(x: string) => this.setState({ templateName: x })}
+        hasSource={currentTab === "porter"}
+        setPage={(x: string) => this.setState({ currentPage: x })}
+        form={form}
+        valuesToOverride={valuesToOverride}
+        clearValuesToOverride={() => this.setState({ valuesToOverride: null })}
+      />
+    );
+  };
+
+  renderIcon = () => {
+    let icon = this.props.currentTemplate?.icon;
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer>
+        <i className="material-icons">layers</i>
+      </Polymer>
+    );
+  };
+
+  render() {
+    let { currentTab } = this.props;
+    let { name } = this.props.currentTemplate;
+    if (hardcodedNames[name]) {
+      name = hardcodedNames[name];
+    }
+
+    return (
+      <StyledLaunchFlow>
+        <TitleSection>
+          <i className="material-icons" onClick={this.props.hideLaunchFlow}>
+            keyboard_backspace
+          </i>
+          {this.renderIcon()}
+          <Title>
+            New {name} {currentTab === "porter" ? null : "Instance"}
+          </Title>
+        </TitleSection>
+        {this.renderCurrentPage()}
+        <Br />
+      </StyledLaunchFlow>
+    );
+  }
+}
+
+LaunchFlow.contextType = Context;
+export default withRouter(LaunchFlow);
+
+const Br = styled.div`
+  width: 100%;
+  height: 120px;
+`;
+
+const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -3px;
+
+  > i {
+    color: ${(props) => props.theme.containerIcon};
+    font-size: 24px;
+    margin-left: 12px;
+    margin-right: 3px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858faaaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(90% - 130px);
+  min-width: 300px;
+  padding-top: 20px;
+  margin-top: calc(50vh - 340px);
+`;

+ 407 - 0
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -0,0 +1,407 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+
+import {
+  ActionConfigType,
+  ChoiceType,
+  ClusterType,
+  StorageType,
+} from "shared/types";
+
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/values-form/InputRow";
+import SaveButton from "components/SaveButton";
+import Helper from "components/values-form/Helper";
+import FormWrapper from "components/values-form/FormWrapper";
+import Selector from "components/Selector";
+import Loading from "components/Loading";
+
+type PropsType = {
+  onSubmit: (x?: any) => void;
+  hasSource: boolean;
+  setPage: (x: string) => void;
+  form: any;
+  valuesToOverride: any;
+  clearValuesToOverride: () => void;
+
+  templateName: string;
+  setTemplateName: (x: string) => void;
+  selectedNamespace: string;
+  setSelectedNamespace: (x: string) => void;
+  saveValuesStatus: string;
+};
+
+type StateType = {
+  tabOptions: ChoiceType[];
+  currentTab: string;
+  clusterOptions: { label: string; value: string }[];
+  selectedCluster: string;
+  clusterMap: { [clusterId: string]: ClusterType };
+  namespaceOptions: { label: string; value: string }[];
+};
+
+export default class SettingsPage extends Component<PropsType, StateType> {
+  state = {
+    tabOptions: [] as ChoiceType[],
+    currentTab: "",
+    clusterOptions: [] as { label: string; value: string }[],
+    selectedCluster: this.context.currentCluster.name,
+    clusterMap: {} as { [clusterId: string]: ClusterType },
+    namespaceOptions: [] as { label: string; value: string }[],
+  };
+
+  componentDidMount() {
+    window.scrollBy(0, -window.innerHeight);
+
+    // Retrieve tab options
+    let tabOptions = [] as ChoiceType[];
+    this.props.form?.tabs.map((tab: any, i: number) => {
+      if (tab.context.type === "helm/values") {
+        tabOptions.push({ value: tab.name, label: tab.label });
+      }
+    });
+
+    this.setState({
+      tabOptions,
+      currentTab: tabOptions[0] && tabOptions[0]["value"],
+    });
+
+    // TODO: query with selected filter once implemented
+    let { currentProject, currentCluster } = this.context;
+    api.getClusters("<token>", {}, { id: currentProject.id }).then((res) => {
+      if (res.data) {
+        let clusterOptions: { label: string; value: string }[] = [];
+        let clusterMap: { [clusterId: string]: ClusterType } = {};
+        res.data.forEach((cluster: ClusterType, i: number) => {
+          clusterOptions.push({ label: cluster.name, value: cluster.name });
+          clusterMap[cluster.name] = cluster;
+        });
+        if (res.data.length > 0) {
+          this.setState({ clusterOptions, clusterMap });
+        }
+      }
+    });
+
+    this.updateNamespaces(currentCluster.id);
+  }
+
+  updateNamespaces = (id: number) => {
+    let { currentProject } = this.context;
+    api
+      .getNamespaces(
+        "<token>",
+        {
+          cluster_id: id,
+        },
+        { id: currentProject.id }
+      )
+      .then((res) => {
+        if (res.data) {
+          let namespaceOptions = res.data.items.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (res.data.items.length > 0) {
+            this.setState({ namespaceOptions });
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  renderSettingsRegion = () => {
+    let { saveValuesStatus, selectedNamespace, onSubmit } = this.props;
+
+    if (this.state.currentTab === "") {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    }
+    if (this.state.tabOptions.length > 0) {
+      let {
+        form,
+        valuesToOverride,
+        clearValuesToOverride,
+        onSubmit,
+      } = this.props;
+      return (
+        <>
+          <Heading>Additional Settings</Heading>
+          <Helper>
+            Configure additional settings for this template. (Optional)
+          </Helper>
+          <FormWrapper
+            formData={form}
+            saveValuesStatus={saveValuesStatus}
+            valuesToOverride={valuesToOverride}
+            clearValuesToOverride={clearValuesToOverride}
+            externalValues={{
+              namespace: selectedNamespace,
+              clusterId: this.context.currentCluster.id,
+              isLaunch: true,
+            }}
+            onSubmit={onSubmit}
+          />
+        </>
+      );
+    } else {
+      return (
+        <Wrapper>
+          <Placeholder>
+            To configure this chart through Porter,
+            <Link
+              target="_blank"
+              href="https://docs.getporter.dev/docs/porter-templates"
+            >
+              refer to our docs
+            </Link>
+            .
+          </Placeholder>
+          <SaveButton
+            text="Deploy"
+            onClick={onSubmit}
+            status={saveValuesStatus}
+            makeFlush={true}
+          />
+        </Wrapper>
+      );
+    }
+  };
+
+  renderHeaderSection = () => {
+    let { hasSource, templateName, setTemplateName } = this.props;
+
+    if (hasSource) {
+      return (
+        <BackButton
+          width="155px"
+          onClick={() => {
+            this.props.setPage("source");
+          }}
+        >
+          <i className="material-icons">first_page</i>
+          Source Settings
+        </BackButton>
+      );
+    }
+
+    return (
+      <>
+        <Heading>Name</Heading>
+        <Helper>
+          Randomly generated if left blank
+          <Warning
+            highlight={!isAlphanumeric(templateName) && templateName !== ""}
+          >
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+        </Helper>
+        <InputWrapper>
+          <InputRow
+            type="string"
+            value={templateName}
+            setValue={setTemplateName}
+            placeholder="ex: perspective-vortex"
+            width="470px"
+          />
+        </InputWrapper>
+      </>
+    );
+  };
+
+  render() {
+    let { selectedCluster } = this.state;
+
+    let { selectedNamespace, setSelectedNamespace } = this.props;
+
+    return (
+      <PaddingWrapper>
+        <StyledSettingsPage>
+          {this.renderHeaderSection()}
+          <Heading>Destination</Heading>
+          <Helper>
+            Specify the cluster and namespace you would like to deploy your
+            application to.
+          </Helper>
+          <ClusterSection>
+            <ClusterLabel>
+              <i className="material-icons">device_hub</i>Cluster
+            </ClusterLabel>
+            <Selector
+              activeValue={selectedCluster}
+              setActiveValue={(cluster: string) => {
+                this.context.setCurrentCluster(this.state.clusterMap[cluster]);
+                this.updateNamespaces(this.state.clusterMap[cluster].id);
+                this.setState({
+                  selectedCluster: cluster,
+                });
+              }}
+              options={this.state.clusterOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+            <NamespaceLabel>
+              <i className="material-icons">view_list</i>Namespace
+            </NamespaceLabel>
+            <Selector
+              key={"namespace"}
+              activeValue={selectedNamespace}
+              setActiveValue={setSelectedNamespace}
+              options={this.state.namespaceOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+          </ClusterSection>
+          {this.renderSettingsRegion()}
+        </StyledSettingsPage>
+      </PaddingWrapper>
+    );
+  }
+}
+
+SettingsPage.contextType = Context;
+
+const LoadingWrapper = styled.div`
+  margin-top: 80px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+  margin-bottom: -6px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  margin-top: 25px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const ClusterLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const NamespaceLabel = styled.div`
+  margin-left: 15px;
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const Link = styled.a`
+  margin-left: 5px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  position: relative;
+  padding-top: 20px;
+  padding-bottom: 70px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ClusterSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 2px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const PaddingWrapper = styled.div`
+  padding-bottom: 40px;
+`;
+
+const StyledSettingsPage = styled.div`
+  position: relative;
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;

+ 454 - 0
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -0,0 +1,454 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import { RouteComponentProps, withRouter } from "react-router";
+import close from "assets/close.png";
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/values-form/InputRow";
+import Helper from "components/values-form/Helper";
+import ImageSelector from "components/image-selector/ImageSelector";
+import ActionConfEditor from "components/repo-selector/ActionConfEditor";
+import SaveButton from "components/SaveButton";
+import { ActionConfigType } from "shared/types";
+
+type PropsType = RouteComponentProps & {
+  templateName: string;
+  setTemplateName: (x: string) => void;
+  setValuesToOverride: (x: any) => void;
+  setPage: (x: string) => void;
+  sourceType: string;
+  setSourceType: (x: string) => void;
+
+  imageUrl: string;
+  setImageUrl: (x: string) => void;
+  imageTag: string;
+  setImageTag: (x: string) => void;
+
+  actionConfig: ActionConfigType;
+  setActionConfig: (x: ActionConfigType) => void;
+  procfileProcess: string;
+  setProcfileProcess: (x: string) => void;
+  branch: string;
+  setBranch: (x: string) => void;
+  repoType: string;
+  setRepoType: (x: string) => void;
+  dockerfilePath: string | null;
+  setDockerfilePath: (x: string) => void;
+  procfilePath: string | null;
+  setProcfilePath: (x: string) => void;
+  folderPath: string | null;
+  setFolderPath: (x: string) => void;
+  selectedRegistry: any;
+  setSelectedRegistry: (x: string) => void;
+};
+
+type StateType = {};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  branch: "",
+  git_repo_id: 0,
+};
+
+class SourcePage extends Component<PropsType, StateType> {
+  renderSourceSelector = () => {
+    let { capabilities } = this.context;
+    let { sourceType, setSourceType } = this.props;
+
+    if (sourceType === "") {
+      return (
+        <BlockList>
+          {capabilities.github && (
+            <Block onClick={() => setSourceType("repo")}>
+              <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+              <BlockTitle>Git Repository</BlockTitle>
+              <BlockDescription>
+                Deploy using source from a Git repo.
+              </BlockDescription>
+            </Block>
+          )}
+          <Block onClick={() => setSourceType("registry")}>
+            <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+            <BlockTitle>Docker Registry</BlockTitle>
+            <BlockDescription>
+              Deploy a container from an image registry.
+            </BlockDescription>
+          </Block>
+        </BlockList>
+      );
+    }
+
+    // Display image selector
+    if (sourceType === "registry") {
+      let { imageUrl, setImageUrl, imageTag, setImageTag } = this.props;
+      return (
+        <StyledSourceBox>
+          <CloseButton
+            onClick={() => {
+              setSourceType("");
+              setImageUrl("");
+              setImageTag("");
+            }}
+          >
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Subtitle>
+            Specify the container image you would like to connect to this
+            template.
+            <Highlight
+              onClick={() => this.props.history.push("integrations/registry")}
+            >
+              Manage Docker registries
+            </Highlight>
+            <Required>*</Required>
+          </Subtitle>
+          <DarkMatter antiHeight="-4px" />
+          <ImageSelector
+            selectedTag={imageTag}
+            selectedImageUrl={imageUrl}
+            setSelectedImageUrl={setImageUrl}
+            setSelectedTag={setImageTag}
+            forceExpanded={true}
+          />
+          <br />
+        </StyledSourceBox>
+      );
+    }
+
+    // Display repo selector
+    let {
+      history,
+      setValuesToOverride,
+      setImageUrl,
+      actionConfig,
+      setActionConfig,
+      branch,
+      setBranch,
+      procfileProcess,
+      setProcfileProcess,
+      dockerfilePath,
+      setDockerfilePath,
+      procfilePath,
+      setProcfilePath,
+      folderPath,
+      setFolderPath,
+      selectedRegistry,
+      setSelectedRegistry,
+    } = this.props;
+    return (
+      <StyledSourceBox>
+        <CloseButton onClick={() => setSourceType("")}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Subtitle>
+          Provide a repo folder to use as source.
+          <Highlight onClick={() => history.push("integrations/repo")}>
+            Manage Git repos
+          </Highlight>
+          <Required>*</Required>
+        </Subtitle>
+        <DarkMatter antiHeight="-4px" />
+        <ActionConfEditor
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={(actionConfig: ActionConfigType) => {
+            setActionConfig(actionConfig);
+            setImageUrl(actionConfig.image_repo_uri);
+            /*
+            setParentState({ actionConfig }, () =>
+              setParentState({ imageUrl: actionConfig.image_repo_uri })
+            )
+            */
+          }}
+          procfileProcess={procfileProcess}
+          setProcfileProcess={(procfileProcess: string) => {
+            setProcfileProcess(procfileProcess);
+            setValuesToOverride({
+              "container.command": {
+                value: procfileProcess || "",
+              },
+              showStartCommand: {
+                value: !procfileProcess,
+              },
+            });
+          }}
+          setBranch={setBranch}
+          setDockerfilePath={setDockerfilePath}
+          setProcfilePath={setProcfilePath}
+          procfilePath={procfilePath}
+          dockerfilePath={dockerfilePath}
+          folderPath={folderPath}
+          setFolderPath={setFolderPath}
+          reset={() => {
+            setActionConfig({ ...defaultActionConfig });
+            setBranch("");
+            setDockerfilePath(null);
+            setFolderPath(null);
+          }}
+          setSelectedRegistry={setSelectedRegistry}
+          selectedRegistry={selectedRegistry}
+        />
+        <br />
+      </StyledSourceBox>
+    );
+  };
+
+  checkSourceSelected = () => {
+    let { imageUrl, selectedRegistry } = this.props;
+    return imageUrl || selectedRegistry;
+  };
+
+  // TODO: consolidate status w/ helper at button-level
+  getButtonStatus = () => {
+    let { imageUrl, selectedRegistry, imageTag, templateName } = this.props;
+    if (!isAlphanumeric(templateName) && templateName !== "") {
+      return "Name contains illegal characters";
+    }
+    if (imageUrl || selectedRegistry) {
+      return "";
+    }
+    return "No source selected";
+  };
+
+  getButtonHelper = () => {
+    let { imageUrl, imageTag } = this.props;
+    if (imageUrl && !imageTag) {
+      return 'Tag "latest" will be used by default';
+    }
+  };
+
+  render() {
+    let { templateName, setTemplateName, setPage } = this.props;
+
+    return (
+      <StyledSourcePage>
+        <Heading>Name</Heading>
+        <Helper>
+          Randomly generated if left blank
+          <Warning
+            highlight={!isAlphanumeric(templateName) && templateName !== ""}
+          >
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+        </Helper>
+        <InputWrapper>
+          <InputRow
+            type="string"
+            value={templateName}
+            setValue={setTemplateName}
+            placeholder="ex: perspective-vortex"
+            width="470px"
+          />
+        </InputWrapper>
+        <Heading>Deployment Method</Heading>
+        <Helper>
+          Deploy from a Git repository or a Docker registry:
+          <Required>*</Required>
+        </Helper>
+        <Br />
+        {this.renderSourceSelector()}
+        <Helper>
+          Learn more about
+          <Highlight
+            href="https://docs.getporter.dev/docs/add-ons"
+            target="_blank"
+          >
+            deploying services to Porter
+          </Highlight>
+        </Helper>
+        <Buffer />
+        <SaveButton
+          text="Continue"
+          disabled={!this.checkSourceSelected()}
+          onClick={() => setPage("settings")}
+          status={this.getButtonStatus()}
+          makeFlush={true}
+          helper={this.getButtonHelper()}
+        />
+      </StyledSourcePage>
+    );
+  }
+}
+
+SourcePage.contextType = Context;
+export default withRouter(SourcePage);
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourcePage = styled.div`
+  position: relative;
+  margin-top: -5px;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 5px;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props) => (props.disabled ? "" : "#ffffff11")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+  margin-bottom: -6px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const Highlight = styled.a`
+  color: #8590ff;
+  text-decoration: none;
+  margin-left: 5px;
+  cursor: pointer;
+  display: inline;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  margin-top: 6px;
+  overflow: auto;
+  margin-bottom: 25px;
+`;

+ 1 - 206
dashboard/src/main/home/new-project/NewProject.tsx

@@ -77,125 +77,12 @@ const Br = styled.div`
   height: 100px;
 `;
 
-const Link = styled.a`
-  cursor: pointer;
-  margin-left: 5px;
-`;
-
-const GuideButton = styled.a`
-  display: flex;
-  align-items: center;
-  margin-left: 20px;
-  color: #aaaabb;
-  font-size: 13px;
-  margin-bottom: -1px;
-  border: 1px solid #aaaabb;
-  padding: 5px 10px;
-  padding-left: 6px;
-  border-radius: 5px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff11;
-    color: #ffffff;
-    border: 1px solid #ffffff;
-
-    > i {
-      color: #ffffff;
-    }
-  }
-
-  > i {
-    color: #aaaabb;
-    font-size: 16px;
-    margin-right: 6px;
-  }
-`;
-
-const Flex = styled.div`
-  display: flex;
-  height: 170px;
-  width: 100%;
-  margin-top: -10px;
-  color: #ffffff;
-  align-items: center;
-  justify-content: center;
-`;
-
-const BlockOverlay = styled.div`
-  position: absolute;
-  width: 100%;
-  height: 100%;
-  background: #00000055;
-  top: 0;
-  left: 0;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const DarkMatter = styled.div`
-  margin-top: -30px;
-`;
-
-const FormSection = styled.div`
-  background: #ffffff11;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  background: #26282f;
-  border-radius: 5px;
-  min-height: 170px;
-  padding: 25px;
-  padding-bottom: 15px;
-  font-size: 13px;
-  animation: fadeIn 0.3s 0s;
-  position: relative;
-`;
-
-const Placeholder = styled.div`
-  background: #ffffff11;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  background: #26282f;
-  border-radius: 5px;
-  height: 170px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
   display: inline-block;
 `;
 
-const Highlight = styled.div`
-  margin-left: 5px;
-  color: #8590ff;
-  cursor: pointer;
-`;
-
 const Letter = styled.div`
   height: 100%;
   width: 100%;
@@ -226,7 +113,7 @@ const ProjectIcon = styled.div`
   position: relative;
   margin-right: 15px;
   font-weight: 400;
-  margin-top: 17px;
+  margin-top: 9px;
 `;
 
 const InputWrapper = styled.div`
@@ -242,98 +129,6 @@ const Warning = styled.span`
     props.makeFlush ? "" : "5px"};
 `;
 
-const Icon = styled.img`
-  height: 42px;
-  margin-top: 30px;
-  margin-bottom: 15px;
-  filter: ${(props: { bw?: boolean }) => (props.bw ? "grayscale(1)" : "")};
-`;
-
-const BlockDescription = styled.div`
-  margin-bottom: 12px;
-  color: #ffffff66;
-  text-align: center;
-  font-weight: default;
-  font-size: 13px;
-  padding: 0px 25px;
-  height: 2.4em;
-  font-size: 12px;
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-`;
-
-const BlockTitle = styled.div`
-  margin-bottom: 12px;
-  width: 80%;
-  text-align: center;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const Block = styled.div`
-  align-items: center;
-  user-select: none;
-  border-radius: 5px;
-  display: flex;
-  font-size: 13px;
-  overflow: hidden;
-  font-weight: 500;
-  padding: 3px 0px 5px;
-  flex-direction: column;
-  align-item: center;
-  justify-content: space-between;
-  height: 170px;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "pointer"};
-  color: #ffffff;
-  position: relative;
-  background: #26282f;
-  box-shadow: 0 3px 5px 0px #00000022;
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#ffffff11"};
-  }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const ShinyBlock = styled(Block)`
-  background: linear-gradient(
-    36deg,
-    rgba(240, 106, 40, 0.9) 0%,
-    rgba(229, 83, 229, 0.9) 100%
-  );
-  :hover {
-    background: linear-gradient(
-      36deg,
-      rgba(240, 106, 40, 1) 0%,
-      rgba(229, 83, 229, 1) 100%
-    );
-  }
-`;
-
-const BlockList = styled.div`
-  overflow: visible;
-  margin-top: 25px;
-  margin-bottom: 27px;
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
-`;
-
 const Title = styled.div`
   font-size: 24px;
   font-weight: 600;

+ 10 - 0
dashboard/src/shared/api.tsx

@@ -230,6 +230,15 @@ const deletePod = baseApi<
   return `/api/projects/${pathParams.id}/k8s/pods/${pathParams.namespace}/${pathParams.name}`;
 });
 
+const getPodEvents = baseApi<
+  {
+    cluster_id: number;
+  },
+  { name: string; namespace: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/pods/${pathParams.namespace}/${pathParams.name}/events/list`;
+});
+
 const deleteProject = baseApi<{}, { id: number }>("DELETE", (pathParams) => {
   return `/api/projects/${pathParams.id}`;
 });
@@ -851,6 +860,7 @@ export default {
   getNamespaces,
   getNGINXIngresses,
   getOAuthIds,
+  getPodEvents,
   getProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 10 - 0
internal/kubernetes/agent.go

@@ -215,6 +215,16 @@ func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
 	)
 }
 
+// ListEvents lists the events of a given object.
+func (a *Agent) ListEvents(name string, namespace string) (*v1.EventList, error) {
+	return a.Clientset.CoreV1().Events(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
+		},
+	)
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(

+ 51 - 0
server/api/k8s_handler.go

@@ -75,6 +75,57 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleListPodEvents retrieves all events tied to a pod.
+func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	events, err := agent.ListEvents(name, namespace)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(events); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
 // HandleCreateConfigMap deletes the pod given the name and namespace.
 func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)

+ 14 - 0
server/router/router.go

@@ -1210,6 +1210,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/k8s/pods/{namespace}/{name}/events/list",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleListPodEvents, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/k8s/configmap/create",