Browse Source

Merge branch 'master' into 251-cluster-ip-address-on-dashboard

Nicolas Frati 5 years ago
parent
commit
928500da62
48 changed files with 1413 additions and 343 deletions
  1. 6 10
      dashboard/src/App.tsx
  2. 189 0
      dashboard/src/components/PageNotFound.tsx
  3. 53 1
      dashboard/src/components/Selector.tsx
  4. 0 1
      dashboard/src/components/values-form/UploadArea.tsx
  5. 2 2
      dashboard/src/main/Main.tsx
  6. 23 0
      dashboard/src/main/MainWrapper.tsx
  7. 52 71
      dashboard/src/main/home/Home.tsx
  8. 98 0
      dashboard/src/main/home/NoClusterPlaceholder.tsx
  9. 63 48
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  10. 21 2
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  11. 15 6
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  12. 5 5
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  13. 0 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  14. 9 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  15. 3 3
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  16. 143 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  17. 4 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  18. 3 13
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  19. 3 9
      dashboard/src/main/home/dashboard/Dashboard.tsx
  20. 11 4
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  21. 12 5
      dashboard/src/main/home/integrations/Integrations.tsx
  22. 35 36
      dashboard/src/main/home/launch/Launch.tsx
  23. 0 14
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  24. 23 33
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  25. 12 6
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  26. 4 0
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  27. 11 2
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  28. 0 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  29. 199 0
      dashboard/src/main/home/modals/NamespaceModal.tsx
  30. 4 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  31. 17 9
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  32. 3 4
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  33. 6 2
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  34. 12 11
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  35. 2 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  36. 28 4
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  37. 6 2
      dashboard/src/main/home/sidebar/Drawer.tsx
  38. 10 7
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  39. 82 13
      dashboard/src/main/home/sidebar/Sidebar.tsx
  40. 18 2
      dashboard/src/shared/Context.tsx
  41. 23 0
      dashboard/src/shared/api.tsx
  42. 31 8
      dashboard/src/shared/routing.tsx
  43. 2 2
      docs/deploy/applications/deploying-from-docker-registry.md
  44. 1 0
      go.sum
  45. 4 0
      internal/forms/k8s.go
  46. 24 0
      internal/kubernetes/agent.go
  47. 113 0
      server/api/k8s_handler.go
  48. 28 0
      server/router/router.go

+ 6 - 10
dashboard/src/App.tsx

@@ -1,18 +1,14 @@
 import React, { Component } from "react";
+import { BrowserRouter } from "react-router-dom";
 
-import { ContextProvider } from "./shared/Context";
-import Main from "./main/Main";
+import MainWrapper from "./main/MainWrapper";
 
-type PropsType = {};
-
-type StateType = {};
-
-export default class App extends Component<PropsType, StateType> {
+export default class App extends Component {
   render() {
     return (
-      <ContextProvider>
-        <Main />
-      </ContextProvider>
+      <BrowserRouter>
+        <MainWrapper />
+      </BrowserRouter>
     );
   }
 }

+ 189 - 0
dashboard/src/components/PageNotFound.tsx

@@ -0,0 +1,189 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { pushFiltered } from "shared/routing";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class PageNotFound extends Component<PropsType, StateType> {
+  state = {};
+
+  render() {
+    let { pathname } = this.props.location;
+    let params = this.props.match.params as any;
+    let { baseRoute } = params;
+    if (baseRoute === "applications") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Application Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="140px"
+              onClick={() =>
+                pushFiltered(this.props, "/applications", ["project_id"])
+              }
+            >
+              <i className="material-icons">arrow_back</i>
+              Applications
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    } else if (baseRoute === "jobs") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Job Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="90px"
+              onClick={() => pushFiltered(this.props, "/jobs", ["project_id"])}
+            >
+              <i className="material-icons">arrow_back</i>
+              Jobs
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    }
+    return (
+      <StyledPageNotFound>
+        <Mega>
+          404
+          <Inside>Page Not Found</Inside>
+        </Mega>
+        <Flex>
+          <BackButton
+            width="145px"
+            onClick={() =>
+              pushFiltered(this.props, "/dashboard", ["project_id"])
+            }
+          >
+            <i className="material-icons">home</i>
+            Return Home
+          </BackButton>
+          {pathname && (
+            <>
+              <Splitter>|</Splitter>
+              <Helper>Could not find "{pathname}"</Helper>
+            </>
+          )}
+        </Flex>
+      </StyledPageNotFound>
+    );
+  }
+}
+
+export default withRouter(PageNotFound);
+
+const Splitter = styled.div`
+  margin: 0 20px;
+  font-size: 27px;
+  font-weight: 200;
+  color: #ffffff15;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Helper = styled.div`
+  font-size: 15px;
+  max-width: 550px;
+  margin-right: -50px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const StyledPageNotFound = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  padding-bottom: 20px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 53 - 1
dashboard/src/components/Selector.tsx

@@ -1,9 +1,12 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 type PropsType = {
   activeValue: string;
+  refreshOptions?: () => void;
   options: { value: string; label: string }[];
+  addButton?: boolean;
   setActiveValue: (x: string) => void;
   width: string;
   height?: string;
@@ -76,6 +79,21 @@ export default class Selector extends Component<PropsType, StateType> {
     }
   };
 
+  renderAddButton = () => {
+    if (this.props.addButton) {
+      return (
+        <NewOption
+          onClick={() => {
+            this.context.setCurrentModal("NamespaceModal");
+          }}
+        >
+          <Plus>+</Plus>
+          Add Namespace
+        </NewOption>
+      );
+    }
+  };
+
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
@@ -91,6 +109,7 @@ export default class Selector extends Component<PropsType, StateType> {
         >
           {this.renderDropdownLabel()}
           {this.renderOptionList()}
+          {this.renderAddButton()}
         </Dropdown>
       );
     }
@@ -107,11 +126,17 @@ export default class Selector extends Component<PropsType, StateType> {
 
   render() {
     let { activeValue } = this.props;
+
     return (
       <StyledSelector width={this.props.width}>
         <MainSelector
           ref={this.parentRef}
-          onClick={() => this.setState({ expanded: !this.state.expanded })}
+          onClick={() => {
+            if (this.props.refreshOptions) {
+              this.props.refreshOptions();
+            }
+            this.setState({ expanded: !this.state.expanded });
+          }}
           expanded={this.state.expanded}
           width={this.props.width}
           height={this.props.height}
@@ -127,6 +152,13 @@ export default class Selector extends Component<PropsType, StateType> {
   }
 }
 
+Selector.contextType = Context;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
 const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
@@ -141,6 +173,26 @@ const DropdownLabel = styled.div`
   margin: 10px 13px;
 `;
 
+const NewOption = styled.div`
+  display: flex;
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff00;
+  height: 37px;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
 const Option = styled.div`
   width: 100%;
   border-top: 1px solid #00000000;

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

@@ -35,7 +35,6 @@ export default class UploadArea extends Component<PropsType, StateType> {
 
   render() {
     let { label, placeholder } = this.props;
-    console.log(this.state.fileName);
     if (this.state.fileName) {
       placeholder = `Uploaded ${this.state.fileName}`;
     }

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

@@ -180,7 +180,7 @@ export default class Main extends Component<PropsType, StateType> {
           }}
         />
         <Route
-          path={`/:baseRoute`}
+          path={`/:baseRoute/:cluster?/:namespace?`}
           render={(routeProps) => {
             const baseRoute = routeProps.match.params.baseRoute;
             if (
@@ -210,7 +210,7 @@ export default class Main extends Component<PropsType, StateType> {
     return (
       <StyledMain>
         <GlobalStyle />
-        <BrowserRouter>{this.renderMain()}</BrowserRouter>
+        {this.renderMain()}
         <CurrentError currentError={this.context.currentError} />
       </StyledMain>
     );

+ 23 - 0
dashboard/src/main/MainWrapper.tsx

@@ -0,0 +1,23 @@
+import React, { Component } from "react";
+import { BrowserRouter } from "react-router-dom";
+
+import { ContextProvider } from "../shared/Context";
+import Main from "./Main";
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class MainWrapper extends Component<PropsType, StateType> {
+  render() {
+    let { history, location } = this.props;
+    return (
+      <ContextProvider history={history} location={location}>
+        <Main />
+      </ContextProvider>
+    );
+  }
+}
+
+export default withRouter(MainWrapper);

+ 52 - 71
dashboard/src/main/home/Home.tsx

@@ -5,7 +5,7 @@ import styled from "styled-components";
 import api from "shared/api";
 import { H } from "highlight.run";
 import { Context } from "shared/Context";
-import { PorterUrl } from "shared/routing";
+import { PorterUrl, pushQueryParams, pushFiltered } from "shared/routing";
 import { ClusterType, ProjectType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
@@ -19,10 +19,12 @@ import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModa
 import IntegrationsModal from "./modals/IntegrationsModal";
 import Modal from "./modals/Modal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
 import Navbar from "./navbar/Navbar";
 import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
+import PageNotFound from "components/PageNotFound";
 
 type PropsType = RouteComponentProps & {
   logOut: () => void;
@@ -43,6 +45,7 @@ type StateType = {
 };
 
 // TODO: Handle cluster connected but with some failed infras (no successful set)
+// TODO: Set up current view / sidebar tab as dynamic Routes
 class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
@@ -75,9 +78,11 @@ class Home extends Component<PropsType, StateType> {
           creating = res.data[i].status === "creating";
         }
         if (creating) {
-          this.props.history.push("dashboard?tab=provisioner");
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "provisioner",
+          });
         } else if (this.state.ghRedirect) {
-          this.props.history.push("integrations");
+          pushFiltered(this.props, "/integrations", ["project_id"]);
           this.setState({ ghRedirect: false });
         }
       });
@@ -98,14 +103,21 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getProjects = (id?: number) => {
-    let { user, setProjects } = this.context;
+    let { user, setProjects, setCurrentProject } = this.context;
     let { currentProject } = this.props;
+    let queryString = window.location.search;
+    let urlParams = new URLSearchParams(queryString);
+    let projectId = urlParams.get("project_id");
+    if (!projectId && currentProject?.id) {
+      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
+    }
+
     api
       .getProjects("<token>", {}, { id: user.userId })
       .then((res) => {
         if (res.data) {
           if (res.data.length === 0) {
-            this.props.history.push("new-project");
+            pushFiltered(this.props, "/new-project", ["project_id"]);
           } else if (res.data.length > 0 && !currentProject) {
             setProjects(res.data);
 
@@ -116,7 +128,7 @@ class Home extends Component<PropsType, StateType> {
                   foundProject = project;
                 }
               });
-              this.context.setCurrentProject(foundProject);
+              setCurrentProject(foundProject || res.data[0]);
             }
             if (!foundProject) {
               res.data.forEach((project: ProjectType, i: number) => {
@@ -127,10 +139,9 @@ class Home extends Component<PropsType, StateType> {
                   foundProject = project;
                 }
               });
-              this.context.setCurrentProject(
-                foundProject ? foundProject : res.data[0]
+              setCurrentProject(foundProject || res.data[0], () =>
+                this.initializeView()
               );
-              this.initializeView();
             }
           }
         }
@@ -175,7 +186,9 @@ class Home extends Component<PropsType, StateType> {
         project_id: this.props.currentProject.id,
       }
     );
-    return this.props.history.push("dashboard?tab=provisioner");
+    return pushFiltered(this.props, "/dashboard", ["project_id"], {
+      tab: "provisioner",
+    });
   };
 
   checkDO = () => {
@@ -205,7 +218,9 @@ class Home extends Component<PropsType, StateType> {
             });
           } else if (infras[0] === "docr") {
             this.provisionDOCR(tgtIntegration.id, tier, () => {
-              this.props.history.push("dashboard?tab=provisioner");
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "provisioner",
+              });
             });
           } else {
             this.provisionDOKS(tgtIntegration.id, region, clusterName);
@@ -217,6 +232,11 @@ class Home extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    let { match } = this.props;
+    let params = match.params as any;
+    let { cluster } = params;
+    console.log("cluster is", cluster);
+
     let { user } = this.context;
 
     // Initialize Highlight
@@ -238,9 +258,8 @@ class Home extends Component<PropsType, StateType> {
     }
 
     let provision = urlParams.get("provision");
-    let defaultProjectId = null;
+    let defaultProjectId = parseInt(urlParams.get("project_id"));
     if (provision === "do") {
-      defaultProjectId = parseInt(urlParams.get("project_id"));
       this.setState({ handleDO: true });
       this.checkDO();
     }
@@ -271,43 +290,16 @@ class Home extends Component<PropsType, StateType> {
 
   // TODO: move into ClusterDashboard
   renderDashboard = () => {
-    let { currentCluster, setCurrentModal } = this.context;
-    if (currentCluster && !currentCluster.name) {
+    let { currentCluster } = this.context;
+    if (currentCluster?.id === -1) {
+      return <Loading />;
+    } else if (!currentCluster || !currentCluster.name) {
       return (
         <DashboardWrapper>
-          <Placeholder>
-            <Bold>Porter - Getting</Bold>
-            <br />
-            <br />
-            1. Navigate to{" "}
-            <A onClick={() => setCurrentModal("ClusterConfigModal")}>
-              + Add a Cluster
-            </A>{" "}
-            and provide a kubeconfig. *<br />
-            <br />
-            2. Choose which contexts you would like to use from the{" "}
-            <A
-              onClick={() => {
-                setCurrentModal("ClusterConfigModal", { currentTab: "select" });
-              }}
-            >
-              Select Clusters
-            </A>{" "}
-            tab.
-            <br />
-            <br />
-            3. For additional information, please refer to our <A>docs</A>.
-            <br />
-            <br />
-            <br />* Make sure all fields are explicitly declared (e.g., certs
-            and keys).
-          </Placeholder>
+          <PageNotFound />
         </DashboardWrapper>
       );
-    } else if (!currentCluster) {
-      return <Loading />;
     }
-
     return (
       <DashboardWrapper>
         <ClusterDashboard
@@ -371,17 +363,18 @@ class Home extends Component<PropsType, StateType> {
   };
 
   projectOverlayCall = () => {
-    let { user, setProjects } = this.context;
+    let { user, setProjects, setCurrentProject } = this.context;
     api
       .getProjects("<token>", {}, { id: user.userId })
       .then((res) => {
         if (res.data) {
           setProjects(res.data);
           if (res.data.length > 0) {
-            this.context.setCurrentProject(res.data[0]);
+            setCurrentProject(res.data[0]);
           } else {
-            this.context.setCurrentProject(null);
-            this.props.history.push("new-project");
+            setCurrentProject(null, () =>
+              pushFiltered(this.props, "/new-project", ["project_id"])
+            );
           }
           this.context.setCurrentModal(null, null);
         }
@@ -460,7 +453,7 @@ class Home extends Component<PropsType, StateType> {
       })
       .catch(console.log);
     setCurrentModal(null, null);
-    this.props.history.push("dashboard?tab=overview");
+    pushFiltered(this.props, "/dashboard", []);
   };
 
   render() {
@@ -508,6 +501,15 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
+        {currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 
@@ -557,27 +559,6 @@ const DashboardWrapper = styled.div`
   padding-bottom: 120px;
 `;
 
-const A = styled.a`
-  color: #ffffff;
-  text-decoration: underline;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-`;
-
-const Placeholder = styled.div`
-  font-family: "Work Sans", sans-serif;
-  color: #6f6f6f;
-  font-size: 16px;
-  margin-left: 20px;
-  margin-top: 24vh;
-  user-select: none;
-`;
-
-const Bold = styled.div`
-  font-weight: bold;
-  font-size: 20px;
-`;
-
 const StyledHome = styled.div`
   width: 100vw;
   height: 100vh;

+ 98 - 0
dashboard/src/main/home/NoClusterPlaceholder.tsx

@@ -0,0 +1,98 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { pushFiltered } from "shared/routing";
+
+import { Context } from "shared/Context";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class NoClusterPlaceholder extends Component<PropsType, StateType> {
+  state = {};
+
+  render() {
+    let { setCurrentModal, currentProject } = this.context;
+
+    return (
+      <StyledNoClusterPlaceholder>
+        <Bold>
+          <i className="material-icons">tips_and_updates</i>
+          Porter - Getting Started
+        </Bold>
+        <br />
+        <br />
+        1. If you're deploying from a repo{" "}
+        <A
+          onClick={() =>
+            window.open(`/api/oauth/projects/${currentProject.id}/github`)
+          }
+        >
+          link your GitHub account
+        </A>
+        <br />
+        <br />
+        2.{" "}
+        <A
+          onClick={() =>
+            pushFiltered(this.props, "/dashboard", ["project_id"], {
+              tab: "create-cluster",
+            })
+          }
+        >
+          Create a new cluster
+        </A>{" "}
+        or{" "}
+        <A onClick={() => setCurrentModal("ClusterInstructionsModal")}>
+          add an existing cluster
+        </A>{" "}
+        *
+        <br />
+        <br />
+        3. To receive community updates{" "}
+        <A onClick={() => window.open("https://discord.gg/34n7NN7FJ7")}>
+          join our official Discord
+        </A>
+        <br />
+        <br />
+        <br />* Required. For more information{" "}
+        <A onClick={() => window.open("https://docs.getporter.dev/docs")}>
+          refer to our docs
+        </A>
+      </StyledNoClusterPlaceholder>
+    );
+  }
+}
+
+NoClusterPlaceholder.contextType = Context;
+
+export default withRouter(NoClusterPlaceholder);
+
+const A = styled.a`
+  color: #ffffff;
+  text-decoration: underline;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+`;
+
+const StyledNoClusterPlaceholder = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  margin-top: 12px;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: bold;
+  font-size: 20px;
+  display: flex;
+  align-items: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 63 - 48
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -2,17 +2,18 @@ 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 { PorterUrl } from "shared/routing";
+import { 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 ExpandedJobChart from "./expanded-chart/ExpandedJobChart";
+import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
@@ -30,9 +31,10 @@ type StateType = {
   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: "default",
+    namespace: null as string,
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
@@ -41,14 +43,21 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   };
 
   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: this.context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         {
-          id: this.context.currentProject.id,
+          id: currentProject.id,
         }
       )
       .then((res) => {
@@ -62,21 +71,30 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   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,
-      });
+      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) {
-      this.setState({
-        namespace: "default",
-        sortType: "Newest",
-        currentChart: null,
-      });
+      this.setState(
+        {
+          sortType: "Newest",
+          currentChart: null,
+        },
+        () =>
+          pushQueryParams(this.props, {
+            namespace:
+              this.state.namespace === null ? "default" : this.state.namespace,
+          })
+      );
     }
   }
 
@@ -97,11 +115,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   };
 
   renderBody = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
+    let { currentCluster, currentView } = this.props;
     return (
       <>
         <ControlRow>
-          <Button onClick={() => this.props.history.push("launch")}>
+          <Button
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
+          >
             <i className="material-icons">add</i> Launch Template
           </Button>
           <SortFilterWrapper>
@@ -110,7 +130,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               sortType={this.state.sortType}
             />
             <NamespaceSelector
-              setNamespace={(namespace) => this.setState({ namespace })}
+              setNamespace={(namespace) =>
+                this.setState({ namespace }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
               namespace={this.state.namespace}
             />
           </SortFilterWrapper>
@@ -121,9 +147,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           currentCluster={currentCluster}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
         />
       </>
     );
@@ -131,33 +154,12 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
   renderContents = () => {
     let { currentCluster, setSidebar, currentView } = this.props;
-    if (this.state.currentChart && currentView === "jobs") {
-      return (
-        <ExpandedJobChart
-          namespace={this.state.namespace}
-          currentCluster={this.props.currentCluster}
-          currentChart={this.state.currentChart}
-          closeChart={() => this.setState({ currentChart: null })}
-          setSidebar={setSidebar}
-        />
-      );
-    } else if (this.state.currentChart) {
-      return (
-        <ExpandedChart
-          namespace={this.state.namespace}
-          currentCluster={this.props.currentCluster}
-          currentChart={this.state.currentChart}
-          closeChart={() => this.setState({ currentChart: null })}
-          isMetricsInstalled={this.state.isMetricsInstalled}
-          setSidebar={setSidebar}
-        />
-      );
-    } else if (currentView === "env-groups") {
+    if (currentView === "env-groups") {
       return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
     }
 
     return (
-      <div>
+      <>
         <TitleSection>
           {this.renderDashboardIcon()}
           <Title>{currentView}</Title>
@@ -175,12 +177,25 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
 
         {this.renderBody()}
-      </div>
+      </>
     );
   };
 
   render() {
-    return <div>{this.renderContents()}</div>;
+    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>
+      </Switch>
+    );
   }
 }
 

+ 21 - 2
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -39,15 +39,34 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           let namespaceOptions: { label: string; value: string }[] = [
             { label: "All", value: "" },
           ];
+
+          // Set namespace from URL if specified
+          let queryString = window.location.search;
+          let urlParams = new URLSearchParams(queryString);
+          let urlNamespace = urlParams.get("namespace");
+          if (urlNamespace === "ALL") {
+            urlNamespace = "";
+          }
+
+          let defaultNamespace = "default";
           res.data.items.forEach(
             (x: { metadata: { name: string } }, i: number) => {
               namespaceOptions.push({
                 label: x.metadata.name,
                 value: x.metadata.name,
               });
+              if (x.metadata.name === urlNamespace) {
+                defaultNamespace = urlNamespace;
+              }
             }
           );
-          this.setState({ namespaceOptions });
+          this.setState({ namespaceOptions }, () => {
+            if (urlNamespace === "" || defaultNamespace === "") {
+              this.props.setNamespace("");
+            } else if (this.props.namespace !== defaultNamespace) {
+              this.props.setNamespace(defaultNamespace);
+            }
+          });
         }
       })
       .catch((err) => {
@@ -63,7 +82,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
+    if (prevProps.namespace !== this.props.namespace) {
       this.updateOptions();
     }
   }

+ 15 - 6
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,13 +1,14 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import { ChartType, StorageType } from "shared/types";
+import { ChartType } 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";
 
-type PropsType = {
+type PropsType = RouteComponentProps & {
   chart: ChartType;
-  setCurrentChart: (c: ChartType) => void;
   controllers: Record<string, any>;
 };
 
@@ -16,7 +17,7 @@ type StateType = {
   update: any[];
 };
 
-export default class Chart extends Component<PropsType, StateType> {
+class Chart extends Component<PropsType, StateType> {
   state = {
     expand: false,
     update: [] as any[],
@@ -43,14 +44,20 @@ export default class Chart extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { chart, setCurrentChart } = this.props;
+    let { chart } = this.props;
 
     return (
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
         onMouseLeave={() => this.setState({ expand: false })}
         expand={this.state.expand}
-        onClick={() => setCurrentChart(chart)}
+        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>
@@ -84,6 +91,8 @@ export default class Chart extends Component<PropsType, StateType> {
 
 Chart.contextType = Context;
 
+export default withRouter(Chart);
+
 const BottomWrapper = styled.div`
   display: flex;
   justify-content: space-between;

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

@@ -13,7 +13,6 @@ type PropsType = {
   currentCluster: ClusterType;
   namespace: string;
   sortType: string;
-  setCurrentChart: (c: ChartType) => void;
   currentView: PorterUrl;
 };
 
@@ -218,7 +217,8 @@ export default class ChartList extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-    this.updateCharts(this.getControllers);
+    (this.props.namespace || this.props.namespace === "") &&
+      this.updateCharts(this.getControllers);
     this.setControllerWebsockets([
       "deployment",
       "statefulset",
@@ -243,14 +243,15 @@ export default class ChartList extends Component<PropsType, StateType> {
       prevProps.sortType !== this.props.sortType ||
       prevProps.currentView !== this.props.currentView
     ) {
-      this.updateCharts(this.getControllers);
+      (this.props.namespace || this.props.namespace === "") &&
+        this.updateCharts(this.getControllers);
     }
   }
 
   renderChartList = () => {
     let { loading, error, charts } = this.state;
 
-    if (loading) {
+    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
       return (
         <LoadingWrapper>
           <Loading />
@@ -277,7 +278,6 @@ export default class ChartList extends Component<PropsType, StateType> {
         <Chart
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
-          setCurrentChart={this.props.setCurrentChart}
           controllers={
             this.state.controllers[`${chart.namespace}-${chart.name}`] ||
             ({} as Record<string, any>)

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -3,9 +3,7 @@ import styled from "styled-components";
 
 import key from "assets/key.svg";
 
-import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
-import StatusIndicator from "components/StatusIndicator";
 
 type PropsType = {
   envGroup: any;

+ 9 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -13,6 +13,7 @@ import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushQueryParams } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
@@ -31,7 +32,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
   state = {
     expand: false,
     update: [] as any[],
-    namespace: "default",
+    namespace: null as string,
     expandedEnvGroup: null as any,
     createEnvMode: false,
     sortType: localStorage.getItem("SortType")
@@ -74,7 +75,13 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
                 sortType={this.state.sortType}
               />
               <NamespaceSelector
-                setNamespace={(namespace) => this.setState({ namespace })}
+                setNamespace={(namespace) =>
+                  this.setState({ namespace }, () => {
+                    pushQueryParams(this.props, {
+                      namespace: this.state.namespace || "ALL",
+                    });
+                  })
+                }
                 namespace={this.state.namespace}
               />
             </SortFilterWrapper>

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -61,7 +61,6 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
             sortedGroups.sort((a: any, b: any) =>
               a.metadata.name > b.metadata.name ? 1 : -1
             );
-            console.log(sortedGroups);
             break;
           default:
             sortedGroups.sort((a: any, b: any) =>
@@ -90,14 +89,15 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
       prevProps.namespace !== this.props.namespace ||
       prevProps.sortType !== this.props.sortType
     ) {
-      this.updateEnvGroups();
+      (this.props.namespace || this.props.namespace === "") &&
+        this.updateEnvGroups();
     }
   }
 
   renderEnvGroupList = () => {
     let { loading, error, envGroups } = this.state;
 
-    if (loading) {
+    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
       return (
         <LoadingWrapper>
           <Loading />

+ 143 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -0,0 +1,143 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import {
+  ResourceType,
+  ChartType,
+  StorageType,
+  ClusterType,
+} from "shared/types";
+import api from "shared/api";
+import { PorterUrl, pushQueryParams, pushFiltered } from "shared/routing";
+import ExpandedJobChart from "./ExpandedJobChart";
+import ExpandedChart from "./ExpandedChart";
+import Loading from "components/Loading";
+import PageNotFound from "components/PageNotFound";
+
+type PropsType = RouteComponentProps & {
+  setSidebar: (x: boolean) => void;
+  isMetricsInstalled: boolean;
+};
+
+type StateType = {
+  loading: boolean;
+  currentChart: ChartType;
+};
+
+class ExpandedChartWrapper extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    currentChart: null as ChartType,
+  };
+
+  // Retrieve full chart data (includes form and values)
+  getChartData = () => {
+    let { match } = this.props;
+    let { namespace, chartName } = match.params as any;
+    let { currentProject, currentCluster } = this.context;
+    if (currentProject && currentCluster) {
+      // TODO: add query for retrieving max revision #
+      api
+        .getRevisions(
+          "<token>",
+          {
+            namespace: namespace,
+            cluster_id: currentCluster.id,
+            storage: StorageType.Secret,
+          },
+          { id: currentProject.id, name: chartName }
+        )
+        .then((res) => {
+          res.data.sort((a: ChartType, b: ChartType) => {
+            return -(a.version - b.version);
+          });
+          let maxVersion = res.data[0].version;
+          api
+            .getChart(
+              "<token>",
+              {
+                namespace: namespace,
+                cluster_id: currentCluster.id,
+                storage: StorageType.Secret,
+              },
+              {
+                name: chartName,
+                revision: maxVersion,
+                id: currentProject.id,
+              }
+            )
+            .then((res) => {
+              this.setState({ currentChart: res.data, loading: false });
+            })
+            .catch((err) => {
+              console.log("err", err.response.data);
+              this.setState({ loading: false });
+            });
+        })
+        .catch((err) => {
+          console.log("err", err.response.data);
+          this.setState({ loading: false });
+        });
+    }
+  };
+
+  componentDidMount() {
+    this.setState({ loading: true });
+    this.getChartData();
+  }
+
+  render() {
+    let { setSidebar, location, match } = this.props;
+    let { baseRoute, namespace } = match.params as any;
+    let { loading, currentChart } = this.state;
+    if (loading) {
+      return <Loading />;
+    } else if (currentChart && baseRoute === "jobs") {
+      return (
+        <ExpandedJobChart
+          namespace={namespace}
+          currentChart={currentChart}
+          currentCluster={this.context.currentCluster}
+          closeChart={() =>
+            pushFiltered(this.props, "/jobs", ["project_id"], {
+              cluster: this.context.currentCluster.name,
+              namespace: namespace,
+            })
+          }
+          setSidebar={setSidebar}
+        />
+      );
+    } else if (currentChart && baseRoute === "applications") {
+      return (
+        <ExpandedChart
+          namespace={namespace}
+          isMetricsInstalled={this.props.isMetricsInstalled}
+          currentChart={currentChart}
+          currentCluster={this.context.currentCluster}
+          closeChart={() =>
+            pushFiltered(this.props, "/applications", ["project_id"], {
+              cluster: this.context.currentCluster.name,
+              namespace: namespace,
+            })
+          }
+          setSidebar={setSidebar}
+        />
+      );
+    }
+    return <PageNotFound />;
+  }
+}
+
+ExpandedChartWrapper.contextType = Context;
+
+export default withRouter(ExpandedChartWrapper);
+
+const NotFoundPlaceholder = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+`;

+ 4 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -9,6 +9,7 @@ import {
   DetailedIngressError,
 } from "shared/types";
 import Helper from "components/values-form/Helper";
+import { pushFiltered } from "shared/routing";
 
 import { RouteComponentProps, withRouter } from "react-router";
 
@@ -161,7 +162,9 @@ class Templates extends Component<PropsType, StateType> {
           <TemplateBlock
             onClick={() => {
               this.context.setCurrentCluster(cluster);
-              this.props.history.push("applications");
+              pushFiltered(this.props, "/applications", ["project_id"], {
+                cluster: cluster.name,
+              });
             }}
             key={i}
           >

+ 3 - 13
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -6,6 +6,7 @@ import { ClusterType } from "shared/types";
 
 import ClusterList from "./ClusterList";
 import Loading from "components/Loading";
+import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
 type PropsType = {
   currentCluster: ClusterType;
@@ -36,25 +37,14 @@ export default class ClusterPlaceholder extends Component<
   }
 
   render() {
-    if (this.state.loading) {
+    if (this.state.loading || this.props.currentCluster?.id === -1) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
     } else if (!this.props.currentCluster) {
-      return (
-        <StyledStatusPlaceholder>
-          You need to connect a cluster to use Porter.
-          <Highlight
-            onClick={() => {
-              this.context.setCurrentModal("ClusterInstructionsModal", {});
-            }}
-          >
-            + Connect an existing cluster
-          </Highlight>
-        </StyledStatusPlaceholder>
-      );
+      return <NoClusterPlaceholder />;
     } else {
       return <ClusterList currentCluster={this.props.currentCluster} />;
     }

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

@@ -13,19 +13,13 @@ import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/values-form/FormDebugger";
 
-import { setSearchParam } from "shared/routing";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   projectId: number | null;
   setRefreshClusters: (x: boolean) => void;
 };
 
-const tabOptions = [
-  { label: "Project Overview", value: "overview" },
-  { label: "Create a Cluster", value: "create-cluster" },
-  { label: "Provisioner Status", value: "provisioner" },
-];
-
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 
@@ -101,7 +95,7 @@ class Dashboard extends Component<PropsType, StateType> {
   }
 
   onShowProjectSettings = () => {
-    this.props.history.push("project-settings");
+    pushFiltered(this.props, "/project-settings", ["project_id"]);
   };
 
   currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
@@ -125,7 +119,7 @@ class Dashboard extends Component<PropsType, StateType> {
   };
 
   setCurrentTab = (x: string) => {
-    this.props.history.push(setSearchParam(this.props.location, "tab", x));
+    pushQueryParams(this.props, { tab: x });
   };
 
   render() {

+ 11 - 4
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -7,6 +7,7 @@ import { integrationList } from "shared/common";
 import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import api from "shared/api";
+import { pushFiltered } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   category: string;
@@ -129,7 +130,9 @@ class IntegrationCategories extends Component<PropsType, StateType> {
             <Flex>
               <i
                 className="material-icons"
-                onClick={() => this.props.history.push("/integrations")}
+                onClick={() =>
+                  pushFiltered(this.props, "/integrations", ["project_id"])
+                }
               >
                 keyboard_backspace
               </i>
@@ -141,8 +144,10 @@ class IntegrationCategories extends Component<PropsType, StateType> {
                 this.context.setCurrentModal("IntegrationsModal", {
                   category: currentCategory,
                   setCurrentIntegration: (x: string) =>
-                    this.props.history.push(
-                      `/integrations/${this.props.category}/create/${x}`
+                    pushFiltered(
+                      this.props,
+                      `/integrations/${this.props.category}/create/${x}`,
+                      ["project_id"]
                     ),
                 })
               }
@@ -172,7 +177,9 @@ class IntegrationCategories extends Component<PropsType, StateType> {
             <Flex>
               <i
                 className="material-icons"
-                onClick={() => this.props.history.push("/integrations")}
+                onClick={() =>
+                  pushFiltered(this.props, "/integrations", ["project_id"])
+                }
               >
                 keyboard_backspace
               </i>

+ 12 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -3,6 +3,7 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 
 import { integrationList } from "shared/common";
 import styled from "styled-components";
+import { pushFiltered } from "shared/routing";
 
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";
@@ -29,7 +30,7 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
             const { integration, category } = rp.match.params;
             if (!IntegrationCategoryStrings.includes(category)) {
-              this.props.history.push("/integrations");
+              pushFiltered(this.props, "/integrations", ["project_id"]);
             }
             let icon =
               integrationList[integration] && integrationList[integration].icon;
@@ -40,7 +41,9 @@ class Integrations extends Component<PropsType, StateType> {
                     <i
                       className="material-icons"
                       onClick={() =>
-                        this.props.history.push(`/integrations/${category}`)
+                        pushFiltered(this.props, `/integrations/${category}`, [
+                          "project_id",
+                        ])
                       }
                     >
                       keyboard_backspace
@@ -52,7 +55,9 @@ class Integrations extends Component<PropsType, StateType> {
                 <CreateIntegrationForm
                   integrationName={integration}
                   closeForm={() => {
-                    this.props.history.push(`/integrations/${category}`);
+                    pushFiltered(this.props, `/integrations/${category}`, [
+                      "project_id",
+                    ]);
                   }}
                 />
                 <Br />
@@ -65,7 +70,7 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
             const currentCategory = rp.match.params.category;
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
-              this.props.history.push("/integrations");
+              pushFiltered(this.props, "/integrations", ["project_id"]);
             }
             return (
               <IntegrationCategories
@@ -83,7 +88,9 @@ class Integrations extends Component<PropsType, StateType> {
             <IntegrationList
               currentCategory={""}
               integrations={["kubernetes", "registry", "repo"]}
-              setCurrent={(x) => this.props.history.push(`/integrations/${x}`)}
+              setCurrent={(x) =>
+                pushFiltered(this.props, `/integrations/${x}`, ["project_id"])
+              }
               isCategory={true}
               updateIntegrationList={() => {}}
             />

+ 35 - 36
dashboard/src/main/home/launch/Launch.tsx

@@ -9,6 +9,7 @@ import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
+import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
 import hardcodedNames from "./hardcodedNameDict";
 import semver from "semver";
@@ -197,6 +198,38 @@ export default class Templates extends Component<PropsType, StateType> {
     }
   };
 
+  renderContents = () => {
+    if (this.context.currentCluster) {
+      return (
+        <>
+          <TabSelector
+            options={tabOptions}
+            currentTab={this.state.currentTab}
+            setCurrentTab={(value: string) =>
+              this.setState({
+                currentTab: value,
+                currentTemplate: null,
+              })
+            }
+          />
+          {this.renderTabContents()}
+        </>
+      );
+    } else if (this.context.currentCluster?.id === -1) {
+      return <Loading />;
+    } else if (!this.context.currentCluster) {
+      return (
+        <>
+          <Banner>
+            <i className="material-icons">error_outline</i>
+            No cluster connected to this project.
+          </Banner>
+          <NoClusterPlaceholder />
+        </>
+      );
+    }
+  };
+
   render() {
     if (!this.state.isOnLaunchFlow || !this.state.currentTemplate) {
       return (
@@ -207,41 +240,7 @@ export default class Templates extends Component<PropsType, StateType> {
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>
-          {this.context.currentCluster ? (
-            <>
-              <TabSelector
-                options={tabOptions}
-                currentTab={this.state.currentTab}
-                setCurrentTab={(value: string) =>
-                  this.setState({
-                    currentTab: value,
-                    currentTemplate: null,
-                  })
-                }
-              />
-              {this.renderTabContents()}
-            </>
-          ) : (
-            <>
-              <Banner>
-                <i className="material-icons">error_outline</i>
-                No cluster connected to this project.
-              </Banner>
-              <StyledStatusPlaceholder>
-                You need to connect a cluster to use Porter.
-                <Highlight
-                  onClick={() => {
-                    this.context.setCurrentModal(
-                      "ClusterInstructionsModal",
-                      {}
-                    );
-                  }}
-                >
-                  + Connect an existing cluster
-                </Highlight>
-              </StyledStatusPlaceholder>
-            </>
-          )}
+          {this.renderContents()}
         </TemplatesWrapper>
       );
     } else {
@@ -277,7 +276,7 @@ const Placeholder = styled.div`
 const Banner = styled.div`
   height: 40px;
   width: 100%;
-  margin: 30px 0 30px;
+  margin: 30px 0 38px;
   font-size: 13px;
   display: flex;
   border-radius: 5px;

+ 0 - 14
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -5,9 +5,7 @@ import { PorterTemplate } from "shared/types";
 import api from "shared/api";
 
 import TemplateInfo from "./TemplateInfo";
-import LaunchTemplate from "./LaunchTemplate";
 import Loading from "components/Loading";
-import { template } from "lodash";
 
 type PropsType = {
   currentTemplate: PorterTemplate;
@@ -88,18 +86,6 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
         </LoadingWrapper>
       );
     }
-    if (this.props.skipDescription || this.state.showLaunchTemplate) {
-      return (
-        <LaunchTemplate
-          currentTab={this.props.currentTab}
-          currentTemplate={this.props.currentTemplate}
-          hideLaunch={() => this.setState({ showLaunchTemplate: false })}
-          hideBackButton={this.props.skipDescription}
-          values={this.state.values}
-          form={this.state.form}
-        />
-      );
-    }
 
     return (
       <FadeWrapper>

+ 23 - 33
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -4,6 +4,7 @@ import randomWords from "random-words";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import api from "shared/api";
+import { pushFiltered } from "shared/routing";
 import close from "assets/close.png";
 import { RouteComponentProps, withRouter } from "react-router";
 
@@ -162,11 +163,15 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       .then((_) => {
         // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard
+          // TODO: redirect to appropriate cluster if not current context
           let dst =
-            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+            this.props.currentTemplate.name === "job"
+              ? "/jobs"
+              : "/applications";
           setTimeout(() => {
-            this.props.history.push(dst);
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 500);
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
@@ -301,39 +306,16 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           setTimeout(() => {
             let dst =
               this.props.currentTemplate.name === "job"
-                ? "jobs"
-                : "applications";
-            this.props.history.push(dst);
+                ? "/jobs"
+                : "/applications";
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 1000);
         });
-        /*
-        try {
-          window.analytics.track("Deployed Application", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            sourceType: this.state.sourceType,
-            values: values,
-          });
-        } catch (error) {
-          console.log(error);
-        }
-        */
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-        /*
-        try {
-          window.analytics.track("Failed to Deploy Application", {
-            name: this.props.currentTemplate.name,
-            namespace: this.state.selectedNamespace,
-            sourceType: this.state.sourceType,
-            values: values,
-            error: err,
-          });
-        } catch (error) {
-          console.log(error);
-        }
-        */
       });
   };
 
@@ -584,7 +566,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             Specify the container image you would like to connect to this
             template.
             <Highlight
-              onClick={() => this.props.history.push("integrations/registry")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/registry", [
+                  "project_id",
+                ])
+              }
             >
               Manage Docker registries
             </Highlight>
@@ -610,7 +596,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           <Subtitle>
             Provide a repo folder to use as source.
             <Highlight
-              onClick={() => this.props.history.push("integrations/repo")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/repo", ["project_id"])
+              }
             >
               Manage Git repos
             </Highlight>
@@ -686,6 +674,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   render() {
+    console.log("RENDERING");
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
 
@@ -755,6 +744,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(namespace: string) =>
               this.setState({ selectedNamespace: namespace })
             }
+            addButton={true}
             options={this.state.namespaceOptions}
             width="250px"
             dropdownWidth="335px"

+ 12 - 6
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -6,6 +6,7 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
 
 import hardcodedNames from "../hardcodedNameDict";
 import SourcePage from "./SourcePage";
@@ -163,9 +164,13 @@ class LaunchFlow extends Component<PropsType, StateType> {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
           let dst =
-            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
+            this.props.currentTemplate.name === "job"
+              ? "/jobs"
+              : "/applications";
           setTimeout(() => {
-            this.props.history.push(dst);
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 500);
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
@@ -311,7 +316,6 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .then((res: any) => {
         if (sourceType === "repo") {
           let env = rawValues["container.env.normal"];
-          console.log(env);
           this.createGHAction(name, selectedNamespace, env);
         }
         // this.props.setCurrentView('cluster-dashboard');
@@ -320,9 +324,11 @@ class LaunchFlow extends Component<PropsType, StateType> {
           setTimeout(() => {
             let dst =
               this.props.currentTemplate.name === "job"
-                ? "jobs"
-                : "applications";
-            this.props.history.push(dst);
+                ? "/jobs"
+                : "/applications";
+            pushFiltered(this.props, dst, ["project_id"], {
+              cluster: currentCluster.name,
+            });
           }, 1000);
         });
       })

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

@@ -253,6 +253,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
             </NamespaceLabel>
             <Selector
               key={"namespace"}
+              refreshOptions={() => {
+                this.updateNamespaces(this.context.currentCluster.id);
+              }}
+              addButton={true}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}

+ 11 - 2
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -5,6 +5,7 @@ import { Context } from "shared/Context";
 import { RouteComponentProps, withRouter } from "react-router";
 import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
+import { pushFiltered } from "shared/routing";
 
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
@@ -99,7 +100,11 @@ class SourcePage extends Component<PropsType, StateType> {
             Specify the container image you would like to connect to this
             template.
             <Highlight
-              onClick={() => this.props.history.push("integrations/registry")}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations/registry", [
+                  "project_id",
+                ])
+              }
             >
               Manage Docker registries
             </Highlight>
@@ -145,7 +150,11 @@ class SourcePage extends Component<PropsType, StateType> {
         </CloseButton>
         <Subtitle>
           Provide a repo folder to use as source.
-          <Highlight onClick={() => history.push("integrations/repo")}>
+          <Highlight
+            onClick={() =>
+              pushFiltered(this.props, "/integrations/repo", ["project_id"])
+            }
+          >
             Manage Git repos
           </Highlight>
           <Required>*</Required>

+ 0 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -55,7 +55,6 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           envGroups: res?.data?.items as any[],
           loading: false,
         });
-        console.log(res.data.items);
       })
       .catch((err) => {
         this.setState({ loading: false, error: true });

+ 199 - 0
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -0,0 +1,199 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+type PropsType = {};
+
+type StateType = {
+  namespaceName: string;
+  status: string | null;
+};
+
+export default class NamespaceModal extends Component<PropsType, StateType> {
+  state = {
+    namespaceName: "",
+    status: null as string | null,
+  };
+
+  createNamespace = () => {
+    api
+      .createNamespace(
+        "<token>",
+        {
+          name: this.state.namespaceName,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.context.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ status: "successful" }, () => {
+          setTimeout(() => {
+            this.context.setCurrentModal(null, null);
+          }, 1000);
+        });
+      })
+      .catch((err) => {
+        this.setState({ status: "Could not create" });
+      });
+  };
+
+  render() {
+    return (
+      <StyledUpdateProjectModal>
+        <CloseButton
+          onClick={() => {
+            this.context.setCurrentModal(null, null);
+          }}
+        >
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Add Namespace</ModalTitle>
+        <Subtitle>Name</Subtitle>
+
+        <InputWrapper>
+          <DashboardIcon>
+            <i className="material-icons">space_dashboard</i>
+          </DashboardIcon>
+          <InputRow
+            type="string"
+            value={this.state.namespaceName}
+            setValue={(x: string) => this.setState({ namespaceName: x })}
+            placeholder="ex: porter-workers"
+            width="480px"
+          />
+        </InputWrapper>
+
+        {/* <Help
+          href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+          target="_blank"
+        >
+          <i className="material-icons">help_outline</i> Help
+        </Help> */}
+
+        <SaveButton
+          text="Create Namespace"
+          color="#616FEEcc"
+          onClick={() => this.createNamespace()}
+          status={this.state.status}
+        />
+      </StyledUpdateProjectModal>
+    );
+  }
+}
+
+NamespaceModal.contextType = Context;
+
+const Help = styled.a`
+  position: absolute;
+  left: 31px;
+  bottom: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+  font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-right: 9px;
+    font-size: 16px;
+  }
+`;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 4 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -4,6 +4,7 @@ import close from "assets/close.png";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
 
 import SaveButton from "components/SaveButton";
 import InputRow from "components/values-form/InputRow";
@@ -51,7 +52,9 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
           this.props.setRefreshClusters(true);
           this.setState({ status: "successful", showDeleteOverlay: false });
           this.context.setCurrentModal(null, null);
-          this.props.history.push("dashboard?tab=overview");
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "overview",
+          });
           return;
         }
 

+ 17 - 9
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 import SelectRow from "components/values-form/SelectRow";
 import InputRow from "components/values-form/InputRow";
@@ -172,9 +173,8 @@ class AWSFormSection extends Component<PropsType, StateType> {
   // Step 1: Create a project
   // TODO: promisify this function
   createProject = (callback?: any) => {
-    console.log("Creating project");
-    let { projectName, handleError } = this.props;
-    let { user, setProjects, setCurrentProject, currentProject } = this.context;
+    let { projectName } = this.props;
+    let { user, setProjects, setCurrentProject } = this.context;
 
     api
       .createProject("<token>", { name: projectName }, {})
@@ -261,7 +261,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
           { id: currentProject.id }
         )
       )
-      .then(() => this.props.history.push("dashboard?tab=provisioner"))
+      .then(() =>
+        pushFiltered(this.props, "/dashboard", ["project_id"], {
+          tab: "provisioner",
+        })
+      )
       .catch(this.catchError);
   };
 
@@ -278,7 +282,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project exists, only provision ECR
         this.provisionECR().then(() =>
-          this.props.history.push("dashboard?tab=provisioner")
+          pushFiltered(this.props, "/dashboard", ["project_id"], {
+            tab: "provisioner",
+          })
         );
       } else {
         // Case: project exists, only provision EKS
@@ -291,9 +297,11 @@ class AWSFormSection extends Component<PropsType, StateType> {
       } else if (selectedInfras[0].value === "ecr") {
         // Case: project DNE, only provision ECR
         this.createProject(() =>
-          this.provisionECR().then(() => {
-            this.props.history.push("dashboard?tab=provisioner");
-          })
+          this.provisionECR().then(() =>
+            pushFiltered(this.props, "/dashboard", ["project_id"], {
+              tab: "provisioner",
+            })
+          )
         );
       } else {
         // Case: project DNE, only provision EKS

+ 3 - 4
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams } from "shared/routing";
 
 import InputRow from "components/values-form/InputRow";
 import CheckboxRow from "components/values-form/CheckboxRow";
@@ -133,7 +134,6 @@ export default class DOFormSection extends Component<PropsType, StateType> {
 
   // Step 1: Create a project
   createProject = (callback?: any) => {
-    console.log("Creating project");
     let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
 
@@ -152,8 +152,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           }
         );
         setProjects(res_1.data);
-        setCurrentProject(proj);
-        callback && callback(proj.id);
+        setCurrentProject(proj, () => callback && callback(proj.id));
       })
       .catch(this.catchError);
   };

+ 6 - 2
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -5,6 +5,7 @@ import api from "shared/api";
 import { ProjectType } from "shared/types";
 import { isAlphanumeric } from "shared/common";
 import { Context } from "shared/Context";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 import SaveButton from "components/SaveButton";
 import { RouteComponentProps, withRouter } from "react-router";
@@ -45,8 +46,11 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
             let proj = res.data.find((el: ProjectType) => {
               return el.name === projectName;
             });
-            setCurrentProject(proj);
-            this.props.history.push("dashboard?tab=overview");
+            setCurrentProject(proj, () =>
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "overview",
+              })
+            );
           }
         }
       })

+ 12 - 11
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -5,7 +5,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
+import { InfraType, ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 
 import UploadArea from "components/values-form/UploadArea";
 import SelectRow from "components/values-form/SelectRow";
@@ -164,8 +165,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
   // Step 1: Create a project
   createProject = (callback?: any) => {
-    console.log("Creating project");
-    let { projectName, handleError } = this.props;
+    let { projectName } = this.props;
     let { user, setProjects, setCurrentProject } = this.context;
 
     api
@@ -185,8 +185,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
           )
           .then((res) => {
             setProjects(res.data);
-            setCurrentProject(proj);
-            callback && callback();
+            setCurrentProject(proj, () => callback && callback());
           })
           .catch(this.catchError);
       })
@@ -222,9 +221,11 @@ class GCPFormSection extends Component<PropsType, StateType> {
         },
         { project_id: currentProject.id }
       )
-      .then((res) => {
-        this.props.history.push("dashboard?tab=provisioner");
-      })
+      .then((res) =>
+        pushFiltered(this.props, "/dashboard", ["project_id"], {
+          tab: "provisioner",
+        })
+      )
       .catch(this.catchError);
   };
 
@@ -243,7 +244,6 @@ class GCPFormSection extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res?.data) {
-          console.log("gcp provisioned with response: ", res.data);
           let { id } = res.data;
 
           if (selectedInfras.length === 2) {
@@ -252,7 +252,9 @@ class GCPFormSection extends Component<PropsType, StateType> {
           } else if (selectedInfras[0].value === "gcr") {
             // Case: project exists, only provision GCR
             this.provisionGCR(id).then(() =>
-              this.props.history.push("dashboard?tab=provisioner")
+              pushFiltered(this.props, "/dashboard", ["project_id"], {
+                tab: "provisioner",
+              })
             );
           } else {
             // Case: project exists, only provision GKE
@@ -319,7 +321,6 @@ class GCPFormSection extends Component<PropsType, StateType> {
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-    console.log("gcpkeydata", gcpKeyData);
     return (
       <StyledGCPFormSection>
         <FormSection>

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

@@ -12,6 +12,7 @@ import DOFormSection from "./DOFormSection";
 import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   isInNewProject?: boolean;
@@ -40,7 +41,7 @@ class NewProject extends Component<PropsType, StateType> {
     setCurrentError(
       "Provisioning failed. Check your credentials and try again."
     );
-    this.props.history.push("dashboard?tab=overview");
+    pushFiltered(this.props, "/dashboard", ["project_id"], { tab: "overview" });
   };
 
   renderSelectedProvider = (override?: string) => {

+ 28 - 4
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -38,7 +38,12 @@ class ClusterSection extends Component<PropsType, StateType> {
   };
 
   updateClusters = () => {
-    let { user, currentProject, setCurrentCluster } = this.context;
+    let {
+      user,
+      currentProject,
+      setCurrentCluster,
+      currentCluster,
+    } = this.context;
 
     // TODO: query with selected filter once implemented
     api
@@ -55,11 +60,31 @@ class ClusterSection extends Component<PropsType, StateType> {
           let clusters = res.data;
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
+            let queryString = window.location.search;
+            let urlParams = new URLSearchParams(queryString);
+            let paramClusterName = urlParams.get("cluster");
+            let params = this.props.match.params as any;
+            let pathClusterName = params.cluster;
+
+            // Set cluster from URL if in path or params
+            let defaultCluster = null as ClusterType;
+            if (paramClusterName || pathClusterName) {
+              clusters.forEach((cluster: ClusterType) => {
+                if (!defaultCluster) {
+                  if (cluster.name === pathClusterName) {
+                    defaultCluster = cluster;
+                  } else if (cluster.name === paramClusterName) {
+                    defaultCluster = cluster;
+                  }
+                }
+              });
+            }
+
             this.setState({ clusters });
             let saved = JSON.parse(
               localStorage.getItem(currentProject.id + "-cluster")
             );
-            if (saved && saved !== "null") {
+            if (!defaultCluster && saved && saved !== "null") {
               // Ensures currentCluster isn't prematurely set (causes issues downstream)
               let loaded = false;
               for (let i = 0; i < clusters.length; i++) {
@@ -77,7 +102,7 @@ class ClusterSection extends Component<PropsType, StateType> {
                 setCurrentCluster(clusters[0]);
               }
             } else {
-              setCurrentCluster(clusters[0]);
+              setCurrentCluster(defaultCluster || clusters[0]);
             }
           } else if (
             this.props.currentView !== "provisioner" &&
@@ -85,7 +110,6 @@ class ClusterSection extends Component<PropsType, StateType> {
           ) {
             this.setState({ clusters: [] });
             setCurrentCluster(null);
-            // this.props.history.push("dashboard?tab=overview");
           }
         }
       })

+ 6 - 2
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -5,6 +5,7 @@ import close from "assets/close.png";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   toggleDrawer: () => void;
@@ -33,8 +34,11 @@ class Drawer extends Component<PropsType, StateType> {
             key={i}
             active={cluster.name === currentCluster.name}
             onClick={() => {
-              setCurrentCluster(cluster);
-              this.props.history.push("applications");
+              setCurrentCluster(cluster, () => {
+                pushFiltered(this.props, "/applications", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              });
             }}
           >
             <ClusterIcon>

+ 10 - 7
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -4,6 +4,7 @@ import gradient from "assets/gradient.png";
 
 import { Context } from "shared/Context";
 import { ProjectType } from "shared/types";
+import { pushQueryParams, pushFiltered } from "shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 
 type PropsType = RouteComponentProps & {
@@ -45,7 +46,6 @@ class ProjectSection extends Component<PropsType, StateType> {
 
   renderOptionList = () => {
     let { setCurrentProject } = this.context;
-
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
@@ -53,8 +53,9 @@ class ProjectSection extends Component<PropsType, StateType> {
           selected={project.name === this.props.currentProject.name}
           onClick={() => {
             this.setState({ expanded: false });
-            setCurrentProject(project);
-            this.props.history.push("dashboard");
+            setCurrentProject(project, () =>
+              pushFiltered(this.props, "/dashboard", ["project_id"])
+            );
           }}
         >
           <ProjectIcon>
@@ -76,9 +77,9 @@ class ProjectSection extends Component<PropsType, StateType> {
             <Option
               selected={false}
               lastItem={true}
-              onClick={() => {
-                this.props.history.push("new-project");
-              }}
+              onClick={() =>
+                pushFiltered(this.props, "/new-project", ["project_id"])
+              }
             >
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectLabel>Create a Project</ProjectLabel>
@@ -114,7 +115,9 @@ class ProjectSection extends Component<PropsType, StateType> {
       );
     }
     return (
-      <InitializeButton onClick={() => this.props.history.push("new-project")}>
+      <InitializeButton
+        onClick={() => pushFiltered(this.props, "new-project", ["project_id"])}
+      >
         <Plus>+</Plus> Create a Project
       </InitializeButton>
     );

+ 82 - 13
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -15,6 +15,7 @@ import ClusterSection from "./ClusterSection";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered, pushQueryParams } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   forceSidebar: boolean;
@@ -106,7 +107,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
             selected={currentView === "applications"}
             onClick={() => {
-              this.props.history.push("/applications");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/applications",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/applications",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
           >
             <Img src={monoweb} />
@@ -115,7 +139,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
             selected={currentView === "jobs"}
             onClick={() => {
-              this.props.history.push("/jobs");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/jobs",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/jobs",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
           >
             <Img src={monojob} />
@@ -124,7 +171,30 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
             selected={currentView === "env-groups"}
             onClick={() => {
-              this.props.history.push("/env-groups");
+              let params = this.props.match.params as any;
+              let pathNamespace = params.namespace;
+
+              // If namespace is currently only in path (ex: ExpandedChart) set to param
+              if (pathNamespace) {
+                pushFiltered(
+                  this.props,
+                  "/env-groups",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                    namespace: pathNamespace,
+                  }
+                );
+              } else {
+                pushFiltered(
+                  this.props,
+                  "/env-groups",
+                  ["project_id", "cluster", "namespace"],
+                  {
+                    cluster: currentCluster.name,
+                  }
+                );
+              }
             }}
           >
             <Img src={sliders} />
@@ -136,7 +206,7 @@ class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    let { currentView } = this.props;
+    let { currentView, history, location } = this.props;
     let { currentProject, setCurrentModal } = this.context;
     if (currentProject) {
       return (
@@ -145,7 +215,7 @@ class Sidebar extends Component<PropsType, StateType> {
           <NavButton
             onClick={() =>
               currentView !== "provisioner" &&
-              this.props.history.push("/dashboard?tab=overview")
+              pushFiltered(this.props, "/dashboard", ["project_id"])
             }
             selected={
               currentView === "dashboard" || currentView === "provisioner"
@@ -155,7 +225,7 @@ class Sidebar extends Component<PropsType, StateType> {
             Dashboard
           </NavButton>
           <NavButton
-            onClick={() => this.props.history.push("/launch")}
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
             selected={currentView === "launch"}
           >
             <Img src={rocket} />
@@ -163,12 +233,9 @@ class Sidebar extends Component<PropsType, StateType> {
           </NavButton>
           <NavButton
             selected={currentView === "integrations"}
-            onClick={() => {
-              this.props.history.push("/integrations");
-            }}
-            // onClick={() => {
-            //   setCurrentModal("IntegrationsInstructionsModal", {});
-            // }}
+            onClick={() =>
+              pushFiltered(this.props, "/integrations", ["project_id"])
+            }
           >
             <Img src={integrations} />
             Integrations
@@ -177,7 +244,9 @@ class Sidebar extends Component<PropsType, StateType> {
             return obj.user_id === this.context.user.userId;
           })[0].kind === "admin" && (
             <NavButton
-              onClick={() => this.props.history.push("/project-settings")}
+              onClick={() =>
+                pushFiltered(this.props, "/project-settings", ["project_id"])
+              }
               selected={this.props.currentView === "project-settings"}
             >
               <Img enlarge={true} src={settings} />

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

@@ -1,12 +1,20 @@
 import React, { Component } from "react";
 
 import { ProjectType, ClusterType, CapabilityType } from "shared/types";
+import { pushQueryParams } from "shared/routing";
 
 const Context = React.createContext({});
 
 const { Provider } = Context;
 const ContextConsumer = Context.Consumer;
 
+type PropsType = {
+  history: any;
+  location: any;
+};
+
+type StateType = any;
+
 /**
  * Component managing a universal (application-wide) data store.
  *
@@ -18,7 +26,7 @@ const ContextConsumer = Context.Consumer;
  *    components consuming Context)
  * 4) As a rule of thumb, Context should not be used for UI-related state
  */
-class ContextProvider extends Component {
+class ContextProvider extends Component<PropsType, StateType> {
   state = {
     currentModal: null as string | null,
     currentModalData: null as any,
@@ -29,7 +37,14 @@ class ContextProvider extends Component {
     setCurrentError: (currentError: string) => {
       this.setState({ currentError });
     },
-    currentCluster: null as ClusterType | null,
+    currentCluster: {
+      id: -1,
+      name: "",
+      server: "",
+      service_account_id: -1,
+      infra_id: -1,
+      service: "",
+    },
     setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
       localStorage.setItem(
         this.state.currentProject.id + "-cluster",
@@ -41,6 +56,7 @@ class ContextProvider extends Component {
     },
     currentProject: null as ProjectType | null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
+      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
         localStorage.setItem("currentProject", currentProject.id.toString());
       } else {

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

@@ -808,6 +808,27 @@ const deleteConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
 });
 
+const createNamespace = baseApi<
+  {
+    name: string;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/create?cluster_id=${cluster_id}`;
+});
+
+const deleteNamespace = baseApi<
+  {
+    name: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  let { id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/delete`;
+});
+
 const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -830,6 +851,7 @@ export default {
   createGHAction,
   createGKE,
   createInvite,
+  createNamespace,
   createPasswordReset,
   createPasswordResetVerify,
   createPasswordResetFinalize,
@@ -839,6 +861,7 @@ export default {
   deleteConfigMap,
   deleteGitRepoIntegration,
   deleteInvite,
+  deleteNamespace,
   deletePod,
   deleteProject,
   deleteRegistryIntegration,

+ 31 - 8
dashboard/src/shared/routing.tsx

@@ -23,15 +23,38 @@ export const PorterUrls = [
   "jobs",
 ];
 
-export const setSearchParam = (
-  location: Location<any>,
-  key: string,
-  value: string
-) => {
+// TODO: consolidate with pushFiltered
+export const pushQueryParams = (props: any, params: any) => {
+  let { location, history } = props;
   const urlParams = new URLSearchParams(location.search);
-  urlParams.set(key, value);
-  return {
+  Object.keys(params)?.forEach((key: string) => {
+    params[key] && urlParams.set(key, params[key]);
+  });
+  history.push({
     pathname: location.pathname,
     search: urlParams.toString(),
-  };
+  });
+};
+
+export const pushFiltered = (
+  props: any, // Props for retrieving history and location
+  pathname: string, // Path to redirect to
+  keys: string[], // Query params to preserve during redirect
+  params?: any
+) => {
+  let { location, history } = props;
+  let urlParams = new URLSearchParams(location.search);
+  let newUrlParams = new URLSearchParams("");
+  keys?.forEach((key: string) => {
+    let value = urlParams.get(key);
+    value && newUrlParams.set(key, value);
+  });
+  params &&
+    Object.keys(params)?.forEach((key: string) => {
+      params[key] && newUrlParams.set(key, params[key]);
+    });
+  history.push({
+    pathname,
+    search: newUrlParams.toString(),
+  });
 };

+ 2 - 2
docs/deploy/applications/deploying-from-docker-registry.md

@@ -21,6 +21,6 @@ Let's get started!
 
 5. To programmatically redeploy your service (for instance, from a CI pipeline), you will need to call your service's custom webhook. You can find your webhook by expanding your deployed service and going to the **Settings** tab.
 
-![Webhook](https://files.readme.io/23e217a-Screen_Shot_2021-03-18_at_11.29.16_AM.png "Screen Shot 2021-03-18 at 11.29.16 AM.png")
+![Webhook](https://user-images.githubusercontent.com/11699655/120046959-ac25c480-c013-11eb-8b2f-e6bfd704d7fc.png "webhook in the settings tab")
 
-Make sure to replace the `YOUR_COMMIT_HASH` and `IMAGE_REPOSITORY_URL` fields in the generated webhook.
+Make sure to replace the `YOUR_COMMIT_HASH` field with the tag of your Docker image.

+ 1 - 0
go.sum

@@ -1686,6 +1686,7 @@ k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8=
 k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
+k8s.io/apimachinery v0.21.1 h1:Q6XuHGlj2xc+hlMCvqyYfbv3H7SRGn2c8NycxJquDVs=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=

+ 4 - 0
internal/forms/k8s.go

@@ -44,3 +44,7 @@ type ConfigMapForm struct {
 	EnvVariables       map[string]string `json:"variables"`
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 }
+
+type NamespaceForm struct {
+	Name string `json:"name" form:"required"`
+}

+ 24 - 0
internal/kubernetes/agent.go

@@ -233,6 +233,30 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// CreateNamespace creates a namespace with the given name.
+func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	namespace := v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+
+	return a.Clientset.CoreV1().Namespaces().Create(
+		context.TODO(),
+		&namespace,
+		metav1.CreateOptions{},
+	)
+}
+
+// DeleteNamespace deletes the namespace given the name.
+func (a *Agent) DeleteNamespace(name string) error {
+	return a.Clientset.CoreV1().Namespaces().Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
 // ListJobsByLabel lists jobs in a namespace matching a label
 type Label struct {
 	Key string

+ 113 - 0
server/api/k8s_handler.go

@@ -75,6 +75,119 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateNamespace creates a new namespace given the name.
+func (app *App) HandleCreateNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	fmt.Println(vals)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	ns := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(ns); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	namespace, err := agent.CreateNamespace(ns.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteNamespace deletes a namespace given the name.
+func (app *App) HandleDeleteNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	namespace := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	err = agent.DeleteNamespace(namespace.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleListPodEvents retrieves all events tied to a pod.
 func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)

+ 28 - 0
server/router/router.go

@@ -1071,6 +1071,34 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/k8s/namespaces/create",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/namespaces/delete",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/kubeconfig",