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

Merge pull request #497 from porter-dev/beta.3.env-groups-fe

Beta.3.env groups fe
abelanger5 5 лет назад
Родитель
Сommit
e33f6d6965
29 измененных файлов с 2466 добавлено и 83 удалено
  1. 4 0
      dashboard/src/assets/key.svg
  2. 6 0
      dashboard/src/assets/sliders.svg
  3. 4 3
      dashboard/src/components/StatusIndicator.tsx
  4. 114 17
      dashboard/src/components/values-form/KeyValueArray.tsx
  5. 27 0
      dashboard/src/components/values-form/ValuesForm.tsx
  6. 2 2
      dashboard/src/main/home/Home.tsx
  7. 47 30
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  8. 113 0
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  9. 0 1
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  10. 325 0
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  11. 259 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  12. 185 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  13. 166 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  14. 463 0
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  15. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  16. 13 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  17. 9 1
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  18. 236 0
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  19. 14 8
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  20. 1 2
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  21. 3 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  22. 10 14
      dashboard/src/main/home/sidebar/Sidebar.tsx
  23. 61 0
      dashboard/src/shared/api.tsx
  24. 2 0
      dashboard/src/shared/routing.tsx
  25. 1 0
      go.sum
  26. 6 0
      internal/forms/k8s.go
  27. 64 0
      internal/kubernetes/agent.go
  28. 259 0
      server/api/k8s_handler.go
  29. 70 0
      server/router/router.go

+ 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>

+ 4 - 3
dashboard/src/components/StatusIndicator.tsx

@@ -87,11 +87,11 @@ const Spinner = styled.img`
   width: 15px;
   height: 15px;
   margin-right: 15px;
-  margin-bottom: -1px;
+  margin-bottom: -3px;
 `;
 
 const StatusColor = styled.div`
-  margin-bottom: 1px;
+  margin-top: 1px;
   width: 8px;
   height: 8px;
   background: ${(props: { status: string }) =>
@@ -103,6 +103,7 @@ const StatusColor = styled.div`
       ? "#00d12a"
       : "#f5cb42"};
   border-radius: 20px;
+  margin-left: 3px;
   margin-right: 16px;
 `;
 
@@ -113,7 +114,7 @@ const Status = styled.div`
   flex-direction: row;
   text-transform: capitalize;
   align-items: center;
-  font-family: "Hind Siliguri", sans-serif;
+  font-family: "Work Sans", sans-serif;
   color: #aaaabb;
   animation: fadeIn 0.5s;
   margin-left: ${(props: { margin_left: string }) => props.margin_left};

+ 114 - 17
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -1,5 +1,9 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import Modal from "../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
+
+import sliders from "assets/sliders.svg";
 
 type PropsType = {
   label?: string;
@@ -7,15 +11,20 @@ type PropsType = {
   setValues: (x: any) => void;
   width?: string;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
+  envLoader?: boolean;
 };
 
 type StateType = {
   values: any[];
+  showEnvModal: boolean;
 };
 
 export default class KeyValueArray extends Component<PropsType, StateType> {
   state = {
     values: [] as any[],
+    showEnvModal: false,
   };
 
   componentDidMount() {
@@ -34,6 +43,17 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
   };
 
+  objectToValues = (obj: any) => {
+    let values = [] as any[];
+    Object.keys(obj).forEach((key: string, i: number) => {
+      let entry = {} as any;
+      entry.key = key;
+      entry.value = obj[key];
+      values.push(entry);
+    });
+    return values;
+  };
+
   renderDeleteButton = (i: number) => {
     if (!this.props.disabled) {
       return (
@@ -93,28 +113,86 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     );
   };
 
+  renderEnvModal = () => {
+    if (this.state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEnvModal: false })}
+          width="665px"
+          height="342px"
+        >
+          <LoadEnvGroupModal
+            namespace={this.props.namespace}
+            clusterId={this.props.clusterId}
+            closeModal={() => this.setState({ showEnvModal: false })}
+            setValues={(values: any) => {
+              this.props.setValues(values);
+              this.setState({ values: this.objectToValues(values) });
+            }}
+          />
+        </Modal>
+      );
+    }
+  };
+
   render() {
     return (
-      <StyledInputArray>
-        <Label>{this.props.label}</Label>
-        {this.state.values.length === 0 ? <></> : this.renderInputList()}
-        {this.props.disabled ? (
-          <></>
-        ) : (
-          <AddRowButton
-            onClick={() => {
-              this.state.values.push({ key: "", value: "" });
-              this.setState({ values: this.state.values });
-            }}
-          >
-            <i className="material-icons">add</i> Add Row
-          </AddRowButton>
-        )}
-      </StyledInputArray>
+      <>
+        <StyledInputArray>
+          <Label>{this.props.label}</Label>
+          {this.state.values.length === 0 ? <></> : this.renderInputList()}
+          {this.props.disabled ? (
+            <></>
+          ) : (
+            <InputWrapper>
+              <AddRowButton
+                onClick={() => {
+                  this.state.values.push({ key: "", value: "" });
+                  this.setState({ values: this.state.values });
+                }}
+              >
+                <i className="material-icons">add</i> Add Row
+              </AddRowButton>
+              <Spacer />
+              {this.props.namespace && this.props.envLoader && (
+                <LoadButton
+                  onClick={() =>
+                    this.setState({ showEnvModal: !this.state.showEnvModal })
+                  }
+                >
+                  <img src={sliders} /> Load from Env Group
+                </LoadButton>
+              )}
+            </InputWrapper>
+          )}
+        </StyledInputArray>
+        {this.renderEnvModal()}
+      </>
     );
   }
 }
 
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 999;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
 const Spacer = styled.div`
   width: 10px;
   height: 20px;
@@ -123,7 +201,6 @@ const Spacer = styled.div`
 const AddRowButton = styled.div`
   display: flex;
   align-items: center;
-  margin-top: 5px;
   width: 270px;
   font-size: 13px;
   color: #aaaabb;
@@ -146,6 +223,25 @@ const AddRowButton = styled.div`
   }
 `;
 
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
 const DeleteButton = styled.div`
   width: 15px;
   height: 15px;
@@ -171,6 +267,7 @@ const DeleteButton = styled.div`
 const InputWrapper = styled.div`
   display: flex;
   align-items: center;
+  margin-top: 5px;
 `;
 
 const Input = styled.input`

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

@@ -21,6 +21,8 @@ type PropsType = {
   setMetaState?: any;
   handleEnvChange?: (x: any) => void;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
 };
 
 type StateType = any;
@@ -74,10 +76,35 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
             />
           );
+        case "env-key-value-array":
+          return (
+            <KeyValueArray
+              key={i}
+              envLoader={true}
+              namespace={this.props.namespace}
+              clusterId={this.props.clusterId}
+              values={this.props.metaState[key]}
+              setValues={(x: any) => {
+                this.props.setMetaState({ [key]: x });
+
+                // Need to pull env vars out of form.yaml for createGHA build env vars
+                if (
+                  this.props.handleEnvChange &&
+                  key === "container.env.normal"
+                ) {
+                  this.props.handleEnvChange(x);
+                }
+              }}
+              label={item.label}
+              disabled={this.props.disabled}
+            />
+          );
         case "key-value-array":
           return (
             <KeyValueArray
               key={i}
+              namespace={this.props.namespace}
+              clusterId={this.props.clusterId}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });

+ 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 />;

+ 47 - 30
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -3,12 +3,14 @@ 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 EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
@@ -88,6 +90,47 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   };
 
+  getDescription = (currentView: string): string => {
+    if (currentView === "jobs") {
+      return "Scripts and tasks that run once or on a repeating interval.";
+    } else {
+      return "Continuously running web services, workers, and add-ons.";
+    }
+  };
+
+  renderBody = () => {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    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,6 +154,8 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setSidebar={setSidebar}
         />
       );
+    } else if (currentView === "env-groups") {
+      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
     }
 
     return (
@@ -126,40 +171,12 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               <i className="material-icons">info</i> Info
             </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}.`}
-          </Description>
+          <Description>{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>
     );
   };

+ 113 - 0
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -0,0 +1,113 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  image: any;
+  title: string;
+  description: string;
+};
+
+type StateType = {};
+
+export default class DashboardHeader extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <>
+        <TitleSection>
+          <Img src={this.props.image} />
+          <Title>{this.props.title}</Title>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+          <Description>{this.props.description}</Description>
+        </InfoSection>
+
+        <LineBreak />
+      </>
+    );
+  }
+}
+
+const Img = styled.img`
+  width: 30px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+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 Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-transform: capitalize;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

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

@@ -116,7 +116,6 @@ const InfoWrapper = styled.div`
 const LastDeployed = styled.div`
   font-size: 13px;
   margin-left: 10px;
-  margin-top: -1px;
   display: flex;
   align-items: center;
   color: #aaaabb66;

+ 325 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -0,0 +1,325 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import sliders from "assets/sliders.svg";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+
+import InputRow from "components/values-form/InputRow";
+import KeyValueArray from "components/values-form/KeyValueArray";
+import Selector from "components/Selector";
+import Helper from "components/values-form/Helper";
+import SaveButton from "components/SaveButton";
+import { isAlphanumeric } from "shared/common";
+
+type PropsType = {
+  goBack: () => void;
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+  envGroupName: string;
+  selectedNamespace: string;
+  namespaceOptions: any[];
+  envVariables: any;
+  submitStatus: string;
+};
+
+export default class CreateEnvGroup extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+    envGroupName: "",
+    selectedNamespace: "default",
+    namespaceOptions: [] as any[],
+    envVariables: {} as any,
+    submitStatus: "",
+  };
+
+  componentDidMount() {
+    this.updateNamespaces();
+  }
+
+  isDisabled = () => {
+    return (
+      !isAlphanumeric(this.state.envGroupName) || this.state.envGroupName === ""
+    );
+  };
+
+  onSubmit = () => {
+    this.setState({ submitStatus: "loading" });
+    api
+      .createConfigMap(
+        "<token>",
+        {
+          name: this.state.envGroupName,
+          namespace: this.state.selectedNamespace,
+          variables: this.state.envVariables,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ submitStatus: "successful" });
+        this.props.goBack();
+      })
+      .catch((err) => {
+        this.setState({ submitStatus: "Could not create" });
+      });
+  };
+
+  updateNamespaces = () => {
+    let { currentProject } = this.context;
+    api
+      .getNamespaces(
+        "<token>",
+        {
+          cluster_id: this.props.currentCluster.id,
+        },
+        { id: currentProject.id }
+      )
+      .then((res) => {
+        if (res.data) {
+          let namespaceOptions = res.data.items.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (res.data.items.length > 0) {
+            this.setState({ namespaceOptions });
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  render() {
+    return (
+      <>
+        <StyledCreateEnvGroup>
+          <HeaderSection>
+            <Button onClick={this.props.goBack}>
+              <i className="material-icons">keyboard_backspace</i>
+              Back
+            </Button>
+            <Title>Create an Environment Group</Title>
+          </HeaderSection>
+          <DarkMatter antiHeight="-13px" />
+          <Heading isAtTop={true}>Name</Heading>
+          <Subtitle>
+            <Warning
+              makeFlush={true}
+              highlight={
+                !isAlphanumeric(this.state.envGroupName) &&
+                this.state.envGroupName !== ""
+              }
+            >
+              Lowercase letters, numbers, and "-" only.
+            </Warning>
+          </Subtitle>
+          <DarkMatter antiHeight="-29px" />
+          <InputRow
+            type="text"
+            value={this.state.envGroupName}
+            setValue={(x: string) => this.setState({ envGroupName: x })}
+            placeholder="ex: doctor-scientist"
+            width="100%"
+          />
+
+          <Heading>Destination</Heading>
+          <Subtitle>
+            Specify the namespace you would like to create this environment
+            group in.
+          </Subtitle>
+          <DestinationSection>
+            <NamespaceLabel>
+              <i className="material-icons">view_list</i>Namespace
+            </NamespaceLabel>
+            <Selector
+              key={"namespace"}
+              activeValue={this.state.selectedNamespace}
+              setActiveValue={(namespace: string) =>
+                this.setState({ selectedNamespace: namespace })
+              }
+              options={this.state.namespaceOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+          </DestinationSection>
+
+          <Heading>Environment Variables</Heading>
+          <Helper>
+            Set environment variables for your secrets and environment-specific
+            configuration.
+          </Helper>
+          <KeyValueArray
+            namespace={this.state.selectedNamespace}
+            values={this.state.envVariables}
+            setValues={(x: any) => this.setState({ envVariables: x })}
+          />
+          <SaveButton
+            disabled={this.isDisabled()}
+            text="Create Env Group"
+            onClick={this.onSubmit}
+            status={
+              this.isDisabled()
+                ? "Missing required fields"
+                : this.state.submitStatus
+            }
+            makeFlush={true}
+          />
+        </StyledCreateEnvGroup>
+        <Buffer />
+      </>
+    );
+  }
+}
+
+CreateEnvGroup.contextType = Context;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
+const StyledCreateEnvGroup = styled.div`
+  padding-bottom: 70px;
+  position: relative;
+`;
+
+const NamespaceLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const DestinationSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 2px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 15px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const HeaderSection = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 40px;
+
+  > i {
+    cursor: pointer;
+    font-size 20px;
+    color: #969Fbbaa;
+    padding: 2px;
+    border: 2px solid #969fbbaa;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
+  > img {
+    width: 20px;
+    margin-left: 17px;
+    margin-right: 7px;
+  }
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;

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

@@ -0,0 +1,259 @@
+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;
+    let name = envGroup?.metadata?.name;
+    let timestamp = envGroup?.metadata?.creationTimestamp;
+    let namespace = envGroup?.metadata?.namespace;
+    let varCount = Object.values(envGroup?.data || {}).length;
+
+    return (
+      <StyledEnvGroup
+        onMouseEnter={() => this.setState({ expand: true })}
+        onMouseLeave={() => this.setState({ expand: false })}
+        expand={this.state.expand}
+        onClick={() => setExpanded()}
+      >
+        <Title>
+          <IconWrapper>
+            <Icon src={key} />
+          </IconWrapper>
+          {name}
+        </Title>
+
+        <BottomWrapper>
+          <InfoWrapper>
+            <LastDeployed>
+              Last updated {this.readableDate(timestamp)}
+            </LastDeployed>
+          </InfoWrapper>
+
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>{namespace}</NamespaceTag>
+          </TagWrapper>
+        </BottomWrapper>
+
+        <Version>{varCount} 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;
+    }
+  }
+`;

+ 185 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -0,0 +1,185 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import sliders from "assets/sliders.svg";
+
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+
+import DashboardHeader from "../DashboardHeader";
+import NamespaceSelector from "../NamespaceSelector";
+import SortSelector from "../SortSelector";
+import EnvGroupList from "./EnvGroupList";
+import CreateEnvGroup from "./CreateEnvGroup";
+import ExpandedEnvGroup from "./ExpandedEnvGroup";
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+  sortType: string;
+  expandedEnvGroup: any;
+  namespace: string;
+  createEnvMode: boolean;
+};
+
+class EnvGroupDashboard extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+    namespace: "default",
+    expandedEnvGroup: null as any,
+    createEnvMode: false,
+    sortType: localStorage.getItem("SortType")
+      ? localStorage.getItem("SortType")
+      : "Newest",
+  };
+
+  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}`;
+  };
+
+  renderBody = () => {
+    if (this.state.createEnvMode) {
+      return (
+        <CreateEnvGroup
+          goBack={() => this.setState({ createEnvMode: false })}
+          currentCluster={this.props.currentCluster}
+        />
+      );
+    } else {
+      return (
+        <>
+          <ControlRow>
+            <Button
+              onClick={() =>
+                this.setState({ createEnvMode: !this.state.createEnvMode })
+              }
+            >
+              <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={this.props.currentCluster}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+            setExpandedEnvGroup={(envGroup: any) =>
+              this.setState({ expandedEnvGroup: envGroup })
+            }
+          />
+        </>
+      );
+    }
+  };
+
+  renderContents = () => {
+    if (this.state.expandedEnvGroup) {
+      return (
+        <ExpandedEnvGroup
+          namespace={this.state.namespace}
+          currentCluster={this.props.currentCluster}
+          envGroup={this.state.expandedEnvGroup}
+          closeExpanded={() => this.setState({ expandedEnvGroup: null })}
+        />
+      );
+    } else {
+      return (
+        <>
+          <DashboardHeader
+            image={sliders}
+            title="Environment Groups"
+            description="Groups of environment variables for storing secrets and configuration."
+          />
+          {this.renderBody()}
+        </>
+      );
+    }
+  };
+
+  render() {
+    return <>{this.renderContents()}</>;
+  }
+}
+
+EnvGroupDashboard.contextType = Context;
+
+export default withRouter(EnvGroupDashboard);
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

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

@@ -0,0 +1,166 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ClusterType } from "shared/types";
+
+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: [] as any[],
+    loading: false,
+    error: false,
+  };
+
+  updateEnvGroups = () => {
+    api
+      .listConfigMaps(
+        "<token>",
+        {
+          namespace: this.props.namespace,
+          cluster_id: this.props.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        let sortedGroups = res?.data?.items;
+        switch (this.props.sortType) {
+          case "Oldest":
+            sortedGroups.sort((a: any, b: any) =>
+              Date.parse(a.metadata.creationTimestamp) >
+              Date.parse(b.metadata.creationTimestamp)
+                ? 1
+                : -1
+            );
+            break;
+          case "Alphabetical":
+            sortedGroups.sort((a: any, b: any) =>
+              a.metadata.name > b.metadata.name ? 1 : -1
+            );
+            console.log(sortedGroups);
+            break;
+          default:
+            sortedGroups.sort((a: any, b: any) =>
+              Date.parse(a.metadata.creationTimestamp) >
+              Date.parse(b.metadata.creationTimestamp)
+                ? -1
+                : 1
+            );
+        }
+        this.setState({ envGroups: sortedGroups, loading: false });
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
+  };
+
+  componentDidMount() {
+    this.setState({ loading: true });
+    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;
+`;

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

@@ -0,0 +1,463 @@
+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";
+
+type PropsType = {
+  namespace: string;
+  envGroup: any;
+  currentCluster: ClusterType;
+  closeExpanded: () => void;
+};
+
+type StateType = {
+  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 = {
+    loading: true,
+    currentTab: "environment",
+    showDeleteOverlay: false,
+    deleting: false,
+    saveValuesStatus: null as string | null,
+    values: this.props.envGroup.data as any,
+  };
+
+  handleUpdateValues = (config?: any) => {
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ saveValuesStatus: "loading" });
+    api
+      .updateConfigMap(
+        "<token>",
+        {
+          name,
+          namespace,
+          variables: this.state.values,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ saveValuesStatus: "successful" });
+      })
+      .catch((err) => {
+        this.setState({ saveValuesStatus: "error" });
+      });
+  };
+
+  renderTabContents = () => {
+    let currentTab = this.state.currentTab;
+    let { envGroup, namespace } = this.props;
+    let name = envGroup.metadata.name;
+
+    switch (currentTab) {
+      case "environment":
+        return (
+          <TabWrapper>
+            <InnerWrapper>
+              <Heading>Environment Variables</Heading>
+              <Helper>
+                Set environment variables for your secrets and
+                environment-specific configuration.
+              </Helper>
+              <KeyValueArray
+                namespace={namespace}
+                values={this.state.values || {}}
+                setValues={(x: any) => this.setState({ values: x })}
+              />
+            </InnerWrapper>
+            <SaveButton
+              text="Update"
+              onClick={() => this.handleUpdateValues()}
+              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 {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 = () => {
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ deleting: true });
+    api
+      .deleteConfigMap(
+        "<token>",
+        {
+          name,
+          namespace,
+          cluster_id: this.props.currentCluster.id,
+        },
+        { id: this.context.currentProject.id }
+      )
+      .then((res) => {
+        this.props.closeExpanded();
+        this.setState({ deleting: false });
+        // console.log("CONFIGMAP", res);
+      })
+      .catch((err) => {
+        this.setState({ deleting: false, showDeleteOverlay: false });
+        // console.log("CONFIGMAP", err);
+      });
+  };
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return (
+        <DeleteOverlay>
+          <Loading />
+        </DeleteOverlay>
+      );
+    }
+  };
+
+  render() {
+    let { closeExpanded } = this.props;
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let timestamp = envGroup.metadata.creationTimestamp;
+    let namespace = envGroup.metadata.namespace;
+
+    return (
+      <>
+        <CloseOverlay onClick={closeExpanded} />
+        <StyledExpandedChart>
+          <ConfirmOverlay
+            show={this.state.showDeleteOverlay}
+            message={`Are you sure you want to delete ${name}?`}
+            onYes={this.handleDeleteEnvGroup}
+            onNo={() => this.setState({ showDeleteOverlay: false })}
+          />
+          {this.renderDeleteOverlay()}
+
+          <HeaderWrapper>
+            <TitleSection>
+              <Title>
+                <IconWrapper>
+                  <Icon src={key} />
+                </IconWrapper>
+                {name}
+              </Title>
+              <InfoWrapper>
+                <LastDeployed>
+                  Last updated {this.readableDate(timestamp)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace <NamespaceTag>{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);
+    }
+  }
+`;

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

@@ -351,6 +351,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                         metaState={metaState}
                         setMetaState={setMetaState}
                         sections={tab.sections}
+                        // For env group loader
+                        namespace={this.props.namespace}
                       />
                     );
                   }

+ 13 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -141,7 +141,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
       status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
-          collatedStatus = "waiting";
+          collatedStatus = s.state?.waiting.reason === "CrashLoopBackOff" ? "failed" : "waiting";
         } else if (s.state?.terminated) {
           collatedStatus = "failed";
         }
@@ -195,6 +195,18 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = available == total ? "running" : "waiting";
 
+    controller?.status?.conditions?.forEach(
+      (condition: any) => {
+        if (
+          condition.type == "Progressing" &&
+          condition.status == "False" &&
+          condition.reason == "ProgressDeadlineExceeded"
+        ) {
+          status = 'failed';
+        }
+      }
+    );
+    
     if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
       status = "completed";
     }
@@ -210,9 +222,6 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       >
         {this.state.raw.map((pod, i) => {
           let status = this.getPodStatus(pod.status);
-          if (i === 2) {
-            status = "failed";
-          }
           return (
             <Tab
               key={pod.metadata?.name}

+ 9 - 1
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -40,6 +40,7 @@ type StateType = {
   saveValuesStatus: string | null;
   selectedNamespace: string;
   selectedCluster: string;
+  selectedClusterId: number;
   selectedImageUrl: string | null;
   sourceType: string;
   selectedTag: string | null;
@@ -70,6 +71,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     clusterMap: {} as { [clusterId: string]: ClusterType },
     saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
+    selectedClusterId: this.context.currentCluster.id,
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
     sourceType: "",
@@ -412,6 +414,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
                   setMetaState={setMetaState}
                   key={tab.name}
                   sections={tab.sections}
+                  // For env group loader
+                  namespace={this.state.selectedNamespace}
+                  clusterId={this.state.selectedClusterId}
                 />
               );
             }
@@ -738,7 +743,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(cluster: string) => {
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
-              this.setState({ selectedCluster: cluster });
+              this.setState({
+                selectedCluster: cluster,
+                selectedClusterId: this.state.clusterMap[cluster].id,
+              });
             }}
             options={this.state.clusterOptions}
             width="250px"

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

@@ -0,0 +1,236 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import sliders from "assets/sliders.svg";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  namespace: string;
+  clusterId: number;
+  closeModal: () => void;
+  setValues: (values: any) => void;
+};
+
+type StateType = {
+  envGroups: any[];
+  loading: boolean;
+  error: boolean;
+  selectedEnvGroup: any;
+  buttonStatus: string;
+};
+
+export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
+  state = {
+    envGroups: [] as any[],
+    loading: true,
+    error: false,
+    selectedEnvGroup: null as any,
+    buttonStatus: "",
+  };
+
+  onSubmit = () => {
+    this.props.setValues(this.state.selectedEnvGroup.data);
+    this.props.closeModal();
+  };
+
+  updateEnvGroups = () => {
+    api
+      .listConfigMaps(
+        "<token>",
+        {
+          namespace: this.props.namespace,
+          cluster_id: this.props.clusterId || this.context.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          envGroups: res?.data?.items as any[],
+          loading: false,
+        });
+        console.log(res.data.items);
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
+  };
+
+  componentDidMount() {
+    this.updateEnvGroups();
+  }
+
+  renderEnvGroupList = () => {
+    if (this.state.loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (this.state.envGroups.length === 0) {
+      return (
+        <Placeholder>
+          No environment groups found in this namespace ({this.props.namespace}
+          ).
+        </Placeholder>
+      );
+    } else {
+      return this.state.envGroups.map((envGroup: any, i: number) => {
+        return (
+          <EnvGroupRow
+            key={i}
+            isSelected={this.state.selectedEnvGroup === envGroup}
+            lastItem={i === this.state.envGroups.length - 1}
+            onClick={() => this.setState({ selectedEnvGroup: envGroup })}
+          >
+            <img src={sliders} />
+            {envGroup.metadata.name}
+          </EnvGroupRow>
+        );
+      });
+    }
+  };
+
+  render() {
+    return (
+      <StyledLoadEnvGroupModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Load from Environment Group</ModalTitle>
+        <Subtitle>
+          Select an existing group of environment variables in this namespace (
+          {this.props.namespace}).
+        </Subtitle>
+
+        <EnvGroupList>{this.renderEnvGroupList()}</EnvGroupList>
+
+        <SaveButton
+          disabled={!this.state.selectedEnvGroup}
+          text="Load Selected Env Group"
+          status={
+            !this.state.selectedEnvGroup
+              ? "No env group selected"
+              : "Existing env variables will be overidden"
+          }
+          onClick={this.onSubmit}
+        />
+      </StyledLoadEnvGroupModal>
+    );
+  }
+}
+
+LoadEnvGroupModal.contextType = Context;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 150px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #aaaabb;
+  font-size: 13px;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 150px;
+`;
+
+const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
+  :hover {
+    background: #ffffff11;
+  }
+
+  > img,
+  i {
+    width: 16px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;
+
+const EnvGroupList = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  border-radius: 3px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  max-height: 160px;
+  overflow-y: auto;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 15px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledLoadEnvGroupModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 14 - 8
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -62,13 +62,13 @@ const regionOptions = [
 ];
 
 const machineTypeOptions = [
-  { value: "t2.medium", label: "t2.medium"},
-  { value: "t2.xlarge", label: "t2.xlarge"},
-  { value: "t2.2xlarge", label: "t2.2xlarge"},
-  { value: "t3.medium", label: "t3.medium"},
-  { value: "t3.xlarge", label: "t3.xlarge"},
-  { value: "t3.2xlarge", label: "t3.2xlarge"},
-]
+  { value: "t2.medium", label: "t2.medium" },
+  { value: "t2.xlarge", label: "t2.xlarge" },
+  { value: "t2.2xlarge", label: "t2.2xlarge" },
+  { value: "t3.medium", label: "t3.medium" },
+  { value: "t3.xlarge", label: "t3.xlarge" },
+  { value: "t3.2xlarge", label: "t3.2xlarge" },
+];
 
 // TODO: Consolidate across forms w/ HOC
 class AWSFormSection extends Component<PropsType, StateType> {
@@ -275,7 +275,13 @@ class AWSFormSection extends Component<PropsType, StateType> {
 
   render() {
     let { setSelectedProvisioner } = this.props;
-    let { awsRegion, awsMachineType, awsAccessId, awsSecretKey, selectedInfras } = this.state;
+    let {
+      awsRegion,
+      awsMachineType,
+      awsAccessId,
+      awsSecretKey,
+      selectedInfras,
+    } = this.state;
 
     return (
       <StyledAWSFormSection>

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

@@ -35,7 +35,6 @@ const provisionOptions = [
 
 const tierOptions = [
   { value: "basic", label: "Basic" },
-  { value: "starter", label: "Starter" },
   { value: "professional", label: "Professional" },
 ];
 
@@ -56,7 +55,7 @@ const regionOptions = [
 export default class DOFormSection extends Component<PropsType, StateType> {
   state = {
     selectedInfras: [...provisionOptions],
-    subscriptionTier: "starter",
+    subscriptionTier: "basic",
     doRegion: "nyc1",
     provisionConfirmed: false,
   };

+ 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>
         </>
       );
     }

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

@@ -710,6 +710,62 @@ const upgradeChartValues = baseApi<
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 
+const listConfigMaps = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/list`;
+});
+
+const getConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap`;
+});
+
+const createConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/create?cluster_id=${cluster_id}`;
+});
+
+const updateConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/update?cluster_id=${cluster_id}`;
+});
+
+const deleteConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -728,7 +784,9 @@ export default {
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createProject,
+  createConfigMap,
   deleteCluster,
+  deleteConfigMap,
   deleteGitRepoIntegration,
   deleteInvite,
   deletePod,
@@ -747,6 +805,7 @@ export default {
   getChartControllers,
   getClusterIntegrations,
   getClusters,
+  getConfigMap,
   getGitRepoList,
   getGitRepos,
   getImageRepos,
@@ -776,6 +835,7 @@ export default {
   getApplicationTemplates,
   getUser,
   linkGithubProject,
+  listConfigMaps,
   logInUser,
   logOutUser,
   provisionECR,
@@ -784,5 +844,6 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
+  updateConfigMap,
   upgradeChartValues,
 };

+ 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",
 ];
 

+ 1 - 0
go.sum

@@ -1507,6 +1507,7 @@ k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
+k8s.io/apimachinery v0.21.0 h1:3Fx+41if+IRavNcKOz09FwEXDBG6ORh6iMsTSelhkMA=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 6 - 0
internal/forms/k8s.go

@@ -37,3 +37,9 @@ func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 
 	return nil
 }
+
+type ConfigMapForm struct {
+	Name string `json:"name" form:"required"`
+	Namespace string `json:"namespace" form:"required"`
+	EnvVariables map[string]string `json:"variables"`
+}

+ 64 - 0
internal/kubernetes/agent.go

@@ -58,6 +58,70 @@ type ListOptions struct {
 	FieldSelector string
 }
 
+// CreateConfigMap creates the configmap given the key-value pairs and namespace
+func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter": "true",
+				},
+			},
+			Data: configMap,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+// UpdateConfigMap updates the configmap given its name and namespace
+func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Update(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter": "true",
+				},
+			},
+			Data: configMap,
+		},
+		metav1.UpdateOptions{},
+	)
+}
+
+// DeleteConfigMap deletes the configmap given its name and namespace
+func (a *Agent) DeleteConfigMap(name string, namespace string) (error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetConfigMap retrieves the configmap given its name and namespace
+func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// ListConfigMaps simply lists namespaces
+func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: "porter=true",
+		},
+	)
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(

+ 259 - 0
server/api/k8s_handler.go

@@ -20,6 +20,7 @@ import (
 const (
 	ErrK8sDecode ErrorCode = iota + 600
 	ErrK8sValidate
+	ErrEnvDecode
 )
 
 var upgrader = websocket.Upgrader{
@@ -74,6 +75,264 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("CREATING CONFGIMAP")
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	_, err = agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleListConfigMaps lists all configmaps in a namespace.
+func (app *App) HandleListConfigMaps(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMaps, err := agent.ListConfigMaps(vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMaps); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleGetConfigMap retreives the configmap given the name and namespace.
+func (app *App) HandleGetConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap, err := agent.GetConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	err = agent.DeleteConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleUpdateConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	_, err = agent.UpdateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {

+ 70 - 0
server/router/router.go

@@ -1252,6 +1252,76 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/create",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleCreateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/k8s/configmap/delete",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDeleteConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap/list",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleListConfigMaps, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/update",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleUpdateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/subdomain routes
 		r.Method(
 			"POST",