Просмотр исходного кода

Merge pull request #807 from porter-dev/master

repo search + deploy addon fix
sunguroku 5 лет назад
Родитель
Сommit
44fba3fa27

+ 57 - 0
dashboard/src/components/Button.tsx

@@ -0,0 +1,57 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  disabled?: boolean;
+  children: React.ReactNode;
+  onClick: () => void;
+}
+
+const Button: React.FC<Props> = ({ children, disabled, onClick }) => {
+  return (
+    <ButtonWrapper disabled={disabled} onClick={onClick}>
+      {children}
+    </ButtonWrapper>
+  );
+};
+
+export default Button;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 106 - 126
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -1,15 +1,14 @@
-import React, { Component } from "react";
+import React, { useState, useEffect, useContext } from "react";
 import styled from "styled-components";
 
 import { ActionConfigType } from "shared/types";
-import { Context } from "shared/Context";
 
 import RepoList from "./RepoList";
 import BranchList from "./BranchList";
 import ContentsList from "./ContentsList";
 import ActionDetails from "./ActionDetails";
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType | null;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
@@ -27,11 +26,6 @@ type PropsType = {
   selectedRegistry: any;
 };
 
-type StateType = {
-  loading: boolean;
-  error: boolean;
-};
-
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
@@ -39,133 +33,116 @@ const defaultActionConfig: ActionConfigType = {
   git_repo_id: 0,
 };
 
-export default class ActionConfEditor extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    error: false,
-  };
+const ActionConfEditor: React.FC<Props> = (props) => {
+  const { actionConfig, setBranch, setActionConfig, branch } = props;
 
-  renderExpanded = () => {
-    let { actionConfig, branch, setActionConfig, setBranch } = this.props;
-
-    if (!actionConfig.git_repo) {
-      return (
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else if (!branch) {
+    return (
+      <>
         <ExpandedWrapper>
-          <RepoList
+          <BranchList
             actionConfig={actionConfig}
-            setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
-            readOnly={false}
+            setBranch={(branch: string) => setBranch(branch)}
           />
         </ExpandedWrapper>
-      );
-    } else if (!branch) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <BranchList
-              actionConfig={actionConfig}
-              setBranch={(branch: string) => setBranch(branch)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="135px"
-            onClick={() => {
-              setActionConfig({ ...defaultActionConfig });
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
-        </>
-      );
-    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <ContentsList
-              actionConfig={actionConfig}
-              branch={branch}
-              setActionConfig={setActionConfig}
-              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
-              setProcfilePath={(x: string) => this.props.setProcfilePath(x)}
-              setFolderPath={(x: string) => this.props.setFolderPath(x)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="145px"
-            onClick={() => {
-              setBranch("");
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </>
-      );
-    }
-
-    if (
-      this.props.procfilePath &&
-      this.props.folderPath &&
-      !this.props.dockerfilePath &&
-      !this.props.procfileProcess
-    ) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <ContentsList
-              actionConfig={actionConfig}
-              branch={branch}
-              setActionConfig={setActionConfig}
-              procfilePath={this.props.procfilePath}
-              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
-              setProcfilePath={(x: string) => this.props.setProcfilePath(x)}
-              setProcfileProcess={(x: string) =>
-                this.props.setProcfileProcess(x)
-              }
-              setFolderPath={(x: string) => this.props.setFolderPath(x)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="145px"
-            onClick={() => {
-              setBranch("");
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </>
-      );
-    }
-
+        <Br />
+        <BackButton
+          width="135px"
+          onClick={() => {
+            setActionConfig({ ...defaultActionConfig });
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Repo
+        </BackButton>
+      </>
+    );
+  } else if (!props.dockerfilePath && !props.folderPath) {
     return (
-      <ActionDetails
-        branch={branch}
-        setDockerfilePath={this.props.setDockerfilePath}
-        setFolderPath={this.props.setFolderPath}
-        setProcfilePath={this.props.setProcfilePath}
-        setProcfileProcess={this.props.setProcfileProcess}
-        actionConfig={actionConfig}
-        setActionConfig={setActionConfig}
-        dockerfilePath={this.props.dockerfilePath}
-        procfilePath={this.props.procfilePath}
-        folderPath={this.props.folderPath}
-        setSelectedRegistry={this.props.setSelectedRegistry}
-        selectedRegistry={this.props.selectedRegistry}
-      />
+      <>
+        <ContentsList
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={setActionConfig}
+          setDockerfilePath={(x: string) => props.setDockerfilePath(x)}
+          setProcfilePath={(x: string) => props.setProcfilePath(x)}
+          setFolderPath={(x: string) => props.setFolderPath(x)}
+        />
+        <Br />
+        <BackButton
+          width="145px"
+          onClick={() => {
+            setBranch("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Branch
+        </BackButton>
+      </>
     );
-  };
+  }
 
-  render() {
-    return <>{this.renderExpanded()}</>;
+  if (
+    props.procfilePath &&
+    props.folderPath &&
+    !props.dockerfilePath &&
+    !props.procfileProcess
+  ) {
+    return (
+      <>
+        <ContentsList
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={setActionConfig}
+          procfilePath={props.procfilePath}
+          setDockerfilePath={(x: string) => props.setDockerfilePath(x)}
+          setProcfilePath={(x: string) => props.setProcfilePath(x)}
+          setProcfileProcess={(x: string) => props.setProcfileProcess(x)}
+          setFolderPath={(x: string) => props.setFolderPath(x)}
+        />
+        <Br />
+        <BackButton
+          width="145px"
+          onClick={() => {
+            setBranch("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Branch
+        </BackButton>
+      </>
+    );
   }
-}
 
-ActionConfEditor.contextType = Context;
+  return (
+    <ActionDetails
+      branch={branch}
+      setDockerfilePath={props.setDockerfilePath}
+      setFolderPath={props.setFolderPath}
+      setProcfilePath={props.setProcfilePath}
+      setProcfileProcess={props.setProcfileProcess}
+      actionConfig={actionConfig}
+      setActionConfig={setActionConfig}
+      dockerfilePath={props.dockerfilePath}
+      procfilePath={props.procfilePath}
+      folderPath={props.folderPath}
+      setSelectedRegistry={props.setSelectedRegistry}
+      selectedRegistry={props.selectedRegistry}
+    />
+  );
+};
+
+export default ActionConfEditor;
 
 const Br = styled.div`
   width: 100%;
@@ -201,7 +178,10 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+  overflow: hidden;
+`;
 
 const BackButton = styled.div`
   display: flex;

+ 117 - 17
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -11,6 +11,11 @@ import { FileType, ActionConfigType } from "../../shared/types";
 
 import Loading from "../Loading";
 
+interface AutoBuildpack {
+  name?: string;
+  valid: boolean;
+}
+
 type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
@@ -29,6 +34,7 @@ type StateType = {
   currentDir: string;
   dockerfiles: string[];
   processes: Record<string, string>;
+  autoBuildpack: AutoBuildpack;
 };
 
 export default class ContentsList extends Component<PropsType, StateType> {
@@ -39,6 +45,10 @@ export default class ContentsList extends Component<PropsType, StateType> {
     currentDir: "",
     dockerfiles: [] as string[],
     processes: null as Record<string, string>,
+    autoBuildpack: {
+      valid: false,
+      name: "",
+    },
   };
 
   componentDidMount() {
@@ -90,10 +100,44 @@ export default class ContentsList extends Component<PropsType, StateType> {
         this.setState({ loading: false, error: true });
       });
 
+    api
+      .detectBuildpack(
+        "<token>",
+        {
+          dir: this.state.currentDir || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      )
+      .then(({ data }) => {
+        this.setState({
+          autoBuildpack: data,
+        });
+      })
+      .catch((err) => {
+        console.log(err);
+        this.setState({
+          autoBuildpack: {
+            valid: false,
+          },
+        });
+      });
+
+    let ppath =
+      this.props.procfilePath ||
+      `${this.state.currentDir ? this.state.currentDir : "."}/Procfile`;
     api
       .getProcfileContents(
         "<token>",
-        { path: "./Procfile" },
+        {
+          path: ppath,
+        },
         {
           project_id: currentProject.id,
           git_repo_id: actionConfig.git_repo_id,
@@ -197,8 +241,8 @@ export default class ContentsList extends Component<PropsType, StateType> {
       if (fileName.includes("Dockerfile")) {
         dockerfiles.push(fileName);
       }
-      if (this.state.currentDir === "" && fileName == "Procfile") {
-        this.props.setProcfilePath("./Procfile");
+      if (fileName == "Procfile") {
+        this.props.setProcfilePath(`${this.state.currentDir || "."}/Procfile`);
       }
     });
     if (dockerfiles.length > 0) {
@@ -217,6 +261,18 @@ export default class ContentsList extends Component<PropsType, StateType> {
       let processes = this.state.processes
         ? Object.keys(this.state.processes)
         : [];
+      if (this.state.processes == null) {
+        return (
+          <Overlay>
+            <BgOverlay>
+              <LoadingWrapper>
+                <Loading />
+              </LoadingWrapper>
+            </BgOverlay>
+          </Overlay>
+        );
+      }
+
       return (
         <Overlay>
           <BgOverlay
@@ -250,7 +306,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
                   }}
                   isLast={processes.length - 1 === i}
                 >
-                  <Indicator selected={false}></Indicator>
+                  <Indicator selected={false} />
                   {process}
                 </Row>
               );
@@ -309,19 +365,36 @@ export default class ContentsList extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        {this.renderJumpToParent()}
-        {this.renderContentList()}
-        <FlexWrapper>
-          <UseButton onClick={this.handleContinue}>Continue</UseButton>
-          <StatusWrapper
-            href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
-            target="_blank"
-          >
-            <i className="material-icons">help_outline</i>
-            <div>Auto build requirements</div>
-          </StatusWrapper>
-        </FlexWrapper>
-        {this.renderOverlay()}
+        {this.state.autoBuildpack && this.state.autoBuildpack.valid && (
+          <Banner>
+            <i className="material-icons">info</i>{" "}
+            <p>
+              <b>{this.state.autoBuildpack.name}</b> buildpack was{" "}
+              <a
+                href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+                target="_blank"
+              >
+                detected automatically
+              </a>
+              . Alternatively, select an application folder below:
+            </p>
+          </Banner>
+        )}
+        <ExpandedWrapperAlt>
+          {this.renderJumpToParent()}
+          {this.renderContentList()}
+          <FlexWrapper>
+            <UseButton onClick={this.handleContinue}>Continue</UseButton>
+            <StatusWrapper
+              href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+              target="_blank"
+            >
+              <i className="material-icons">help_outline</i>
+              <div>Auto build requirements</div>
+            </StatusWrapper>
+          </FlexWrapper>
+          {this.renderOverlay()}
+        </ExpandedWrapperAlt>
       </>
     );
   }
@@ -547,3 +620,30 @@ const LoadingWrapper = styled.div`
   justify-content: center;
   color: #ffffff44;
 `;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 5px 0 10px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #ffffff11;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;

+ 173 - 151
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,170 +1,128 @@
-import React, { Component } from "react";
+import React, { useState, useContext, useEffect, useRef } 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";
 import { Context } from "shared/Context";
 
 import Loading from "../Loading";
+import Button from "../Button";
+import { AxiosResponse } from "axios";
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType | null;
   setActionConfig: (x: ActionConfigType) => void;
   userId?: number;
   readOnly: boolean;
 };
 
-type StateType = {
-  repos: RepoType[];
-  loading: boolean;
-  error: boolean;
-  searchFilter: string;
-};
-
-export default class RepoList extends Component<PropsType, StateType> {
-  state = {
-    repos: [] as RepoType[],
-    loading: true,
-    error: false,
-    searchFilter: "",
-  };
+const RepoList: React.FC<Props> = ({
+  actionConfig,
+  setActionConfig,
+  userId,
+  readOnly,
+}) => {
+  const [repos, setRepos] = useState<RepoType[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const [searchFilter, setSearchFilter] = useState(null);
+  const [searchInput, setSearchInput] = useState("");
+  const { currentProject } = useContext(Context);
 
   // TODO: Try to unhook before unmount
-  componentDidMount() {
-    let { currentProject } = this.context;
-
-    // Get repos
-    if (!this.props.userId && this.props.userId !== 0) {
-      api
-        .getGitRepos("<token>", {}, { project_id: currentProject.id })
-        .then(async (res) => {
-          if (res.data.length == 0) {
-            this.setState({ loading: false, error: false });
-            return;
-          }
-
-          var allRepos: any = [];
-          var errors: any = [];
-
-          var promises = res.data.map((gitrepo: any, id: number) => {
+  useEffect(() => {
+    // load git repo ids, and then repo names from that
+    // this only happens once during the lifecycle
+    new Promise((resolve, reject) => {
+      if (!userId && userId !== 0) {
+        api
+          .getGitRepos("<token>", {}, { project_id: currentProject.id })
+          .then(async (res) => {
+            resolve(res.data.map((gitrepo: any) => gitrepo.id));
+          })
+          .catch((err) => {
+            reject(err);
+          });
+      } else {
+        resolve([userId]);
+      }
+    })
+      .then((ids: number[]) => {
+        Promise.all(
+          ids.map((id) => {
             return new Promise((resolve, reject) => {
               api
                 .getGitRepoList(
                   "<token>",
                   {},
-                  { project_id: currentProject.id, git_repo_id: gitrepo.id }
+                  { project_id: currentProject.id, git_repo_id: id }
                 )
                 .then((res) => {
-                  res.data.forEach((repo: any, id: number) => {
-                    repo.GHRepoID = gitrepo.id;
-                  });
-
                   resolve(res.data);
                 })
                 .catch((err) => {
-                  errors.push(err);
-                  resolve([]);
+                  reject(err);
                 });
             });
-          });
-
-          var sepRepos = await Promise.all(promises);
-
-          allRepos = [].concat.apply([], sepRepos);
-
-          // remove duplicates based on name
-          allRepos = allRepos.filter((repo: any, index: number, self: any) => {
-            var keep =
-              index ===
-              self.findIndex((_repo: any) => {
-                return repo.FullName === _repo.FullName;
-              });
-
-            return keep;
-          });
-
-          // sort repos based on name
-          allRepos.sort((a: any, b: any) => {
-            if (a.FullName < b.FullName) {
-              return -1;
-            } else if (a.FullName > b.FullName) {
-              return 1;
-            } else {
-              return 0;
-            }
-          });
-
-          if (allRepos.length == 0 && errors.length > 0) {
-            this.setState({ loading: false, error: true });
-          } else {
-            this.setState({
-              repos: allRepos,
-              loading: false,
-              error: false,
-            });
-          }
-        })
-        .catch((_) => this.setState({ loading: false, error: true }));
-    } else {
-      let grid = this.props.userId;
-
-      api
-        .getGitRepoList(
-          "<token>",
-          {},
-          { project_id: currentProject.id, git_repo_id: grid }
+          })
         )
-        .then((res) => {
-          var repos: any = res.data;
-
-          repos.forEach((repo: any, id: number) => {
-            repo.GHRepoID = grid;
-          });
-
-          repos.sort((a: any, b: any) => {
-            if (a.FullName < b.FullName) {
-              return -1;
-            } else if (a.FullName > b.FullName) {
-              return 1;
-            } else {
-              return 0;
-            }
+          .then((repos: RepoType[][]) => {
+            const names = new Set();
+            // note: would be better to use .flat() here but you need es2019 for
+            setRepos(
+              repos
+                .map((arr, idx) =>
+                  arr.map((el) => {
+                    el.GHRepoID = ids[idx];
+                    return el;
+                  })
+                )
+                .reduce((acc, val) => acc.concat(val), [])
+                .reduce((acc, val) => {
+                  if (!names.has(val.FullName)) {
+                    names.add(val.FullName);
+                    return acc.concat(val);
+                  } else {
+                    return acc;
+                  }
+                }, [])
+            );
+            setLoading(false);
+          })
+          .catch((_) => {
+            setLoading(false);
+            setError(true);
           });
+      })
+      .catch((_) => {
+        setLoading(false);
+        setError(true);
+      });
+  }, []);
 
-          this.setState({ repos: repos, loading: false, error: false });
-        })
-        .catch((err) => {
-          this.setState({ loading: false, error: true });
-        });
-    }
-  }
-
-  setRepo = (x: RepoType) => {
-    let { actionConfig, setActionConfig } = this.props;
+  const setRepo = (x: RepoType) => {
     let updatedConfig = actionConfig;
     updatedConfig.git_repo = x.FullName;
     updatedConfig.git_repo_id = x.GHRepoID;
     setActionConfig(updatedConfig);
   };
 
-  renderRepoList = () => {
-    let { repos, loading, error } = this.state;
+  const renderRepoList = () => {
     if (loading) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error || !repos) {
+    } else if (error) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
     } else if (repos.length == 0) {
       return (
         <LoadingWrapper>
           No connected Github repos found. You can
           <A
-            href={`/api/oauth/projects/${this.context.currentProject.id}/github?redirected=true`}
+            href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
           >
             log in with GitHub
           </A>
@@ -173,58 +131,110 @@ export default class RepoList extends Component<PropsType, StateType> {
       );
     }
 
-    return repos
-      .filter((repo: RepoType, i: number) => {
-        return repo.FullName.includes(this.state.searchFilter || "");
-      })
-      .map((repo: RepoType, i: number) => {
+    // show 10 most recently used repos if user hasn't searched anything yet
+    let results =
+      searchFilter != null
+        ? repos.filter((repo: RepoType) => {
+            return repo.FullName.includes(searchFilter || "");
+          })
+        : repos.slice(0, 10);
+
+    if (results.length == 0) {
+      return <LoadingWrapper>No matching Github repos found.</LoadingWrapper>;
+    } else {
+      return results.map((repo: RepoType, i: number) => {
         return (
           <RepoName
             key={i}
-            isSelected={repo.FullName === this.props.actionConfig.git_repo}
+            isSelected={repo.FullName === actionConfig.git_repo}
             lastItem={i === repos.length - 1}
-            onClick={() => this.setRepo(repo)}
-            readOnly={this.props.readOnly}
+            onClick={() => setRepo(repo)}
+            readOnly={readOnly}
           >
             <img src={github} />
             {repo.FullName}
           </RepoName>
         );
       });
+    }
   };
 
-  renderExpanded = () => {
-    if (this.props.readOnly) {
-      return <ExpandedWrapperAlt>{this.renderRepoList()}</ExpandedWrapperAlt>;
+  const renderExpanded = () => {
+    if (readOnly) {
+      return <ExpandedWrapperAlt>{renderRepoList()}</ExpandedWrapperAlt>;
     } else {
       return (
-        <ExpandedWrapper>
-          <InfoRow
-            isSelected={false}
-            lastItem={false}
-            readOnly={this.props.readOnly}
-          >
-            <i className="material-icons">search</i>
-            <SearchInput
-              value={this.state.searchFilter}
-              onChange={(e: any) => {
-                this.setState({ searchFilter: e.target.value });
-              }}
-              placeholder="Search repos..."
-            />
-          </InfoRow>
-          <ExpandedWrapper>{this.renderRepoList()}</ExpandedWrapper>
-        </ExpandedWrapper>
+        <>
+          <SearchRowTop>
+            <SearchBar>
+              <i className="material-icons">search</i>
+              <SearchInput
+                value={searchInput}
+                onChange={(e: any) => {
+                  setSearchInput(e.target.value);
+                }}
+                onKeyPress={({ key }) => {
+                  if (key === "Enter") {
+                    setSearchFilter(searchInput);
+                  }
+                }}
+                placeholder="Search repos..."
+              />
+            </SearchBar>
+            <ButtonWrapper disabled={loading || error}>
+              <Button
+                onClick={() => setSearchFilter(searchInput)}
+                disabled={loading || error}
+              >
+                Search
+              </Button>
+            </ButtonWrapper>
+          </SearchRowTop>
+          <RepoListWrapper>
+            <ExpandedWrapper>{renderRepoList()}</ExpandedWrapper>
+          </RepoListWrapper>
+        </>
       );
     }
   };
 
-  render() {
-    return <>{this.renderExpanded()}</>;
+  return <>{renderExpanded()}</>;
+};
+
+export default RepoList;
+
+const ButtonWrapper = styled.div`
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
   }
-}
+  height: 40px;
+  display: flex;
+  align-items: center;
+`;
 
-RepoList.contextType = Context;
+const RepoListWrapper = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  overflow-y: auto;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  background: #ffffff11;
+  border-bottom: 1px solid #606166;
+  margin-bottom: 10px;
+`;
+
+const SearchRowTop = styled(SearchRow)`
+  border-bottom: 0;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+`;
 
 const RepoName = styled.div`
   display: flex;
@@ -296,7 +306,7 @@ const ExpandedWrapper = styled.div`
   width: 100%;
   border-radius: 3px;
   border: 0px solid #ffffff44;
-  max-height: 235px;
+  max-height: 221px;
   top: 40px;
 
   > i {
@@ -321,6 +331,19 @@ const A = styled.a`
   cursor: pointer;
 `;
 
+const SearchBar = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 13px;
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
 const SearchInput = styled.input`
   outline: none;
   border: none;
@@ -328,6 +351,5 @@ const SearchInput = styled.input`
   background: none;
   width: 100%;
   color: white;
-  padding: 0;
   height: 20px;
 `;

+ 1 - 0
dashboard/src/components/values-form/FormDebugger.tsx

@@ -272,6 +272,7 @@ tabs:
       label: Required Field A
       required: true
       variable: field_a
+      info: This is some info
     - type: string-input
       placeholder: "ex: sapporo"
       required: true

+ 45 - 3
dashboard/src/components/values-form/InputRow.tsx

@@ -1,8 +1,10 @@
 import React, { ChangeEvent, Component } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
 import styled from "styled-components";
 
 type PropsType = {
   label?: string;
+  info?: string;
   type: string;
   value: string | number;
   setValue?: (x: string | number) => void;
@@ -32,12 +34,34 @@ export default class InputRow extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { label, value, type, unit, placeholder, width } = this.props;
+    let { label, value, type, unit, placeholder, width, info } = this.props;
     return (
       <StyledInputRow className={this.props.className}>
-        {label && (
+        {(label || info) && (
           <Label>
-            {label} <Required>{this.props.isRequired ? " *" : null}</Required>
+            {label}
+            {info && (
+              <Tooltip
+                title={
+                  <div
+                    style={{
+                      fontFamily: "Work Sans, sans-serif",
+                      fontSize: "12px",
+                      fontWeight: "normal",
+                      padding: "5px 6px",
+                    }}
+                  >
+                    {info}
+                  </div>
+                }
+                placement="top"
+              >
+                <StyledInfoTooltip>
+                  <i className="material-icons">help_outline</i>
+                </StyledInfoTooltip>
+              </Tooltip>
+            )}
+            {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
         )}
         <InputWrapper>
@@ -106,3 +130,21 @@ const StyledInputRow = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
 `;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

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

@@ -164,6 +164,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 this.props.setMetaState(key, x);
               }}
               label={item.label}
+              info={item.info}
               unit={item.settings ? item.settings.unit : null}
               disabled={isDisabled}
             />

+ 406 - 406
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -1,406 +1,406 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import monojob from "assets/monojob.png";
-import monoweb from "assets/monoweb.png";
-import { Switch, Route } from "react-router-dom";
-
-import { Context } from "shared/Context";
-import { ChartType, ClusterType } from "shared/types";
-import {
-  getQueryParam,
-  PorterUrl,
-  pushFiltered,
-  pushQueryParams,
-} from "shared/routing";
-
-import ChartList from "./chart/ChartList";
-import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
-import NamespaceSelector from "./NamespaceSelector";
-import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
-import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
-import { RouteComponentProps, withRouter } from "react-router";
-
-import api from "shared/api";
-import { Dashboard } from "./dashboard/Dashboard";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-  setSidebar: (x: boolean) => void;
-  currentView: PorterUrl;
-};
-
-type StateType = {
-  namespace: string;
-  sortType: string;
-  currentChart: ChartType | null;
-  isMetricsInstalled: boolean;
-};
-
-// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
-class ClusterDashboard extends Component<PropsType, StateType> {
-  state = {
-    namespace: null as string,
-    sortType: localStorage.getItem("SortType")
-      ? localStorage.getItem("SortType")
-      : "Newest",
-    currentChart: null as ChartType | null,
-    isMetricsInstalled: false,
-  };
-
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-    let params = this.props.match.params as any;
-    let pathClusterName = params.cluster;
-    // Don't add cluster as query param if present in path
-    if (!pathClusterName) {
-      pushQueryParams(this.props, { cluster: currentCluster.name });
-    }
-    api
-      .getPrometheusIsInstalled(
-        "<token>",
-        {
-          cluster_id: currentCluster.id,
-        },
-        {
-          id: currentProject.id,
-        }
-      )
-      .then((res) => {
-        this.setState({ isMetricsInstalled: true });
-      })
-      .catch(() => {
-        this.setState({ isMetricsInstalled: false });
-      });
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    // Reset namespace filter and close expanded chart on cluster change
-    if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState(
-        {
-          namespace: "default",
-          sortType: localStorage.getItem("SortType")
-            ? localStorage.getItem("SortType")
-            : "Newest",
-          currentChart: null,
-        },
-        () => pushQueryParams(this.props, { namespace: "default" })
-      );
-    }
-
-    if (prevProps.currentView !== this.props.currentView) {
-      let params = this.props.match.params as any;
-      let currentNamespace = params.namespace;
-      if (!currentNamespace) {
-        currentNamespace = getQueryParam(this.props, "namespace");
-      }
-      this.setState(
-        {
-          sortType: "Newest",
-          currentChart: null,
-          namespace: currentNamespace || "default",
-        },
-        () =>
-          pushQueryParams(this.props, {
-            namespace:
-              this.state.namespace === null ? "default" : this.state.namespace,
-          })
-      );
-    }
-  }
-
-  renderDashboardIcon = () => {
-    if (this.props.currentView === "jobs") {
-      return <Img src={monojob} />;
-    } else {
-      return <Img src={monoweb} />;
-    }
-  };
-
-  getDescription = (currentView: string): string => {
-    if (currentView === "jobs") {
-      return "Scripts and tasks that run once or on a repeating interval.";
-    } else {
-      return "Continuously running web services, workers, and add-ons.";
-    }
-  };
-
-  renderBody = () => {
-    let { currentCluster, currentView } = this.props;
-    return (
-      <>
-        <ControlRow>
-          <Button
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-          >
-            <i className="material-icons">add</i> Launch Template
-          </Button>
-          <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
-            <NamespaceSelector
-              setNamespace={(namespace) =>
-                this.setState({ namespace }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-          </SortFilterWrapper>
-        </ControlRow>
-
-        <ChartList
-          currentView={currentView}
-          currentCluster={currentCluster}
-          namespace={this.state.namespace}
-          sortType={this.state.sortType}
-        />
-      </>
-    );
-  };
-
-  renderContents = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
-    if (currentView === "env-groups") {
-      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-    }
-
-    return (
-      <>
-        <TitleSection>
-          {this.renderDashboardIcon()}
-          <Title>{currentView}</Title>
-        </TitleSection>
-
-        <InfoSection>
-          <TopRow>
-            <InfoLabel>
-              <i className="material-icons">info</i> Info
-            </InfoLabel>
-          </TopRow>
-          <Description>{this.getDescription(currentView)}</Description>
-        </InfoSection>
-
-        <LineBreak />
-
-        {this.renderBody()}
-      </>
-    );
-  };
-
-  render() {
-    let { setSidebar } = this.props;
-    return (
-      <Switch>
-        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
-          <ExpandedChartWrapper
-            setSidebar={setSidebar}
-            isMetricsInstalled={this.state.isMetricsInstalled}
-          />
-        </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
-          {this.renderContents()}
-        </Route>
-        <Route path={["/cluster-dashboard"]}>
-          <Dashboard />
-        </Route>
-      </Switch>
-    );
-  }
-}
-
-ClusterDashboard.contextType = Context;
-
-export default withRouter(ClusterDashboard);
-
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const TopRow = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Description = styled.div`
-  color: #aaaabb;
-  margin-top: 13px;
-  margin-left: 2px;
-  font-size: 13px;
-`;
-
-const InfoLabel = styled.div`
-  width: 72px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  color: #7a838f;
-  font-size: 13px;
-  > i {
-    color: #8b949f;
-    font-size: 18px;
-    margin-right: 5px;
-  }
-`;
-
-const InfoSection = styled.div`
-  margin-top: 20px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 35px;
-`;
-
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7a838fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px 35px;
-`;
-
-const Overlay = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 24px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-`;
-
-const DashboardImage = styled.img`
-  height: 45px;
-  width: 45px;
-  border-radius: 5px;
-`;
-
-const DashboardIcon = styled.div`
-  position: relative;
-  height: 45px;
-  min-width: 45px;
-  width: 45px;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #676c7c;
-  border: 2px solid #8e94aa;
-
-  > i {
-    font-size: 22px;
-  }
-`;
-
-const Img = styled.img`
-  width: 30px;
-`;
-
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 18px;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  text-transform: capitalize;
-`;
-
-const TitleSection = styled.div`
-  height: 80px;
-  margin-top: 10px;
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding-left: 0px;
-
-  > i {
-    margin-left: 10px;
-    cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
-    padding: 5px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-    margin-bottom: -3px;
-  }
-`;
-
-const SortFilterWrapper = styled.div`
-  width: 468px;
-  display: flex;
-  justify-content: space-between;
-`;
+import React, { Component } from "react";
+import styled from "styled-components";
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
+import { Switch, Route } from "react-router-dom";
+
+import { Context } from "shared/Context";
+import { ChartType, ClusterType } from "shared/types";
+import {
+  getQueryParam,
+  PorterUrl,
+  pushFiltered,
+  pushQueryParams,
+} from "shared/routing";
+
+import ChartList from "./chart/ChartList";
+import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
+import NamespaceSelector from "./NamespaceSelector";
+import SortSelector from "./SortSelector";
+import ExpandedChart from "./expanded-chart/ExpandedChart";
+import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import api from "shared/api";
+import { Dashboard } from "./dashboard/Dashboard";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+  setSidebar: (x: boolean) => void;
+  currentView: PorterUrl;
+};
+
+type StateType = {
+  namespace: string;
+  sortType: string;
+  currentChart: ChartType | null;
+  isMetricsInstalled: boolean;
+};
+
+// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
+class ClusterDashboard extends Component<PropsType, StateType> {
+  state = {
+    namespace: null as string,
+    sortType: localStorage.getItem("SortType")
+      ? localStorage.getItem("SortType")
+      : "Newest",
+    currentChart: null as ChartType | null,
+    isMetricsInstalled: false,
+  };
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathClusterName = params.cluster;
+    // Don't add cluster as query param if present in path
+    if (!pathClusterName) {
+      pushQueryParams(this.props, { cluster: currentCluster.name });
+    }
+    api
+      .getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ isMetricsInstalled: true });
+      })
+      .catch(() => {
+        this.setState({ isMetricsInstalled: false });
+      });
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    // Reset namespace filter and close expanded chart on cluster change
+    if (prevProps.currentCluster !== this.props.currentCluster) {
+      this.setState(
+        {
+          namespace: "default",
+          sortType: localStorage.getItem("SortType")
+            ? localStorage.getItem("SortType")
+            : "Newest",
+          currentChart: null,
+        },
+        () => pushQueryParams(this.props, { namespace: "default" })
+      );
+    }
+
+    if (prevProps.currentView !== this.props.currentView) {
+      let params = this.props.match.params as any;
+      let currentNamespace = params.namespace;
+      if (!currentNamespace) {
+        currentNamespace = getQueryParam(this.props, "namespace");
+      }
+      this.setState(
+        {
+          sortType: "Newest",
+          currentChart: null,
+          namespace: currentNamespace || "default",
+        },
+        () =>
+          pushQueryParams(this.props, {
+            namespace:
+              this.state.namespace === null ? "default" : this.state.namespace,
+          })
+      );
+    }
+  }
+
+  renderDashboardIcon = () => {
+    if (this.props.currentView === "jobs") {
+      return <Img src={monojob} />;
+    } else {
+      return <Img src={monoweb} />;
+    }
+  };
+
+  getDescription = (currentView: string): string => {
+    if (currentView === "jobs") {
+      return "Scripts and tasks that run once or on a repeating interval.";
+    } else {
+      return "Continuously running web services, workers, and add-ons.";
+    }
+  };
+
+  renderBody = () => {
+    let { currentCluster, currentView } = this.props;
+    return (
+      <>
+        <ControlRow>
+          <Button
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
+          >
+            <i className="material-icons">add</i> Launch Template
+          </Button>
+          <SortFilterWrapper>
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+            <NamespaceSelector
+              setNamespace={(namespace) =>
+                this.setState({ namespace }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
+              namespace={this.state.namespace}
+            />
+          </SortFilterWrapper>
+        </ControlRow>
+
+        <ChartList
+          currentView={currentView}
+          currentCluster={currentCluster}
+          namespace={this.state.namespace}
+          sortType={this.state.sortType}
+        />
+      </>
+    );
+  };
+
+  renderContents = () => {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    if (currentView === "env-groups") {
+      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
+    }
+
+    return (
+      <>
+        <TitleSection>
+          {this.renderDashboardIcon()}
+          <Title>{currentView}</Title>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+          <Description>{this.getDescription(currentView)}</Description>
+        </InfoSection>
+
+        <LineBreak />
+
+        {this.renderBody()}
+      </>
+    );
+  };
+
+  render() {
+    let { setSidebar } = this.props;
+    return (
+      <Switch>
+        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
+          <ExpandedChartWrapper
+            setSidebar={setSidebar}
+            isMetricsInstalled={this.state.isMetricsInstalled}
+          />
+        </Route>
+        <Route path={["/jobs", "/applications", "/env-groups"]}>
+          {this.renderContents()}
+        </Route>
+        <Route path={["/cluster-dashboard"]}>
+          <Dashboard />
+        </Route>
+      </Switch>
+    );
+  }
+}
+
+ClusterDashboard.contextType = Context;
+
+export default withRouter(ClusterDashboard);
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const ButtonAlt = styled(Button)`
+  min-width: 150px;
+  max-width: 150px;
+  background: #7a838fdd;
+
+  :hover {
+    background: #69727eee;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+`;
+
+const DashboardImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const Img = styled.img`
+  width: 30px;
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-transform: capitalize;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
+`;

+ 95 - 66
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,31 +1,27 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
-import { ChartType } from "shared/types";
+import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
-import { pushFiltered, pushQueryParams } from "shared/routing";
-import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
+import api from "shared/api";
 
-type PropsType = RouteComponentProps & {
+type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
 };
 
-type StateType = {
-  expand: boolean;
-  update: any[];
-};
-
-class Chart extends Component<PropsType, StateType> {
-  state = {
-    expand: false,
-    update: [] as any[],
-  };
-
-  renderIcon = () => {
-    let { chart } = this.props;
+const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+  const [expand, setExpand] = useState<boolean>(false);
+  const [chartControllers, setChartControllers] = useState<any>([]);
+  const context = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+  const match = useRouteMatch();
 
+  const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
     } else {
@@ -33,65 +29,98 @@ class Chart extends Component<PropsType, StateType> {
     }
   };
 
-  readableDate = (s: string) => {
-    let ts = new Date(s);
-    let date = ts.toLocaleDateString();
-    let time = ts.toLocaleTimeString([], {
+  const getControllerForChart = async (chart: ChartType) => {
+    try {
+      const { currentCluster, currentProject } = context;
+      const res = await api.getChartControllers(
+        "<token>",
+        {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version,
+        }
+      );
+
+      const controllersUid = res.data.map((c: any) => {
+        return c.metadata.uid;
+      });
+      setChartControllers(controllersUid);
+    } catch (error) {
+      context.setCurrentError(JSON.stringify(error));
+    }
+  };
+
+  useEffect(() => {
+    getControllerForChart(chart);
+  }, [chart]);
+
+  const readableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
       hour: "numeric",
       minute: "2-digit",
     });
     return `${time} on ${date}`;
   };
 
-  render() {
-    let { chart } = this.props;
-
-    return (
-      <StyledChart
-        onMouseEnter={() => this.setState({ expand: true })}
-        onMouseLeave={() => this.setState({ expand: false })}
-        expand={this.state.expand}
-        onClick={() => {
-          let { location, match } = this.props;
-          let urlParams = new URLSearchParams(location.search);
-          let cluster = urlParams.get("cluster");
-          let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
-          pushFiltered(this.props, route, ["project_id"]);
-        }}
-      >
-        <Title>
-          <IconWrapper>{this.renderIcon()}</IconWrapper>
-          {chart.name}
-        </Title>
+  const filteredControllers = useMemo(() => {
+    let tmpControllers: any = {};
+    chartControllers.forEach((uid: any) => {
+      if (!controllers[uid]) {
+        return;
+      }
+      tmpControllers[uid] = controllers[uid];
+    });
+    return tmpControllers;
+  }, [chartControllers, controllers]);
 
-        <BottomWrapper>
-          <InfoWrapper>
-            <StatusIndicator
-              controllers={this.props.controllers}
-              status={chart.info.status}
-              margin_left={"17px"}
-            />
-            <LastDeployed>
-              <Dot>•</Dot> Last deployed{" "}
-              {this.readableDate(chart.info.last_deployed)}
-            </LastDeployed>
-          </InfoWrapper>
+  return (
+    <StyledChart
+      onMouseEnter={() => setExpand(true)}
+      onMouseLeave={() => setExpand(false)}
+      expand={expand}
+      onClick={() => {
+        let urlParams = new URLSearchParams(location.search);
+        let cluster = urlParams.get("cluster");
+        let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
+        pushFiltered({ location, history }, route, ["project_id"]);
+      }}
+    >
+      <Title>
+        <IconWrapper>{renderIcon()}</IconWrapper>
+        {chart.name}
+      </Title>
 
-          <TagWrapper>
-            Namespace
-            <NamespaceTag>{chart.namespace}</NamespaceTag>
-          </TagWrapper>
-        </BottomWrapper>
+      <BottomWrapper>
+        <InfoWrapper>
+          <StatusIndicator
+            controllers={filteredControllers}
+            status={chart.info.status}
+            margin_left={"17px"}
+          />
+          <LastDeployed>
+            <Dot>•</Dot> Last deployed {readableDate(chart.info.last_deployed)}
+          </LastDeployed>
+        </InfoWrapper>
 
-        <Version>v{chart.version}</Version>
-      </StyledChart>
-    );
-  }
-}
+        <TagWrapper>
+          Namespace
+          <NamespaceTag>{chart.namespace}</NamespaceTag>
+        </TagWrapper>
+      </BottomWrapper>
 
-Chart.contextType = Context;
+      <Version>v{chart.version}</Version>
+    </StyledChart>
+  );
+};
 
-export default withRouter(Chart);
+export default Chart;
 
 const BottomWrapper = styled.div`
   display: flex;

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

@@ -8,8 +8,9 @@ import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
 import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 
-type PropsType = {
+type Props = {
   currentCluster: ClusterType;
   namespace: string;
   // TODO Convert to enum
@@ -17,19 +18,21 @@ type PropsType = {
   currentView: PorterUrl;
 };
 
-const ChartList: React.FunctionComponent<PropsType> = ({
+const ChartList: React.FunctionComponent<Props> = ({
   namespace,
   sortType,
   currentView,
 }) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
   const [charts, setCharts] = useState<ChartType[]>([]);
-  const [chartLookupTable, setChartLookupTable] = useState<
-    Record<string, string>
-  >({});
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
-  const [websockets, setWebsockets] = useState<WebSocket[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
 
@@ -97,106 +100,45 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       console.log(error);
       context.setCurrentError(JSON.stringify(error));
       setIsError(true);
-    } finally {
-      setIsLoading(false);
     }
   };
 
   const setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-
-    let ws = new WebSocket(
-      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
-    );
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
-      let chartKey = chartLookupTable[object.metadata.uid];
-
-      // ignore if updated object does not belong to any chart in the list.
-      if (!chartKey) {
-        return;
-      }
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
 
-      let chartControllers = controllers[chartKey];
-      chartControllers[object.metadata.uid] = object;
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
 
-      setControllers((oldControllers) => ({
-        ...oldControllers,
-        [chartKey]: chartControllers,
-      }));
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
     };
 
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
-    };
+    newWebsocket(kind, apiPath, wsConfig);
 
-    return ws;
+    openWebsocket(kind);
   };
 
   const setControllerWebsockets = (controllers: any[]) => {
-    let websockets = controllers.map((kind: string) => {
+    controllers.map((kind: string) => {
       return setupWebsocket(kind);
     });
-    setWebsockets(websockets);
-  };
-
-  const getControllerForChart = async (chart: ChartType) => {
-    try {
-      const { currentCluster, currentProject } = context;
-      const res = await api.getChartControllers(
-        "<token>",
-        {
-          namespace: chart.namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
-          name: chart.name,
-          revision: chart.version,
-        }
-      );
-
-      let chartControllers = {} as Record<string, Record<string, any>>;
-
-      res.data.forEach((c: any) => {
-        c.metadata.kind = c.kind;
-        chartControllers[c.metadata.uid] = c;
-      });
-
-      res.data.forEach(async (c: any) => {
-        setChartLookupTable((oldChartLookupTable) => ({
-          ...oldChartLookupTable,
-          [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
-        }));
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [`${chart.namespace}-${chart.name}`]: chartControllers,
-        }));
-      });
-    } catch (error) {
-      context.setCurrentError(JSON.stringify(error));
-    }
-  };
-
-  const getControllers = (charts: any[]) => {
-    charts.forEach(async (chart: any) => {
-      // don't retrieve controllers for chart that failed to even deploy.
-      if (chart.info.status == "failed") return;
-      await getControllerForChart(chart);
-    });
   };
 
   // Setup basic websockets on start
@@ -207,18 +149,11 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       "daemonset",
       "replicaset",
     ]);
-  }, []);
 
-  // Close Websockets on unmount
-  useEffect(() => {
     return () => {
-      if (websockets.length) {
-        websockets.forEach((ws) => {
-          ws.close();
-        });
-      }
+      closeAllWebsockets();
     };
-  }, [websockets]);
+  }, []);
 
   useEffect(() => {
     let isSubscribed = true;
@@ -227,7 +162,7 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       updateCharts().then((charts) => {
         if (isSubscribed) {
           setCharts(charts);
-          getControllers(charts);
+          setIsLoading(false);
         }
       });
     }
@@ -262,10 +197,7 @@ const ChartList: React.FunctionComponent<PropsType> = ({
         <Chart
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
-          controllers={
-            controllers[`${chart.namespace}-${chart.name}`] ||
-            ({} as Record<string, any>)
-          }
+          controllers={controllers || {}}
         />
       );
     });

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -9,7 +9,6 @@ import NodeList from "./NodeList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 
-
 type TabEnum = "nodes" | "settings" | "namespaces";
 
 const tabOptions: {

+ 387 - 387
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -1,387 +1,387 @@
-import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
-import styled from "styled-components";
-import { Context } from "shared/Context";
-import { ClusterType, ProjectType } from "shared/types";
-import { pushFiltered } from "shared/routing";
-import { useHistory, useLocation } from "react-router";
-
-const OptionsDropdown: React.FC = ({ children }) => {
-  const [isOpen, setIsOpen] = useState(false);
-
-  const handleClick = (e: any) => {
-    e.stopPropagation();
-    setIsOpen(!isOpen);
-  };
-
-  const handleOnBlur = () => {
-    setIsOpen(false);
-  };
-
-  return (
-    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
-      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
-      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
-    </OptionsButton>
-  );
-};
-
-const useWebsocket = (
-  currentProject: ProjectType,
-  currentCluster: ClusterType
-) => {
-  const wsRef = useRef<WebSocket | undefined>(undefined);
-
-  useEffect(() => {
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    wsRef.current = new WebSocket(
-      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/namespace/status?cluster_id=${currentCluster.id}`
-    );
-
-    wsRef.current.onopen = () => {
-      console.log("Connected to websocket");
-    };
-
-    wsRef.current.onclose = () => {
-      console.log("closing websocket");
-    };
-
-    return () => {
-      wsRef.current.close();
-    };
-  }, []);
-
-  return wsRef;
-};
-
-export const NamespaceList: React.FunctionComponent = () => {
-  const {
-    currentCluster,
-    currentProject,
-    setCurrentModal,
-    setCurrentError,
-  } = useContext(Context);
-  const location = useLocation();
-  const history = useHistory();
-  const [namespaces, setNamespaces] = useState([]);
-  const websocket = useWebsocket(currentProject, currentCluster);
-  const onDelete = (namespace: any) => {
-    setCurrentModal("DeleteNamespaceModal", namespace);
-  };
-
-  const isAvailableForDeletion = (namespaceName: string) => {
-    // Only the namespaces that doesn't start with kube- or has by name default will be
-    // available for deletion (as those are the k8s namespaces)
-    return !/(^default$)|(^kube-.*)/.test(namespaceName);
-  };
-
-  useEffect(() => {
-    if (!websocket) {
-      return;
-    }
-
-    websocket.current.onerror = (err: ErrorEvent) => {
-      setCurrentError(err.message);
-      websocket.current.close();
-    };
-
-    websocket.current.onmessage = (evt: MessageEvent) => {
-      const data = JSON.parse(evt.data);
-      if (data.Kind !== "namespace") {
-        return;
-      }
-      if (data.event_type === "ADD") {
-        setNamespaces((oldNamespaces) => [...oldNamespaces, data.Object]);
-      }
-
-      if (data.event_type === "DELETE") {
-        setNamespaces((oldNamespaces) => {
-          const oldNamespaceIndex = oldNamespaces.findIndex(
-            (namespace) => namespace.metadata.name === data.Object.metadata.name
-          );
-          oldNamespaces.splice(oldNamespaceIndex, 1);
-          return [...oldNamespaces];
-        });
-      }
-
-      if (data.event_type === "UPDATE") {
-        setNamespaces((oldNamespaces) => {
-          const oldNamespaceIndex = oldNamespaces.findIndex(
-            (namespace) => namespace.metadata.name === data.Object.metadata.name
-          );
-          oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
-          return oldNamespaces;
-        });
-      }
-    };
-  }, [websocket]);
-
-  const sortAlphabetically = (prev: any, current: any) => {
-    return prev.metadata.name > current.metadata.name ? 1 : -1;
-  };
-
-  const sortedNamespaces = useMemo<any[]>(() => {
-    const nonDeletableNamespaces = namespaces
-      .filter((namespace) => !isAvailableForDeletion(namespace.metadata.name))
-      .sort(sortAlphabetically);
-    const deletableNamespaces = namespaces
-      .filter((namespace) => isAvailableForDeletion(namespace.metadata.name))
-      .sort(sortAlphabetically);
-
-    return [...deletableNamespaces, ...nonDeletableNamespaces];
-  }, [namespaces]);
-
-  return (
-    <NamespaceListWrapper>
-      <ControlRow>
-        <Button
-          onClick={() =>
-            setCurrentModal(
-              "NamespaceModal",
-              namespaces.map((namespace) => ({
-                value: namespace.metadata.name,
-              }))
-            )
-          }
-        >
-          <i className="material-icons">add</i> Add namespace
-        </Button>
-      </ControlRow>
-      <NamespacesGrid>
-        {sortedNamespaces.map((namespace) => {
-          return (
-            <StyledCard
-              key={namespace?.metadata?.name}
-              onClick={() =>
-                pushFiltered({ location, history }, `/applications`, [], {
-                  cluster: currentCluster.name,
-                  namespace: namespace.metadata.name,
-                })
-              }
-            >
-              <ContentContainer>
-                <Title>{namespace?.metadata?.name}</Title>
-                <Status margin_left={"0px"}>
-                  <StatusColor status={namespace.status.phase} />
-                  {namespace?.status?.phase}
-                </Status>
-              </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) && (
-                <OptionsDropdown>
-                  <DropdownOption onClick={() => onDelete(namespace)}>
-                    <i className="material-icons-outlined">delete</i>
-                    <span>Delete</span>
-                  </DropdownOption>
-                </OptionsDropdown>
-              )}
-            </StyledCard>
-          );
-        })}
-      </NamespacesGrid>
-    </NamespaceListWrapper>
-  );
-};
-
-const NamespaceListWrapper = styled.div`
-  margin-top: 35px;
-  padding-bottom: 80px;
-`;
-
-const NamespacesGrid = styled.div`
-  margin-top: 32px;
-  padding-bottom: 150px;
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(2, minmax(200px, 1fr));
-`;
-
-const Title = styled.div`
-  font-size: 14px;
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const StatusColor = styled.div`
-  margin-top: 1px;
-  width: 8px;
-  height: 8px;
-  background: ${(props: { status: string }) =>
-    props.status === "Active"
-      ? "#4797ff"
-      : props.status === "Terminating"
-      ? "#ed5f85"
-      : "#f5cb42"};
-  border-radius: 20px;
-  margin-left: 3px;
-  margin-right: 16px;
-`;
-
-const Status = styled.div`
-  display: flex;
-  height: 20px;
-  font-size: 13px;
-  flex-direction: row;
-  text-transform: capitalize;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  color: #aaaabb;
-  animation: fadeIn 0.5s;
-  margin-left: ${(props: { margin_left: string }) => props.margin_left};
-
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const StyledCard = styled.div`
-  background: #26282f;
-  min-height: 80px;
-  width: 100%;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  border: 1px solid #26282f;
-  box-shadow: 0 5px 8px 0px #00000033;
-  border-radius: 5px;
-  padding: 14px;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-
-  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
-  :hover {
-    transform: scale(1.05);
-    box-shadow: 0 8px 20px 0px #00000030;
-    cursor: pointer;
-  }
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-  height: 100%;
-`;
-
-const OptionsButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  color: #ffffff44;
-  :hover {
-    background: #32343a;
-    cursor: pointer;
-  }
-`;
-
-const DropdownMenu = styled.div`
-  position: absolute;
-  right: 12px;
-  top: 30px;
-  overflow: hidden;
-  width: 120px;
-  height: auto;
-  background: #26282f;
-  box-shadow: 0 8px 20px 0px #00000088;
-  color: white;
-`;
-
-const DropdownOption = styled.div`
-  width: 100%;
-  height: 37px;
-  font-size: 13px;
-  cursor: pointer;
-  padding-left: 10px;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  :hover {
-    background: #ffffff22;
-  }
-  :not(:first-child) {
-    border-top: 1px solid #00000000;
-  }
-
-  :not(:last-child) {
-    border-bottom: 1px solid #ffffff15;
-  }
-
-  > i {
-    margin-right: 5px;
-    font-size: 16px;
-  }
-`;
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { ClusterType, ProjectType } from "shared/types";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
+
+const OptionsDropdown: React.FC = ({ children }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const useWebsocket = (
+  currentProject: ProjectType,
+  currentCluster: ClusterType
+) => {
+  const wsRef = useRef<WebSocket | undefined>(undefined);
+
+  useEffect(() => {
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    wsRef.current = new WebSocket(
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/namespace/status?cluster_id=${currentCluster.id}`
+    );
+
+    wsRef.current.onopen = () => {
+      console.log("Connected to websocket");
+    };
+
+    wsRef.current.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    return () => {
+      wsRef.current.close();
+    };
+  }, []);
+
+  return wsRef;
+};
+
+export const NamespaceList: React.FunctionComponent = () => {
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+  } = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+  const [namespaces, setNamespaces] = useState([]);
+  const websocket = useWebsocket(currentProject, currentCluster);
+  const onDelete = (namespace: any) => {
+    setCurrentModal("DeleteNamespaceModal", namespace);
+  };
+
+  const isAvailableForDeletion = (namespaceName: string) => {
+    // Only the namespaces that doesn't start with kube- or has by name default will be
+    // available for deletion (as those are the k8s namespaces)
+    return !/(^default$)|(^kube-.*)/.test(namespaceName);
+  };
+
+  useEffect(() => {
+    if (!websocket) {
+      return;
+    }
+
+    websocket.current.onerror = (err: ErrorEvent) => {
+      setCurrentError(err.message);
+      websocket.current.close();
+    };
+
+    websocket.current.onmessage = (evt: MessageEvent) => {
+      const data = JSON.parse(evt.data);
+      if (data.Kind !== "namespace") {
+        return;
+      }
+      if (data.event_type === "ADD") {
+        setNamespaces((oldNamespaces) => [...oldNamespaces, data.Object]);
+      }
+
+      if (data.event_type === "DELETE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1);
+          return [...oldNamespaces];
+        });
+      }
+
+      if (data.event_type === "UPDATE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
+          return oldNamespaces;
+        });
+      }
+    };
+  }, [websocket]);
+
+  const sortAlphabetically = (prev: any, current: any) => {
+    return prev.metadata.name > current.metadata.name ? 1 : -1;
+  };
+
+  const sortedNamespaces = useMemo<any[]>(() => {
+    const nonDeletableNamespaces = namespaces
+      .filter((namespace) => !isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+    const deletableNamespaces = namespaces
+      .filter((namespace) => isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+
+    return [...deletableNamespaces, ...nonDeletableNamespaces];
+  }, [namespaces]);
+
+  return (
+    <NamespaceListWrapper>
+      <ControlRow>
+        <Button
+          onClick={() =>
+            setCurrentModal(
+              "NamespaceModal",
+              namespaces.map((namespace) => ({
+                value: namespace.metadata.name,
+              }))
+            )
+          }
+        >
+          <i className="material-icons">add</i> Add namespace
+        </Button>
+      </ControlRow>
+      <NamespacesGrid>
+        {sortedNamespaces.map((namespace) => {
+          return (
+            <StyledCard
+              key={namespace?.metadata?.name}
+              onClick={() =>
+                pushFiltered({ location, history }, `/applications`, [], {
+                  cluster: currentCluster.name,
+                  namespace: namespace.metadata.name,
+                })
+              }
+            >
+              <ContentContainer>
+                <Title>{namespace?.metadata?.name}</Title>
+                <Status margin_left={"0px"}>
+                  <StatusColor status={namespace.status.phase} />
+                  {namespace?.status?.phase}
+                </Status>
+              </ContentContainer>
+              {isAvailableForDeletion(namespace?.metadata?.name) && (
+                <OptionsDropdown>
+                  <DropdownOption onClick={() => onDelete(namespace)}>
+                    <i className="material-icons-outlined">delete</i>
+                    <span>Delete</span>
+                  </DropdownOption>
+                </OptionsDropdown>
+              )}
+            </StyledCard>
+          );
+        })}
+      </NamespacesGrid>
+    </NamespaceListWrapper>
+  );
+};
+
+const NamespaceListWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const NamespacesGrid = styled.div`
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(2, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "Active"
+      ? "#4797ff"
+      : props.status === "Terminating"
+      ? "#ed5f85"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  margin-left: ${(props: { margin_left: string }) => props.margin_left};
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  min-height: 80px;
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border: 1px solid #26282f;
+  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 5px;
+  padding: 14px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+
+  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+  :hover {
+    transform: scale(1.05);
+    box-shadow: 0 8px 20px 0px #00000030;
+    cursor: pointer;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  height: 100%;
+`;
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 5px;
+    font-size: 16px;
+  }
+`;

+ 6 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -420,12 +420,14 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   renderTabContents = (currentTab: string, submitValues?: any) => {
-    let saveButton = <SaveButton
+    let saveButton = (
+      <SaveButton
         text="Rerun Job"
         onClick={() => this.handleSaveValues(submitValues, true)}
         status={this.state.saveValuesStatus}
         makeFlush={true}
       />
+    );
 
     switch (currentTab) {
       case "jobs":
@@ -620,7 +622,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               isInModal={true}
               renderTabContents={this.renderTabContents}
               tabOptionsOnly={true}
-              onSubmit={(formValues) => this.handleSaveValues(formValues, false)}
+              onSubmit={(formValues) =>
+                this.handleSaveValues(formValues, false)
+              }
               saveValuesStatus={this.state.saveValuesStatus}
               saveButtonText="Save Config"
             />

+ 11 - 16
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -118,9 +118,8 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .catch((err) => {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
-        if (parsedErr) {
-          err = parsedErr;
-        }
+        err = parsedErr || err.message || JSON.stringify(err);
+
         this.setState({
           saveValuesStatus: `Could not create GitHub Action: ${err}`,
         });
@@ -142,7 +141,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
     }
 
     api
-      .deployTemplate(
+      .deployAddon(
         "<token>",
         {
           templateName: this.props.currentTemplate.name,
@@ -182,12 +181,13 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .catch((err) => {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
-        if (parsedErr) {
-          err = parsedErr;
-        }
+
+        err = parsedErr || err.message || JSON.stringify(err);
+
         this.setState({
-          saveValuesStatus: parsedErr,
+          saveValuesStatus: err,
         });
+
         setCurrentError(err);
         window.analytics.track("Failed to Deploy Add-on", {
           name: this.props.currentTemplate.name,
@@ -260,7 +260,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
 
     // pause jobs automatically
     if (this.props.currentTemplate?.name == "job") {
-      _.set(values, "paused", true)
+      _.set(values, "paused", true);
     }
 
     var url: string;
@@ -285,9 +285,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
             .catch((err) => {
               let parsedErr =
                 err?.response?.data?.errors && err.response.data.errors[0];
-              if (parsedErr) {
-                err = parsedErr;
-              }
+              err = parsedErr || err.message || JSON.stringify(err);
               this.setState({
                 saveValuesStatus: `Could not create subdomain: ${err}`,
               });
@@ -341,10 +339,7 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .catch((err: any) => {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
-        console.log(parsedErr);
-        if (parsedErr) {
-          err = parsedErr;
-        }
+        err = parsedErr || err.message || JSON.stringify(err);
         this.setState({
           saveValuesStatus: `Could not deploy template: ${err}`,
         });

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

@@ -1,9 +1,15 @@
 import React, { Component } from "react";
 
-import { ProjectType, ClusterType, CapabilityType } from "shared/types";
+import {
+  ProjectType,
+  ClusterType,
+  CapabilityType,
+  ContextProps,
+} from "shared/types";
+
 import { pushQueryParams } from "shared/routing";
 
-const Context = React.createContext<GlobalContextType>({} as GlobalContextType);
+const Context = React.createContext<Partial<ContextProps>>(null);
 
 const { Provider } = Context;
 const ContextConsumer = Context.Consumer;

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

@@ -292,6 +292,27 @@ const deployTemplate = baseApi<
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
 
+const deployAddon = baseApi<
+  {
+    templateName: string;
+    formValues?: any;
+    storage: StorageType;
+    namespace: string;
+    name: string;
+  },
+  {
+    id: number;
+    cluster_id: number;
+    name: string;
+    version: string;
+    repo_url?: string;
+  }
+>("POST", (pathParams) => {
+  let { cluster_id, id, name, version, repo_url } = pathParams;
+
+  return `/api/projects/${id}/deploy/addon/${name}/${version}?cluster_id=${cluster_id}&repo_url=${repo_url}`;
+});
+
 const destroyCluster = baseApi<
   {
     eks_name: string;
@@ -304,6 +325,20 @@ const destroyCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
+const detectBuildpack = baseApi<
+  {},
+  {
+    project_id: number;
+    git_repo_id: number;
+    kind: string;
+    owner: string;
+    name: string;
+    branch: string;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/buildpack/detect`;
+});
+
 const getBranchContents = baseApi<
   {
     dir: string;
@@ -900,9 +935,11 @@ export default {
   deleteRegistryIntegration,
   createSubdomain,
   deployTemplate,
+  deployAddon,
   destroyEKS,
   destroyGKE,
   destroyDOKS,
+  detectBuildpack,
   getBranchContents,
   getBranches,
   getCapabilities,

+ 136 - 0
dashboard/src/shared/hooks/useWebsockets.ts

@@ -0,0 +1,136 @@
+import { useRef } from "react";
+
+interface NewWebsocketOptions {
+  onopen?: () => void;
+  onmessage?: (evt: MessageEvent) => void;
+  onerror?: (err: ErrorEvent) => void;
+  onclose?: (ev: CloseEvent) => void;
+}
+
+interface WebsocketConfig extends NewWebsocketOptions {
+  url: string;
+}
+
+type WebsocketConfigMap = {
+  [id: string]: WebsocketConfig;
+};
+
+type WebsocketMap = {
+  [id: string]: WebSocket;
+};
+
+export const useWebsockets = () => {
+  const websocketMap = useRef<WebsocketMap>({});
+  const websocketConfigMap = useRef<WebsocketConfigMap>({});
+
+  /**
+   * Setup for a new websocket, after calling new websocket you can open the connection with openWebsocket
+   * @param id Id to access later the websocket config/connection
+   * @param apiEndpoint Endpoint to connect the websocket e.g: /api/websocket
+   * @param options Websocket listeners
+   * @returns An object with the config setted for that websocket. This config will be used to open the ws on openWebsocket
+   */
+  const newWebsocket = (
+    id: string,
+    apiEndpoint: string,
+    options: NewWebsocketOptions
+  ): WebsocketConfig => {
+    if (!id) {
+      console.log("Id cannot be empty");
+      return;
+    }
+
+    if (!apiEndpoint) {
+      console.log("Api endpoint string cannot be empty");
+      return;
+    }
+
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+
+    const url = `${protocol}://${window.location.host}${apiEndpoint}`;
+
+    const mockFunction = () => {};
+
+    const wsConfig: WebsocketConfig = {
+      url,
+      onopen: options?.onopen || mockFunction,
+      onmessage: options?.onmessage || mockFunction,
+      onerror: options?.onerror || mockFunction,
+      onclose: options?.onclose || mockFunction,
+    };
+
+    websocketConfigMap.current = {
+      ...websocketConfigMap.current,
+      [id]: wsConfig,
+    };
+    return wsConfig;
+  };
+
+  /**
+   * Opens the websocket connection based on a config previously setted by
+   * newWebsocket
+   */
+  const openWebsocket = (id: string) => {
+    const wsConfig = websocketConfigMap.current[id];
+
+    // Prevent calling openWebsocket before newWebsocket
+    if (!wsConfig) {
+      console.log("Couldn't find ws config");
+      return;
+    }
+    // In case of having a previous websocket opened with the same ID, close the previous one
+    const prevWs = getWebsocket(id);
+
+    if (prevWs) {
+      prevWs.close();
+    }
+    const { url, ...listeners } = wsConfig;
+
+    const ws = new WebSocket(wsConfig.url);
+
+    Object.assign(ws, listeners);
+
+    websocketMap.current = {
+      ...websocketMap.current,
+      [id]: ws,
+    };
+  };
+
+  /**
+   * Close specific websocket
+   */
+  const closeWebsocket = (id: string, code?: number, reason?: string) => {
+    const ws = websocketMap.current[id];
+
+    if (!ws) {
+      console.log(`Couldn't find websocket to close for id: ${id}`);
+      return;
+    }
+
+    ws.close(code, reason);
+  };
+
+  /**
+   * Closes all websockets opened by the useWebsocket hook
+   */
+  const closeAllWebsockets = () => {
+    Object.keys(websocketMap.current).forEach((key) => {
+      closeWebsocket(key);
+    });
+  };
+
+  /**
+   * Get websocket by id
+   */
+  const getWebsocket = (id: string) => {
+    return websocketMap.current[id];
+  };
+
+  return {
+    newWebsocket,
+    openWebsocket,
+    getWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  };
+};

+ 22 - 0
dashboard/src/shared/types.tsx

@@ -113,6 +113,7 @@ export interface Section {
 // FormElement represents a form element
 export interface FormElement {
   type: string;
+  info?: string;
   label: string;
   required?: boolean;
   name?: string;
@@ -188,3 +189,24 @@ export interface CapabilityType {
   github: boolean;
   provisioner: boolean;
 }
+
+export interface ContextProps {
+  currentModal?: string;
+  currentModalData: any;
+  setCurrentModal: (currentModal: string, currentModalData?: any) => void;
+  currentError?: string;
+  setCurrentError: (currentError: string) => void;
+  currentCluster?: ClusterType;
+  setCurrentCluster: (currentCluster: ClusterType, callback?: any) => void;
+  currentProject?: ProjectType;
+  setCurrentProject: (currentProject: ProjectType, callback?: any) => void;
+  projects: ProjectType[];
+  setProjects: (projects: ProjectType[]) => void;
+  user: any;
+  setUser: (userId: number, email: string) => void;
+  devOpsMode: boolean;
+  setDevOpsMode: (devOpsMode: boolean) => void;
+  capabilities: CapabilityType;
+  setCapabilities: (capabilities: CapabilityType) => void;
+  clearContext: () => void;
+}

+ 109 - 1
server/api/deploy_handler.go

@@ -125,7 +125,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// create release with webhook token in db
-	repository := rel.Config["image"].(map[string]interface{})["repository"]
+	image, ok := rel.Config["image"].(map[string]interface{})
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field image in config"), w)
+		return
+	}
+
+	repository := image["repository"]
 	repoStr, ok := repository.(string)
 
 	if !ok {
@@ -176,6 +182,108 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleDeployAddon triggers a addon deployment from a template
+func (app *App) HandleDeployAddon(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	name := chi.URLParam(r, "name")
+	version := chi.URLParam(r, "version")
+
+	// if version passed as latest, pass empty string to loader to get latest
+	if version == "latest" {
+		version = ""
+	}
+
+	getChartForm := &forms.ChartForm{
+		Name:    name,
+		Version: version,
+		RepoURL: app.ServerConf.DefaultApplicationHelmRepoURL,
+	}
+
+	// if a repo_url is passed as query param, it will be populated
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	getChartForm.PopulateRepoURLFromQueryParams(vals)
+
+	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	form := &forms.InstallChartTemplateForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+		ChartTemplateForm: &forms.ChartTemplateForm{},
+	}
+
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.Repo.Cluster,
+	)
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       form.ChartTemplateForm.Name,
+		Namespace:  form.ReleaseForm.Form.Namespace,
+		Values:     form.ChartTemplateForm.FormValues,
+		Cluster:    form.ReleaseForm.Cluster,
+		Repo:       *app.Repo,
+		Registries: registries,
+	}
+
+	_, err = agent.InstallChart(conf, app.DOConf)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error installing a new chart: " + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleUninstallTemplate triggers a chart deployment from a template
 func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 127 - 16
server/api/git_repo_handler.go

@@ -4,13 +4,13 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"golang.org/x/oauth2"
 	"net/http"
 	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
-
-	"golang.org/x/oauth2"
+	"sync"
 
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
@@ -59,6 +59,12 @@ type DirectoryItem struct {
 	Type string
 }
 
+// AutoBuildpack represents an automatically detected buildpack
+type AutoBuildpack struct {
+	Valid bool   `json:"valid"`
+	Name  string `json:"name"`
+}
+
 // HandleListRepos retrieves a list of repo names
 func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 	tok, err := app.githubTokenFromRequest(r)
@@ -68,12 +74,9 @@ func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	res := make([]Repo, 0)
-
 	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
 
-	allRepos := make([]*github.Repository, 0)
-
+	// figure out number of repositories
 	opt := &github.RepositoryListOptions{
 		ListOptions: github.ListOptions{
 			PerPage: 100,
@@ -81,23 +84,72 @@ func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 		Sort: "updated",
 	}
 
-	for {
-		repos, resp, err := client.Repositories.List(context.Background(), "", opt)
+	allRepos, resp, err := client.Repositories.List(context.Background(), "", opt)
 
-		if err != nil {
-			app.handleErrorInternal(err, w)
-			return
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// make workers to get pages concurrently
+	const WCOUNT = 5
+	numPages := resp.LastPage + 1
+	var workerErr error
+	var mu sync.Mutex
+	var wg sync.WaitGroup
+
+	worker := func(cp int) {
+		defer wg.Done()
+
+		for cp < numPages {
+			cur_opt := &github.RepositoryListOptions{
+				ListOptions: github.ListOptions{
+					Page:    cp,
+					PerPage: 100,
+				},
+				Sort: "updated",
+			}
+
+			repos, _, err := client.Repositories.List(context.Background(), "", cur_opt)
+
+			if err != nil {
+				mu.Lock()
+				workerErr = err
+				mu.Unlock()
+				return
+			}
+
+			mu.Lock()
+			allRepos = append(allRepos, repos...)
+			mu.Unlock()
+
+			cp += WCOUNT
 		}
+	}
 
-		allRepos = append(allRepos, repos...)
+	var numJobs int
+	if numPages > WCOUNT {
+		numJobs = WCOUNT
+	} else {
+		numJobs = numPages
+	}
 
-		if resp.NextPage == 0 {
-			break
-		}
+	wg.Add(numJobs)
 
-		opt.Page = resp.NextPage
+	// page 1 is already loaded so we start with 2
+	for i := 1; i <= numJobs; i++ {
+		go worker(i + 1)
 	}
 
+	wg.Wait()
+
+	if workerErr != nil {
+		app.handleErrorInternal(workerErr, w)
+		return
+	}
+
+	res := make([]Repo, 0)
+
 	for _, repo := range allRepos {
 		res = append(res, Repo{
 			FullName: repo.GetFullName(),
@@ -165,6 +217,65 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(res)
 }
 
+// HandleDetectBuildpack attempts to figure which buildpack will be auto used based on directory contents
+func (app *App) HandleDetectBuildpack(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	queryParams, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+	owner := chi.URLParam(r, "owner")
+	name := chi.URLParam(r, "name")
+	branch := chi.URLParam(r, "branch")
+
+	repoContentOptions := github.RepositoryContentGetOptions{}
+	repoContentOptions.Ref = branch
+	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), owner, name, queryParams["dir"][0], &repoContentOptions)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	var BREQS = map[string]string{
+		"requirements.txt": "Python",
+		"Gemfile":          "Ruby",
+		"package.json":     "Node.js",
+		"pom.xml":          "Java",
+		"composer.json":    "PHP",
+	}
+
+	res := AutoBuildpack{
+		Valid: true,
+	}
+	matches := 0
+
+	for i := range directoryContents {
+		name := *directoryContents[i].Name
+
+		bname, ok := BREQS[name]
+		if ok {
+			matches++
+			res.Name = bname
+		}
+	}
+
+	if matches != 1 {
+		res.Valid = false
+		res.Name = ""
+	}
+
+	json.NewEncoder(w).Encode(res)
+}
+
 // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
 func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
 	tok, err := app.githubTokenFromRequest(r)

+ 28 - 0
server/router/router.go

@@ -1075,6 +1075,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/buildpack/detect",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveGitRepoAccess(
+						requestlog.NewHandler(a.HandleDetectBuildpack, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/contents",
@@ -1462,6 +1476,20 @@ func New(a *api.App) *chi.Mux {
 					mw.ReadAccess,
 				),
 			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/deploy/addon/{name}/{version}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeployAddon, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
 		})
 
 		// Create group for long-running Helm operations