Explorar el Código

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku hace 5 años
padre
commit
17911961e8
Se han modificado 58 ficheros con 12708 adiciones y 788 borrados
  1. 10378 1
      dashboard/package-lock.json
  2. 1 0
      dashboard/package.json
  3. 66 0
      dashboard/src/components/RadioSelector.tsx
  4. 2 2
      dashboard/src/components/ResourceTab.tsx
  5. 4 5
      dashboard/src/components/SaveButton.tsx
  6. 2 2
      dashboard/src/components/Selector.tsx
  7. 1 1
      dashboard/src/components/TabRegion.tsx
  8. 1 1
      dashboard/src/components/TooltipParent.tsx
  9. 71 34
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  10. 251 37
      dashboard/src/components/repo-selector/ActionDetails.tsx
  11. 18 1
      dashboard/src/components/repo-selector/BranchList.tsx
  12. 0 158
      dashboard/src/components/repo-selector/ButtonTray.tsx
  13. 228 30
      dashboard/src/components/repo-selector/ContentsList.tsx
  14. 32 1
      dashboard/src/components/repo-selector/RepoList.tsx
  15. 0 177
      dashboard/src/components/repo-selector/RepoSelector.tsx
  16. 7 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  17. 0 2
      dashboard/src/components/values-form/Helper.tsx
  18. 1 1
      dashboard/src/components/values-form/InputRow.tsx
  19. 8 1
      dashboard/src/components/values-form/ValuesForm.tsx
  20. 2 2
      dashboard/src/main/CurrentError.tsx
  21. 6 6
      dashboard/src/main/Login.tsx
  22. 5 5
      dashboard/src/main/Main.tsx
  23. 6 6
      dashboard/src/main/Register.tsx
  24. 24 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  25. 10 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  26. 0 69
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  27. 104 26
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx
  28. 397 20
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  29. 283 71
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  30. 1 0
      dashboard/src/main/home/new-project/NewProject.tsx
  31. 62 3
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  32. 50 1
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  33. 61 2
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  34. 2 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  35. 1 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  36. 2 2
      dashboard/src/shared/Context.tsx
  37. 3 3
      dashboard/src/shared/ansiparser.tsx
  38. 85 58
      dashboard/src/shared/api.tsx
  39. 6 6
      dashboard/src/shared/baseApi.tsx
  40. 17 17
      dashboard/src/shared/common.tsx
  41. 5 5
      dashboard/src/shared/feedback.tsx
  42. 2 2
      dashboard/src/shared/rosettaStone.tsx
  43. 2 2
      dashboard/src/shared/routing.tsx
  44. 1 2
      dashboard/src/shared/types.tsx
  45. 16 9
      internal/forms/git_action.go
  46. 12 0
      internal/forms/metrics.go
  47. 43 7
      internal/integrations/ci/actions/actions.go
  48. 20 2
      internal/integrations/ci/actions/steps.go
  49. 151 0
      internal/kubernetes/prometheus/metrics.go
  50. 9 2
      internal/models/gitrepo.go
  51. 42 0
      internal/registry/registry.go
  52. 2 0
      server/api/deploy_handler.go
  53. 30 0
      server/api/git_action_handler.go
  54. 117 0
      server/api/k8s_handler.go
  55. 14 2
      server/api/release_handler.go
  56. 3 2
      server/router/middleware/auth.go
  57. 28 0
      server/router/router.go
  58. 13 0
      services/deploy_init_container/Dockerfile

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 10378 - 1
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -12,6 +12,7 @@
     "@types/material-ui": "^0.21.8",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
+    "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/gradient": "^1.0.0",

+ 66 - 0
dashboard/src/components/RadioSelector.tsx

@@ -0,0 +1,66 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  selected: string;
+  setSelected: (x: string) => void;
+  options: { value: string; label: string }[];
+};
+
+type StateType = {};
+
+export default class RadioSelector extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <StyledRadioSelector>
+        {this.props.options.map(
+          (option: { label: string; value: string }, i: number) => {
+            let selected = option.value === this.props.selected;
+            return (
+              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+                <Indicator selected={selected}>
+                  {selected && <Circle />}
+                </Indicator>
+                {option.label}
+              </RadioRow>
+            );
+          }
+        )}
+      </StyledRadioSelector>
+    );
+  }
+}
+
+const RadioRow = styled.div`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  margin-bottom: 12px;
+  :hover {
+    > div {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  margin-right: 4px;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Circle = styled.div`
+  width: 8px;
+  height: 8px;
+  background: #ffffff55;
+  border-radius: 15px;
+`;
+
+const StyledRadioSelector = styled.div``;

+ 2 - 2
dashboard/src/components/ResourceTab.tsx

@@ -26,7 +26,7 @@ type StateType = {
 export default class ResourceTab extends Component<PropsType, StateType> {
   state = {
     expanded: this.props.expanded || false,
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderDropdownIcon = () => {
@@ -95,7 +95,7 @@ export default class ResourceTab extends Component<PropsType, StateType> {
       handleClick,
       selected,
       status,
-      roundAllCorners,
+      roundAllCorners
     } = this.props;
     return (
       <StyledResourceTab

+ 4 - 5
dashboard/src/components/SaveButton.tsx

@@ -132,15 +132,14 @@ const Button = styled.button`
   text-align: left;
   border: 0;
   border-radius: 5px;
-  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
-  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  background: ${props => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${props => (!props.disabled ? "0 2px 5px 0 #00000030" : "none")};
+  cursor: ${props => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {
     outline: 0;
   }
   :hover {
-    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+    filter: ${props => (!props.disabled ? "brightness(120%)" : "")};
   }
 `;

+ 2 - 2
dashboard/src/components/Selector.tsx

@@ -17,7 +17,7 @@ type StateType = {};
 
 export default class Selector extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   wrapperRef: any = React.createRef();
@@ -192,7 +192,7 @@ const Dropdown = styled.div`
 
 const StyledSelector = styled.div<{ width: string }>`
   position: relative;
-  width: ${(props) => props.width};
+  width: ${props => props.width};
 `;
 
 const MainSelector = styled.div`

+ 1 - 1
dashboard/src/components/TabRegion.tsx

@@ -27,7 +27,7 @@ export default class TabRegion extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
     let { options, currentTab } = this.props;
     if (prevProps.options !== options) {
-      if (options.filter((x) => x.value === currentTab).length === 0) {
+      if (options.filter(x => x.value === currentTab).length === 0) {
         this.props.setCurrentTab(this.defaultTab());
       }
     }

+ 1 - 1
dashboard/src/components/TooltipParent.tsx

@@ -11,7 +11,7 @@ type StateType = {
 
 export default class TooltipParent extends Component<PropsType, StateType> {
   state = {
-    showTooltip: false,
+    showTooltip: false
   };
 
   renderTooltip = (): JSX.Element | undefined => {

+ 71 - 34
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -12,11 +12,15 @@ import ActionDetails from "./ActionDetails";
 type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
-  pathIsSet: boolean;
   setActionConfig: (x: ActionConfigType) => void;
   setBranch: (x: string) => void;
-  setPath: (x: boolean) => void;
   reset: any;
+  dockerfilePath: string;
+  setDockerfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
 };
 
 type StateType = {
@@ -24,6 +28,12 @@ type StateType = {
   error: boolean;
 };
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+};
+
 export default class ActionConfEditor extends Component<PropsType, StateType> {
   state = {
     loading: true,
@@ -31,14 +41,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    let {
-      actionConfig,
-      branch,
-      pathIsSet,
-      setActionConfig,
-      setBranch,
-      setPath,
-    } = this.props;
+    let { actionConfig, branch, setActionConfig, setBranch } = this.props;
 
     if (!actionConfig.git_repo) {
       return (
@@ -50,7 +53,8 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           />
         </ExpandedWrapper>
       );
-    } else if (!branch) {
+    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
+      /* else if (!branch) {
       return (
         <>
           <ExpandedWrapperAlt>
@@ -59,10 +63,14 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               setBranch={(branch: string) => setBranch(branch)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton width="135px" onClick={() => setActionConfig({ ...defaultActionConfig })}>
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
-    } else if (!pathIsSet) {
+    } */
       return (
         <>
           <ExpandedWrapperAlt>
@@ -70,32 +78,33 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               actionConfig={actionConfig}
               branch={branch}
               setActionConfig={setActionConfig}
-              setPath={() => setPath(true)}
+              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
+              setFolderPath={(x: string) => this.props.setFolderPath(x)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton
+            width="135px"
+            onClick={() => setActionConfig({ ...defaultActionConfig })}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
     }
     return (
-      <>
-        <ExpandedWrapperAlt>
-          <ActionDetails
-            actionConfig={actionConfig}
-            setActionConfig={setActionConfig}
-          />
-        </ExpandedWrapperAlt>
-        {this.renderResetButton()}
-      </>
-    );
-  };
-
-  renderResetButton = () => {
-    return (
-      <BackButton width="150px" onClick={this.props.reset}>
-        <i className="material-icons">keyboard_backspace</i>
-        Reset Selection
-      </BackButton>
+      <ActionDetails
+        branch={branch}
+        setDockerfilePath={this.props.setDockerfilePath}
+        setFolderPath={this.props.setFolderPath}
+        actionConfig={actionConfig}
+        setActionConfig={setActionConfig}
+        dockerfilePath={this.props.dockerfilePath}
+        folderPath={this.props.folderPath}
+        setSelectedRegistry={this.props.setSelectedRegistry}
+        selectedRegistry={this.props.selectedRegistry}
+      />
     );
   };
 
@@ -106,6 +115,31 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
 
 ActionConfEditor.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;
@@ -121,10 +155,13 @@ const BackButton = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-top: 10px;
+  margin-top: 22px;
   cursor: pointer;
   font-size: 13px;
+  height: 35px;
   padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
   border: 1px solid #ffffff55;
   border-radius: 3px;
   width: ${(props: { width: string }) => props.width};

+ 251 - 37
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -2,86 +2,300 @@ import ImageSelector from "components/image-selector/ImageSelector";
 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 Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../values-form/InputRow";
 
 type PropsType = {
   actionConfig: ActionConfigType | null;
   setActionConfig: (x: ActionConfigType) => void;
+  branch: string;
+  dockerfilePath: string;
+  folderPath: string;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   dockerRepo: string;
   error: boolean;
+  registries: any[] | null;
+  loading: boolean;
 };
 
+const dummyRegistries = [
+  { id: 1, service: "ecr", url: "https://idfkasdfasdf" },
+  { id: 12, service: "ecr", url: "https://dfasdfidfkasdfasdf" },
+  { id: 11, service: "gcr", url: "https://idfkasdfasdf" },
+] as any[];
+
 export default class ActionDetails extends Component<PropsType, StateType> {
   state = {
     dockerRepo: "",
     error: false,
+    registries: null as any[] | null,
+    loading: true,
   };
 
-  setPath = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
-    setActionConfig(updatedConfig);
+  componentDidMount() {
+    api
+      .getProjectRegistries(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      )
+      .then((res: any) => {
+        this.setState({ registries: res.data, loading: false });
+        if (res.data.length === 1) {
+          this.props.setSelectedRegistry(res.data[0]);
+        }
+      })
+      .catch((err: any) => console.log(err));
+  }
+
+  renderIntegrationList = () => {
+    let { loading, registries } = this.state;
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    }
+
+    return registries.map((registry: any, i: number) => {
+      let icon =
+        integrationList[registry.service] &&
+        integrationList[registry.service].icon;
+      if (!icon) {
+        icon = integrationList["docker"].icon;
+      }
+      return (
+        <RegistryItem
+          key={i}
+          isSelected={this.props.selectedRegistry && registry.id === this.props.selectedRegistry.id}
+          lastItem={i === registries.length - 1}
+          onClick={() => this.props.setSelectedRegistry(registry)}
+        >
+          <img src={icon && icon} />
+          {registry.url}
+        </RegistryItem>
+      );
+    });
   };
 
-  setURL = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.image_repo_uri = x;
-    setActionConfig(updatedConfig);
+  renderRegistrySection = () => {
+    let { registries } = this.state;
+    if (!registries || registries.length === 0 || registries.length === 1) {
+      return;
+    } else {
+      return (
+        <>
+          <Subtitle>Container Registry</Subtitle>
+          <ExpandedWrapper>{this.renderIntegrationList()}</ExpandedWrapper>
+        </>
+      );
+    }
   };
 
-  renderConfirmation = () => {
+  render() {
     return (
-      <Holder>
+      <>
+        <DarkMatter />
         <InputRow
           disabled={true}
           label="Git Repository"
           type="text"
           width="100%"
           value={this.props.actionConfig.git_repo}
-          setValue={(x: string) => console.log(x)}
         />
-        <InputRow
-          disabled={true}
-          label="Dockerfile Path"
-          type="text"
-          width="100%"
-          value={this.props.actionConfig.dockerfile_path}
-          setValue={(x: string) => console.log(x)}
-        />
-        <Label>Target Image URL</Label>
-        <ImageSelector
-          selectedTag="latest"
-          selectedImageUrl={this.props.actionConfig.image_repo_uri}
-          setSelectedImageUrl={this.setURL}
-          setSelectedTag={() => null}
-          forceExpanded={true}
-          noTagSelection={true}
-        />
-      </Holder>
-    );
-  };
+        {this.props.dockerfilePath ? (
+          <InputRow
+            disabled={true}
+            label="Dockerfile Path"
+            type="text"
+            width="100%"
+            value={this.props.dockerfilePath}
+          />
+        ) : (
+          <InputRow
+            disabled={true}
+            label="Folder Path"
+            type="text"
+            width="100%"
+            value={this.props.folderPath}
+          />
+        )}
+        {this.renderRegistrySection()}
+        <Br />
 
-  render() {
-    return <div>{this.renderConfirmation()}</div>;
+        <Flex>
+          <BackButton
+            width="140px"
+            onClick={() => {
+              this.props.setDockerfilePath(null);
+              this.props.setFolderPath(null);
+            }}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Folder
+          </BackButton>
+          {this.props.selectedRegistry ? (
+            <StatusWrapper successful={true}>
+              <i className="material-icons">done</i> Source selected.
+            </StatusWrapper>
+          ) : (
+            <StatusWrapper>
+              <i className="material-icons">error_outline</i> A connected
+              container registry is required
+            </StatusWrapper>
+          )}
+        </Flex>
+      </>
+    );
   }
 }
 
-const Label = styled.div`
+ActionDetails.contextType = Context;
+
+const Subtitle = styled.div`
+  margin-top: 21px;
+`;
+
+const RegistryItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props: { lastItem: boolean; isSelected: boolean }) =>
+      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" : ""};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    filter: grayscale(100%);
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
   display: flex;
   align-items: center;
   font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  background: #ffffff11;
+  overflow-y: auto;
+  margin-bottom: 15px;
+`;
+
+const StatusWrapper = styled.div<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
   font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+  margin-left: 20px;
+  margin-top: 26px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
+  }
+
+  animation: statusFloatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes statusFloatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;
 
-ActionDetails.contextType = Context;
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const AdvancedHeader = styled.div`
+  margin-top: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+  margin-bottom: -8px;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-bottom: -18px;
+`;
 
 const Holder = styled.div`
   padding: 0px 12px 24px 12px;

+ 18 - 1
dashboard/src/components/repo-selector/BranchList.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
+import info from "assets/info.svg";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -79,7 +80,15 @@ export default class BranchList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return <div>{this.renderBranchList()}</div>;
+    return (
+      <>
+        <InfoRow lastItem={false}>
+          <img src={info} />
+          Select Branch
+        </InfoRow>
+        {this.renderBranchList()}
+      </>
+    );
   }
 }
 
@@ -114,6 +123,14 @@ const BranchName = styled.div`
   }
 `;
 
+const InfoRow = styled(BranchName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

+ 0 - 158
dashboard/src/components/repo-selector/ButtonTray.tsx

@@ -1,158 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import api from "../../shared/api";
-import { ActionConfigType } from "../../shared/types";
-import { Context } from "../../shared/Context";
-
-type PropsType = {
-  chartName: string | null;
-  chartNamespace: string | null;
-  pathIsSet: boolean;
-  branch: string;
-  actionConfig: ActionConfigType | null;
-  setBranch: (x: string) => void;
-  setActionConfig: (x: ActionConfigType) => void;
-  setPath: (x: boolean) => void;
-};
-
-type StateType = {};
-
-export default class RepoSelector extends Component<PropsType, StateType> {
-  createGHAction = () => {
-    let { currentProject, currentCluster } = this.context;
-    let { actionConfig, chartName, chartNamespace } = this.props;
-
-    api
-      .createGHAction(
-        "<token>",
-        {
-          git_repo: actionConfig.git_repo,
-          image_repo_uri: actionConfig.image_repo_uri,
-          dockerfile_path: actionConfig.dockerfile_path,
-          git_repo_id: actionConfig.git_repo_id,
-        },
-        {
-          project_id: currentProject.id,
-          CLUSTER_ID: currentCluster.id,
-          RELEASE_NAME: chartName,
-          RELEASE_NAMESPACE: chartNamespace,
-        }
-      )
-      .then((res) => console.log(res.data))
-      .catch(console.log);
-  };
-
-  setSelectedRepo = () => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.git_repo = "";
-    updatedConfig.git_repo_id = null as number;
-    setActionConfig(updatedConfig);
-  };
-
-  goToBranchSelect = () => {
-    let { actionConfig, setActionConfig, setBranch } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = "";
-    setBranch("");
-    setActionConfig(updatedConfig);
-  };
-
-  goToPathSelect = () => {
-    let { actionConfig, setActionConfig, setPath } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.image_repo_uri = "";
-    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.slice(0, -11);
-    setPath(false);
-    setActionConfig(updatedConfig);
-  };
-
-  renderExpanded = () => {
-    let { actionConfig, pathIsSet, branch } = this.props;
-
-    if (!actionConfig.git_repo) {
-      return <></>;
-    } else if (!branch) {
-      return (
-        <ButtonTray>
-          <BackButton width="130px" onClick={() => this.setSelectedRepo()}>
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
-        </ButtonTray>
-      );
-    } else if (!pathIsSet) {
-      return (
-        <ButtonTray>
-          <BackButton onClick={() => this.goToBranchSelect()} width="140px">
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </ButtonTray>
-      );
-    }
-    return (
-      <ButtonTray>
-        <BackButton width="130px" onClick={() => this.goToPathSelect()}>
-          <i className="material-icons">keyboard_backspace</i>
-          Select Dockerfile
-        </BackButton>
-        <BackButton
-          disabled={
-            !actionConfig.git_repo ||
-            !actionConfig.dockerfile_path ||
-            !actionConfig.image_repo_uri
-          }
-          width="146px"
-          onClick={() => this.createGHAction()}
-        >
-          <i className="material-icons">local_shipping</i>
-          Create Github Action
-        </BackButton>
-      </ButtonTray>
-    );
-  };
-
-  render() {
-    return <>{this.renderExpanded()}</>;
-  }
-}
-
-RepoSelector.contextType = Context;
-
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 10px;
-  cursor: pointer;
-  font-size: 13px;
-  padding: 5px 10px;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { width: string; disabled?: boolean }) => props.width};
-  color: ${(props: { width: string; disabled?: boolean }) =>
-    props.disabled ? "#ffffff55" : "white"};
-  pointer-events: ${(props: { width: string; disabled?: boolean }) =>
-    props.disabled ? "none" : "auto"};
-
-  :hover {
-    background: #ffffff11;
-  }
-
-  > i {
-    color: ${(props: { width: string; disabled?: boolean }) =>
-      props.disabled ? "#ffffff55" : "white"};
-    font-size: 18px;
-    margin-right: 10px;
-  }
-`;
-
-const ButtonTray = styled.div`
-  margin-top: 10px;
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-  align-items: center;
-`;

+ 228 - 30
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import file from "assets/file.svg";
 import folder from "assets/folder.svg";
 import info from "assets/info.svg";
+import close from "assets/close.png";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -14,44 +15,46 @@ type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
-  setPath: () => void;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   loading: boolean;
   error: boolean;
   contents: FileType[];
+  currentDir: string;
+  dockerfiles: string[];
 };
 
+const dummyDockerfiles = ["dev.Dockerfile", "prod.Dockerfile", "Dockerfile"];
+
 export default class ContentsList extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
     contents: [] as FileType[],
+    currentDir: "",
+    dockerfiles: [] as string[],
   };
 
-  setSubdirectory = (x: string, fileName?: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    let path = x;
-    console.log(fileName);
-    updatedConfig.dockerfile_path = path;
-    setActionConfig(updatedConfig);
+  componentDidMount() {
     this.updateContents();
-    if (fileName?.includes("Dockerfile")) {
-      this.props.setPath();
-    }
+  }
+
+  setSubdirectory = (x: string) => {
+    this.setState({ currentDir: x }, () => this.updateContents());
   };
 
   updateContents = () => {
-    let { actionConfig, branch } = this.props;
     let { currentProject } = this.context;
-
+    let { actionConfig, branch } = this.props;
+    console.log(this.state.currentDir);
     // Get branch contents
     api
       .getBranchContents(
         "<token>",
-        { dir: actionConfig.dockerfile_path },
+        { dir: this.state.currentDir },
         {
           project_id: currentProject.id,
           git_repo_id: actionConfig.git_repo_id,
@@ -85,10 +88,6 @@ export default class ContentsList extends Component<PropsType, StateType> {
       });
   };
 
-  componentDidMount() {
-    this.updateContents();
-  }
-
   renderContentList = () => {
     let { contents, loading, error } = this.state;
     if (loading) {
@@ -108,7 +107,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
         return (
           <Item
             key={i}
-            isSelected={item.Path === this.props.actionConfig.dockerfile_path}
+            isSelected={item.Path === this.state.currentDir}
             lastItem={i === contents.length - 1}
             onClick={() => this.setSubdirectory(item.Path)}
           >
@@ -124,7 +123,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
             key={i}
             lastItem={i === contents.length - 1}
             isADocker
-            onClick={() => this.setSubdirectory(item.Path, fileName)}
+            onClick={() => this.props.setDockerfilePath(item.Path)}
           >
             <img src={file} />
             {fileName}
@@ -141,15 +140,11 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderJumpToParent = () => {
-    let { actionConfig } = this.props;
-    if (actionConfig.dockerfile_path !== "") {
-      let splits = actionConfig.dockerfile_path.split("/");
+    if (this.state.currentDir !== "") {
+      let splits = this.state.currentDir.split("/");
       let subdir = "";
       if (splits.length !== 1) {
-        subdir = actionConfig.dockerfile_path.replace(
-          splits[splits.length - 1],
-          ""
-        );
+        subdir = this.state.currentDir.replace(splits[splits.length - 1], "");
         if (subdir.charAt(subdir.length - 1) === "/") {
           subdir = subdir.slice(0, subdir.length - 1);
         }
@@ -165,23 +160,226 @@ export default class ContentsList extends Component<PropsType, StateType> {
     return (
       <FileItem lastItem={false}>
         <img src={info} />
-        Select path to Dockerfile
+        Select Application Folder
       </FileItem>
     );
   };
 
+  handleContinue = () => {
+    let dockerfiles = [] as string[];
+    this.state.contents.forEach((item: FileType, i: number) => {
+      let splits = item.Path.split("/");
+      let fileName = splits[splits.length - 1];
+      if (fileName.includes("Dockerfile")) {
+        dockerfiles.push(fileName);
+      }
+    });
+    if (dockerfiles.length > 0) {
+      this.setState({ dockerfiles });
+    } else {
+      if (this.state.currentDir !== "") {
+        this.props.setFolderPath(this.state.currentDir);
+      } else {
+        this.props.setFolderPath("./");
+      }
+    }
+  };
+
+  renderOverlay = () => {
+    if (this.state.dockerfiles.length > 0) {
+      return (
+        <Overlay>
+          <BgOverlay onClick={() => this.setState({ dockerfiles: [] })} />
+          <CloseButton onClick={() => this.setState({ dockerfiles: [] })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Label>
+            Porter has detected at least one Dockerfile in this folder. Would
+            you like to use an existing Dockerfile?
+          </Label>
+          <DockerfileList>
+            {this.state.dockerfiles.map((dockerfile: string, i: number) => {
+              return (
+                <Row
+                  key={i}
+                  onClick={() =>
+                    this.props.setDockerfilePath(
+                      `${this.state.currentDir || "."}/${dockerfile}`
+                    )
+                  }
+                  isLast={this.state.dockerfiles.length - 1 === i}
+                >
+                  <Indicator selected={false}></Indicator>
+                  {dockerfile}
+                </Row>
+              );
+            })}
+          </DockerfileList>
+          <ConfirmButton
+            onClick={() =>
+              this.props.setFolderPath(this.state.currentDir || "./")
+            }
+          >
+            No, I don't want to use a Dockerfile
+          </ConfirmButton>
+        </Overlay>
+      );
+    }
+  };
+
   render() {
     return (
-      <div>
+      <>
         {this.renderJumpToParent()}
         {this.renderContentList()}
-      </div>
+        <UseButton onClick={this.handleContinue}>Continue</UseButton>
+        {this.renderOverlay()}
+      </>
     );
   }
 }
 
 ContentsList.contextType = Context;
 
+const BgOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.8);
+  z-index: -1;
+`;
+
+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 Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  margin-right: 13px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Label = styled.div`
+  max-width: 420px;
+  line-height: 1.5em;
+  text-align: center;
+  font-size: 14px;
+`;
+
+const DockerfileList = styled.div`
+  border-radius: 3px;
+  margin-top: 20px;
+  border: 1px solid #aaaabb;
+  background: #ffffff22;
+  width: 100%;
+  max-width: 500px;
+  max-height: 140px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLast: boolean }>`
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  align-items: center;
+  border-bottom: ${(props) => !props.isLast && "1px solid #aaaabb"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 7px 12px;
+  outline: none;
+  border: 1px solid white;
+  margin-top: 25px;
+  border-radius: 10px;
+  text-align: center;
+  cursor: pointer;
+  opacity: 0;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from {
+      transform: translateY(20px);
+      opacity: 0;
+    }
+    to {
+      transform: translateY(0px);
+      opacity: 1;
+    }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const Overlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 0 90px;
+`;
+
+const UseButton = styled.div`
+  position: absolute;
+  bottom: 28px;
+  left: 185px;
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #616feecc;
+  font-weight: 500;
+  padding: 10px 15px;
+  border-radius: 3px;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
 const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;

+ 32 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
+import info from "assets/info.svg";
 
 import api from "shared/api";
 import { RepoType, ActionConfigType } from "shared/types";
@@ -52,6 +53,15 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
                   repo.GHRepoID = grid;
                 });
                 allRepos = allRepos.concat(res.data);
+                allRepos.sort((a: any, b: any) => {
+                  if (a.FullName < b.FullName) {
+                    return -1;
+                  } else if (a.FullName > b.FullName) {
+                    return 1;
+                  } else {
+                    return 0;
+                  }
+                });
                 this.setState({
                   repos: allRepos,
                   loading: false,
@@ -80,6 +90,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           res.data.forEach((repo: any, id: number) => {
             repo.GHRepoID = grid;
           });
+          // TODO: sort repos alphabetically
           this.setState({ repos: res.data, loading: false, error: false });
         })
         .catch((err) => {
@@ -142,7 +153,19 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     if (this.props.readOnly) {
       return <ExpandedWrapperAlt>{this.renderRepoList()}</ExpandedWrapperAlt>;
     } else {
-      return <ExpandedWrapper>{this.renderRepoList()}</ExpandedWrapper>;
+      return (
+        <ExpandedWrapper>
+          <InfoRow
+            isSelected={false}
+            lastItem={false}
+            readOnly={this.props.readOnly}
+          >
+            <img src={info} />
+            Select Repo
+          </InfoRow>
+          {this.renderRepoList()}
+        </ExpandedWrapper>
+      );
     }
   };
 
@@ -195,6 +218,14 @@ const RepoName = styled.div`
   }
 `;
 
+const InfoRow = styled(RepoName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

+ 0 - 177
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,177 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import github from "assets/github.png";
-import info from "assets/info.svg";
-import { RepoType, ChartType, ActionConfigType } from "shared/types";
-import { Context } from "shared/Context";
-
-import ButtonTray from "./ButtonTray";
-import ActionConfEditor from "./ActionConfEditor";
-
-type PropsType = {
-  chart: ChartType | null;
-  forceExpanded?: boolean;
-  actionConfig: ActionConfigType | null;
-  setActionConfig: (x: ActionConfigType) => void;
-  resetActionConfig: () => void;
-};
-
-type StateType = {
-  isExpanded: boolean;
-  repos: RepoType[];
-  branch: string;
-  pathIsSet: boolean;
-  dockerfileSelected: boolean;
-};
-
-export default class RepoSelector extends Component<PropsType, StateType> {
-  state = {
-    isExpanded: this.props.forceExpanded,
-    repos: [] as RepoType[],
-    branch: "",
-    pathIsSet: false,
-    dockerfileSelected: false,
-  };
-
-  renderExpanded = () => {
-    let { actionConfig, setActionConfig, chart } = this.props;
-
-    return (
-      <div>
-        <ActionConfEditor
-          actionConfig={actionConfig}
-          branch={this.state.branch}
-          pathIsSet={this.state.pathIsSet}
-          setActionConfig={setActionConfig}
-          setBranch={(branch: string) => this.setState({ branch })}
-          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
-          reset={() => {
-            this.setState({
-              branch: "",
-              pathIsSet: false,
-              dockerfileSelected: false,
-            });
-            this.props.resetActionConfig();
-          }}
-        />
-        <ButtonTray
-          chartName={chart.name}
-          chartNamespace={chart.namespace}
-          pathIsSet={this.state.pathIsSet}
-          branch={this.state.branch}
-          actionConfig={actionConfig}
-          setBranch={(branch: string) => this.setState({ branch })}
-          setActionConfig={setActionConfig}
-          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
-        />
-      </div>
-    );
-  };
-
-  renderSelected = () => {
-    let { actionConfig } = this.props;
-    if (actionConfig.git_repo) {
-      let subdir =
-        actionConfig.dockerfile_path === ""
-          ? ""
-          : "/" + actionConfig.dockerfile_path;
-      return (
-        <RepoLabel>
-          <img src={github} />
-          {actionConfig.git_repo + subdir}
-          <SelectedBranch>
-            {!this.state.branch ? "(Select Branch)" : this.state.branch}
-          </SelectedBranch>
-        </RepoLabel>
-      );
-    }
-    return (
-      <RepoLabel>
-        <img src={info} />
-        No source selected
-      </RepoLabel>
-    );
-  };
-
-  handleClick = () => {
-    if (!this.props.forceExpanded) {
-      this.setState({ isExpanded: !this.state.isExpanded });
-    }
-  };
-
-  render() {
-    return (
-      <>
-        <StyledRepoSelector
-          onClick={this.handleClick}
-          isExpanded={this.state.isExpanded}
-          forceExpanded={this.props.forceExpanded}
-        >
-          {this.renderSelected()}
-          {this.props.forceExpanded ? null : (
-            <i className="material-icons">
-              {this.state.isExpanded ? "close" : "build"}
-            </i>
-          )}
-        </StyledRepoSelector>
-
-        {this.state.isExpanded ? this.renderExpanded() : null}
-      </>
-    );
-  }
-}
-
-RepoSelector.contextType = Context;
-
-const SelectedBranch = styled.div`
-  color: #ffffff55;
-  margin-left: 10px;
-`;
-
-const RepoLabel = styled.div`
-  display: flex;
-  align-items: center;
-
-  > img {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-  }
-`;
-
-const StyledRepoSelector = styled.div`
-  width: 100%;
-  margin-top: 22px;
-  border: 1px solid #ffffff55;
-  background: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.isExpanded ? "#ffffff11" : ""};
-  border-radius: 3px;
-  user-select: none;
-  height: 40px;
-  font-size: 13px;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.forceExpanded ? "" : "pointer"};
-  :hover {
-    background: #ffffff11;
-
-    > i {
-      background: #ffffff22;
-    }
-  }
-
-  > i {
-    font-size: 16px;
-    color: #ffffff66;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border-radius: 20px;
-    padding: 4px;
-  }
-`;

+ 7 - 0
dashboard/src/components/values-form/CheckboxRow.tsx

@@ -5,6 +5,7 @@ type PropsType = {
   label: string;
   checked: boolean;
   toggle: () => void;
+  required?: boolean;
 };
 
 type StateType = {};
@@ -18,12 +19,18 @@ export default class CheckboxRow extends Component<PropsType, StateType> {
             <i className="material-icons">done</i>
           </Checkbox>
           {this.props.label}
+          {this.props.required && <Required>*</Required>}
         </CheckboxWrapper>
       </StyledCheckboxRow>
     );
   }
 }
 
+const Required = styled.section`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
 const CheckboxWrapper = styled.div`
   display: flex;
   align-items: center;

+ 0 - 2
dashboard/src/components/values-form/Helper.tsx

@@ -11,6 +11,4 @@ const StyledHelper = styled.div`
   font-size: 13px;
   margin-bottom: 15px;
   margin-top: 20px;
-  display: flex;
-  align-items: center;
 `;

+ 1 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -5,7 +5,7 @@ type PropsType = {
   label?: string;
   type: string;
   value: string | number;
-  setValue: (x: string | number) => void;
+  setValue?: (x: string | number) => void;
   unit?: string;
   placeholder?: string;
   width?: string;

+ 8 - 1
dashboard/src/components/values-form/ValuesForm.tsx

@@ -19,6 +19,7 @@ type PropsType = {
   sections?: Section[];
   metaState?: any;
   setMetaState?: any;
+  handleEnvChange?: (x: any) => void;
 };
 
 type StateType = any;
@@ -47,7 +48,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
-              <ResourceList>
+              <ResourceList key={i}>
                 {item.value.map((resource: any, i: number) => {
                   return (
                     <ExpandableResource
@@ -75,9 +76,15 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "key-value-array":
           return (
             <KeyValueArray
+              key={i}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });
+
+                // Need to pull env vars out of form.yaml for createGHA build env vars
+                if (this.props.handleEnvChange && key === "container.env.normal") {
+                  this.props.handleEnvChange(x);
+                }
               }}
               label={item.label}
             />

+ 2 - 2
dashboard/src/main/CurrentError.tsx

@@ -12,7 +12,7 @@ type StateType = {};
 
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
-    expanded: false,
+    expanded: false
   };
 
   componentDidUpdate(prevProps: PropsType) {
@@ -32,7 +32,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
             <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton
-              onClick={(e) => {
+              onClick={e => {
                 this.context.setCurrentError(null);
                 e.stopPropagation();
               }}

+ 6 - 6
dashboard/src/main/Login.tsx

@@ -23,7 +23,7 @@ export default class Login extends Component<PropsType, StateType> {
     email: "",
     password: "",
     emailError: false,
-    credentialError: false,
+    credentialError: false
   };
 
   handleKeyDown = (e: any) => {
@@ -57,11 +57,11 @@ export default class Login extends Component<PropsType, StateType> {
           "",
           {
             email: email,
-            password: password,
+            password: password
           },
           {}
         )
-        .then((res) => {
+        .then(res => {
           // TODO: case and set credential error
           if (res?.data?.redirect) {
             window.location.href = res.data.redirect;
@@ -70,7 +70,7 @@ export default class Login extends Component<PropsType, StateType> {
             authenticate();
           }
         })
-        .catch((err) =>
+        .catch(err =>
           this.context.setCurrentError(err.response.data.errors[0])
         );
     }
@@ -137,7 +137,7 @@ export default class Login extends Component<PropsType, StateType> {
                   this.setState({
                     email: e.target.value,
                     emailError: false,
-                    credentialError: false,
+                    credentialError: false
                   })
                 }
                 valid={!credentialError && !emailError}
@@ -152,7 +152,7 @@ export default class Login extends Component<PropsType, StateType> {
                 onChange={(e: ChangeEvent<HTMLInputElement>) =>
                   this.setState({
                     password: e.target.value,
-                    credentialError: false,
+                    credentialError: false
                   })
                 }
                 valid={!credentialError}

+ 5 - 5
dashboard/src/main/Main.tsx

@@ -24,7 +24,7 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn: false,
-    initialized: localStorage.getItem("init") === "true",
+    initialized: localStorage.getItem("init") === "true"
   };
 
   componentDidMount() {
@@ -34,19 +34,19 @@ export default class Main extends Component<PropsType, StateType> {
     error && setCurrentError(error);
     api
       .checkAuth("", {}, {})
-      .then((res) => {
+      .then(res => {
         if (res && res.data) {
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
             initialized: true,
-            loading: false,
+            loading: false
           });
         } else {
           this.setState({ isLoggedIn: false, loading: false });
         }
       })
-      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+      .catch(err => this.setState({ isLoggedIn: false, loading: false }));
   }
 
   initialize = () => {
@@ -106,7 +106,7 @@ export default class Main extends Component<PropsType, StateType> {
         />
         <Route
           path={`/:baseRoute`}
-          render={(routeProps) => {
+          render={routeProps => {
             const baseRoute = routeProps.match.params.baseRoute;
             if (
               this.state.isLoggedIn &&

+ 6 - 6
dashboard/src/main/Register.tsx

@@ -25,7 +25,7 @@ export default class Register extends Component<PropsType, StateType> {
     password: "",
     confirmPassword: "",
     emailError: false,
-    confirmPasswordError: false,
+    confirmPasswordError: false
   };
 
   handleKeyDown = (e: any) => {
@@ -66,7 +66,7 @@ export default class Register extends Component<PropsType, StateType> {
           "",
           {
             email: email,
-            password: password,
+            password: password
           },
           {}
         )
@@ -78,7 +78,7 @@ export default class Register extends Component<PropsType, StateType> {
             authenticate();
           }
         })
-        .catch((err) => setCurrentError(err.response.data.errors[0]));
+        .catch(err => setCurrentError(err.response.data.errors[0]));
     }
   };
 
@@ -112,7 +112,7 @@ export default class Register extends Component<PropsType, StateType> {
       password,
       confirmPassword,
       emailError,
-      confirmPasswordError,
+      confirmPasswordError
     } = this.state;
 
     return (
@@ -154,7 +154,7 @@ export default class Register extends Component<PropsType, StateType> {
               onChange={(e: ChangeEvent<HTMLInputElement>) =>
                 this.setState({
                   password: e.target.value,
-                  confirmPasswordError: false,
+                  confirmPasswordError: false
                 })
               }
               valid={true}
@@ -167,7 +167,7 @@ export default class Register extends Component<PropsType, StateType> {
                 onChange={(e: ChangeEvent<HTMLInputElement>) =>
                   this.setState({
                     confirmPassword: e.target.value,
-                    confirmPasswordError: false,
+                    confirmPasswordError: false
                   })
                 }
                 valid={!confirmPasswordError}

+ 24 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -11,6 +11,8 @@ import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import { RouteComponentProps, withRouter } from "react-router";
 
+import api from "shared/api";
+
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   setSidebar: (x: boolean) => void;
@@ -20,6 +22,7 @@ type StateType = {
   namespace: string;
   sortType: string;
   currentChart: ChartType | null;
+  isMetricsInstalled: boolean;
 };
 
 class ClusterDashboard extends Component<PropsType, StateType> {
@@ -29,8 +32,28 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ? localStorage.getItem("SortType")
       : "Newest",
     currentChart: null as ChartType | null,
+    isMetricsInstalled: false,
   };
 
+  componentDidMount() {
+    api
+      .getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: this.context.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ isMetricsInstalled: true });
+      })
+      .catch(() => {
+        this.setState({ isMetricsInstalled: false });
+      });
+  }
+
   componentDidUpdate(prevProps: PropsType) {
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
@@ -77,6 +100,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setCurrentChart={(x: ChartType | null) =>
             this.setState({ currentChart: x })
           }
+          isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
         />
       );

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

@@ -33,6 +33,7 @@ type PropsType = {
   currentCluster: ClusterType;
   setCurrentChart: (x: ChartType | null) => void;
   setSidebar: (x: boolean) => void;
+  isMetricsInstalled: boolean;
 };
 
 type StateType = {
@@ -371,7 +372,15 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     tabOptions.push(
       { label: "Status", value: "status" },
-      //{ label: "Metrics", value: "metrics" },
+    );
+
+    if (this.props.isMetricsInstalled) {
+      tabOptions.push(
+        { label: "Metrics", value: "metrics" },
+      )
+    }
+
+    tabOptions.push(
       { label: "Chart Overview", value: "graph" }
     );
 

+ 0 - 69
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -12,7 +12,6 @@ import {
 import { Context } from "shared/Context";
 
 import ImageSelector from "components/image-selector/ImageSelector";
-import RepoSelector from "components/repo-selector/RepoSelector";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
@@ -26,7 +25,6 @@ type PropsType = {
 };
 
 type StateType = {
-  actionConfig: ActionConfigType;
   sourceType: string;
   selectedImageUrl: string | null;
   selectedTag: string | null;
@@ -37,17 +35,8 @@ type StateType = {
   action: ActionConfigType;
 };
 
-// TODO: put in shared, duped from LaunchTemplate.tsx
-const defaultActionConfig: ActionConfigType = {
-  git_repo: "",
-  image_repo_uri: "",
-  git_repo_id: 0,
-  dockerfile_path: "",
-};
-
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    actionConfig: defaultActionConfig,
     sourceType: "",
     selectedImageUrl: "",
     selectedTag: "",
@@ -59,7 +48,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       git_repo: "",
       image_repo_uri: "",
       git_repo_id: 0,
-      dockerfile_path: "",
     } as ActionConfigType,
   };
 
@@ -148,62 +136,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       });
   };
 
-  renderSourceSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
-
-    if (this.state.action.git_repo.length > 0) {
-      return (
-        <>
-          <Heading>Connected Source</Heading>
-          <Holder>
-            <InputRow
-              disabled={true}
-              label="Git Repository"
-              type="text"
-              width="100%"
-              value={this.state.action.git_repo}
-              setValue={(x: string) => console.log(x)}
-            />
-            <InputRow
-              disabled={true}
-              label="Dockerfile Path"
-              type="text"
-              width="100%"
-              value={this.state.action.dockerfile_path}
-              setValue={(x: string) => console.log(x)}
-            />
-            <InputRow
-              disabled={true}
-              label="Docker Image Repository"
-              type="text"
-              width="100%"
-              value={this.state.action.image_repo_uri}
-              setValue={(x: string) => console.log(x)}
-            />
-          </Holder>
-        </>
-      );
-    }
-
-    return (
-      <>
-        <Heading>Connected Source</Heading>
-        <Helper>Specify a container image and tag.</Helper>
-        <ImageSelector
-          selectedImageUrl={this.state.selectedImageUrl}
-          selectedTag={this.state.selectedTag}
-          setSelectedImageUrl={(x: string) =>
-            this.setState({ selectedImageUrl: x })
-          }
-          setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-          forceExpanded={true}
-        />
-      </>
-    );
-  };
-
   renderWebhookSection = () => {
     if (!this.props.currentChart?.form?.hasSource) {
       return;
@@ -239,7 +171,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          {this.renderSourceSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Button

+ 104 - 26
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -1,14 +1,18 @@
 import React, { useMemo, useCallback } from "react";
 import { AreaClosed, Line, Bar } from "@visx/shape";
-import appleStock, { AppleStock } from "@visx/mock-data/lib/mocks/appleStock";
 import { curveMonotoneX } from "@visx/curve";
 import { scaleTime, scaleLinear } from "@visx/scale";
+import { AxisLeft, AxisBottom } from '@visx/axis';
+
 import {
   withTooltip,
   Tooltip,
   TooltipWithBounds,
   defaultStyles,
 } from "@visx/tooltip";
+
+import { GridRows, GridColumns } from '@visx/grid';
+
 import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
 import { localPoint } from "@visx/event";
 import { LinearGradient } from "@visx/gradient";
@@ -20,9 +24,16 @@ export const accentColor = '#f5cb42';
 export const accentColorDark = '#949eff';
 */
 
-type TooltipData = AppleStock;
 
-const stock = appleStock.slice(800);
+export type MetricsData = {
+  date: number; // unix timestamp
+  value: number; // value 
+}
+
+type TooltipData = MetricsData;
+
+var globalData : MetricsData[]
+
 export const background = "#3b697800";
 export const background2 = "#20405100";
 export const accentColor = "#949eff";
@@ -35,14 +46,28 @@ const tooltipStyles = {
 };
 
 // util
-const formatDate = timeFormat("%b %d, '%y");
+const formatDate = timeFormat("%H:%M:%S %b %d, '%y");
+
+const hourFormat = timeFormat("%H:%M");
+const dayFormat = timeFormat("%b %d");
+
+// map resolutions to formats
+const formats : { [range: string]: (date: Date) => string } = {
+  "1H": hourFormat,
+  "6H": hourFormat,
+  "1D": hourFormat,
+  "1M": dayFormat,
+}
 
 // accessors
-const getDate = (d: AppleStock) => new Date(d.date);
-const getStockValue = (d: AppleStock) => d.close;
-const bisectDate = bisector<AppleStock, Date>((d) => new Date(d.date)).left;
+const getDate = (d: MetricsData) => new Date(d.date*1000);
+const getValue = (d: MetricsData) => d.value;
+
+const bisectDate = bisector<MetricsData, Date>((d) => new Date(d.date*1000)).left;
 
 export type AreaProps = {
+  data: MetricsData[];
+  resolution: string;
   width: number;
   height: number;
   margin?: { top: number; right: number; bottom: number; left: number };
@@ -50,6 +75,8 @@ export type AreaProps = {
 
 export default withTooltip<AreaProps, TooltipData>(
   ({
+    data,
+    resolution,
     width,
     height,
     margin = { top: 0, right: 0, bottom: 0, left: 0 },
@@ -59,10 +86,14 @@ export default withTooltip<AreaProps, TooltipData>(
     tooltipTop = 0,
     tooltipLeft = 0,
   }: AreaProps & WithTooltipProvidedProps<TooltipData>) => {
-    if (width < 10) return null;
+    globalData = data
+
+    if (width == 0 || height == 0 || width < 10) {
+      return null
+    }
 
     // bounds
-    const innerWidth = width - margin.left - margin.right;
+    const innerWidth = width - margin.left - margin.right - 40;
     const innerHeight = height - margin.top - margin.bottom - 20;
 
     // scales
@@ -70,18 +101,18 @@ export default withTooltip<AreaProps, TooltipData>(
       () =>
         scaleTime({
           range: [margin.left, innerWidth + margin.left],
-          domain: extent(stock, getDate) as [Date, Date],
+          domain: extent(globalData, getDate) as [Date, Date],
         }),
-      [innerWidth, margin.left]
+      [innerWidth, margin.left, width, height, data]
     );
-    const stockValueScale = useMemo(
+    const valueScale = useMemo(
       () =>
         scaleLinear({
           range: [innerHeight + margin.top, margin.top],
-          domain: [0, (max(stock, getStockValue) || 0) + innerHeight / 3],
+          domain: [0, 1.25 * max(globalData, getValue)],
           nice: true,
         }),
-      [margin.top, innerHeight]
+      [margin.top, innerHeight, width, height, data]
     );
 
     // tooltip handler
@@ -93,10 +124,11 @@ export default withTooltip<AreaProps, TooltipData>(
       ) => {
         const { x } = localPoint(event) || { x: 0 };
         const x0 = dateScale.invert(x);
-        const index = bisectDate(stock, x0, 1);
-        const d0 = stock[index - 1];
-        const d1 = stock[index];
+        const index = bisectDate(globalData, x0, 1);
+        const d0 = globalData[index - 1];
+        const d1 = globalData[index];
         let d = d0;
+
         if (d1 && getDate(d1)) {
           d =
             x0.valueOf() - getDate(d0).valueOf() >
@@ -104,14 +136,15 @@ export default withTooltip<AreaProps, TooltipData>(
               ? d1
               : d0;
         }
+
         showTooltip({
           tooltipData: d,
-          tooltipLeft: x,
-          tooltipTop: stockValueScale(getStockValue(d)),
+          tooltipLeft: x || 0,
+          tooltipTop: valueScale(getValue(d)) || 0,
         });
       },
-      [showTooltip, stockValueScale, dateScale]
-    );
+      [showTooltip, valueScale, dateScale, width, height, data]
+    )
 
     return (
       <div>
@@ -135,16 +168,61 @@ export default withTooltip<AreaProps, TooltipData>(
             to={accentColor}
             toOpacity={0}
           />
-          <AreaClosed<AppleStock>
-            data={stock}
+          <GridRows
+            left={margin.left}
+            scale={valueScale}
+            width={innerWidth}
+            strokeDasharray="1,3"
+            stroke="white"
+            strokeOpacity={0.2}
+            pointerEvents="none"
+          />
+          <GridColumns
+            top={margin.top}
+            scale={dateScale}
+            height={innerHeight}
+            strokeDasharray="1,3"
+            stroke="white"
+            strokeOpacity={0.2}
+            pointerEvents="none"
+          />
+          <AreaClosed<MetricsData>
+            data={data}
             x={(d) => dateScale(getDate(d)) ?? 0}
-            y={(d) => stockValueScale(getStockValue(d)) ?? 0}
-            yScale={stockValueScale}
+            y={(d) => valueScale(getValue(d)) ?? 0}
+            height={innerHeight}
+            yScale={valueScale}
             strokeWidth={1}
             stroke="url(#area-gradient)"
             fill="url(#area-gradient)"
             curve={curveMonotoneX}
           />
+          <AxisLeft
+            left={10}
+            scale={valueScale}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: 'start',
+              fillOpacity: 0.4,
+              dy: 0,
+            })}
+          />
+          <AxisBottom
+            top={height - 20}
+            scale={dateScale}
+            tickFormat={formats[resolution]}
+            hideAxisLine={true}
+            hideTicks={true}
+            tickLabelProps={() => ({
+              fill: "white",
+              fontSize: 11,
+              textAnchor: 'middle',
+              fillOpacity: 0.4,
+            })}
+          />
           <Bar
             x={margin.left}
             y={margin.top}
@@ -198,7 +276,7 @@ export default withTooltip<AreaProps, TooltipData>(
               left={tooltipLeft + 12}
               style={tooltipStyles}
             >
-              {`$${getStockValue(tooltipData)}`}
+              {getValue(tooltipData)}
             </TooltipWithBounds>
             <Tooltip
               top={-10}

+ 397 - 20
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -2,29 +2,278 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import ParentSize from "@visx/responsive/lib/components/ParentSize";
 
+import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
+import { ChartType, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
-import AreaChart from "./AreaChart";
+import AreaChart, { MetricsData } from "./AreaChart";
 
 type PropsType = {
   currentChart: ChartType;
 };
 
 type StateType = {
+  controllers: any[];
+  selectedController: any;
+  pods: string[];
+  selectedPod: string;
   selectedRange: string;
+  selectedMetric: string;
   selectedMetricLabel: string;
+  controllerDropdownExpanded: boolean;
+  podDropdownExpanded: boolean;
   dropdownExpanded: boolean;
+  data: MetricsData[];
 };
 
-export default class ListSection extends Component<PropsType, StateType> {
+type MetricsCPUDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    cpu: string,
+  }[],
+}[]
+
+type MetricsMemoryDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    memory: string,
+  }[],
+}[]
+
+type MetricsNetworkDataResponse = {
+  pod?: string,
+  results: {
+    date: number,
+    bytes: string,
+  }[],
+}[]
+
+const resolutions : { [range: string]: string } = {
+  "1H": "15s",
+  "6H": "15s",
+  "1D": "15s",
+  "1M": "5h",
+}
+
+const secondsBeforeNow : { [range: string]: number } = {
+  "1H": 60 * 60,
+  "6H": 60 * 60 * 6,
+  "1D": 60 * 60 * 24,
+  "1M": 60 * 60 * 24 * 30,
+}
+
+export default class MetricsSection extends Component<PropsType, StateType> {
   state = {
+    pods: [] as string[],
+    selectedPod: "",
+    controllers: [] as any[],
+    selectedController: null as any,
     selectedRange: "1H",
-    selectedMetricLabel: "CPU Utilization",
+    selectedMetric: "cpu",
+    selectedMetricLabel: "CPU Utilization (vCPUs)",
     dropdownExpanded: false,
+    podDropdownExpanded: false,
+    controllerDropdownExpanded: false,
+    data: [] as MetricsData[],
   };
 
+  componentDidMount() {
+    // get all controllers and read in a list of pods
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api
+      .getChartControllers(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        // TODO -- check at least one controller returned
+
+        // iterate through the controllers to get the list of pods
+        this.setState({ controllers: res.data, selectedController: res.data[0] });
+        
+        this.getPods()
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        this.setState({ controllers: [] });
+      });      
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    // if resolution, data kind, controllers, or pods have changed, update data
+    if (this.state.selectedMetric != prevState.selectedMetric) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedRange != prevState.selectedRange) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedPod != prevState.selectedPod) {
+      this.getMetrics()
+    }
+
+    if (this.state.selectedController?.metadata?.name != prevState.selectedController?.metadata?.name) {
+      this.getMetrics()
+    }
+  }
+
+  getMetrics = () => {
+    if (this.state.pods.length == 0) {
+      return
+    }
+
+    let { currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let kind = this.state.selectedMetric
+    let shouldsum = true
+
+    // calculate start and end range
+    var d = new Date();
+    var end = Math.round(d.getTime() / 1000);
+    var start = end - secondsBeforeNow[this.state.selectedRange]
+
+    let pods = this.state.pods
+
+    if (this.state.selectedPod != "All") {
+      pods = [this.state.selectedPod]
+    }
+
+    api
+      .getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: kind,
+          shouldsum: shouldsum,
+          pods: pods,
+          namespace: currentChart.namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[this.state.selectedRange],
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        // transform the metrics to expected form
+        if (kind == "cpu") {
+          let data = res.data as MetricsCPUDataResponse
+          
+          // if summed, just look at the first data
+            let tData = data[0].results.map(
+              (d: {
+                date: number,
+                cpu: string,
+              }, i: number) => {
+                return {
+                  date: d.date,
+                  value: parseFloat(d.cpu),
+                }
+              }
+            )
+
+            this.setState({ data: tData })
+        } else if (kind == "memory") {
+          let data = res.data as MetricsMemoryDataResponse
+
+          let tData = data[0].results.map(
+            (d: {
+              date: number,
+              memory: string,
+            }, i: number) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.memory) / (1024 * 1024), // put units in Mi
+              }
+            }
+          )
+
+          this.setState({ data: tData })
+        } else if (kind == "network") {
+          let data = res.data as MetricsNetworkDataResponse
+
+          let tData = data[0].results.map(
+            (d: {
+              date: number,
+              bytes: string,
+            }, i: number) => {
+              return {
+                date: d.date,
+                value: parseFloat(d.bytes) / (1024), // put units in Ki
+              }
+            }
+          )
+
+          this.setState({ data: tData })
+        }
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        // this.setState({ controllers: [], loading: false });
+      });
+  }
+
+  getPods = () => {
+    let { selectedController } = this.state;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    let selectors = [] as string[];
+    let ml =
+      selectedController?.spec?.selector?.matchLabels || selectedController?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    selectors.push(selector);
+
+    api
+      .getMatchingPods(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          selectors,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        let pods = res?.data?.map((pod: any) => {
+          return pod?.metadata?.name
+        });
+
+        this.setState({ pods, selectedPod: "All" });
+
+        this.getMetrics()
+      })
+      .catch((err) => {
+        console.log(err);
+        setCurrentError(JSON.stringify(err));
+        return;
+      });
+  }
+
   renderDropdown = () => {
     if (this.state.dropdownExpanded) {
       return (
@@ -33,7 +282,7 @@ export default class ListSection extends Component<PropsType, StateType> {
             onClick={() => this.setState({ dropdownExpanded: false })}
           />
           <Dropdown
-            dropdownWidth="200px"
+            dropdownWidth="230px"
             dropdownMaxHeight="200px"
             onClick={() => this.setState({ dropdownExpanded: false })}
           >
@@ -44,18 +293,106 @@ export default class ListSection extends Component<PropsType, StateType> {
     }
   };
 
+  renderPodDropdown = () => {
+    if (this.state.podDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="400px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ podDropdownExpanded: false })}
+          >
+            {this.renderPodOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderPodOptionList = () => {
+    let allPod = [(
+      <Option
+        key={0}
+        selected={"All" === this.state.selectedPod}
+        onClick={() => this.setState({ selectedPod: "All" })}
+        lastItem={false}
+      >
+        All (summed)
+      </Option>
+    )];
+
+    let podOptions = this.state.pods.map(
+      (option: string, i: number) => {
+        return (
+          <Option
+            key={i + 1}
+            selected={option === this.state.selectedPod}
+            onClick={() => this.setState({ selectedPod: option })}
+            lastItem={i === this.state.pods.length - 1}
+          >
+            {option}
+          </Option>
+        );
+      }
+    )
+
+    return allPod.concat(podOptions)
+  };
+
+  renderControllerDropdown = () => {
+    if (this.state.controllerDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          />
+          <Dropdown
+            dropdownWidth="300px"
+            dropdownMaxHeight="200px"
+            onClick={() => this.setState({ controllerDropdownExpanded: false })}
+          >
+            {this.renderControllerOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  renderControllerOptionList = () => {
+    return this.state.controllers.map(
+      (controller: any, i: number) => {
+        let name = controller?.metadata?.name
+
+        return (
+          <Option
+            key={i}
+            selected={name === this.state.selectedController?.metadata?.name}
+            onClick={() => this.setState({ selectedController: controller })}
+            lastItem={i === this.state.controllers.length - 1}
+          >
+            {name}
+          </Option>
+        );
+      }
+    )
+  };
+
   renderOptionList = () => {
     let metricOptions = [
-      { value: "cpu", label: "CPU Utilization" },
-      { value: "ram", label: "RAM Utilization" },
+      { value: "cpu", label: "CPU Utilization (vCPUs)" },
+      { value: "memory", label: "RAM Utilization (Mi)" },
+      { value: "network", label: "Network Received Bytes (Ki)" },
     ];
     return metricOptions.map(
       (option: { value: string; label: string }, i: number) => {
         return (
           <Option
             key={i}
-            selected={option.label === this.state.selectedMetricLabel}
-            onClick={() => this.setState({ selectedMetricLabel: option.label })}
+            selected={option.value === this.state.selectedMetric}
+            onClick={() => this.setState({ selectedMetric: option.value, selectedMetricLabel: option.label })}
             lastItem={i === metricOptions.length - 1}
           >
             {option.label}
@@ -69,26 +406,50 @@ export default class ListSection extends Component<PropsType, StateType> {
     return (
       <StyledMetricsSection>
         <ParentSize>
-          {({ width, height }) => <AreaChart width={width} height={height} />}
+          {({ width, height }) => <AreaChart 
+            data={this.state.data} 
+            width={width} 
+            height={height} 
+            resolution={this.state.selectedRange}
+            margin={{ top: 60, right: -40, bottom: 0, left: 50 }}
+          />}
         </ParentSize>
         <MetricSelector
           onClick={() =>
             this.setState({ dropdownExpanded: !this.state.dropdownExpanded })
           }
         >
+          <MetricsLabel>
           {this.state.selectedMetricLabel}
+          </MetricsLabel>
           <i className="material-icons">arrow_drop_down</i>
           {this.renderDropdown()}
         </MetricSelector>
+        <ControllerSelector
+          onClick={() =>
+            this.setState({ controllerDropdownExpanded: !this.state.controllerDropdownExpanded })
+          }
+        >
+          <MetricsLabel>{this.state.selectedController?.metadata?.name}</MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderControllerDropdown()}
+        </ControllerSelector>
+        <PodSelector
+          onClick={() =>
+            this.setState({ podDropdownExpanded: !this.state.podDropdownExpanded })
+          }
+        >
+          <MetricsLabel>{this.state.selectedPod}</MetricsLabel>
+          <i className="material-icons">arrow_drop_down</i>
+          {this.renderPodDropdown()}
+        </PodSelector>
         <RangeWrapper>
           <TabSelector
             options={[
               { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
               { value: "1D", label: "1D" },
               { value: "1M", label: "1M" },
-              { value: "3M", label: "3M" },
-              { value: "1Y", label: "1Y" },
-              { value: "ALL", label: "ALL" },
             ]}
             currentTab={this.state.selectedRange}
             setCurrentTab={(x: string) => this.setState({ selectedRange: x })}
@@ -99,7 +460,7 @@ export default class ListSection extends Component<PropsType, StateType> {
   }
 }
 
-ListSection.contextType = Context;
+MetricsSection.contextType = Context;
 
 const DropdownOverlay = styled.div`
   position: fixed;
@@ -151,21 +512,22 @@ const Dropdown = styled.div`
   box-shadow: 0 4px 8px 0px #00000088;
 `;
 
+
 const RangeWrapper = styled.div`
   position: absolute;
-  bottom: 10px;
+  top: 0;
+  right: 0;
   font-weight: bold;
-  left: 0;
-  width: 100%;
+  width: 156px;
 `;
 
 const MetricSelector = styled.div`
-  font-size: 16px;
+  font-size: 13px;
   font-weight: 500;
   color: #ffffff;
   position: absolute;
-  top: 0;
-  left: 5px;
+  top: 10px;
+  left: 0;
   display: flex;
   align-items: center;
   cursor: pointer;
@@ -183,6 +545,21 @@ const MetricSelector = styled.div`
   }
 `;
 
+const MetricsLabel = styled.div`
+white-space: nowrap;
+text-overflow: ellipsis;
+overflow: hidden;
+max-width: 200px;
+`
+
+const ControllerSelector = styled(MetricSelector)`
+  left: 230px;
+`
+
+const PodSelector = styled(MetricSelector)`
+  left: 490px;
+`
+
 const StyledMetricsSection = styled.div`
   width: 100%;
   height: 100%;

+ 283 - 71
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -4,6 +4,7 @@ import randomWords from "random-words";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import api from "shared/api";
+import close from "assets/close.png";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import {
@@ -20,6 +21,7 @@ import SaveButton from "components/SaveButton";
 import ActionConfEditor from "components/repo-selector/ActionConfEditor";
 import ValuesWrapper from "components/values-form/ValuesWrapper";
 import ValuesForm from "components/values-form/ValuesForm";
+import RadioSelector from "components/RadioSelector";
 import { isAlphanumeric } from "shared/common";
 
 type PropsType = RouteComponentProps & {
@@ -47,14 +49,17 @@ type StateType = {
   namespaceOptions: { label: string; value: string }[];
   actionConfig: ActionConfigType;
   branch: string;
-  pathIsSet: boolean;
+  repoType: string;
+  dockerfilePath: string | null;
+  folderPath: string | null;
+  selectedRegistry: any | null;
+  env: any;
 };
 
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
   git_repo_id: 0,
-  dockerfile_path: "",
 };
 
 class LaunchTemplate extends Component<PropsType, StateType> {
@@ -62,11 +67,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     currentView: "repo",
     clusterOptions: [] as { label: string; value: string }[],
     clusterMap: {} as { [clusterId: string]: ClusterType },
-    saveValuesStatus: "No container image specified" as string | null,
+    saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
-    sourceType: "registry",
+    sourceType: "",
     templateName: "",
     selectedTag: "" as string | null,
     tabOptions: [] as ChoiceType[],
@@ -75,7 +80,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     namespaceOptions: [] as { label: string; value: string }[],
     actionConfig: { ...defaultActionConfig },
     branch: "",
-    pathIsSet: false,
+    repoType: "",
+    dockerfilePath: null as string | null,
+    folderPath: null as string | null,
+    selectedRegistry: null as any | null,
+    env: {},
   };
 
   createGHAction = (chartName: string, chartNamespace: string) => {
@@ -86,9 +95,12 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         "<token>",
         {
           git_repo: actionConfig.git_repo,
-          image_repo_uri: actionConfig.image_repo_uri,
-          dockerfile_path: actionConfig.dockerfile_path,
+          registry_id: this.state.selectedRegistry.id,
+          dockerfile_path: this.state.dockerfilePath,
+          folder_path: this.state.folderPath,
+          image_repo_uri: `${this.state.selectedRegistry.url}/${chartName}-${chartNamespace}`,
           git_repo_id: actionConfig.git_repo_id,
+          env: this.state.env,
         },
         {
           project_id: currentProject.id,
@@ -130,10 +142,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
-        if (this.state.sourceType === "repo") {
-          this.createGHAction(name, this.state.selectedNamespace);
-        }
-
+        // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
           setTimeout(() => {
@@ -209,16 +218,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     _.set(values, "ingress.provider", provider);
 
-    console.log(`
-      ${this.props.currentTemplate.name}\n
-      ${this.state.selectedImageUrl}\n
-      ${values}\n
-      ${this.state.selectedNamespace}\n
-      ${name}\n
-      ${currentProject.id}\n
-      ${currentCluster.id}\n}
-    `);
-
     api
       .deployTemplate(
         "<token>",
@@ -238,7 +237,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
+        console.log("Deployed template.");
         if (this.state.sourceType === "repo") {
+          console.log("Creating GHA");
           this.createGHAction(name, this.state.selectedNamespace);
         }
         // this.props.setCurrentView('cluster-dashboard');
@@ -248,6 +249,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             this.props.history.push("cluster-dashboard");
           }, 1000);
         });
+        /*
         try {
           window.analytics.track("Deployed Application", {
             name: this.props.currentTemplate.name,
@@ -258,10 +260,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         } catch (error) {
           console.log(error);
         }
+        */
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-
+        /*
         try {
           window.analytics.track("Failed to Deploy Application", {
             name: this.props.currentTemplate.name,
@@ -273,9 +276,67 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         } catch (error) {
           console.log(error);
         }
+        */
       });
   };
 
+  submitIsDisabled = () => {
+    let {
+      templateName,
+      sourceType,
+      selectedImageUrl,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    // Allow if name is invalid
+    if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+      return true;
+    }
+
+    if (this.props.form?.hasSource) {
+      // Allow if source type is registry and image URL is specified
+      if (sourceType === "registry" && selectedImageUrl) {
+        return false;
+      }
+
+      // Allow if source type is repo and dockerfile or folder path is set
+      if (sourceType === "repo" && (dockerfilePath || folderPath)) {
+        return !this.state.selectedRegistry;
+      }
+
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  getStatus = () => {
+    let {
+      selectedRegistry,
+      sourceType,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    if (this.submitIsDisabled()) {
+      if (
+        sourceType === "repo" &&
+        (dockerfilePath || folderPath) &&
+        !selectedRegistry
+      ) {
+        return "A connected container registry is required";
+      }
+      let { templateName } = this.state;
+      if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+        return "Template name contains illegal characters";
+      }
+      return "No application source specified";
+    } else {
+      return this.state.saveValuesStatus;
+    }
+  };
+
   renderTabContents = () => {
     return (
       <ValuesWrapper
@@ -285,12 +346,8 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             ? this.onSubmit
             : this.onSubmitAddon
         }
-        saveValuesStatus={this.state.saveValuesStatus}
-        disabled={
-          (this.state.templateName.length > 0 &&
-            !isAlphanumeric(this.state.templateName)) ||
-          (this.props.form?.hasSource ? !this.state.selectedImageUrl : false)
-        }
+        saveValuesStatus={this.getStatus()}
+        disabled={this.submitIsDisabled()}
       >
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
@@ -299,6 +356,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
               return (
                 <ValuesForm
                   metaState={metaState}
+                  handleEnvChange={(x: any) => this.setState({ env: x })}
                   setMetaState={setMetaState}
                   key={tab.name}
                   sections={tab.sections}
@@ -373,11 +431,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   setSelectedImageUrl = (x: string) => {
-    if (x === "") {
-      this.setState({ saveValuesStatus: "No container image specified" });
-    } else {
-      this.setState({ saveValuesStatus: "" });
-    }
     this.setState({ selectedImageUrl: x });
   };
 
@@ -397,6 +450,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.state.tabOptions.length > 0) {
       return (
         <>
+          <Heading>Additional Settings</Heading>
           <Subtitle>
             Configure additional settings for this template. (Optional)
           </Subtitle>
@@ -435,18 +489,45 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
-    if (this.state.sourceType === "registry") {
+    if (this.state.sourceType === "") {
       return (
-        <>
+        <BlockList>
+          <Block
+            onClick={() => {
+              this.setState({ sourceType: "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={() => {
+              this.setState({ sourceType: "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>
+      );
+    } else if (this.state.sourceType === "registry") {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select the container image you would like to connect to this
-            template
-            {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
-              link a git repository
-            </Highlight> */}
-            .<Required>*</Required>
+            Specify the container image you would like to connect to this
+            template.
+            <Required>*</Required>
           </Subtitle>
-          <DarkMatter />
+          <DarkMatter antiHeight="-4px" />
           <ImageSelector
             selectedTag={this.state.selectedTag}
             selectedImageUrl={this.state.selectedImageUrl}
@@ -455,19 +536,48 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             forceExpanded={true}
           />
           <br />
-        </>
+        </StyledSourceBox>
+      );
+    } else if (this.state.repoType === "" && false) {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Subtitle>
+            Are you using an existing Dockerfile from your repo?
+            <Required>*</Required>
+          </Subtitle>
+          <RadioSelector
+            options={[
+              {
+                value: "dockerfile",
+                label: "Yes, I am using an existing Dockerfile",
+              },
+              {
+                value: "buildpack",
+                label: "No, I am not using an existing Dockerfile",
+              },
+            ]}
+            selected={this.state.repoType}
+            setSelected={(x: string) => this.setState({ repoType: x })}
+          />
+        </StyledSourceBox>
       );
     } else {
       return (
-        <>
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select a repo to connect to, then a Dockerfile to build from.
+            Provide a repo folder to use as source.
             <Required>*</Required>
           </Subtitle>
+          <DarkMatter antiHeight="-4px" />
           <ActionConfEditor
             actionConfig={this.state.actionConfig}
             branch={this.state.branch}
-            pathIsSet={this.state.pathIsSet}
             setActionConfig={(actionConfig: ActionConfigType) =>
               this.setState({ actionConfig }, () => {
                 this.setSelectedImageUrl(
@@ -476,40 +586,41 @@ class LaunchTemplate extends Component<PropsType, StateType> {
               })
             }
             setBranch={(branch: string) => this.setState({ branch })}
-            setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+            setDockerfilePath={(x: string) =>
+              this.setState({ dockerfilePath: x })
+            }
+            dockerfilePath={this.state.dockerfilePath}
+            folderPath={this.state.folderPath}
+            setFolderPath={(x: string) => this.setState({ folderPath: x })}
             reset={() => {
               this.setState({
                 actionConfig: { ...defaultActionConfig },
                 branch: "",
-                pathIsSet: false,
+                dockerfilePath: null,
+                folderPath: null,
               });
             }}
+            setSelectedRegistry={(x: any) => {
+              this.setState({ selectedRegistry: x });
+            }}
+            selectedRegistry={this.state.selectedRegistry}
           />
           <br />
-        </>
+        </StyledSourceBox>
       );
     }
   };
 
   renderSourceSelector = () => {
-    if (!this.props.form?.hasSource) {
-      return;
-    }
-
     return (
       <>
-        <TabRegion
-          options={[
-            { label: "Registry", value: "registry" },
-            { label: "Github", value: "repo" },
-          ]}
-          currentTab={this.state.sourceType}
-          setCurrentTab={(x) => this.setState({ sourceType: x })}
-        >
-          <StyledSourceBox>
-            {this.renderSourceSelectorContent()}
-          </StyledSourceBox>
-        </TabRegion>
+        <Heading>Deployment Method</Heading>
+        <Subtitle>
+          Choose the deployment method you would like to use for this
+          application.
+          <Required>*</Required>
+        </Subtitle>
+        {this.renderSourceSelectorContent()}
       </>
     );
   };
@@ -532,7 +643,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           </HeaderSection>
         )}
         <DarkMatter antiHeight="-13px" />
-        <Heading isAtTop={name !== "docker"}>Name</Heading>
+        <Heading isAtTop={true}>Name</Heading>
         <Subtitle>
           Randomly generated if left blank.
           <Warning
@@ -552,6 +663,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           placeholder="ex: doctor-scientist"
           width="100%"
         />
+
+        {this.props.form?.hasSource && this.renderSourceSelector()}
+
         <Heading>Destination</Heading>
         <Subtitle>
           Specify the cluster and namespace you would like to deploy your
@@ -589,7 +703,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
-        {this.renderSourceSelector()}
         {this.renderSettingsRegion()}
       </StyledLaunchTemplate>
     );
@@ -597,9 +710,106 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 }
 
 LaunchTemplate.contextType = Context;
-
 export default withRouter(LaunchTemplate);
 
+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 Title = styled.div`
   font-size: 24px;
   font-weight: 600;
@@ -630,7 +840,7 @@ const Heading = styled.div<{ isAtTop?: boolean }>`
   font-weight: 500;
   font-size: 16px;
   margin-bottom: 5px;
-  margin-top: ${(props) => (props.isAtTop ? "30px" : "10px")};
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
   display: flex;
   align-items: center;
 `;
@@ -643,6 +853,7 @@ const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  dislay: inline-block;
 `;
 
 const Link = styled.a`
@@ -734,7 +945,7 @@ const ClusterSection = styled.div`
   font-size: 14px;
   margin-top: 2px;
   font-weight: 500;
-  margin-bottom: 22px;
+  margin-bottom: 32px;
 
   > i {
     font-size: 25px;
@@ -778,10 +989,11 @@ const StyledSourceBox = styled.div`
   height: 100%;
   background: #ffffff11;
   color: #ffffff;
-  padding: 10px 35px 25px;
+  padding: 14px 35px 20px;
   position: relative;
   border-radius: 5px;
   font-size: 13px;
+  margin-top: 6px;
   overflow: auto;
   margin-bottom: 25px;
 `;

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

@@ -182,6 +182,7 @@ const Placeholder = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Highlight = styled.div`

+ 62 - 3
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -9,6 +9,7 @@ import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
 import InputRow from "components/values-form/InputRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import SaveButton from "components/SaveButton";
@@ -28,6 +29,7 @@ type StateType = {
   awsSecretKey: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -41,7 +43,7 @@ const regionOptions = [
   { value: "us-west-1", label: "US West (N. California) us-west-1" },
   { value: "us-west-2", label: "US West (Oregon) us-west-2" },
   { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
-  { value: "ap-east-1", label: "Asia Pacific (Hong Kong)ap-east-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
   { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
   { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
   { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
@@ -66,6 +68,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
     awsSecretKey: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -88,6 +91,10 @@ class AWSFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -202,6 +209,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateAWS = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
     let { selectedInfras } = this.state;
 
@@ -236,6 +244,23 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.awsAccessId ||
+      !this.state.awsSecretKey ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
@@ -284,7 +309,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>AWS Resources</Heading>
-          <Helper>Porter will provision the following AWS resources</Helper>
+          <Helper>
+            Porter will provision the following AWS resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -292,13 +319,38 @@ class AWSFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three t2.medium instances
+            (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
+            resources. Learn more about EKS pricing
+            <Highlight
+              href="https://aws.amazon.com/eks/pricing/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateAWS}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -310,6 +362,13 @@ AWSFormSection.contextType = Context;
 
 export default withRouter(AWSFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 50 - 1
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -7,6 +7,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
+import CheckboxRow from "components/values-form/CheckboxRow";
 import SelectRow from "components/values-form/SelectRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -24,6 +25,7 @@ type StateType = {
   selectedInfras: { value: string; label: string }[];
   subscriptionTier: string;
   doRegion: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -56,6 +58,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     selectedInfras: [...provisionOptions],
     subscriptionTier: "starter",
     doRegion: "nyc1",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -78,6 +81,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -143,6 +150,17 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (!this.state.provisionConfirmed || this.props.projectName === "") {
+      return "Required fields missing";
+    }
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { selectedInfras, subscriptionTier, doRegion } = this.state;
@@ -174,7 +192,8 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           <Br />
           <Heading>DigitalOcean Resources</Heading>
           <Helper>
-            Porter will provision the following DigitalOcean resources
+            Porter will provision the following DigitalOcean resources in your
+            own cloud.
           </Helper>
           <CheckboxList
             options={provisionOptions}
@@ -183,6 +202,28 @@ export default class DOFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three Standard (2vCPUs /
+            2GB RAM) droplets. DigitalOcean will bill you for any provisioned
+            resources. Learn more about DOKS pricing
+            <Highlight
+              href="https://www.digitalocean.com/products/kubernetes/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
@@ -190,6 +231,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           disabled={this.checkFormDisabled()}
           onClick={this.onCreateDO}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -199,6 +241,13 @@ export default class DOFormSection extends Component<PropsType, StateType> {
 
 DOFormSection.contextType = Context;
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 61 - 2
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -8,6 +8,7 @@ import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -28,6 +29,7 @@ type StateType = {
   gcpKeyData: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -69,6 +71,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
     gcpKeyData: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -91,6 +94,10 @@ class GCPFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -216,6 +223,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateGCP = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
 
     if (!projectName) {
@@ -225,6 +233,23 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.gcpProjectId ||
+      !this.state.gcpKeyData ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
@@ -273,7 +298,9 @@ class GCPFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>GCP Resources</Heading>
-          <Helper>Porter will provision the following GCP resources</Helper>
+          <Helper>
+            Porter will provision the following GCP resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -281,13 +308,38 @@ class GCPFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three e2-medium instances
+            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
+            provisioned resources. Learn more about GKE pricing
+            <Highlight
+              href="https://cloud.google.com/kubernetes-engine/pricing"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateGCP}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledGCPFormSection>
@@ -299,6 +351,13 @@ GCPFormSection.contextType = Context;
 
 export default withRouter(GCPFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 2 - 0
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -203,6 +203,7 @@ const PositionWrapper = styled.div<{ selectedProvider: string | null }>``;
 const Highlight = styled.div`
   margin-left: 5px;
   color: #8590ff;
+  display: inline-block;
   cursor: pointer;
 `;
 
@@ -219,6 +220,7 @@ const BlockList = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Icon = styled.img<{ bw?: boolean }>`

+ 1 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -284,7 +284,7 @@ const BottomSection = styled.div`
 const DiscordButton = styled.a`
   position: absolute;
   text-decoration: none;
-  bottom: 15px;
+  bottom: 17px;
   display: flex;
   align-items: center;
   width: calc(100% - 30px);

+ 2 - 2
dashboard/src/shared/Context.tsx

@@ -72,9 +72,9 @@ class ContextProvider extends Component {
         currentProject: null,
         projects: [],
         user: null,
-        devOpsMode: true,
+        devOpsMode: true
       });
-    },
+    }
   };
 
   render() {

+ 3 - 3
dashboard/src/shared/ansiparser.tsx

@@ -8,7 +8,7 @@ const foregroundColors = {
   "35": "magenta",
   "36": "cyan",
   "37": "white",
-  "90": "grey",
+  "90": "grey"
 } as Record<string, string>;
 
 const backgroundColors = {
@@ -19,13 +19,13 @@ const backgroundColors = {
   "44": "blue",
   "45": "magenta",
   "46": "cyan",
-  "47": "white",
+  "47": "white"
 } as Record<string, string>;
 
 const styles = {
   "1": "bold",
   "3": "italic",
-  "4": "underline",
+  "4": "underline"
 } as Record<string, string>;
 
 const eraseChar = (matchingText: any, result: any) => {

+ 85 - 58
dashboard/src/shared/api.tsx

@@ -18,7 +18,7 @@ const connectECRRegistry = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -29,7 +29,7 @@ const connectGCRRegistry = baseApi<
     url: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/registries`;
 });
 
@@ -41,7 +41,7 @@ const createAWSIntegration = baseApi<
     aws_secret_access_key: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
@@ -54,7 +54,7 @@ const createDOCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/docr`;
 });
 
@@ -67,7 +67,7 @@ const createDOKS = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
@@ -80,7 +80,7 @@ const createGCPIntegration = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
@@ -91,16 +91,19 @@ const createGCR = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
 const createGHAction = baseApi<
   {
     git_repo: string;
+    registry_id: number;
     image_repo_uri: string;
     dockerfile_path: string;
+    folder_path: string;
     git_repo_id: number;
+    env: any;
   },
   {
     project_id: number;
@@ -108,7 +111,7 @@ const createGHAction = baseApi<
     RELEASE_NAME: string;
     RELEASE_NAMESPACE: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
   return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
 });
@@ -121,7 +124,7 @@ const createGKE = baseApi<
   {
     project_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
@@ -132,11 +135,11 @@ const createInvite = baseApi<
   {
     id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
-const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
+const createProject = baseApi<{ name: string }, {}>("POST", pathParams => {
   return `/api/projects`;
 });
 
@@ -146,18 +149,18 @@ const deleteCluster = baseApi<
     project_id: number;
     cluster_id: number;
   }
->("DELETE", (pathParams) => {
+>("DELETE", pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
 const deleteInvite = baseApi<{}, { id: number; invId: number }>(
   "DELETE",
-  (pathParams) => {
+  pathParams => {
     return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
   }
 );
 
-const deleteProject = baseApi<{}, { id: number }>("DELETE", (pathParams) => {
+const deleteProject = baseApi<{}, { id: number }>("DELETE", pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
@@ -176,7 +179,7 @@ const deployTemplate = baseApi<
     name: string;
     version: string;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { cluster_id, id, name, version } = pathParams;
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
@@ -189,7 +192,7 @@ const destroyCluster = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -205,7 +208,7 @@ const getBranchContents = baseApi<
     name: string;
     branch: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
 });
 
@@ -218,7 +221,7 @@ const getBranches = baseApi<
     owner: string;
     name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/branches`;
 });
 
@@ -229,7 +232,7 @@ const getChart = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
@@ -244,7 +247,7 @@ const getCharts = baseApi<
     statusFilter: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases`;
 });
 
@@ -255,7 +258,7 @@ const getChartComponents = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
@@ -266,13 +269,13 @@ const getChartControllers = baseApi<
     storage: StorageType;
   },
   { id: number; name: string; revision: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
 });
 
 const getClusterIntegrations = baseApi("GET", "/api/integrations/cluster");
 
-const getClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
@@ -282,7 +285,7 @@ const getGitRepoList = baseApi<
     project_id: number;
     git_repo_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
@@ -291,7 +294,7 @@ const getGitRepos = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
@@ -301,7 +304,7 @@ const getImageRepos = baseApi<
     project_id: number;
     registry_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories`;
 });
 
@@ -312,7 +315,7 @@ const getImageTags = baseApi<
     registry_id: number;
     repo_name: string;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
@@ -321,7 +324,7 @@ const getInfra = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra`;
 });
 
@@ -330,11 +333,11 @@ const getIngress = baseApi<
     cluster_id: number;
   },
   { name: string; namespace: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
-const getInvites = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getInvites = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
@@ -344,16 +347,32 @@ const getMatchingPods = baseApi<
     selectors: string[];
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/pods`;
 });
 
+const getMetrics = baseApi<
+  {
+    cluster_id: number;
+    metric: string;
+    shouldsum: boolean;
+    pods: string[];
+    namespace: string;
+    startrange: number;
+    endrange: number;
+    resolution: string;
+  },
+  { id: number }
+>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/metrics`;
+});
+
 const getNamespaces = baseApi<
   {
     cluster_id: number;
   },
   { id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/k8s/namespaces`;
 });
 
@@ -362,29 +381,35 @@ const getOAuthIds = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.project_id}/integrations/oauth`;
 });
 
-const getProjectClusters = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectClusters = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/clusters`;
 });
 
-const getProjectRegistries = baseApi<{}, { id: number }>(
-  "GET",
-  (pathParams) => {
-    return `/api/projects/${pathParams.id}/registries`;
-  }
-);
+const getProjectRegistries = baseApi<{}, { id: number }>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
 
-const getProjectRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjectRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
-const getProjects = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getProjects = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}/projects`;
 });
 
+const getPrometheusIsInstalled = baseApi<
+  {
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/prometheus/detect`;
+});
+
 const getRegistryIntegrations = baseApi("GET", "/api/integrations/registry");
 
 const getReleaseToken = baseApi<
@@ -394,7 +419,7 @@ const getReleaseToken = baseApi<
     storage: StorageType;
   },
   { name: string; id: number }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
 });
 
@@ -406,7 +431,7 @@ const destroyEKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
@@ -418,7 +443,7 @@ const destroyGKE = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/gke/destroy`;
 });
 
@@ -430,13 +455,13 @@ const destroyDOKS = baseApi<
     project_id: number;
     infra_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/doks/destroy`;
 });
 
 const getRepoIntegrations = baseApi("GET", "/api/integrations/repo");
 
-const getRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getRepos = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
@@ -447,20 +472,20 @@ const getRevisions = baseApi<
     storage: StorageType;
   },
   { id: number; name: string }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
 const getTemplateInfo = baseApi<{}, { name: string; version: string }>(
   "GET",
-  (pathParams) => {
+  pathParams => {
     return `/api/templates/${pathParams.name}/${pathParams.version}`;
   }
 );
 
 const getTemplates = baseApi("GET", "/api/templates");
 
-const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
+const getUser = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -469,7 +494,7 @@ const linkGithubProject = baseApi<
   {
     project_id: number;
   }
->("GET", (pathParams) => {
+>("GET", pathParams => {
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
@@ -486,7 +511,7 @@ const provisionECR = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/ecr`;
 });
 
@@ -496,7 +521,7 @@ const provisionEKS = baseApi<
     aws_integration_id: string;
   },
   { id: number }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   return `/api/projects/${pathParams.id}/provision/eks`;
 });
 
@@ -516,7 +541,7 @@ const rollbackChart = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
@@ -530,7 +555,7 @@ const uninstallTemplate = baseApi<
     namespace: string;
     storage: StorageType;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id, storage, namespace } = pathParams;
   return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
@@ -541,7 +566,7 @@ const updateUser = baseApi<
     allowedContexts?: string[];
   },
   { id: number }
->("PUT", (pathParams) => {
+>("PUT", pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
@@ -556,7 +581,7 @@ const upgradeChartValues = baseApi<
     name: string;
     cluster_id: number;
   }
->("POST", (pathParams) => {
+>("POST", pathParams => {
   let { id, name, cluster_id } = pathParams;
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
@@ -598,12 +623,14 @@ export default {
   getIngress,
   getInvites,
   getMatchingPods,
+  getMetrics,
   getNamespaces,
   getOAuthIds,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
   getProjects,
+  getPrometheusIsInstalled,
   getRegistryIntegrations,
   getReleaseToken,
   getRepoIntegrations,
@@ -621,5 +648,5 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
-  upgradeChartValues,
+  upgradeChartValues
 };

+ 6 - 6
dashboard/src/shared/baseApi.tsx

@@ -21,23 +21,23 @@ export const baseApi = <T extends {}, S = {}>(
     if (requestType === "POST") {
       return axios.post(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "PUT") {
       return axios.put(endpointString, params, {
         headers: {
-          Authorization: `Bearer ${token}`,
-        },
+          Authorization: `Bearer ${token}`
+        }
       });
     } else if (requestType === "DELETE") {
       return axios.delete(endpointString, params);
     } else {
       return axios.get(endpointString, {
         params,
-        paramsSerializer: function (params) {
+        paramsSerializer: function(params) {
           return qs.stringify(params, { arrayFormat: "repeat" });
-        },
+        }
       });
     }
   };

+ 17 - 17
dashboard/src/shared/common.tsx

@@ -10,7 +10,7 @@ export const infraNames: any = {
   gcr: "Google Container Registry (GCR)",
   gke: "Google Kubernetes Engine (GKE)",
   docr: "Digital Ocean Container Registry",
-  doks: "Digital Ocean Kubernetes Service",
+  doks: "Digital Ocean Kubernetes Service"
 };
 
 export const integrationList: any = {
@@ -18,68 +18,68 @@ export const integrationList: any = {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
     label: "Kubernetes",
-    buttonText: "Add a Cluster",
+    buttonText: "Add a Cluster"
   },
   repo: {
     icon:
       "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
     label: "Git Repository",
-    buttonText: "Link a Github Account",
+    buttonText: "Link a Github Account"
   },
   registry: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
     label: "Docker Registry",
-    buttonText: "Add a Registry",
+    buttonText: "Add a Registry"
   },
   gke: {
     icon: "https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png",
-    label: "Google Kubernetes Engine (GKE)",
+    label: "Google Kubernetes Engine (GKE)"
   },
   eks: {
     icon: "https://img.stackshare.io/service/7991/amazon-eks.png",
-    label: "Amazon Elastic Kubernetes Service (EKS)",
+    label: "Amazon Elastic Kubernetes Service (EKS)"
   },
   kube: {
     icon:
       "https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png",
-    label: "Upload Kubeconfig",
+    label: "Upload Kubeconfig"
   },
   docker: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",
-    label: "Docker Hub",
+    label: "Docker Hub"
   },
   gcr: {
     icon:
       "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
-    label: "Google Container Registry (GCR)",
+    label: "Google Container Registry (GCR)"
   },
   ecr: {
     icon:
       "https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4",
-    label: "Elastic Container Registry (ECR)",
+    label: "Elastic Container Registry (ECR)"
   },
   aws: {
     icon: aws,
-    label: "AWS",
+    label: "AWS"
   },
   gcp: {
     icon: gcp,
-    label: "GCP",
+    label: "GCP"
   },
   do: {
     icon: digitalOcean,
-    label: "DigitalOcean",
+    label: "DigitalOcean"
   },
   github: {
     icon: github,
-    label: "GitHub",
+    label: "GitHub"
   },
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
-    label: "Gitlab",
-  },
+    label: "Gitlab"
+  }
 };
 
 export const isAlphanumeric = (x: string | null) => {
@@ -92,6 +92,6 @@ export const isAlphanumeric = (x: string | null) => {
 
 export const getIgnoreCase = (object: any, key: string) => {
   return object[
-    Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
+    Object.keys(object).find(k => k.toLowerCase() === key.toLowerCase())
   ];
 };

+ 5 - 5
dashboard/src/shared/feedback.tsx

@@ -10,18 +10,18 @@ export const handleSubmitFeedback = (
       {
         key: process.env.DISCORD_KEY,
         cid: process.env.DISCORD_CID,
-        message: msg,
+        message: msg
       },
       {
         headers: {
-          Authorization: `Bearer <>`,
-        },
+          Authorization: `Bearer <>`
+        }
       }
     )
-    .then((res) => {
+    .then(res => {
       callback && callback(null, res);
     })
-    .catch((err) => {
+    .catch(err => {
       callback && callback(err, null);
     });
 };

+ 2 - 2
dashboard/src/shared/rosettaStone.tsx

@@ -11,11 +11,11 @@ export const kindToIcon: { [kind: string]: string } = {
   Role: "portrait",
   RoleBinding: "swap_horizontal_circle",
   ConfigMap: "map",
-  PodSecurityPolicy: "security",
+  PodSecurityPolicy: "security"
 };
 
 export const edgeColors: { [kind: string]: string } = {
   LabelRel: "#32a85f",
   ControlRel: "#fcb603",
-  SpecRel: "#949EFF",
+  SpecRel: "#949EFF"
 };

+ 2 - 2
dashboard/src/shared/routing.tsx

@@ -14,7 +14,7 @@ export const PorterUrls = [
   "integrations",
   "new-project",
   "cluster-dashboard",
-  "project-settings",
+  "project-settings"
 ];
 
 export const setSearchParam = (
@@ -26,6 +26,6 @@ export const setSearchParam = (
   urlParams.set(key, value);
   return {
     pathname: location.pathname,
-    search: urlParams.toString(),
+    search: urlParams.toString()
   };
 };

+ 1 - 2
dashboard/src/shared/types.tsx

@@ -66,7 +66,7 @@ export interface EdgeType {
 export enum StorageType {
   Secret = "secret",
   ConfigMap = "configmap",
-  Memory = "memory",
+  Memory = "memory"
 }
 
 // PorterTemplate represents a bundled Porter template
@@ -165,5 +165,4 @@ export interface ActionConfigType {
   git_repo: string;
   image_repo_uri: string;
   git_repo_id: number;
-  dockerfile_path: string;
 }

+ 16 - 9
internal/forms/git_action.go

@@ -7,11 +7,14 @@ import (
 // CreateGitAction represents the accepted values for creating a
 // github action integration
 type CreateGitAction struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
-	GitRepo        string `json:"git_repo" form:"required"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
+	ReleaseID      uint              `json:"release_id" form:"required"`
+	GitRepo        string            `json:"git_repo" form:"required"`
+	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id" form:"required"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }
 
 // ToGitActionConfig converts the form to a gorm git action config model
@@ -21,13 +24,17 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 		GitRepo:        ca.GitRepo,
 		ImageRepoURI:   ca.ImageRepoURI,
 		DockerfilePath: ca.DockerfilePath,
+		FolderPath:     ca.FolderPath,
 		GitRepoID:      ca.GitRepoID,
 	}, nil
 }
 
 type CreateGitActionOptional struct {
-	GitRepo        string `json:"git_repo"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
+	GitRepo        string            `json:"git_repo"`
+	ImageRepoURI   string            `json:"image_repo_uri"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }

+ 12 - 0
internal/forms/metrics.go

@@ -0,0 +1,12 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+)
+
+// MetricsQueryForm is the form for querying pod usage metrics (cpu, memory)
+type MetricsQueryForm struct {
+	*K8sForm
+
+	*prometheus.QueryOpts
+}

+ 43 - 7
internal/integrations/ci/actions/actions.go

@@ -26,10 +26,12 @@ type GithubActions struct {
 
 	WebhookToken string
 	PorterToken  string
+	BuildEnv     map[string]string
 	ProjectID    uint
 	ReleaseName  string
 
 	DockerFilePath string
+	FolderPath     string
 	ImageRepoURL   string
 
 	defaultBranch string
@@ -69,6 +71,13 @@ func (g *GithubActions) Setup() (string, error) {
 		return "", err
 	}
 
+	// create a new secret with the build variables
+	err = g.createEnvSecret(client)
+
+	if err != nil {
+		return "", err
+	}
+
 	fileBytes, err := g.GetGithubActionYAML()
 
 	if err != nil {
@@ -107,6 +116,20 @@ type GithubActionYAML struct {
 }
 
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getCheckoutCodeStep(),
+		getDownloadPorterStep(),
+		getConfigurePorterStep(g.getPorterTokenSecretName()),
+	}
+
+	if g.DockerFilePath == "" {
+		gaSteps = append(gaSteps, getBuildPackPushStep(g.getBuildEnvSecretName(), g.FolderPath, g.ImageRepoURL))
+	} else {
+		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
+	}
+
+	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
+
 	actionYAML := &GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
@@ -119,13 +142,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-deploy": {
 				RunsOn: "ubuntu-latest",
-				Steps: []GithubActionYAMLStep{
-					getCheckoutCodeStep(),
-					getDownloadPorterStep(),
-					getConfigurePorterStep(g.getPorterTokenSecretName()),
-					getDockerBuildPushStep(g.DockerFilePath, g.ImageRepoURL),
-					deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL),
-				},
+				Steps:  gaSteps,
 			},
 		},
 	}
@@ -195,12 +212,31 @@ func (g *GithubActions) createGithubSecret(
 	return nil
 }
 
+func (g *GithubActions) createEnvSecret(client *github.Client) error {
+	// convert the env object to a string
+	lines := make([]string, 0)
+
+	for key, val := range g.BuildEnv {
+		lines = append(lines, fmt.Sprintf(`%s=%s`, key, val))
+	}
+
+	secretName := g.getBuildEnvSecretName()
+
+	return g.createGithubSecret(client, secretName, strings.Join(lines, "\n"))
+}
+
 func (g *GithubActions) getWebhookSecretName() string {
 	return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
 		strings.ToUpper(g.ReleaseName), "-", "_", -1),
 	)
 }
 
+func (g *GithubActions) getBuildEnvSecretName() string {
+	return fmt.Sprintf("ENV_%s", strings.Replace(
+		strings.ToUpper(g.ReleaseName), "-", "_", -1),
+	)
+}
+
 func (g *GithubActions) getPorterYMLFileName() string {
 	return fmt.Sprintf("porter_%s.yml", strings.Replace(
 		strings.ToLower(g.ReleaseName), "-", "_", -1),

+ 20 - 2
internal/integrations/ci/actions/steps.go

@@ -44,15 +44,33 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 }
 
 const dockerBuildPush string = `
+export $(echo "${{secrets.%s}}" | xargs)
 docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
 sudo docker push %s:$(git rev-parse --short HEAD)
 `
 
-func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep {
+func getDockerBuildPushStep(envSecretName, dockerFilePath, repoURL string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Docker build, push",
 		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+		Run:  fmt.Sprintf(dockerBuildPush, envSecretName, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+	}
+}
+
+const buildPackPush string = `
+export $(echo "${{secrets.%s}}" | xargs)
+sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
+sudo apt-get update
+sudo apt-get install pack-cli
+pack build %s:$(git rev-parse --short HEAD) --path %s --builder heroku/buildpacks:18
+sudo docker push %s:$(git rev-parse --short HEAD)
+`
+
+func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Docker build, push",
+		ID:   "docker_build_push",
+		Run:  fmt.Sprintf(buildPackPush, envSecretName, repoURL, folderPath, repoURL),
 	}
 }
 

+ 151 - 0
internal/kubernetes/prometheus/metrics.go

@@ -0,0 +1,151 @@
+package prometheus
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the prometheus service name
+func GetPrometheusService(clientset kubernetes.Interface) (*v1.Service, bool, error) {
+	services, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app=prometheus,component=server,heritage=Helm",
+	})
+
+	if err != nil {
+		return nil, false, err
+	}
+
+	if len(services.Items) == 0 {
+		return nil, false, nil
+	}
+
+	return &services.Items[0], true, nil
+}
+
+type QueryOpts struct {
+	Metric     string   `schema:"metric"`
+	ShouldSum  bool     `schema:"shouldsum"`
+	PodList    []string `schema:"pods"`
+	Namespace  string   `schema:"namespace"`
+	StartRange uint     `schema:"startrange"`
+	EndRange   uint     `schema:"endrange"`
+	Resolution string   `schema:"resolution"`
+}
+
+func QueryPrometheus(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
+) ([]byte, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
+	}
+
+	podSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
+	query := ""
+
+	if opts.Metric == "cpu" {
+		query = fmt.Sprintf("rate(container_cpu_usage_seconds_total{%s}[5m])", podSelector)
+	} else if opts.Metric == "memory" {
+		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
+	} else if opts.Metric == "network" {
+		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, strings.Join(opts.PodList, "|"))
+		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
+	}
+
+	if opts.ShouldSum {
+		query = fmt.Sprintf("sum(%s)", query)
+	}
+
+	queryParams := map[string]string{
+		"query": query,
+		"start": fmt.Sprintf("%d", opts.StartRange),
+		"end":   fmt.Sprintf("%d", opts.EndRange),
+		"step":  opts.Resolution,
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/query_range",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseQuery(rawQuery, opts.Metric)
+}
+
+type promRawQuery struct {
+	Data struct {
+		Result []struct {
+			Metric struct {
+				Pod string `json:"pod,omitempty"`
+			} `json:"metric,omitempty"`
+
+			Values [][]interface{} `json:"values"`
+		} `json:"result"`
+	} `json:"data"`
+}
+
+type promParsedSingletonQueryResult struct {
+	Date   interface{} `json:"date,omitempty"`
+	CPU    interface{} `json:"cpu,omitempty"`
+	Memory interface{} `json:"memory,omitempty"`
+	Bytes  interface{} `json:"bytes,omitempty"`
+}
+
+type promParsedSingletonQuery struct {
+	Pod     string                           `json:"pod,omitempty"`
+	Results []promParsedSingletonQueryResult `json:"results"`
+}
+
+func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
+	rawQueryObj := &promRawQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	res := make([]*promParsedSingletonQuery, 0)
+
+	for _, result := range rawQueryObj.Data.Result {
+		singleton := &promParsedSingletonQuery{
+			Pod: result.Metric.Pod,
+		}
+
+		singletonResults := make([]promParsedSingletonQueryResult, 0)
+
+		for _, values := range result.Values {
+			singletonResult := &promParsedSingletonQueryResult{
+				Date: values[0],
+			}
+
+			if metric == "cpu" {
+				singletonResult.CPU = values[1]
+			} else if metric == "memory" {
+				singletonResult.Memory = values[1]
+			} else if metric == "network" {
+				singletonResult.Bytes = values[1]
+			}
+
+			singletonResults = append(singletonResults, *singletonResult)
+		}
+
+		singleton.Results = singletonResults
+
+		res = append(res, singleton)
+	}
+
+	return json.Marshal(res)
+}

+ 9 - 2
internal/models/gitrepo.go

@@ -14,7 +14,7 @@ type GitRepo struct {
 	ProjectID uint `json:"project_id"`
 
 	// The username/organization that this repo integration is linked to
-	RepoEntity string `json:"repo_entity"`
+	RepoEntity string `json:"repo_entity" gorm:"unique"`
 
 	// The various auth mechanisms available to the integration
 	OAuthIntegrationID uint
@@ -62,7 +62,10 @@ type GitActionConfig struct {
 	GitRepoID uint `json:"git_repo_id"`
 
 	// The path to the dockerfile in the git repo
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
+	DockerfilePath string `json:"dockerfile_path"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // GitActionConfigExternal is an external GitActionConfig to be shared over REST
@@ -78,6 +81,9 @@ type GitActionConfigExternal struct {
 
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path" form:"required"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // Externalize generates an external GitActionConfig to be shared over REST
@@ -87,5 +93,6 @@ func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 		ImageRepoURI:   r.ImageRepoURI,
 		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
+		FolderPath:     r.FolderPath,
 	}
 }

+ 42 - 0
internal/registry/registry.go

@@ -373,6 +373,48 @@ func (r *Registry) setTokenCacheFunc(
 	}
 }
 
+// CreateRepository creates a repository for a registry, if needed
+// (currently only required for ECR)
+func (r *Registry) CreateRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	// if aws, create repository
+	if r.AWSIntegrationID != 0 {
+		return r.createECRRepository(repo, name)
+	}
+
+	// otherwise, no-op
+	return nil
+}
+
+func (r *Registry) createECRRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	svc := ecr.New(sess)
+
+	_, err = svc.CreateRepository(&ecr.CreateRepositoryInput{
+		RepositoryName: &name,
+	})
+
+	return err
+}
+
 // ListImages lists the images for an image repository
 func (r *Registry) ListImages(
 	repoName string,

+ 2 - 0
server/api/deploy_handler.go

@@ -146,6 +146,8 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
+			BuildEnv:       form.GithubActionConfig.BuildEnv,
+			RegistryID:     form.GithubActionConfig.RegistryID,
 		}
 
 		// validate the form

+ 30 - 0
server/api/git_action_handler.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/registry"
 )
 
 // HandleCreateGitAction creates a new Github action in a repository for a given
@@ -81,6 +82,33 @@ func (app *App) createGitActionFromForm(
 		return nil
 	}
 
+	// if the registry was provisioned through Porter, create a repository if necessary
+	if form.RegistryID != 0 {
+		// read the registry
+		reg, err := app.Repo.Registry.ReadRegistry(form.RegistryID)
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return nil
+		}
+
+		if reg.InfraID != 0 {
+			_reg := registry.Registry(*reg)
+			regAPI := &_reg
+
+			// parse the name from the registry
+			nameSpl := strings.Split(form.ImageRepoURI, "/")
+			repoName := nameSpl[len(nameSpl)-1]
+
+			err := regAPI.CreateRepository(*app.Repo, repoName)
+
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return nil
+			}
+		}
+	}
+
 	// convert the form to a git action config
 	gitAction, err := form.ToGitActionConfig()
 
@@ -136,8 +164,10 @@ func (app *App) createGitActionFromForm(
 		ProjectID:      uint(projID),
 		ReleaseName:    name,
 		DockerFilePath: gitAction.DockerfilePath,
+		FolderPath:     gitAction.FolderPath,
 		ImageRepoURL:   gitAction.ImageRepoURI,
 		PorterToken:    encoded,
+		BuildEnv:       form.BuildEnv,
 	}
 
 	_, err = gaRunner.Setup()

+ 117 - 0
server/api/k8s_handler.go

@@ -2,13 +2,16 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/url"
 
 	"github.com/go-chi/chi"
+	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -321,3 +324,117 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 		return
 	}
 }
+
+// HandleDetectPrometheusInstalled detects a prometheus installation in the target cluster
+func (app *App) HandleDetectPrometheusInstalled(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	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)
+	}
+
+	// detect prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if !found {
+		http.NotFound(w, r)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+func (app *App) HandleGetPodMetrics(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.MetricsQueryForm{
+		K8sForm: &forms.K8sForm{
+			OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+		QueryOpts: &prometheus.QueryOpts{},
+	}
+
+	form.K8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// decode from JSON to form value
+	decoder := schema.NewDecoder()
+	decoder.IgnoreUnknownKeys(true)
+
+	if err := decoder.Decode(form.QueryOpts, vals); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// 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)
+	}
+
+	// get prometheus service
+	promSvc, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if !found {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	rawQuery, err := prometheus.QueryPrometheus(agent.Clientset, promSvc, form.QueryOpts)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	fmt.Fprint(w, string(rawQuery))
+}

+ 14 - 2
server/api/release_handler.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"helm.sh/helm/v3/pkg/release"
@@ -70,7 +71,8 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 // PorterRelease is a helm release with a form attached
 type PorterRelease struct {
 	*release.Release
-	Form *models.FormYAML `json:"form"`
+	Form       *models.FormYAML `json:"form"`
+	HasMetrics bool             `json:"has_metrics"`
 }
 
 // HandleGetRelease retrieves a single release based on a name and revision
@@ -149,7 +151,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		HelmRelease:   release,
 	}
 
-	res := &PorterRelease{release, nil}
+	res := &PorterRelease{release, nil, false}
 
 	for _, file := range release.Chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
@@ -176,6 +178,16 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// get prometheus service
+	_, found, err := prometheus.GetPrometheusService(agent.K8sAgent.Clientset)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	res.HasMetrics = found
+
 	if err := json.NewEncoder(w).Encode(res); err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return

+ 3 - 2
server/router/middleware/auth.go

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"encoding/json"
 	"errors"
-	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -702,7 +701,9 @@ func (auth *Auth) getTokenFromRequest(r *http.Request) *token.Token {
 
 	tok, err := token.GetTokenFromEncoded(reqToken, auth.tokenConf)
 
-	fmt.Printf("ERROR WAS %v\n", err)
+	if err != nil {
+		return nil
+	}
 
 	return tok
 }

+ 28 - 0
server/router/router.go

@@ -1050,6 +1050,34 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/prometheus/detect",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDetectPrometheusInstalled, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/metrics",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetPodMetrics, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/{namespace}/pod/{name}/logs",

+ 13 - 0
services/deploy_init_container/Dockerfile

@@ -0,0 +1,13 @@
+FROM alpine
+CMD ["echo", "-------------------------------------------------------------------\n\
+👋 Hello from Porter!\n\
+-------------------------------------------------------------------\n\
+-------------------------------------------------------------------\n\
+Your application is being deployed.\n\
+To view build logs, navigate to your connected GitHub repo and     \n\
+select the Actions tab.\n\
+-------------------------------------------------------------------\n\
+-------------------------------------------------------------------\n\
+For more information, visit:\n\
+https://docs.getporter.dev/docs/setting-up-cicd-1\n\
+-------------------------------------------------------------------"]

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio