Преглед изворни кода

Merge pull request #2143 from porter-dev/nico/por-521-frontend-stacks-work

[POR-521] Frontend stacks
abelanger5 пре 3 година
родитељ
комит
f5f8c37d8f
31 измењених фајлова са 2225 додато и 148 уклоњено
  1. 3 1
      dashboard/src/components/repo-selector/RepoList.tsx
  2. 1 0
      dashboard/src/main/home/Home.tsx
  3. 12 102
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  4. 27 6
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  5. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  6. 19 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  7. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  8. 29 27
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  9. 103 0
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  10. 153 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx
  11. 217 0
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  12. 25 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/Status.tsx
  13. 98 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  14. 286 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  15. 233 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  16. 96 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  17. 145 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  18. 106 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  19. 54 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx
  20. 71 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/TemplateSelector.tsx
  21. 61 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/VersionSelector.tsx
  22. 148 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  23. 39 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  24. 40 0
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  25. 47 0
      dashboard/src/main/home/cluster-dashboard/stacks/shared.ts
  26. 88 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  27. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  28. 13 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  29. 90 0
      dashboard/src/shared/api.tsx
  30. 3 1
      dashboard/src/shared/routing.tsx
  31. 11 0
      dashboard/src/shared/types.tsx

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

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useRef, useState } from "react";
 import styled from "styled-components";
-import github from "assets/github.png";
+import github from "assets/github-white.png";
 
 import api from "shared/api";
 import { ActionConfigType, RepoType } from "shared/types";
@@ -401,6 +401,8 @@ const ProviderSelectorStyles = {
     position: absolute;
     background: #37393f;
     border-radius: 3px;
+    max-height: 300px;
+    overflow-y: auto;
     width: calc(100% - 4px);
     box-shadow: 0 8px 20px 0px #00000088;
   `,

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -468,6 +468,7 @@ class Home extends Component<PropsType, StateType> {
                 "/env-groups",
                 "/databases",
                 "/preview-environments",
+                "/stacks",
               ]}
               render={() => {
                 let { currentCluster } = this.context;

+ 12 - 102
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -29,8 +29,6 @@ import LastRunStatusSelector from "./LastRunStatusSelector";
 import loadable from "@loadable/component";
 import Loading from "components/Loading";
 import JobRunTable from "./chart/JobRunTable";
-import SwitchBase from "@material-ui/core/internal/SwitchBase";
-import Selector from "components/Selector";
 import TabSelector from "components/TabSelector";
 import TagFilter from "./TagFilter";
 
@@ -47,6 +45,14 @@ const LazyPreviewEnvironmentsRoutes = loadable(
   }
 );
 
+const LazyStackRoutes = loadable(
+  // @ts-ignore
+  () => import("./stacks/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
   WithAuthProps & {
     currentCluster: ClusterType;
@@ -273,6 +279,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     let { setSidebar } = this.props;
     return (
       <Switch>
+        <Route path={"/stacks"}>
+          <LazyStackRoutes />
+        </Route>
         <Route path={"/preview-environments"}>
           <LazyPreviewEnvironmentsRoutes />
         </Route>
@@ -338,11 +347,6 @@ const HidableElement = styled.div<{ show: boolean }>`
   display: ${(props) => (props.show ? "unset" : "none")};
 `;
 
-const Br = styled.div`
-  width: 100%;
-  height: 1px;
-`;
-
 const ControlRow = styled.div`
   display: flex;
   margin-left: auto;
@@ -352,39 +356,6 @@ const ControlRow = styled.div`
   padding-left: 0px;
 `;
 
-const TopRow = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Description = styled.div`
-  color: #aaaabb;
-  margin-top: 13px;
-  margin-left: 2px;
-  font-size: 13px;
-`;
-
-const InfoLabel = styled.div`
-  width: 72px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  color: #7a838f;
-  font-size: 13px;
-  > i {
-    color: #8b949f;
-    font-size: 18px;
-    margin-right: 5px;
-  }
-`;
-
-const InfoSection = styled.div`
-  margin-top: 20px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 35px;
-`;
-
 const Button = styled.div`
   display: flex;
   flex-direction: row;
@@ -431,67 +402,6 @@ const Button = styled.div`
   }
 `;
 
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7a838fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px 35px;
-`;
-
-const Overlay = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 24px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-`;
-
-const DashboardImage = styled.img`
-  height: 45px;
-  width: 45px;
-  border-radius: 5px;
-`;
-
-const DashboardIcon = styled.div`
-  position: relative;
-  height: 45px;
-  min-width: 45px;
-  width: 45px;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #676c7c;
-  border: 2px solid #8e94aa;
-
-  > i {
-    font-size: 22px;
-  }
-`;
-
-const Img = styled.img`
-  width: 30px;
-`;
-
 const SortFilterWrapper = styled.div`
   display: flex;
   justify-content: space-between;
@@ -499,4 +409,4 @@ const SortFilterWrapper = styled.div`
   > div:not(:first-child) {
     margin-left: 30px;
   }
-`;
+`;

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

@@ -28,6 +28,7 @@ type Props = {
   disableBottomPadding?: boolean;
   closeChartRedirectUrl?: string;
   selectedTag?: any;
+  appFilters?: string[];
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -42,6 +43,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   disableBottomPadding,
   closeChartRedirectUrl,
   selectedTag,
+  appFilters,
 }) => {
   const {
     newWebsocket,
@@ -336,12 +338,22 @@ const ChartList: React.FunctionComponent<Props> = ({
         });
       })
       .filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
+        if (currentView === "jobs" && chart.chart.metadata.name === "job") {
+          return true;
+        }
+
+        if (
+          ["applications", "cluster-dashboard"].includes(currentView) &&
+          chart.chart.metadata.name !== "job"
+        ) {
+          return true;
+        }
+
+        if (currentView === "stacks") {
+          return true;
+        }
+
+        return false;
       })
       .filter((chart: ChartType) => {
         if (currentView !== "jobs") {
@@ -356,6 +368,15 @@ const ChartList: React.FunctionComponent<Props> = ({
           { status: null } as any
         );
         return status.status === lastRunStatus;
+      })
+      .filter((chart: ChartType) => {
+        if (!Array.isArray(appFilters) || appFilters?.length === 0) {
+          return true;
+        }
+
+        return appFilters.some((filter) => {
+          return chart.name.toLowerCase() === filter.toLowerCase();
+        });
       });
 
     if (sortType == "Newest") {

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

@@ -528,7 +528,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo) {
+    if (currentChart?.git_action_config?.git_repo && !currentChart.is_stack) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",

+ 19 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -10,7 +10,7 @@ import {
 } from "shared/types";
 import api from "shared/api";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import ExpandedJobChart, { ExpandedJobChartFC } from "./ExpandedJobChart";
+import { ExpandedJobChartFC } from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
 import PageNotFound from "components/PageNotFound";
@@ -61,12 +61,18 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           }/${chart.namespace}/${chart.name}`;
 
           if (isJob && this.props.match.params?.baseRoute === "applications") {
-            pushFiltered(this.props, route, ["project_id"]);
+            pushFiltered(this.props, route, [
+              "project_id",
+              "closeChartRedirectUrl",
+            ]);
             return;
           }
 
           if (!isJob && this.props.match.params?.baseRoute !== "applications") {
-            pushFiltered(this.props, route, ["project_id"]);
+            pushFiltered(this.props, route, [
+              "project_id",
+              "closeChartRedirectUrl",
+            ]);
             return;
           }
         })
@@ -100,12 +106,19 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           namespace={namespace}
           currentChart={currentChart}
           currentCluster={this.context.currentCluster}
-          closeChart={() =>
+          closeChart={() => {
+            let urlParams = new URLSearchParams(window.location.search);
+
+            if (urlParams.get("closeChartRedirectUrl")) {
+              this.props.history.push(urlParams.get("closeChartRedirectUrl"));
+              return;
+            }
+
             pushFiltered(this.props, "/jobs", ["project_id"], {
               cluster: this.context.currentCluster.name,
               namespace: namespace,
-            })
-          }
+            });
+          }}
           setSidebar={setSidebar}
         />
       );

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -232,7 +232,11 @@ class RevisionSection extends Component<PropsType, StateType> {
             <RollbackButton
               disabled={
                 isCurrent ||
-                !this.props.isAuthorized("application", "", ["get", "update"])
+                !this.props.isAuthorized("application", "", [
+                  "get",
+                  "update",
+                ]) ||
+                this.props.chart.is_stack
               }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })

+ 29 - 27
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -217,33 +217,35 @@ const SettingsSection: React.FC<PropsType> = ({
 
     return (
       <>
-        <>
-          <Heading>Source Settings</Heading>
-          <Helper>Specify an image tag to use.</Helper>
-          <ImageSelector
-            selectedTag={selectedTag}
-            selectedImageUrl={selectedImageUrl}
-            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-            setSelectedTag={(x: string) => setSelectedTag(x)}
-            forceExpanded={true}
-            disableImageSelect={isDeployedFromGithub(currentChart)}
-          />
-          {!loadingWebhookToken && (
-            <>
-              <Br />
-              <Br />
-              <Br />
-              <SaveButton
-                clearPosition={true}
-                statusPosition="right"
-                text="Save Source Settings"
-                status={saveValuesStatus}
-                onClick={handleSubmit}
-              />
-            </>
-          )}
-          <Br />
-        </>
+        {!currentChart.is_stack ? (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={isDeployedFromGithub(currentChart)}
+            />
+            {!loadingWebhookToken && (
+              <>
+                <Br />
+                <Br />
+                <Br />
+                <SaveButton
+                  clearPosition={true}
+                  statusPosition="right"
+                  text="Save Source Settings"
+                  status={saveValuesStatus}
+                  onClick={handleSubmit}
+                />
+              </>
+            )}
+            <Br />
+          </>
+        ) : null}
 
         <>
           <Heading>Redeploy Webhook</Heading>

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

@@ -0,0 +1,103 @@
+import DynamicLink from "components/DynamicLink";
+import React, { useEffect, useState } from "react";
+import { useHistory, useLocation } from "react-router";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import DashboardHeader from "../DashboardHeader";
+import NamespaceSelector from "../NamespaceSelector";
+import StackList from "./_StackList";
+const Dashboard = () => {
+  const [currentNamespace, setCurrentNamespace] = useState("default");
+
+  const location = useLocation();
+  const history = useHistory();
+  const { getQueryParam, pushQueryParams } = useRouting();
+
+  const handleNamespaceChange = (namespace: string) => {
+    setCurrentNamespace(namespace);
+    pushQueryParams({ namespace });
+  };
+
+  useEffect(() => {
+    const newNamespace = getQueryParam("namespace");
+    if (newNamespace !== currentNamespace) {
+      setCurrentNamespace(newNamespace);
+    }
+  }, [location.search, history]);
+
+  return (
+    <>
+      <DashboardHeader
+        materialIconClass="material-icons-outlined"
+        image={"lan"}
+        title="Stacks"
+        description="Groups of applications deployed from a shared source."
+      />
+      <ActionRow>
+        <Button to={"/stacks/launch"}>
+          <i className="material-icons">add</i>
+          Create Stack
+        </Button>
+        <NamespaceSelector
+          namespace={currentNamespace}
+          setNamespace={handleNamespaceChange}
+        />
+      </ActionRow>
+      <StackList namespace={currentNamespace} />
+    </>
+  );
+};
+
+export default Dashboard;
+
+const Button = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  min-width: 130px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const ActionRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+`;

+ 153 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx

@@ -0,0 +1,153 @@
+import Loading from "components/Loading";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import ChartList from "../chart/ChartList";
+import SortSelector from "../SortSelector";
+import Status from "./components/Status";
+import {
+  Br,
+  InfoWrapper,
+  LastDeployed,
+  LineBreak,
+  SepDot,
+  Text,
+} from "./components/styles";
+import { getStackStatus, getStackStatusMessage } from "./shared";
+import { Stack } from "./types";
+
+const ExpandedStack = () => {
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [stack, setStack] = useState<Stack>();
+  const [sortType, setSortType] = useState("Alphabetical");
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    console.log(stack_id);
+    let isSubscribed = true;
+
+    api
+      .getStack(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack_id,
+          namespace,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setStack(res.data);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+  }, [stack_id]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <div>
+      <TitleSection
+        materialIconClass="material-icons-outlined"
+        icon={"lan"}
+        capitalize
+      >
+        {stack.name}
+      </TitleSection>
+      <Br />
+      <InfoWrapper>
+        <LastDeployed>
+          <Status
+            status={getStackStatus(stack)}
+            message={getStackStatusMessage(stack)}
+          />
+          <SepDot>•</SepDot>
+          <Text color="#aaaabb">
+            {!stack.latest_revision?.id
+              ? `No version found`
+              : `v${stack.latest_revision.id}`}
+          </Text>
+          <SepDot>•</SepDot>
+          Last updated {readableDate(stack.updated_at)}
+        </LastDeployed>
+      </InfoWrapper>
+
+      {/* Stack error message */}
+      {stack.latest_revision &&
+      stack.latest_revision.status === "failed" &&
+      stack.latest_revision.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <StackErrorMessageStyles.Title color="#b7b7c9">
+            Error reason:
+          </StackErrorMessageStyles.Title>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {stack.latest_revision.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <LineBreak />
+
+      <SortSelector
+        setSortType={setSortType}
+        sortType={sortType}
+        currentView="stacks"
+      />
+
+      <ChartListWrapper>
+        <ChartList
+          currentCluster={currentCluster}
+          currentView="stacks"
+          namespace={namespace}
+          sortType="Alphabetical"
+          appFilters={
+            stack?.latest_revision?.resources?.map((res) => res.name) || []
+          }
+          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+        />
+      </ChartListWrapper>
+    </div>
+  );
+};
+
+export default ExpandedStack;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const StackErrorMessageStyles = {
+  Text: styled(Text)`
+    font-size: 14px;
+    margin-bottom: 10px;
+  `,
+  Wrapper: styled.div`
+    display: flex;
+    flex-direction: column;
+    margin-top: 5px;
+  `,
+  Title: styled(Text)`
+    font-size: 16px;
+    font-weight: bold;
+  `,
+};

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -0,0 +1,217 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import Placeholder from "components/Placeholder";
+import styled from "styled-components";
+import { Stack } from "./types";
+import { readableDate } from "shared/string_utils";
+import { CardGrid, Card } from "./launch/components/styles";
+import Status, { StatusProps } from "./components/Status";
+import {
+  Flex,
+  InfoWrapper,
+  LastDeployed,
+  SepDot,
+  Text,
+} from "./components/styles";
+import { getStackStatus, getStackStatusMessage } from "./shared";
+
+const StackList = ({ namespace }: { namespace: string }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [stacks, setStacks] = useState<Stack[]>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [deleting, setDeleting] = useState<string | null>(null);
+
+  const handleDelete = (stack: Stack) => {
+    setDeleting(stack.id);
+    api
+      .deleteStack(
+        "<token>",
+        {},
+        {
+          namespace,
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+        }
+      )
+      .then(() => {
+        setStacks((prev) => prev.filter((s) => s.id !== stack.id));
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setDeleting(null);
+      });
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIsLoading(true);
+
+    api
+      .listStacks(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setStacks(res.data);
+        }
+      })
+      .catch((err) => {
+        if (isSubscribed) {
+          setCurrentError(err);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+  }, [namespace]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (stacks?.length === 0) {
+    return (
+      <Placeholder height="250px">
+        <div>
+          <h3>No stacks found</h3>
+          <p>You can create a stack by clicking the "Create Stack" button.</p>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <CardGrid>
+        {stacks.map((stack) => (
+          <StackCard
+            as={DynamicLink}
+            key={stack?.id}
+            to={`/stacks/${namespace}/${stack?.id}`}
+          >
+            <DataContainer>
+              <StackName>
+                <StackIcon>
+                  <i className="material-icons-outlined">lan</i>
+                </StackIcon>
+                <span>{stack.name}</span>
+              </StackName>
+
+              <InfoWrapper>
+                <LastDeployed>
+                  <Status
+                    status={getStackStatus(stack)}
+                    message={getStackStatusMessage(stack)}
+                  />
+                  <SepDot>•</SepDot>
+                  <Text color="#aaaabb">
+                    {!stack.latest_revision?.id
+                      ? `No version found`
+                      : `v${stack.latest_revision.id}`}
+                  </Text>
+                  <SepDot>•</SepDot>
+                  Last updated {readableDate(stack.updated_at)}
+                </LastDeployed>
+              </InfoWrapper>
+            </DataContainer>
+            <Flex>
+              <RowButton
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  handleDelete(stack);
+                }}
+                disabled={
+                  deleting === stack.id || (deleting && deleting === stack.id)
+                }
+              >
+                <i className="material-icons">delete</i>
+
+                {deleting === stack.id ? <Loading /> : "Delete"}
+              </RowButton>
+            </Flex>
+          </StackCard>
+        ))}
+      </CardGrid>
+    </>
+  );
+};
+
+export default StackList;
+
+const RowButton = styled.button`
+  min-width: 82px;
+  white-space: nowrap;
+  font-size: 12px;
+  padding: 8px 10px;
+  font-weight: 400;
+  height: 32px;
+  margin-right: 5px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  display: flex;
+  align-items: center;
+  background: #ffffff08;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;
+
+const StackIcon = styled.div`
+  margin-bottom: -4px;
+
+  > i {
+    font-size: 18px;
+    margin-left: -1px;
+    margin-right: 9px;
+    color: #ffffff66;
+  }
+`;
+
+const StackName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  max-width: calc(100% - 100px);
+  overflow: hidden;
+`;
+
+const StackCard = styled(Card)`
+  font-size: 13px;
+  font-weight: 500;
+`;

+ 25 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/Status.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+import { StatusStyles } from "./styles";
+import loading from "assets/loading.gif";
+
+export type StatusProps = {
+  status: "loading" | "failed" | "successful" | "unknown";
+  message: string;
+  className?: string;
+};
+
+const Status = ({ status, message, className }: StatusProps) => {
+  return (
+    <>
+      <StatusStyles.Status className={className}>
+        {status === "loading" && <StatusStyles.Spinner src={loading} />}
+        {status === "failed" && <StatusStyles.Failed />}
+        {status === "successful" && <StatusStyles.Successful />}
+        {status === "unknown" && <StatusStyles.Unknown />}
+        {message}
+      </StatusStyles.Status>
+    </>
+  );
+};
+
+export default Status;

+ 98 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts

@@ -0,0 +1,98 @@
+import styled from "styled-components";
+
+const StatusBase = styled.div`
+  margin-top: 1px;
+  width: 8px;
+  height: 8px;
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+export const StatusStyles = {
+  Spinner: styled.img`
+    width: 15px;
+    height: 15px;
+    margin-right: 15px;
+    margin-bottom: -3px;
+  `,
+  Failed: styled(StatusBase)`
+    background: #ed5f85;
+  `,
+  Successful: styled(StatusBase)`
+    background: #4797ff;
+  `,
+  Unknown: styled(StatusBase)`
+    background: #f5cb42;
+  `,
+  Status: styled.div`
+    display: flex;
+    height: 20px;
+    font-size: 13px;
+    flex-direction: row;
+    text-transform: capitalize;
+    align-items: center;
+    font-family: "Work Sans", sans-serif;
+    color: #aaaabb;
+    animation: fadeIn 0.5s;
+
+    @keyframes fadeIn {
+      from {
+        opacity: 0;
+      }
+      to {
+        opacity: 1;
+      }
+    }
+  `,
+};
+
+export const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+export const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
+
+export const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  font-weight: 400;
+  color: #ffffff66;
+  margin-left: 1px;
+`;
+
+export const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+export const Text = styled.span<{ color: string }>`
+  color: ${({ color }) => color};
+`;
+
+export const SepDot = styled.div`
+  color: #aaaabb66;
+  margin: 0 9px;
+`;
+
+export const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 286 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -0,0 +1,286 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import Loading from "components/Loading";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+import _ from "lodash";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import { StacksLaunchContext } from "./Store";
+import DynamicLink from "components/DynamicLink";
+import styled from "styled-components";
+import Heading from "components/form-components/Heading";
+import TitleSection from "components/TitleSection";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
+
+const NewApp = () => {
+  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+  const { currentCluster } = useContext(Context);
+
+  const params = useParams<{
+    template_name: string;
+    version: string;
+  }>();
+
+  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [saveButtonStatus, setSaveButtonStatus] = useState("");
+
+  const [appName, setAppName] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!params.template_name || !params.version) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setHasError(false);
+
+    api
+      .getTemplateInfo<ExpandedPorterTemplate>(
+        "<token>",
+        {},
+        { name: params.template_name, version: params.version }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setTemplate(res.data);
+        }
+      })
+      .catch((err) => {
+        setHasError(true);
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [params]);
+
+  if (isLoading) {
+    return <Wrapper><Loading /></Wrapper>;
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+
+  const handleSubmit = async (rawValues: any) => {
+    setSaveButtonStatus("loading");
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    const stackSourceConfig =
+      newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX];
+    if (!stackSourceConfig) {
+      return;
+    }
+
+    let url = stackSourceConfig.image_repo_uri;
+    let tag = stackSourceConfig.image_tag;
+
+    if (url?.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (!_.isEmpty(stackSourceConfig.build)) {
+      if (template?.metadata?.name === "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
+      default:
+        provider = "";
+    }
+
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (url && tag) {
+      _.set(values, "image.repository", url);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (template?.metadata?.name == "job") {
+      _.set(values, "paused", true);
+    }
+
+    if (appName === "") {
+      setSaveButtonStatus("App name cannot be empty");
+      return;
+    }
+
+    addAppResource({
+      name: appName,
+      source_config_name: newStack.source_configs[0]?.name || "",
+      template_name: params.template_name,
+      template_version: params.version,
+      values,
+    });
+
+    setSaveButtonStatus("successful");
+    setTimeout(() => {
+      setSaveButtonStatus("");
+      pushFiltered("/stacks/launch/overview", []);
+    }, 1000);
+  };
+
+  return (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection>
+        <DynamicLink to={`/stacks/launch/overview`}>
+          <BackButton>
+            <i className="material-icons">
+              keyboard_backspace
+            </i>
+          </BackButton>
+        </DynamicLink>
+        <Polymer>
+        <Icon src={hardcodedIcons[template.metadata.name]} />
+        </Polymer>
+        Add {template.metadata.name.charAt(0).toUpperCase() + template.metadata.name.slice(1)} to Stack
+      </TitleSection>
+      <Heading>Application Name <Required>*</Required></Heading>
+      <InputRow
+        type="string"
+        value={appName}
+        setValue={(val: string) => setAppName(val)}
+        placeholder="ex: perspective-vortex"
+        width="470px"
+      />
+
+      <div style={{ position: "relative" }}>
+      <Heading>Application Settings</Heading>
+      <Helper>Configure settings for this application.</Helper>
+      <PorterFormWrapper
+        formData={template.form}
+        onSubmit={handleSubmit}
+        isLaunch
+        saveValuesStatus={saveButtonStatus}
+        saveButtonText="Add Application"
+      />
+      </div>
+    </StyledLaunchFlow>
+  );
+};
+
+export default NewApp;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Wrapper = styled.div`
+  margin-top: calc(50vh - 150px);
+`;
+
+const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

+ 233 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -0,0 +1,233 @@
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { StacksLaunchContext } from "./Store";
+import InputRow from "components/form-components/InputRow";
+import Selector from "components/Selector";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import useAuth from "shared/auth/useAuth";
+import { useRouting } from "shared/routing";
+import { CardGrid, SubmitButton } from "./components/styles";
+import { AppCard } from "./components/AppCard";
+import { AddResourceButton } from "./components/AddResourceButton";
+import styled from "styled-components";
+
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import TitleSection from "components/TitleSection";
+
+const Overview = () => {
+  const {
+    newStack,
+    namespace,
+    setStackName,
+    setStackNamespace,
+    submit,
+  } = useContext(StacksLaunchContext);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAuthorized] = useAuth();
+
+  const [namespaceOptions, setNamespaceOptions] = useState<
+    { label: string; value: string }[]
+  >([]);
+
+  const [submitButtonStatus, setSubmitButtonStatus] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const updateNamespaces = (cluster_id: number) => {
+    api
+      .getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id,
+        }
+      )
+      .then((res) => {
+        if (res.data) {
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (availableNamespaces.length > 0) {
+            setNamespaceOptions(namespaceOptions);
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  const handleSubmit = () => {
+    setSubmitButtonStatus("loading");
+
+    submit().then(() => {
+      console.log("submit");
+      setTimeout(() => {
+        setSubmitButtonStatus("");
+        pushFiltered("/stacks", []);
+      }, 1000);
+    });
+  };
+
+  useEffect(() => {
+    updateNamespaces(currentCluster.id);
+  }, [currentCluster]);
+
+  const isValid = useMemo(() => {
+    if (namespace === "") {
+      return false;
+    }
+
+    if (newStack.name === "") {
+      return false;
+    }
+
+    if (newStack.source_configs.length === 0) {
+      return false;
+    }
+
+    if (newStack.app_resources.length === 0) {
+      return false;
+    }
+
+    return true;
+  }, [namespace, newStack.name]);
+
+  return (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
+        <Polymer>
+          <i className="material-icons">lan</i>
+        </Polymer>
+        New Application Stack
+      </TitleSection>
+
+      <Heading>Stack Name</Heading>
+      <Helper>
+        Give this application stack a unique name:
+        <Required>*</Required>
+      </Helper>
+      <InputRow
+        type="string"
+        placeholder="ex: perspective-vortices"
+        width="470px"
+        value={newStack.name}
+        setValue={(newName: string) => setStackName(newName)}
+      />
+
+      <Heading>Destination</Heading>
+      <Helper>
+        Specify the namespace you would like to deploy this stack to.
+      </Helper>
+      <ClusterSection>
+        <NamespaceLabel>
+          <i className="material-icons">view_list</i> Namespace
+        </NamespaceLabel>
+        <Selector
+          key={"namespace"}
+          refreshOptions={() => {
+            updateNamespaces(currentCluster.id);
+          }}
+          addButton={isAuthorized("namespace", "", ["get", "create"])}
+          activeValue={namespace}
+          setActiveValue={(val) => setStackNamespace(val)}
+          options={namespaceOptions}
+          width="250px"
+          dropdownWidth="335px"
+          closeOverlay={true}
+        />
+      </ClusterSection>
+
+      <Heading>Applications</Heading>
+      <Helper>
+        At least one application is required:
+        <Required>*</Required>
+      </Helper>
+      <CardGrid>
+        {newStack.app_resources.map((app) => (
+          <AppCard key={app.name} app={app} />
+        ))}
+
+        <AddResourceButton />
+      </CardGrid>
+
+      <SubmitButton
+        disabled={!isValid || submitButtonStatus !== ""}
+        text="Create Stack"
+        onClick={handleSubmit}
+        clearPosition
+        statusPosition="left"
+        status={submitButtonStatus}
+      >
+        Create Stack
+      </SubmitButton>
+    </StyledLaunchFlow>
+  );
+};
+
+export default Overview;
+
+const NamespaceLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const ClusterSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 20px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "1px"};
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

+ 96 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -0,0 +1,96 @@
+import ImageSelector from "components/image-selector/ImageSelector";
+import React, { useContext, useState } from "react";
+import { StacksLaunchContext } from "./Store";
+import { CreateStackBody } from "../types";
+import { useRouting } from "shared/routing";
+import { SubmitButton } from "./components/styles";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import styled from "styled-components";
+import TitleSection from "components/TitleSection";
+
+const SelectSource = () => {
+  const { addSourceConfig } = useContext(StacksLaunchContext);
+
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("");
+  const { pushFiltered } = useRouting();
+
+  const handleNext = () => {
+    if (!imageUrl || !imageTag) {
+      return;
+    }
+
+    const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
+      image_repo_uri: imageUrl,
+      image_tag: imageTag,
+    };
+
+    addSourceConfig(newSource);
+    pushFiltered("/stacks/launch/overview", []);
+  };
+
+  return (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
+        <Polymer>
+          <i className="material-icons">lan</i>
+        </Polymer>
+        New Application Stack
+      </TitleSection>
+      <Heading>Stack Source</Heading>
+      <Helper>
+        Specify a source to deploy all stack applications from:
+        <Required>*</Required>
+      </Helper>
+      <Br />
+      <ImageSelector
+        selectedImageUrl={imageUrl}
+        setSelectedImageUrl={setImageUrl}
+        selectedTag={imageTag}
+        setSelectedTag={setImageTag}
+        forceExpanded
+      />
+      <Br height="30px" />
+      <SubmitButton
+        disabled={!imageUrl || !imageTag}
+        onClick={handleNext}
+        text="Continue"
+        clearPosition
+        makeFlush
+      />
+    </StyledLaunchFlow>
+  );
+};
+
+export default SelectSource;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "1px"};
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  min-width: 300px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+`;

+ 145 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -0,0 +1,145 @@
+import React, { createContext, useContext, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { CreateStackBody } from "../types";
+
+export type StacksLaunchContextType = {
+  newStack: CreateStackBody;
+
+  namespace: string;
+
+  setStackName: (name: string) => void;
+  setStackNamespace: (namespace: string) => void;
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => void;
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+
+  removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+
+  submit: () => Promise<void>;
+};
+
+const defaultValues: StacksLaunchContextType = {
+  newStack: {
+    name: "",
+    app_resources: [],
+    source_configs: [],
+  },
+
+  namespace: "",
+
+  setStackName: (name: string) => {},
+  setStackNamespace: (namespace: string) => {},
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => {},
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
+
+  removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
+
+  submit: async () => {},
+};
+
+export const StacksLaunchContext = createContext<StacksLaunchContextType>(
+  defaultValues
+);
+
+const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [newStack, setNewStack] = useState<CreateStackBody>(
+    defaultValues.newStack
+  );
+  const [namespace, setNamespace] = useState("default");
+
+  const setStackName: StacksLaunchContextType["setStackName"] = (name) => {
+    setNewStack((prev) => ({
+      ...prev,
+      name,
+    }));
+  };
+
+  const setStackNamespace: StacksLaunchContextType["setStackNamespace"] = (
+    namespace
+  ) => {
+    setNamespace(namespace);
+  };
+
+  const addSourceConfig: StacksLaunchContextType["addSourceConfig"] = (
+    sourceConfig
+  ) => {
+    const newSourceConfigName = (index: number) =>
+      sourceConfig.build
+        ? `${sourceConfig.build.method}-${index}`
+        : `${sourceConfig.image_repo_uri}-${sourceConfig.image_tag}-${index}`;
+
+    setNewStack((prev) => ({
+      ...prev,
+      source_configs: [
+        ...prev.source_configs,
+        {
+          name: newSourceConfigName(prev.source_configs.length),
+          ...sourceConfig,
+        },
+      ],
+    }));
+  };
+
+  const addAppResource: StacksLaunchContextType["addAppResource"] = (
+    appResource
+  ) => {
+    setNewStack((prev) => ({
+      ...prev,
+      app_resources: [...prev.app_resources, appResource],
+    }));
+  };
+
+  const removeAppResource: StacksLaunchContextType["removeAppResource"] = (
+    appResource
+  ) => {
+    setNewStack((prev) => ({
+      ...prev,
+      app_resources: prev.app_resources.filter(
+        (ar) => ar.name !== appResource.name
+      ),
+    }));
+  };
+
+  const submit: StacksLaunchContextType["submit"] = async () => {
+    try {
+      await api.createStack("<token>", newStack, {
+        cluster_id: currentCluster.id,
+        namespace: namespace,
+        project_id: currentProject.id,
+      });
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  return (
+    <StacksLaunchContext.Provider
+      value={{
+        newStack,
+        namespace,
+        setStackName,
+        setStackNamespace,
+        addSourceConfig,
+        addAppResource,
+        removeAppResource,
+        submit,
+      }}
+    >
+      {children}
+    </StacksLaunchContext.Provider>
+  );
+};
+
+export default StacksLaunchContextProvider;

+ 106 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx

@@ -0,0 +1,106 @@
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+import { AddResourceButtonStyles } from "./styles";
+import { TemplateSelector } from "./TemplateSelector";
+import { VersionSelector } from "./VersionSelector";
+import DynamicLink from "components/DynamicLink";
+
+import styled from "styled-components";
+
+export const AddResourceButton = () => {
+  const [templates, setTemplates] = useState<PorterTemplate[]>([]);
+  const [currentTemplate, setCurrentTemplate] = useState<PorterTemplate>();
+  const [currentVersion, setCurrentVersion] = useState("");
+
+  const getTemplates = async () => {
+    try {
+      const res = await api.getTemplates<PorterTemplate[]>(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      );
+      let sortedVersionData = res.data
+        .map((template: PorterTemplate) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        })
+        .sort((a, b) => {
+          if (a.name < b.name) {
+            return -1;
+          }
+          if (a.name > b.name) {
+            return 1;
+          }
+          return 0;
+        });
+
+      return sortedVersionData;
+    } catch (err) {}
+  };
+
+  useEffect(() => {
+    getTemplates().then((templates) => {
+      setTemplates(templates);
+      setCurrentTemplate(templates[1]);
+      setCurrentVersion(templates[1].currentVersion);
+    });
+  }, []);
+
+  return (
+    <AddResourceButtonStyles.Wrapper>
+      <AddResourceButtonStyles.Flex>
+        <LinkMask
+          to={`/stacks/launch/new-app/${currentTemplate?.name}/${currentVersion}`}
+        >
+          
+        </LinkMask>
+        <Icon>
+          <i className="material-icons">add</i>
+        </Icon>
+        Add a new{" "}
+        <TemplateSelector
+          options={templates}
+          value={currentTemplate}
+          onChange={(template) => {
+            setCurrentTemplate(template);
+            setCurrentVersion(template.currentVersion);
+          }}
+        />
+        <VersionSelector
+          options={currentTemplate?.versions || []}
+          value={currentVersion}
+          onChange={setCurrentVersion}
+        />
+      </AddResourceButtonStyles.Flex>
+    </AddResourceButtonStyles.Wrapper>
+  );
+};
+
+const LinkMask = styled(DynamicLink)`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+`;
+
+const Icon = styled.div`
+  margin-bottom: -3px;
+  > i {
+    margin-right: 20px;
+    margin-left: 9px;
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;

+ 54 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx

@@ -0,0 +1,54 @@
+import React, { useContext } from "react";
+import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
+import { ButtonWithIcon, Card } from "./styles";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+import styled from "styled-components";
+
+export const AppCard = ({
+  app,
+}: {
+  app: StacksLaunchContextType["newStack"]["app_resources"][0];
+}) => {
+  const { removeAppResource } = useContext(StacksLaunchContext);
+
+  const handleDelete = () => {
+    removeAppResource(app);
+  };
+
+  return (
+    <UnclickableCard>
+      <Flex>
+        <Icon src={hardcodedIcons[app.template_name]} />
+        {app.name}
+      </Flex>
+      <DeleteButton onClick={handleDelete}>
+        <i className="material-icons-outlined">close</i>
+      </DeleteButton>
+    </UnclickableCard>
+  );
+};
+
+const UnclickableCard = styled(Card)`
+  cursor: default;
+  :hover {
+    border: 1px solid #ffffff0f;
+  }
+`;
+
+const DeleteButton = styled(ButtonWithIcon)`
+  margin-right: 5px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Icon = styled.img`
+  height: 30px;
+  margin-right: 15px;
+  margin-left: 5px;
+`;

+ 71 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/TemplateSelector.tsx

@@ -0,0 +1,71 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import { capitalize } from "shared/string_utils";
+import { PorterTemplate } from "shared/types";
+import { SelectorStyles } from "./styles";
+
+export const TemplateSelector = ({
+  value,
+  options,
+  onChange,
+}: {
+  value: PorterTemplate;
+  options: PorterTemplate[];
+  onChange: (newValue: PorterTemplate) => void;
+}) => {
+  const wrapperRef = useRef();
+
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => setIsExpanded(false));
+
+  const getName = (template: PorterTemplate) => {
+    if (template?.name === "web") {
+      return "Web Application";
+    }
+    return capitalize(template?.name || "");
+  };
+
+  if (!Array.isArray(options) || options.length === 0) {
+    return (
+      <SelectorStyles.Wrapper>
+        <SelectorStyles.Button expanded={false}>
+          <Loading />
+        </SelectorStyles.Button>
+      </SelectorStyles.Wrapper>
+    );
+  }
+
+  return (
+    <>
+      <SelectorStyles.Wrapper ref={wrapperRef}>
+        <SelectorStyles.Button
+          expanded={isExpanded}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          {getName(value)}
+          <i className="material-icons">arrow_drop_down</i>
+        </SelectorStyles.Button>
+
+        {isExpanded ? (
+          <SelectorStyles.Dropdown>
+            {options.map((template) => (
+              <SelectorStyles.Option
+                className={template.name === value.name ? "active" : ""}
+                onClick={() => {
+                  onChange(template);
+                  setIsExpanded(false);
+                }}
+              >
+                <SelectorStyles.OptionText>
+                  {getName(template)}
+                </SelectorStyles.OptionText>
+              </SelectorStyles.Option>
+            ))}
+          </SelectorStyles.Dropdown>
+        ) : null}
+      </SelectorStyles.Wrapper>
+    </>
+  );
+};

+ 61 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/VersionSelector.tsx

@@ -0,0 +1,61 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import { capitalize } from "shared/string_utils";
+import { SelectorStyles } from "./styles";
+
+export const VersionSelector = ({
+  value,
+  options,
+  onChange,
+}: {
+  value: string;
+  options: string[];
+  onChange: (newValue: string) => void;
+}) => {
+  const wrapperRef = useRef();
+
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => setIsExpanded(false));
+
+  if (!Array.isArray(options) || options.length === 0) {
+    return (
+      <SelectorStyles.Wrapper>
+        <SelectorStyles.Button expanded={false}>
+          <Loading />
+        </SelectorStyles.Button>
+      </SelectorStyles.Wrapper>
+    );
+  }
+
+  return (
+    <>
+      <SelectorStyles.Wrapper ref={wrapperRef}>
+        <SelectorStyles.Button
+          expanded={isExpanded}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          {capitalize(value)}
+          <i className="material-icons">arrow_drop_down</i>
+        </SelectorStyles.Button>
+
+        {isExpanded ? (
+          <SelectorStyles.Dropdown>
+            {options.map((version) => (
+              <SelectorStyles.Option
+                className={version === value ? "active" : ""}
+                onClick={() => {
+                  onChange(version);
+                  setIsExpanded(false);
+                }}
+              >
+                {capitalize(version)}
+              </SelectorStyles.Option>
+            ))}
+          </SelectorStyles.Dropdown>
+        ) : null}
+      </SelectorStyles.Wrapper>
+    </>
+  );
+};

+ 148 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -0,0 +1,148 @@
+import SaveButton from "components/SaveButton";
+import styled from "styled-components";
+
+export const CardGrid = styled.div`
+  margin-top: 32px;
+  margin-bottom: 32px;
+  display: grid;
+  grid-row-gap: 25px;
+`;
+
+export const Card = styled.div`
+  display: flex;
+  color: #ffffff;
+  background: #2b2e3699;
+  justify-content: space-between;
+  border-radius: 5px;
+  cursor: pointer;
+  height: 75px;
+  padding: 12px;
+  padding-left: 14px;
+  border: 1px solid #ffffff0f;
+  align-items: center;
+
+  :hover {
+    border: 1px solid #ffffff3c;
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+export const SubmitButton = styled(SaveButton)`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+export const AddResourceButtonStyles = {
+  Wrapper: styled(Card)`
+    align-items: center;
+    position: relative;
+    font-size: 14px;
+    height: 50px;
+    :hover {
+      background: #ffffff19;
+    }
+  `,
+  Text: styled.span`
+    font-size: 20px;
+  `,
+  Flex: styled.div`
+    display: flex;
+    align-items: center;
+  `,
+};
+
+export const SelectorStyles = {
+  Wrapper: styled.div`
+    max-width: 200px;
+    position: relative;
+    font-size: 13px;
+
+    margin-left: 10px;
+  `,
+  Button: styled.div`
+    background-color: #ffffff11;
+    border: 1px solid #ffffff22;
+    border-radius: 5px;
+    min-width: 115px;
+    min-height: 30px;
+    padding: 0 15px;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    white-space: nowrap;
+    overflow-y: hidden;
+    text-overflow: ellipsis;
+    cursor: pointer;
+
+    > i {
+      font-size: 20px;
+      transform: ${(props: { expanded: boolean }) =>
+        props.expanded ? "rotate(180deg)" : ""};
+    }
+  `,
+  Dropdown: styled.div`
+    position: absolute;
+    background-color: #26282f;
+    width: 100%;
+    max-height: 200px;
+    overflow-y: auto;
+  `,
+  Option: styled.div`
+    min-height: 35px;
+    padding: 0 15px;
+
+    display: flex;
+    align-items: center;
+
+    cursor: pointer;
+
+    &.active {
+      background-color: #32343c;
+    }
+
+    :hover {
+      background-color: #32343c;
+    }
+
+    :not(:last-child) {
+      border-bottom: 1px solid #ffffff15;
+    }
+  `,
+  OptionText: styled.span`
+    max-width: 115px;
+    overflow-x: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  `,
+};
+
+export const ButtonWithIcon = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  background-color: #ffffff11;
+  border: 1px solid #ffffff22;
+  cursor: pointer;
+
+  &:hover {
+    background-color: #ffffff3c;
+  }
+
+  > i {
+    font-size: 18px;
+  }
+`;

+ 39 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx

@@ -0,0 +1,39 @@
+import React from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import styled from "styled-components";
+import NewApp from "./NewApp";
+import Overview from "./Overview";
+import SelectSource from "./SelectSource";
+import StacksLaunchContextProvider from "./Store";
+
+const LaunchRoutes = () => {
+  const { path } = useRouteMatch();
+
+  return (
+    <LaunchContainer>
+      <StacksLaunchContextProvider>
+        <Switch>
+          <Route path={`${path}/source`}>
+            <SelectSource />
+          </Route>
+          <Route path={`${path}/overview`}>
+            <Overview />
+          </Route>
+          <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
+            <NewApp />
+          </Route>
+          <Route path={`*`}>
+            <Redirect to={`${path}/source`} />
+          </Route>
+        </Switch>
+      </StacksLaunchContextProvider>
+    </LaunchContainer>
+  );
+};
+
+export default LaunchRoutes;
+
+const LaunchContainer = styled.div`
+  margin: 0 auto;
+  width: 100%;
+`;

+ 40 - 0
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -0,0 +1,40 @@
+import React, { useContext } from "react";
+import {
+  Redirect,
+  Route,
+  Switch,
+  useLocation,
+  useRouteMatch,
+} from "react-router";
+import { Context } from "shared/Context";
+import Dashboard from "./Dashboard";
+import ExpandedStack from "./ExpandedStack";
+import LaunchRoutes from "./launch";
+
+const routes = () => {
+  const { path } = useRouteMatch();
+  const { currentProject } = useContext(Context);
+
+  if (!currentProject?.stacks_enabled) {
+    return <Redirect to={`/`} />;
+  }
+
+  return (
+    <Switch>
+      <Route path={`${path}/launch`}>
+        <LaunchRoutes />
+      </Route>
+      <Route path={`${path}/:namespace/:stack_id`}>
+        <ExpandedStack />
+      </Route>
+      <Route path={`${path}/`} exact>
+        <Dashboard />
+      </Route>
+      <Route path={`*`}>
+        <div>Not found</div>
+      </Route>
+    </Switch>
+  );
+};
+
+export default routes;

+ 47 - 0
dashboard/src/main/home/cluster-dashboard/stacks/shared.ts

@@ -0,0 +1,47 @@
+import { StatusProps } from "./components/Status";
+import { Stack } from "./types";
+
+export const getStackStatus = (stack: Stack): StatusProps["status"] => {
+  const latestRevision = stack.latest_revision;
+
+  if (latestRevision === null) {
+    return "unknown";
+  }
+
+  if (latestRevision.status === "deployed") {
+    return "successful";
+  }
+
+  if (latestRevision.status === "deploying") {
+    return "loading";
+  }
+
+  if (latestRevision.status === "failed") {
+    return "failed";
+  }
+
+  return "unknown";
+};
+
+export const getStackStatusMessage = (stack: Stack): StatusProps["message"] => {
+  const latestRevision = stack.latest_revision;
+
+  if (latestRevision === null) {
+    return "";
+  }
+
+  if (latestRevision.status === "failed") {
+    return latestRevision.reason.split(/(?=[A-Z])/).join(" ");
+  }
+
+  switch (latestRevision.status) {
+    case "deploying":
+      return "Deploying";
+    case "deployed":
+      return "Deployed";
+    case "deploying":
+      return "Deploying";
+    default:
+      return "";
+  }
+};

+ 88 - 0
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -0,0 +1,88 @@
+export type CreateStackBody = {
+  name: string;
+  app_resources: {
+    name: string;
+    source_config_name: string;
+    template_name: string;
+    template_version: string;
+    template_repo_url?: string;
+    values: unknown;
+  }[];
+  source_configs: {
+    name: string;
+    image_repo_uri: string;
+    image_tag: string;
+    build?: {
+      method: "pack" | "docker";
+      folder_path: string;
+      git?: unknown;
+      buildpack?: unknown;
+      dockerfile?: unknown;
+    };
+  }[];
+};
+
+export type CreateStackResponse = Stack;
+
+export type GetStacksResponse = Stack[];
+
+export type Stack = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  revisions: StackRevision[];
+
+  latest_revision: StackRevision & {
+    resources: AppResource[];
+    source_configs: SourceConfig[];
+  };
+};
+
+export type StackRevision = {
+  id: number;
+  created_at: string;
+  status: "deploying" | "deployed" | "failed"; // type with enum
+  stack_id: string;
+  reason: "DeployError" | "SaveError" | "RollbackError";
+  message: string;
+};
+
+export type SourceConfig = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  image_repo_uri: string;
+  image_tag: string;
+
+  stack_id: string;
+  stack_revision_id: number;
+
+  build?: {
+    method: "pack" | "docker";
+    folder_path: string;
+    git?: unknown;
+    buildpack?: unknown;
+    dockerfile?: unknown;
+  };
+};
+
+export type AppResource = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  stack_id: string;
+
+  stack_source_config: SourceConfig;
+  stack_revision_id: number;
+  stack_app_data: {
+    template_repo_url: string;
+    template_name: string;
+    template_version: string;
+  };
+};

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

@@ -499,5 +499,5 @@ const StyledLaunchFlow = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
   margin-top: ${(props: { disableMarginTop: boolean }) =>
-    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+    props.disableMarginTop ? "inherit" : "calc(40vh - 310px)"};
 `;

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

@@ -191,8 +191,13 @@ class Sidebar extends Component<PropsType, StateType> {
             )}
           {currentProject?.preview_envs_enabled && (
             <NavButton to="/preview-environments">
-              <InlineSVGWrapper id="Flat" fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
-                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z"/>
+              <InlineSVGWrapper
+                id="Flat"
+                fill="#FFFFFF"
+                xmlns="http://www.w3.org/2000/svg"
+                viewBox="0 0 256 256"
+              >
+                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
               </InlineSVGWrapper>
               <EllipsisTextWrapper
                 onMouseOver={() => {
@@ -218,6 +223,12 @@ class Sidebar extends Component<PropsType, StateType> {
               </EllipsisTextWrapper>
             </NavButton>
           )}
+          {currentProject?.stacks_enabled ? (
+            <NavButton to="/stacks">
+              <Icon className="material-icons-outlined">lan</Icon>
+              Stacks
+            </NavButton>
+          ) : null}
         </>
       );
     }

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

@@ -4,6 +4,7 @@ import { release } from "process";
 import { baseApi } from "./baseApi";
 
 import { BuildConfig, FullActionConfigType, StorageType } from "./types";
+import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
 
 /**
  * Generic api call format
@@ -1927,6 +1928,87 @@ const getGitlabFolderContent = baseApi<
     `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
 );
 
+// STACKS
+
+const createStack = baseApi<
+  CreateStackBody,
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
+);
+
+const listStacks = baseApi<
+  {},
+  { project_id: number; cluster_id: number; namespace: string }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
+);
+
+const getStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
+const getStackRevision = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    revision_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, stack_id, revision_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/${revision_id}`
+);
+
+const rollbackStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/rollback`
+);
+
+const deleteStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2109,4 +2191,12 @@ export default {
   getGitlabRepos,
   getGitlabBranches,
   getGitlabFolderContent,
+
+  // STACKS
+  listStacks,
+  getStack,
+  getStackRevision,
+  createStack,
+  rollbackStack,
+  deleteStack,
 };

+ 3 - 1
dashboard/src/shared/routing.tsx

@@ -13,7 +13,8 @@ export type PorterUrl =
   | "jobs"
   | "onboarding"
   | "databases"
-  | "preview-environments";
+  | "preview-environments"
+  | "stacks";
 
 export const PorterUrls = [
   "dashboard",
@@ -29,6 +30,7 @@ export const PorterUrls = [
   "onboarding",
   "databases",
   "preview-environments",
+  "stacks",
 ];
 
 // TODO: consolidate with pushFiltered

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

@@ -1,3 +1,5 @@
+import ValuesYaml from "main/home/cluster-dashboard/expanded-chart/ValuesYaml";
+
 export interface ClusterType {
   id: number;
   name: string;
@@ -19,6 +21,7 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
+  is_stack: boolean;
   image_repo_uri: string;
   git_action_config: any;
   build_config: BuildConfig;
@@ -164,6 +167,13 @@ export interface PorterTemplate {
   repo_url?: string;
 }
 
+export interface ExpandedPorterTemplate {
+  form: FormYAML;
+  markdown: string;
+  metadata: ChartType["chart"]["metadata"];
+  values: ChartTypeWithExtendedConfig["config"];
+}
+
 // FormYAML represents a chart's values.yaml form abstraction
 export interface FormYAML {
   name?: string;
@@ -248,6 +258,7 @@ export interface ProjectType {
   enable_rds_databases: boolean;
   managed_infra_enabled: boolean;
   api_tokens_enabled: boolean;
+  stacks_enabled: boolean;
   roles: {
     id: number;
     kind: string;