Răsfoiți Sursa

dashboard refactor + fix integrations tab spacing

Justin Rhee 3 ani în urmă
părinte
comite
988848552e

+ 2 - 3
dashboard/src/main/home/Home.tsx

@@ -9,7 +9,7 @@ import { ClusterType, ProjectType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
-import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
+import DashboardRouter from "./cluster-dashboard/DashboardRouter";
 import Dashboard from "./dashboard/Dashboard";
 import Integrations from "./integrations/Integrations";
 import LaunchWrapper from "./launch/LaunchWrapper";
@@ -80,7 +80,6 @@ const Home: React.FC<Props> = props => {
   const [sidebarReady, setSidebarReady] = useState(false);
   const [handleDO, setHandleDO] = useState(false);
   const [ghRedirect, setGhRedirect] = useState(false);
-  const [showWelcomeForm, setShowWelcomeForm] = useState(true);
   const [forceSidebar, setForceSidebar] = useState(true);
 
   const redirectToNewProject = () => {
@@ -454,7 +453,7 @@ const Home: React.FC<Props> = props => {
               }
               return (
                 <DashboardWrapper>
-                  <ClusterDashboard
+                  <DashboardRouter
                     currentCluster={currentCluster}
                     setSidebar={setForceSidebar}
                     currentView={props.currentRoute}

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

@@ -1,473 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import monojob from "assets/monojob.png";
-import monoweb from "assets/monoweb.png";
-import loading from "assets/loading.gif";
-import { Route, Switch } from "react-router-dom";
-
-import { Context } from "shared/Context";
-import { ChartType, ClusterType, JobStatusType } from "shared/types";
-import {
-  getQueryParam,
-  PorterUrl,
-  pushFiltered,
-  pushQueryParams,
-} from "shared/routing";
-
-import DashboardHeader from "./DashboardHeader";
-import ChartList from "./chart/ChartList";
-import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
-import { NamespaceSelector } from "./NamespaceSelector";
-import SortSelector from "./SortSelector";
-import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
-import { RouteComponentProps, withRouter } from "react-router";
-
-import api from "shared/api";
-import DashboardRoutes from "./dashboard/Routes";
-import GuardedRoute from "shared/auth/RouteGuard";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import LastRunStatusSelector from "./LastRunStatusSelector";
-import loadable from "@loadable/component";
-import Loading from "components/Loading";
-import JobRunTable from "./chart/JobRunTable";
-import TagFilter from "./TagFilter";
-import ExpandedEnvGroupDashboard from "./env-groups/ExpandedEnvGroupDashboard";
-import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
-
-// @ts-ignore
-const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
-  fallback: <Loading />,
-});
-
-const LazyPreviewEnvironmentsRoutes = loadable(
-  // @ts-ignore
-  () => import("./preview-environments/routes.tsx"),
-  {
-    fallback: <Loading />,
-  }
-);
-
-const LazyStackRoutes = loadable(
-  // @ts-ignore
-  () => import("./stacks/routes.tsx"),
-  {
-    fallback: <Loading />,
-  }
-);
-
-type PropsType = RouteComponentProps &
-  WithAuthProps & {
-    currentCluster: ClusterType;
-    setSidebar: (x: boolean) => void;
-    currentView: PorterUrl;
-  };
-
-type StateType = {
-  namespace: string;
-  sortType: string;
-  lastRunStatus: JobStatusType | null;
-  currentChart: ChartType | null;
-  isMetricsInstalled: boolean;
-  showRuns: boolean;
-  selectedTag: any;
-};
-
-// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
-class ClusterDashboard extends Component<PropsType, StateType> {
-  state = {
-    namespace: null as string,
-    sortType: localStorage.getItem("SortType")
-      ? localStorage.getItem("SortType")
-      : "Newest",
-    lastRunStatus: "all" as null,
-    currentChart: null as ChartType | null,
-    isMetricsInstalled: false,
-    showRuns: false,
-    selectedTag: "none",
-  };
-
-  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>",
-        {},
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => {
-        this.setState({ isMetricsInstalled: true });
-      })
-      .catch(() => {
-        this.setState({ isMetricsInstalled: false });
-      });
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    // Reset namespace filter and close expanded chart on cluster change
-    if (prevProps.currentCluster !== this.props.currentCluster) {
-      let namespace = "default";
-      if (
-        localStorage.getItem(
-          `${this.context.currentProject.id}-${this.context.currentCluster.id}-namespace`
-        )
-      ) {
-        namespace = localStorage.getItem(
-          `${this.context.currentProject.id}-${this.context.currentCluster.id}-namespace`
-        );
-      }
-      this.setState(
-        {
-          namespace,
-          sortType: localStorage.getItem("SortType")
-            ? localStorage.getItem("SortType")
-            : "Newest",
-          currentChart: null,
-        },
-        () => pushQueryParams(this.props, { namespace: "default" })
-      );
-    }
-    if (prevProps.currentView !== this.props.currentView) {
-      let params = this.props.match.params as any;
-      let currentNamespace = params.namespace;
-      if (!currentNamespace) {
-        currentNamespace = getQueryParam(this.props, "namespace");
-      }
-      this.setState(
-        {
-          sortType: "Newest",
-          currentChart: null,
-          namespace: currentNamespace || "default",
-        },
-        () =>
-          pushQueryParams(this.props, {
-            namespace:
-              this.state.namespace === null ? "default" : this.state.namespace,
-          })
-      );
-    }
-  }
-
-  renderCommonFilters = () => {
-    const { currentView } = this.props;
-
-    return (
-      <>
-        <NamespaceSelector
-          setNamespace={(namespace) =>
-            this.setState({ namespace }, () => {
-              pushQueryParams(this.props, {
-                namespace: this.state.namespace || "ALL",
-              });
-            })
-          }
-          namespace={this.state.namespace}
-        />
-        <TagFilter
-          onSelect={(newSelectedTag) =>
-            this.setState({ selectedTag: newSelectedTag })
-          }
-        />
-      </>
-    );
-  };
-
-  renderBodyForApps = () => {
-    let { currentCluster, currentView } = this.props;
-    const isAuthorizedToAdd = this.props.isAuthorized(
-      "namespace",
-      [],
-      ["get", "create"]
-    );
-
-    if (currentCluster.status === "UPDATING_UNAVAILABLE") {
-      return <ClusterProvisioningPlaceholder />
-    }
-
-    return (
-      <>
-        <ControlRow>
-          <FilterWrapper>{this.renderCommonFilters()}</FilterWrapper>
-          <Flex>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-              currentView={currentView}
-            />
-            {isAuthorizedToAdd && (
-              <Button
-                onClick={() =>
-                  pushFiltered(this.props, "/launch", ["project_id"])
-                }
-              >
-                <i className="material-icons">add</i> Launch template
-              </Button>
-            )}
-          </Flex>
-        </ControlRow>
-
-        <ChartList
-          currentView={currentView}
-          currentCluster={currentCluster}
-          lastRunStatus={this.state.lastRunStatus}
-          namespace={this.state.namespace}
-          sortType={this.state.sortType}
-          selectedTag={this.state.selectedTag}
-        />
-      </>
-    );
-  };
-
-  renderBodyForJobs = () => {
-    let { currentCluster, currentView } = this.props;
-    const isAuthorizedToAdd = this.props.isAuthorized(
-      "namespace",
-      [],
-      ["get", "create"]
-    );
-
-    if (currentCluster.status === "UPDATING_UNAVAILABLE") {
-      return <ClusterProvisioningPlaceholder />
-    }
-
-    return (
-      <>
-        <ControlRow>
-          <FilterWrapper>
-            <LastRunStatusSelector
-              lastRunStatus={this.state.lastRunStatus}
-              setLastRunStatus={(lastRunStatus: JobStatusType) => {
-                this.setState({ lastRunStatus });
-              }}
-            />
-            {this.renderCommonFilters()}
-          </FilterWrapper>
-          <Flex>
-            <ToggleButton>
-              <ToggleOption
-                onClick={() => this.setState({ showRuns: false })}
-                selected={!this.state.showRuns}
-              >
-                Jobs
-              </ToggleOption>
-              <ToggleOption
-                nudgeLeft
-                onClick={() => this.setState({ showRuns: true })}
-                selected={this.state.showRuns}
-              >
-                Runs
-              </ToggleOption>
-            </ToggleButton>
-            {isAuthorizedToAdd && (
-              <Button
-                onClick={() =>
-                  pushFiltered(this.props, "/launch", ["project_id"])
-                }
-              >
-                <i className="material-icons">add</i> Launch template
-              </Button>
-            )}
-          </Flex>
-        </ControlRow>
-        <HidableElement show={this.state.showRuns}>
-          <JobRunTable
-            lastRunStatus={this.state.lastRunStatus}
-            namespace={this.state.namespace}
-            sortType={this.state.sortType as any}
-          />
-        </HidableElement>
-        <HidableElement show={!this.state.showRuns}>
-          <ChartList
-            currentView={currentView}
-            currentCluster={currentCluster}
-            lastRunStatus={this.state.lastRunStatus}
-            namespace={this.state.namespace}
-            sortType={this.state.sortType}
-            selectedTag={this.state.selectedTag}
-          />
-        </HidableElement>
-      </>
-    );
-  };
-
-  render() {
-    let { currentView } = this.props;
-    let { setSidebar } = this.props;
-    return (
-      <Switch>
-        <Route path={"/stacks"}>
-          <LazyStackRoutes />
-        </Route>
-        <Route path={"/preview-environments"}>
-          <LazyPreviewEnvironmentsRoutes />
-        </Route>
-        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
-          <ExpandedChartWrapper
-            setSidebar={setSidebar}
-            isMetricsInstalled={this.state.isMetricsInstalled}
-          />
-        </Route>
-        <GuardedRoute
-          path={"/jobs"}
-          scope="job"
-          resource=""
-          verb={["get", "list"]}
-        >
-          <DashboardHeader
-            image={monojob}
-            title={currentView}
-            description="Scripts and tasks that run once or on a repeating interval."
-            disableLineBreak
-          />
-
-          {this.renderBodyForJobs()}
-        </GuardedRoute>
-        <GuardedRoute
-          path={"/applications"}
-          scope="application"
-          resource=""
-          verb={["get", "list"]}
-        >
-          <DashboardHeader
-            image={monoweb}
-            title={currentView}
-            description="Continuously running web services, workers, and add-ons."
-            disableLineBreak
-          />
-
-          {this.renderBodyForApps()}
-        </GuardedRoute>
-        <GuardedRoute
-          path={"/env-groups/:name"}
-          scope="env_group"
-          resource=""
-          verb={["get", "list"]}
-        >
-          <ExpandedEnvGroupDashboard
-            currentCluster={this.props.currentCluster}
-          />
-        </GuardedRoute>
-        <GuardedRoute
-          path={"/env-groups"}
-          scope="env_group"
-          resource=""
-          verb={["get", "list"]}
-        >
-          <EnvGroupDashboard currentCluster={this.props.currentCluster} />
-        </GuardedRoute>
-        <Route path={"/databases"}>
-          <LazyDatabasesRoutes />
-        </Route>
-        <Route path={["/cluster-dashboard"]}>
-          <DashboardRoutes />
-        </Route>
-      </Switch>
-    );
-  }
-}
-
-ClusterDashboard.contextType = Context;
-
-export default withRouter(withAuth(ClusterDashboard));
-
-const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
-  padding: 0 10px;
-  color: ${(props) => (props.selected ? "" : "#494b4f")};
-  border: 1px solid #494b4f;
-  height: 100%;
-  display: flex;
-  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
-  align-items: center;
-  border-radius: ${(props) =>
-    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
-  :hover {
-    border: 1px solid #7a7b80;
-    z-index: 999;
-  }
-`;
-
-const ToggleButton = styled.div`
-  background: #26292e;
-  border-radius: 5px;
-  font-size: 13px;
-  height: 30px;
-  display: flex;
-  align-items: center;
-  cursor: pointer;
-`;
-
-const HidableElement = styled.div<{ show: boolean }>`
-  display: ${(props) => (props.show ? "unset" : "none")};
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  border-bottom: 30px solid transparent;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  flex-wrap: wrap;
-`;
-
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  margin-left: 10px;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 5px;
-  font-weight: 500;
-  color: white;
-  height: 30px;
-  padding: 0 8px;
-  min-width: 155px;
-  padding-right: 13px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const FilterWrapper = styled.div`
-  display: flex;
-  justify-content: space-between;
-  border-bottom: 30px solid transparent;
-  > div:not(:first-child) {
-  }
-`;

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

@@ -0,0 +1,24 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+};
+
+const TemplateComponent: React.FC<Props> = ({
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useEffect(() => {
+    // Do something
+  }, []);
+
+  return (
+    <StyledTemplateComponent>
+    </StyledTemplateComponent>
+  );
+};
+
+export default TemplateComponent;
+
+const StyledTemplateComponent = styled.div`
+`;

+ 190 - 0
dashboard/src/main/home/cluster-dashboard/DashboardRouter.tsx

@@ -0,0 +1,190 @@
+import React, { useState, useContext, useEffect } from "react";
+import styled from "styled-components";
+import loadable from "@loadable/component";
+import { RouteComponentProps, withRouter } from "react-router";
+import { Route, Switch } from "react-router-dom";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { WithAuthProps, withAuth } from "shared/auth/AuthorizationHoc";
+import { ClusterType } from "shared/types";
+import { 
+  getQueryParam,
+  PorterUrl,
+  pushQueryParams,
+} from "shared/routing";
+
+import Loading from "components/Loading";
+import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
+import DashboardRoutes from "./dashboard/Routes";
+import GuardedRoute from "shared/auth/RouteGuard";
+import AppDashboard from "./apps/AppDashboard";
+import JobDashboard from "./jobs/JobDashboard";
+import ExpandedEnvGroupDashboard from "./env-groups/ExpandedEnvGroupDashboard";
+import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
+
+const LazyDatabasesRoutes = loadable(
+  // @ts-ignore
+  () => import("./databases/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
+const LazyPreviewEnvironmentsRoutes = loadable(
+  // @ts-ignore
+  () => import("./preview-environments/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
+const LazyStackRoutes = loadable(
+  // @ts-ignore
+  () => import("./stacks/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
+type Props = RouteComponentProps & WithAuthProps & {
+  currentCluster: ClusterType;
+  setSidebar: (x: boolean) => void;
+  currentView: PorterUrl;
+};
+
+// 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)
+const DashboardRouter: React.FC<Props> = ({
+  setSidebar,
+  currentView,
+  ...props
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [namespace, setNamespace] = useState(null);
+  const [sortType, setSortType] = useState(
+    localStorage.getItem("SortType") || "Newest"
+  );
+  const [currentChart, setCurrentChart] = useState(null);
+  const [isMetricsInstalled, setIsMetricsInstalled] = useState(false);
+
+  useEffect(() => {
+    // Don't add cluster as query param if present in path
+    const { cluster } = props.match?.params as any;
+    if (!cluster) {
+      pushQueryParams(props, { cluster: currentCluster.name });
+    }
+    api.getPrometheusIsInstalled(
+      "<token>",
+      {},
+      {
+        id: currentProject.id,
+        cluster_id: currentCluster.id,
+      }
+    )
+      .then((res) => {
+        setIsMetricsInstalled(true);
+      })
+      .catch(() => {
+        setIsMetricsInstalled(false);
+      });
+  }, []);
+
+  // Reset namespace filter and close expanded chart on cluster change
+  useEffect(() => {
+    let namespace = "default";
+    let localStorageNamespace = localStorage.getItem(
+      `${currentProject.id}-${currentCluster.id}-namespace`
+    );
+    if (localStorageNamespace) {
+      namespace = localStorageNamespace;
+    }
+    setNamespace(namespace);
+    setSortType(localStorage.getItem("SortType") || "Newest");
+    setCurrentChart(null);
+
+    // ret2
+    pushQueryParams(props, { namespace });
+  }, [currentCluster]);
+
+  useEffect(() => {
+    let { currentNamespace } = props.match?.params as any;
+    if (!currentNamespace) {
+      currentNamespace = getQueryParam(props, "namespace");
+    }
+    setSortType("Newest");
+    setCurrentChart(null);
+    setNamespace(currentNamespace || "default");
+    pushQueryParams(props, { namespace: currentNamespace || "default" });
+  }, [currentView]);
+
+  return (
+    <Switch>
+      <Route path={"/stacks"}><LazyStackRoutes /></Route>
+      <Route path={"/preview-environments"}>
+        <LazyPreviewEnvironmentsRoutes />
+      </Route>
+      <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
+        <ExpandedChartWrapper
+          setSidebar={setSidebar}
+          isMetricsInstalled={isMetricsInstalled}
+        />
+      </Route>
+      <GuardedRoute
+        path={"/applications"}
+        scope="application"
+        resource=""
+        verb={["get", "list"]}
+      >
+        <AppDashboard
+          currentView={currentView}
+          namespace={namespace}
+          setNamespace={setNamespace}
+          sortType={sortType}
+          setSortType={setSortType}
+        />
+      </GuardedRoute>
+      <GuardedRoute
+        path={"/jobs"}
+        scope="job"
+        resource=""
+        verb={["get", "list"]}
+      >
+        <JobDashboard
+          currentView={currentView}
+          namespace={namespace}
+          setNamespace={setNamespace}
+          sortType={sortType}
+        />
+      </GuardedRoute>
+      <GuardedRoute
+        path={"/env-groups/:name"}
+        scope="env_group"
+        resource=""
+        verb={["get", "list"]}
+      >
+        <ExpandedEnvGroupDashboard
+          currentCluster={currentCluster}
+        />
+      </GuardedRoute>
+      <GuardedRoute
+        path={"/env-groups"}
+        scope="env_group"
+        resource=""
+        verb={["get", "list"]}
+      >
+        <EnvGroupDashboard currentCluster={currentCluster} />
+      </GuardedRoute>
+      <Route path={"/databases"}>
+        <LazyDatabasesRoutes />
+      </Route>
+      <Route path={["/cluster-dashboard"]}>
+        <DashboardRoutes />
+      </Route>
+    </Switch>
+  );
+};
+
+export default withRouter(withAuth(DashboardRouter));
+
+const StyledTemplateComponent = styled.div`
+`;

+ 201 - 0
dashboard/src/main/home/cluster-dashboard/apps/AppDashboard.tsx

@@ -0,0 +1,201 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import monoweb from "assets/monoweb.png";
+
+import { Context } from "shared/Context";
+import { JobStatusType } from "shared/types";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { 
+  pushQueryParams,
+  pushFiltered,
+  PorterUrl,
+} from "shared/routing";
+
+import { NamespaceSelector } from "../NamespaceSelector";
+import TagFilter from "../TagFilter";
+import DashboardHeader from "../DashboardHeader";
+import ChartList from "../chart/ChartList";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import SortSelector from "../SortSelector";
+
+type Props = RouteComponentProps & WithAuthProps & {
+  currentView: PorterUrl;
+  namespace?: string;
+  setNamespace?: (namespace: string) => void;
+  sortType: any;
+  setSortType: (sortType: any) => void;
+};
+
+// TODO: Pull namespace (and sort) down out of DashboardRouter
+const AppDashboard: React.FC<Props> = ({
+  currentView,
+  namespace,
+  setNamespace,
+  sortType,
+  setSortType,
+  ...props
+}) => {
+  const { currentCluster } = useContext(Context);
+  const [selectedTag, setSelectedTag] = useState("none");
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={monoweb}
+        title={currentView}
+        description="Continuously running web services, workers, and add-ons."
+        disableLineBreak
+      />
+      {currentCluster.status === "UPDATING_UNAVAILABLE" ? (
+        <ClusterProvisioningPlaceholder />
+      ) : (
+        <>
+          <ControlRow>
+            <FilterWrapper>
+              <NamespaceSelector
+                setNamespace={(x) => {
+                  setNamespace(x);
+                  pushQueryParams(props, {
+                    namespace: x || "ALL",
+                  });
+                }}
+                namespace={namespace}
+              />
+              <TagFilter
+                onSelect={setSelectedTag}
+              />
+            </FilterWrapper>
+            <Flex>
+              <SortSelector
+                setSortType={setSortType}
+                sortType={sortType}
+                currentView={currentView}
+              />
+              {props.isAuthorized(
+                "namespace", 
+                [], 
+                ["get", "create"]
+              ) && (
+                <Button
+                  onClick={() => {
+                    pushFiltered(props, "/launch", ["project_id"])
+                  }}
+                >
+                  <i className="material-icons">add</i> Launch template
+                </Button>
+              )}
+            </Flex>
+          </ControlRow>
+          <ChartList
+            currentView={currentView}
+            currentCluster={currentCluster}
+            namespace={namespace}
+            sortType={sortType}
+            selectedTag={selectedTag}
+          />
+        </>
+      )}
+    </StyledAppDashboard>
+  );
+};
+
+export default withRouter(withAuth(AppDashboard));
+
+const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
+  padding: 0 10px;
+  color: ${(props) => (props.selected ? "" : "#494b4f")};
+  border: 1px solid #494b4f;
+  height: 100%;
+  display: flex;
+  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
+  align-items: center;
+  border-radius: ${(props) =>
+    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
+  :hover {
+    border: 1px solid #7a7b80;
+    z-index: 999;
+  }
+`;
+
+const ToggleButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  border-bottom: 30px solid transparent;
+`;
+
+const StyledAppDashboard = styled.div`
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+`;
+
+const FilterWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  border-bottom: 30px solid transparent;
+  > div:not(:first-child) {
+  }
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  min-width: 155px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const HidableElement = styled.div<{ show: boolean }>`
+  display: ${(props) => (props.show ? "unset" : "none")};
+`;

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

@@ -71,8 +71,10 @@ export const Dashboard: React.FunctionComponent = () => {
     if (
       context.currentCluster.status !== "UPDATING_UNAVAILABLE" &&
       !tabOptions.find((tab) => tab.value === "nodes")
-    ) {      
-      tabOptions.unshift({ label: "Namespaces", value: "namespaces" });
+    ) {  
+      if (!context.currentProject.capi_provisioner_enabled) {
+        tabOptions.unshift({ label: "Namespaces", value: "namespaces" });
+      }
       tabOptions.unshift({ label: "Metrics", value: "metrics" });
       tabOptions.unshift({ label: "Nodes", value: "nodes" }); 
     }
@@ -82,7 +84,7 @@ export const Dashboard: React.FunctionComponent = () => {
       !tabOptions.find((tab) => tab.value === "configuration")
     ) {
       tabOptions.unshift({ value: "configuration", label: "Configuration" });
-    } 
+    }
   }, []);
 
   useEffect(() => {

+ 226 - 0
dashboard/src/main/home/cluster-dashboard/jobs/JobDashboard.tsx

@@ -0,0 +1,226 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import monojob from "assets/monojob.png";
+
+import { Context } from "shared/Context";
+import { JobStatusType } from "shared/types";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { 
+  pushQueryParams,
+  pushFiltered,
+  PorterUrl,
+} from "shared/routing";
+
+import { NamespaceSelector } from "../NamespaceSelector";
+import TagFilter from "../TagFilter";
+import DashboardHeader from "../DashboardHeader";
+import LastRunStatusSelector from "../LastRunStatusSelector";
+import JobRunTable from "../chart/JobRunTable";
+import ChartList from "../chart/ChartList";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+
+type Props = RouteComponentProps & WithAuthProps & {
+  currentView: PorterUrl;
+  namespace?: string;
+  setNamespace?: (namespace: string) => void;
+  sortType: any;
+};
+
+// TODO: Pull namespace (and sort) down out of DashboardRouter
+const JobDashboard: React.FC<Props> = ({
+  currentView,
+  namespace,
+  setNamespace,
+  sortType,
+  ...props
+}) => {
+  const { currentCluster } = useContext(Context);
+  const [lastRunStatus, setLastRunStatus] = useState("all" as JobStatusType);
+  const [showRuns, setShowRuns] = useState(false);
+  const [selectedTag, setSelectedTag] = useState("none");
+
+  return (
+    <StyledJobDashboard>
+      <DashboardHeader
+        image={monojob}
+        title={currentView}
+        description="Scripts and tasks that run once or on a repeating interval."
+        disableLineBreak
+      />
+      {currentCluster.status === "UPDATING_UNAVAILABLE" ? (
+        <ClusterProvisioningPlaceholder />
+      ) : (
+        <>
+          <ControlRow>
+            <FilterWrapper>
+              <LastRunStatusSelector
+                lastRunStatus={lastRunStatus}
+                setLastRunStatus={setLastRunStatus}
+              />
+              <NamespaceSelector
+                setNamespace={(x) => {
+                  setNamespace(x);
+                  pushQueryParams(props, {
+                    namespace: x || "ALL",
+                  });
+                }}
+                namespace={namespace}
+              />
+              <TagFilter
+                onSelect={setSelectedTag}
+              />
+            </FilterWrapper>
+            <Flex>
+              <ToggleButton>
+                <ToggleOption
+                  onClick={() => setShowRuns(false)}
+                  selected={!showRuns}
+                >
+                  Jobs
+                </ToggleOption>
+                <ToggleOption
+                  nudgeLeft
+                  onClick={() => setShowRuns(true)}
+                  selected={showRuns}
+                >
+                  Runs
+                </ToggleOption>
+              </ToggleButton>
+              {props.isAuthorized(
+                "namespace", 
+                [], 
+                ["get", "create"]
+              ) && (
+                <Button
+                  onClick={() => {
+                    pushFiltered(props, "/launch", ["project_id"]);
+                  }}
+                >
+                  <i className="material-icons">add</i> Launch template
+                </Button>
+              )}
+            </Flex>
+          </ControlRow>
+          <HidableElement show={showRuns}>
+            <JobRunTable
+              lastRunStatus={lastRunStatus}
+              namespace={namespace}
+              sortType={sortType}
+            />
+          </HidableElement>
+          <HidableElement show={!showRuns}>
+            <ChartList
+              currentView={currentView}
+              currentCluster={currentCluster}
+              lastRunStatus={lastRunStatus}
+              namespace={namespace}
+              sortType={sortType}
+              selectedTag={selectedTag}
+            />
+          </HidableElement>
+        </>
+      )}
+    </StyledJobDashboard>
+  );
+};
+
+export default withRouter(withAuth(JobDashboard));
+
+const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
+  padding: 0 10px;
+  color: ${(props) => (props.selected ? "" : "#494b4f")};
+  border: 1px solid #494b4f;
+  height: 100%;
+  display: flex;
+  margin-left: ${(props) => (props.nudgeLeft ? "-1px" : "")};
+  align-items: center;
+  border-radius: ${(props) =>
+    props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
+  :hover {
+    border: 1px solid #7a7b80;
+    z-index: 999;
+  }
+`;
+
+const ToggleButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  border-bottom: 30px solid transparent;
+`;
+
+const StyledJobDashboard = styled.div`
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+`;
+
+const FilterWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  border-bottom: 30px solid transparent;
+  > div:not(:first-child) {
+  }
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  min-width: 155px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const HidableElement = styled.div<{ show: boolean }>`
+  display: ${(props) => (props.show ? "unset" : "none")};
+`;

+ 2 - 1
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -12,6 +12,7 @@ import SlackIntegrationList from "./SlackIntegrationList";
 import TitleSection from "components/TitleSection";
 import GitlabIntegrationList from "./GitlabIntegrationList";
 import leftArrow from "assets/left-arrow.svg";
+import Spacer from "components/porter/Spacer";
 
 type Props = RouteComponentProps & {
   category: string;
@@ -150,6 +151,7 @@ const IntegrationCategories: React.FC<Props> = (props) => {
           {buttonText}
         </Button>
       </Flex>
+      <Spacer y={1} />
       {loading ? (
         <Loading />
       ) : props.category === "gitlab" ? (
@@ -209,7 +211,6 @@ const Breadcrumb = styled.div`
 const Flex = styled.div`
   display: flex;
   align-items: center;
-  margin-bottom: -20px;
   justify-content: space-between;
 
   > i {

+ 0 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -259,7 +259,6 @@ const Placeholder = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   justify-content: center;
-  margin-top: 40px;
   color: #aaaabb;
   border-radius: 5px;
   background: #26292e;

+ 3 - 3
dashboard/src/main/home/integrations/IntegrationRow.tsx

@@ -77,7 +77,7 @@ export default class IntegrationRow extends Component<PropsType, StateType> {
           </MaterialIconTray>
         </MainRow>
         {this.props.expanded && !this.state.editMode && (
-          <ImageHodler adjustMargin={this.props.category !== "repo"}>
+          <ImageHolder adjustMargin={this.props.category !== "repo"}>
             {this.props.category !== "repo" ? (
               <ImageList
                 selectedImageUrl={null}
@@ -104,7 +104,7 @@ export default class IntegrationRow extends Component<PropsType, StateType> {
                 userId={this.props.itemId}
               />
             )}
-          </ImageHodler>
+          </ImageHolder>
         )}
         {this.props.expanded && this.state.editMode && (
           <CreateIntegrationForm
@@ -220,7 +220,7 @@ const I = styled.i`
     props.showList ? "rotate(180deg)" : ""};
 `;
 
-const ImageHodler = styled.div`
+const ImageHolder = styled.div`
   width: 100%;
   padding: 12px;
   margin-top: ${(props: { adjustMargin: boolean }) =>

+ 0 - 2
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -105,7 +105,6 @@ const Placeholder = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   justify-content: center;
-  margin-top: 40px;
   color: #aaaabb;
   border-radius: 5px;
   background: #26292e;
@@ -119,7 +118,6 @@ const Label = styled.div`
 `;
 
 const StyledIntegrationList = styled.div`
-  margin-top: 20px;
   margin-bottom: 80px;
 `;
 

+ 56 - 61
dashboard/src/main/home/new-project/NewProject.tsx

@@ -109,68 +109,63 @@ export const NewProjectFC = () => {
   };
 
   const renderContents = () => {
-    let version = capabilities?.version;
-    if (version !== "production" || user.email === "support@porter.run") {
-      return (
-        <>
-          <FadeWrapper>
-            {!isFirstProject && (
-              <BackButton
-                onClick={() => {
-                  pushFiltered("/dashboard", []);
-                }}
-              >
-                <BackButtonImg src={backArrow} />
-              </BackButton>
-            )}
-            <TitleSection>New project</TitleSection>
-          </FadeWrapper>
-          <FadeWrapper delay="0.7s">
-            <Helper>
-              Project name
-              <Warning highlight={validateProjectName().hasError}>
-                (lowercase letters, numbers, and "-" only)
-              </Warning>
-              <Required>*</Required>
-            </Helper>
-          </FadeWrapper>
-          <SlideWrapper delay="1.2s">
-            <InputWrapper>
-              <ProjectIcon>
-                <ProjectImage src={gradient} />
-                <Letter>
-                  {name ? name.toUpperCase().substring(0, 1) : "-"}
-                </Letter>
-              </ProjectIcon>
-              <InputRow
-                type="string"
-                value={name}
-                setValue={(x: string) => {
-                  setButtonStatus("");
-                  setName(x);
-                }}
-                placeholder="ex: perspective-vortex"
-                width="470px"
-                disabled={buttonStatus === "loading"}
-              />
-            </InputWrapper>
-            <NewProjectSaveButton
-              text="Create project"
-              disabled={false}
-              onClick={createProject}
-              status={buttonStatus}
-              makeFlush={true}
-              clearPosition={true}
-              statusPosition="right"
-              saveText="Creating project..."
-              successText="Project created successfully!"
+    return (
+      <>
+        <FadeWrapper>
+          {!isFirstProject && (
+            <BackButton
+              onClick={() => {
+                pushFiltered("/dashboard", []);
+              }}
+            >
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+          )}
+          <TitleSection>New project</TitleSection>
+        </FadeWrapper>
+        <FadeWrapper delay="0.7s">
+          <Helper>
+            Project name
+            <Warning highlight={validateProjectName().hasError}>
+              (lowercase letters, numbers, and "-" only)
+            </Warning>
+            <Required>*</Required>
+          </Helper>
+        </FadeWrapper>
+        <SlideWrapper delay="1.2s">
+          <InputWrapper>
+            <ProjectIcon>
+              <ProjectImage src={gradient} />
+              <Letter>
+                {name ? name.toUpperCase().substring(0, 1) : "-"}
+              </Letter>
+            </ProjectIcon>
+            <InputRow
+              type="string"
+              value={name}
+              setValue={(x: string) => {
+                setButtonStatus("");
+                setName(x);
+              }}
+              placeholder="ex: perspective-vortex"
+              width="470px"
+              disabled={buttonStatus === "loading"}
             />
-          </SlideWrapper>
-        </>
-      );
-    } else {
-      return <WelcomeForm />;
-    }
+          </InputWrapper>
+          <NewProjectSaveButton
+            text="Create project"
+            disabled={false}
+            onClick={createProject}
+            status={buttonStatus}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="right"
+            saveText="Creating project..."
+            successText="Project created successfully!"
+          />
+        </SlideWrapper>
+      </>
+    );
   };
 
   return (