2
0
jusrhee 5 жил өмнө
parent
commit
285b173a3b

+ 4 - 0
dashboard/src/assets/key.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.3344 1.9998H7.6654C4.2764 1.9998 2.0004 4.3778 2.0004 7.9168V16.0838C2.0004 19.6218 4.2764 21.9998 7.6654 21.9998H16.3334C19.7224 21.9998 22.0004 19.6218 22.0004 16.0838V7.9168C22.0004 4.3778 19.7234 1.9998 16.3344 1.9998Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3142 11.2485H17.0142C17.4242 11.2485 17.7642 11.5885 17.7642 11.9985V13.8485C17.7642 14.2685 17.4242 14.5985 17.0142 14.5985C16.5942 14.5985 16.2642 14.2685 16.2642 13.8485V12.7485H14.9342V13.8485C14.9342 14.2685 14.5942 14.5985 14.1842 14.5985C13.7642 14.5985 13.4342 14.2685 13.4342 13.8485V12.7485H11.3142C10.9942 13.8185 10.0142 14.5985 8.84419 14.5985C7.40419 14.5985 6.23419 13.4385 6.23419 11.9985C6.23419 10.5685 7.40419 9.3985 8.84419 9.3985C10.0142 9.3985 10.9942 10.1785 11.3142 11.2485ZM7.73419 11.9985C7.73419 12.6085 8.23419 13.0985 8.84419 13.0985C9.44419 13.0985 9.94419 12.6085 9.94419 11.9985C9.94419 11.3885 9.44419 10.8985 8.84419 10.8985C8.23419 10.8985 7.73419 11.3885 7.73419 11.9985Z" fill="white"/>
+</svg>

+ 6 - 0
dashboard/src/assets/sliders.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M10.0833 15.958H3.50777C2.67555 15.958 2 16.6217 2 17.4393C2 18.2558 2.67555 18.9206 3.50777 18.9206H10.0833C10.9155 18.9206 11.5911 18.2558 11.5911 17.4393C11.5911 16.6217 10.9155 15.958 10.0833 15.958Z" fill="white"/>
+<path opacity="0.4" d="M22 6.37856C22 5.56203 21.3244 4.89832 20.4933 4.89832H13.9178C13.0856 4.89832 12.4101 5.56203 12.4101 6.37856C12.4101 7.19618 13.0856 7.85989 13.9178 7.85989H20.4933C21.3244 7.85989 22 7.19618 22 6.37856Z" fill="white"/>
+<path d="M8.87774 6.37856C8.87774 8.24523 7.33886 9.75821 5.43887 9.75821C3.53999 9.75821 2 8.24523 2 6.37856C2 4.51298 3.53999 3 5.43887 3C7.33886 3 8.87774 4.51298 8.87774 6.37856Z" fill="white"/>
+<path d="M22 17.3992C22 19.2648 20.4611 20.7778 18.5611 20.7778C16.6623 20.7778 15.1223 19.2648 15.1223 17.3992C15.1223 15.5325 16.6623 14.0196 18.5611 14.0196C20.4611 14.0196 22 15.5325 22 17.3992Z" fill="white"/>
+</svg>

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

@@ -294,7 +294,8 @@ class Home extends Component<PropsType, StateType> {
       if (
         currentView === "cluster-dashboard" ||
         currentView === "applications" ||
-        currentView === "jobs"
+        currentView === "jobs" || 
+        currentView === "env-groups"
       ) {
         return this.renderDashboard();
       } else if (currentView === "dashboard") {
@@ -313,7 +314,6 @@ class Home extends Component<PropsType, StateType> {
       } else if (currentView === "project-settings") {
         return <ProjectSettings />;
       }
-
       return <Templates />;
     } else if (currentView === "new-project") {
       return <NewProject />;

+ 91 - 29
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -3,15 +3,18 @@ import styled from "styled-components";
 import gradient from "assets/gradient.jpg";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
+import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
 import { PorterUrl } from "shared/routing";
 
 import ChartList from "./chart/ChartList";
+import EnvGroupList from "./env-groups/EnvGroupList";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
+import ExpandedEnvGroup from "./env-groups/ExpandedEnvGroup";
 import ExpandedJobChart from "./expanded-chart/ExpandedJobChart";
 import { RouteComponentProps, withRouter } from "react-router";
 
@@ -27,6 +30,7 @@ type StateType = {
   namespace: string;
   sortType: string;
   currentChart: ChartType | null;
+  expandedEnvGroup: any;
   isMetricsInstalled: boolean;
 };
 
@@ -37,6 +41,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ? localStorage.getItem("SortType")
       : "Newest",
     currentChart: null as ChartType | null,
+    expandedEnvGroup: null as any,
     isMetricsInstalled: false,
   };
 
@@ -83,11 +88,85 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   renderDashboardIcon = () => {
     if (this.props.currentView === "jobs") {
       return <Img src={monojob} />;
+    } else if (this.props.currentView === "env-groups") {
+      return <Img src={sliders} />;
     } else {
       return <Img src={monoweb} />;
     }
   };
 
+  getDescription = (currentView: string): string => {
+    if (currentView === "jobs") {
+      return "Scripts and tasks that run once or on a repeating interval.";
+    } else if (currentView === "env-groups") {
+      return "Groups of environment variables for storing secrets and configuration."
+    } else {
+      return "Continuously running web services, workers, and add-ons.";
+    }
+  }
+
+  renderBody = () => {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    if (currentView === "env-groups") {
+      return (
+        <>
+          <ControlRow>
+            <Button onClick={() => this.props.history.push("launch")}>
+              <i className="material-icons">add</i> Create Env Group
+            </Button>
+            <SortFilterWrapper>
+              <SortSelector
+                setSortType={(sortType) => this.setState({ sortType })}
+                sortType={this.state.sortType}
+              />
+              <NamespaceSelector
+                setNamespace={(namespace) => this.setState({ namespace })}
+                namespace={this.state.namespace}
+              />
+            </SortFilterWrapper>
+          </ControlRow>
+
+          <EnvGroupList
+            currentCluster={currentCluster}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+            setExpandedEnvGroup={(envGroup: any) => this.setState({ expandedEnvGroup: envGroup })}
+          />
+        </>
+      );
+    } else {
+      return (
+        <>
+          <ControlRow>
+            <Button onClick={() => this.props.history.push("launch")}>
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+            <SortFilterWrapper>
+              <SortSelector
+                setSortType={(sortType) => this.setState({ sortType })}
+                sortType={this.state.sortType}
+              />
+              <NamespaceSelector
+                setNamespace={(namespace) => this.setState({ namespace })}
+                namespace={this.state.namespace}
+              />
+            </SortFilterWrapper>
+          </ControlRow>
+
+          <ChartList
+            currentView={currentView}
+            currentCluster={currentCluster}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+            setCurrentChart={(x: ChartType | null) =>
+              this.setState({ currentChart: x })
+            }
+          />
+        </>
+      );
+    }
+  }
+
   renderContents = () => {
     let { currentCluster, setSidebar, currentView } = this.props;
     if (this.state.currentChart && currentView === "jobs") {
@@ -111,13 +190,22 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setSidebar={setSidebar}
         />
       );
+    } else if (this.state.expandedEnvGroup && currentView === "env-groups") {
+      return (
+        <ExpandedEnvGroup
+          namespace={this.state.namespace}
+          currentCluster={this.props.currentCluster}
+          initialEnvGroup={this.state.expandedEnvGroup}
+          closeExpanded={() => this.setState({ expandedEnvGroup: null })}
+        />
+      );
     }
 
     return (
       <div>
         <TitleSection>
           {this.renderDashboardIcon()}
-          <Title>{currentView}</Title>
+          <Title>{currentView === "env-groups" ? "Environment Groups" : currentView}</Title>
         </TitleSection>
 
         <InfoSection>
@@ -127,39 +215,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </InfoLabel>
           </TopRow>
           <Description>
-            {currentView === "jobs"
-              ? `An overview of past and current jobs for ${currentCluster.name}.`
-              : `An overview of web services and workers running on ${currentCluster.name}.`}
+            {this.getDescription(currentView)}
           </Description>
         </InfoSection>
 
         <LineBreak />
 
-        <ControlRow>
-          <Button onClick={() => this.props.history.push("launch")}>
-            <i className="material-icons">add</i> Launch Template
-          </Button>
-          <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
-            <NamespaceSelector
-              setNamespace={(namespace) => this.setState({ namespace })}
-              namespace={this.state.namespace}
-            />
-          </SortFilterWrapper>
-        </ControlRow>
-
-        <ChartList
-          currentView={currentView}
-          currentCluster={currentCluster}
-          namespace={this.state.namespace}
-          sortType={this.state.sortType}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
-        />
+        {this.renderBody()}
       </div>
     );
   };

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

@@ -0,0 +1,255 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import key from "assets/key.svg";
+
+import { ChartType, StorageType } from "shared/types";
+import { Context } from "shared/Context";
+import StatusIndicator from "components/StatusIndicator";
+
+type PropsType = {
+  envGroup: any;
+  setExpanded: () => void;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+};
+
+export default class EnvGroup extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+  };
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  render() {
+    let { envGroup, setExpanded } = this.props;
+
+    return (
+      <StyledEnvGroup
+        onMouseEnter={() => this.setState({ expand: true })}
+        onMouseLeave={() => this.setState({ expand: false })}
+        expand={this.state.expand}
+        onClick={() => setExpanded()}
+      >
+        <Title>
+          <IconWrapper>
+            <Icon src={key} />
+          </IconWrapper>
+          {envGroup.name}
+        </Title>
+
+        <BottomWrapper>
+          <InfoWrapper>
+            <LastDeployed>
+              Last updated {this.readableDate(envGroup.last_updated)}
+            </LastDeployed>
+          </InfoWrapper>
+
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>{envGroup.namespace}</NamespaceTag>
+          </TagWrapper>
+        </BottomWrapper>
+
+        <Version>12 variables</Version>
+      </StyledEnvGroup>
+    );
+  }
+}
+
+EnvGroup.contextType = Context;
+
+const BottomWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 11px;
+  margin-top: 12px;
+`;
+
+const Version = styled.div`
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  font-size: 12px;
+  color: #aaaabb;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  background: none;
+  font-size: 16px;
+  top: 11px;
+  left: 14px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  position: absolute;
+
+  > i {
+    font-size: 17px;
+    margin-top: -1px;
+  }
+`;
+
+const Title = styled.div`
+  position: relative;
+  text-decoration: none;
+  padding: 12px 35px 12px 45px;
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  width: 80%;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  animation: fadeIn 0.5s;
+
+  > img {
+    background: none;
+    top: 12px;
+    left: 13px;
+
+    padding: 5px 4px;
+    width: 24px;
+    position: absolute;
+  }
+`;
+
+const StyledEnvGroup = styled.div`
+  background: #26282f;
+  cursor: pointer;
+  margin-bottom: 25px;
+  padding: 1px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: calc(100% + 2px);
+  height: calc(100% + 2px);
+
+  animation: ${(props: { expand: boolean }) =>
+      props.expand ? "expand" : "shrink"}
+    0.12s;
+  animation-fill-mode: forwards;
+  animation-timing-function: ease-out;
+
+  @keyframes expand {
+    from {
+      width: calc(100% + 2px);
+      padding-top: 4px;
+      padding-bottom: 14px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+    to {
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px;
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+  }
+
+  @keyframes shrink {
+    from {
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px;
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+    to {
+      width: calc(100% + 2px);
+      padding-top: 4px;
+      padding-bottom: 14px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+  }
+`;

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

@@ -0,0 +1,126 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import EnvGroup from "./EnvGroup";
+import Loading from "components/Loading";
+
+type PropsType = {
+  currentCluster: ClusterType;
+  namespace: string;
+  sortType: string;
+  setExpandedEnvGroup: (envGroup: any) => void;
+};
+
+type StateType = {
+  envGroups: any[];
+  loading: boolean;
+  error: boolean;
+};
+
+const dummyEnvGroups = [
+  { name: "sapporo", last_updated: "12", namespace: "default" },
+  { name: "backend-staging", last_updated: "4", namespace: "default" },
+  { name: "backend-production", last_updated: "7", namespace: "default" },
+];
+
+export default class EnvGroupList extends Component<PropsType, StateType> {
+  state = {
+    envGroups: dummyEnvGroups as any[],
+    loading: false,
+    error: false,
+  };
+
+  updateEnvGroups = () => {
+    // retrieve and set env groups
+  };
+
+  componentDidMount() {
+    this.updateEnvGroups();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    // Ret2: Prevents reload when opening ClusterConfigModal
+    if (
+      prevProps.currentCluster !== this.props.currentCluster ||
+      prevProps.namespace !== this.props.namespace ||
+      prevProps.sortType !== this.props.sortType
+    ) {
+      this.updateEnvGroups();
+    }
+  }
+
+  renderEnvGroupList = () => {
+    let { loading, error, envGroups } = this.state;
+
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (envGroups.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i>
+          No environment groups found in this namespace.
+        </Placeholder>
+      );
+    }
+
+    return this.state.envGroups.map((envGroup: any, i: number) => {
+      return (
+        <EnvGroup
+          key={i}
+          envGroup={envGroup}
+          setExpanded={() => this.props.setExpandedEnvGroup(envGroup)}
+        />
+      );
+    });
+  };
+
+  render() {
+    return <StyledEnvGroupList>{this.renderEnvGroupList()}</StyledEnvGroupList>;
+  }
+}
+
+EnvGroupList.contextType = Context;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledEnvGroupList = styled.div`
+  padding-bottom: 85px;
+`;

+ 412 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -0,0 +1,412 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import yaml from "js-yaml";
+import close from "assets/close.png";
+import key from "assets/key.svg";
+import _ from "lodash";
+
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import SaveButton from "components/SaveButton";
+import ConfirmOverlay from "components/ConfirmOverlay";
+import Loading from "components/Loading";
+import TabRegion from "components/TabRegion";
+import KeyValueArray from "components/values-form/KeyValueArray";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+import SettingsSection from "../expanded-chart/SettingsSection";
+
+type PropsType = {
+  namespace: string;
+  initialEnvGroup: any;
+  currentCluster: ClusterType;
+  closeExpanded: () => void;
+};
+
+type StateType = {
+  currentEnvGroup: any;
+  loading: boolean;
+  currentTab: string | null;
+  showDeleteOverlay: boolean;
+  deleting: boolean;
+  saveValuesStatus: string | null;
+  values: any[];
+};
+
+const tabOptions = [
+  { value: "environment", label: "Environment Variables" },
+  { value: "settings", label: "Settings" }
+];
+
+export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+  state = {
+    currentEnvGroup: this.props.initialEnvGroup,
+    loading: true,
+    currentTab: "environment",
+    showDeleteOverlay: false,
+    deleting: false,
+    saveValuesStatus: null as string | null,
+    values: [] as any[],
+  };
+
+  getEnvGroupData = () => {
+    // Get complete envgroup data
+  };
+
+  handleSaveValues = (config?: any) => {
+    // Save env group values
+  };
+
+  renderTabContents = () => {
+    let currentTab = this.state.currentTab;
+
+    switch (currentTab) {
+      case "environment":
+        return (
+          <TabWrapper>
+            <InnerWrapper>
+              <Heading>Environment Variables</Heading>
+              <Helper>Set environment variables for your secrets and environment-specific configuration.</Helper>
+              <KeyValueArray
+                values={this.state.values}
+                setValues={(x: any) => this.setState({ values: x })}
+              />
+            </InnerWrapper>
+            <SaveButton
+              text="Update"
+              onClick={() => this.handleSaveValues()}
+              status={this.state.saveValuesStatus}
+              makeFlush={true}
+            />
+          </TabWrapper>
+        );
+      default:
+        return (
+          <TabWrapper>
+            <InnerWrapper full={true}>
+              <Heading>Manage Environment Group</Heading>
+              <Helper>Permanently delete this set of environment variables. This action cannot be undone.</Helper>
+              <Button
+                color="#b91133"
+                onClick={() => this.setState({ showDeleteOverlay: true })}
+              >
+                Delete {this.state.currentEnvGroup.name}
+              </Button>
+            </InnerWrapper>
+          </TabWrapper>
+        );
+    }
+  };
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  handleDeleteEnvGroup = () => {
+    
+  };
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return (
+        <DeleteOverlay>
+          <Loading />
+        </DeleteOverlay>
+      );
+    }
+  };
+
+  render() {
+    let { closeExpanded } = this.props;
+    let { currentEnvGroup } = this.state;
+
+    return (
+      <>
+        <CloseOverlay onClick={closeExpanded} />
+        <StyledExpandedChart>
+          <ConfirmOverlay
+            show={this.state.showDeleteOverlay}
+            message={`Are you sure you want to delete ${currentEnvGroup.name}?`}
+            onYes={this.handleDeleteEnvGroup}
+            onNo={() => this.setState({ showDeleteOverlay: false })}
+          />
+          {this.renderDeleteOverlay()}
+
+          <HeaderWrapper>
+            <TitleSection>
+              <Title>
+                <IconWrapper>
+                  <Icon src={key} />
+                </IconWrapper>
+                {currentEnvGroup.name}
+              </Title>
+              <InfoWrapper>
+                <LastDeployed>
+                  Last updated {this.readableDate(currentEnvGroup.last_updated)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace <NamespaceTag>{currentEnvGroup.namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            <CloseButton onClick={closeExpanded}>
+              <CloseButtonImg src={close} />
+            </CloseButton>
+          </HeaderWrapper>
+
+          <TabRegion
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+            options={tabOptions}
+            color={null}
+          >
+            {this.renderTabContents()}
+          </TabRegion>
+        </StyledExpandedChart>
+      </>
+    );
+  }
+}
+
+ExpandedEnvGroup.contextType = Context;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 5px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const InnerWrapper = styled.div<{ full?: boolean }>`
+  width: 100%;
+  height: ${props => props.full ? "100%" : "calc(100% - 65px)"};
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 50px;
+  position: relative;
+  border-radius: 5px;
+  overflow: auto;
+`;
+
+const TabWrapper = styled.div`
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+`;
+
+const DeleteOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0, 0, 0, 0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const HeaderWrapper = styled.div``;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 24px 0px 17px 0px;
+  height: 20px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  position: absolute;
+  right: 0px;
+  bottom: 0px;
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;
+  border-radius: 10px;
+  background: #26272f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px;
+  display: flex;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 3 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -246,6 +246,7 @@ const ClusterName = styled.div`
   width: 130px;
   margin-left: 3px;
   font-weight: 400;
+  color: #ffffff44;
 `;
 
 const DropdownIcon = styled.span`
@@ -289,6 +290,7 @@ const ClusterIcon = styled.div`
     margin-bottom: 0px;
     margin-left: 17px;
     margin-right: 10px;
+    color: #ffffff44;
   }
 `;
 
@@ -306,7 +308,7 @@ const ClusterSelector = styled.div`
   padding-left: 7px;
   width: 100%;
   height: 42px;
-  margin: 8px auto 0 auto;
+  margin: 0 auto 0 auto;
   font-size: 14px;
   font-weight: 500;
   color: white;

+ 10 - 14
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,6 +7,7 @@ import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import discordLogo from "assets/discord.svg";
+import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";
 
@@ -108,13 +109,6 @@ class Sidebar extends Component<PropsType, StateType> {
               this.props.history.push("/applications");
             }}
           >
-            <BranchPad>
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={false} />
-              </Gutter>
-            </BranchPad>
             <Img src={monoweb} />
             Applications
           </NavButton>
@@ -124,16 +118,18 @@ class Sidebar extends Component<PropsType, StateType> {
               this.props.history.push("/jobs");
             }}
           >
-            <BranchPad>
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={true} />
-              </Gutter>
-            </BranchPad>
             <Img src={monojob} />
             Jobs
           </NavButton>
+          <NavButton
+            selected={currentView === "env-groups"}
+            onClick={() => {
+              this.props.history.push("/env-groups");
+            }}
+          >
+            <Img src={sliders} />
+            Env Groups
+          </NavButton>
         </>
       );
     }

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

@@ -8,6 +8,7 @@ export type PorterUrl =
   | "cluster-dashboard"
   | "project-settings"
   | "applications"
+  | "env-groups"
   | "jobs";
 
 export const PorterUrls = [
@@ -18,6 +19,7 @@ export const PorterUrls = [
   "cluster-dashboard",
   "project-settings",
   "applications",
+  "env-groups",
   "jobs",
 ];