Alexander Belanger 5 лет назад
Родитель
Сommit
7fca8386ed
69 измененных файлов с 4271 добавлено и 1003 удалено
  1. 2 2
      README.md
  2. 1 1
      dashboard/docker/dev.Dockerfile
  3. 14 3
      dashboard/package-lock.json
  4. 3 1
      dashboard/package.json
  5. 4 0
      dashboard/src/assets/edit.svg
  6. 4 0
      dashboard/src/assets/plus.svg
  7. BIN
      dashboard/src/assets/tag.png
  8. 17 3
      dashboard/src/components/SaveButton.tsx
  9. 108 0
      dashboard/src/components/TabRegion.tsx
  10. 316 0
      dashboard/src/components/image-selector/ImageSelector.tsx
  11. 142 0
      dashboard/src/components/image-selector/TagList.tsx
  12. 0 3
      dashboard/src/components/repo-selector/BranchList.tsx
  13. 14 0
      dashboard/src/components/values-form/Heading.tsx
  14. 14 0
      dashboard/src/components/values-form/Helper.tsx
  15. 18 6
      dashboard/src/components/values-form/InputRow.tsx
  16. 64 0
      dashboard/src/components/values-form/TextArea.tsx
  17. 52 48
      dashboard/src/components/values-form/ValuesForm.tsx
  18. 46 25
      dashboard/src/main/home/Home.tsx
  19. 8 5
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  20. 65 5
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  21. 130 23
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  22. 194 252
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  23. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  24. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  25. 8 14
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  26. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  27. 0 108
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx
  28. 0 65
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx
  29. 313 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  30. 103 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  31. 156 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  32. 119 0
      dashboard/src/main/home/integrations/IntegrationList.tsx
  33. 247 0
      dashboard/src/main/home/integrations/Integrations.tsx
  34. 84 0
      dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx
  35. 81 0
      dashboard/src/main/home/integrations/integration-form/ECRForm.tsx
  36. 101 0
      dashboard/src/main/home/integrations/integration-form/EKSForm.tsx
  37. 70 0
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  38. 90 0
      dashboard/src/main/home/integrations/integration-form/GKEForm.tsx
  39. 37 0
      dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx
  40. 154 0
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  41. 0 294
      dashboard/src/main/home/modals/LaunchTemplateModal.tsx
  42. 4 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  43. 6 22
      dashboard/src/main/home/templates/Templates.tsx
  44. 1 1
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  45. 100 11
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  46. 22 14
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  47. 22 1
      dashboard/src/shared/api.tsx
  48. 30 0
      dashboard/src/shared/common.tsx
  49. 39 21
      dashboard/src/shared/types.tsx
  50. 3 3
      go.mod
  51. 42 0
      go.sum
  52. 13 0
      internal/forms/release.go
  53. 56 0
      internal/helm/agent.go
  54. 41 0
      internal/helm/grapher/object.go
  55. 151 8
      internal/kubernetes/agent.go
  56. 57 0
      internal/models/templates.go
  57. 21 0
      node_modules/@types/js-base64/LICENSE
  58. 16 0
      node_modules/@types/js-base64/README.md
  59. 82 0
      node_modules/@types/js-base64/index.d.ts
  60. 62 0
      node_modules/@types/js-base64/package.json
  61. 221 0
      server/api/deploy_handler.go
  62. 113 0
      server/api/deploy_handler_test.go
  63. 65 2
      server/api/k8s_handler.go
  64. 36 0
      server/api/registry_handler.go
  65. 96 0
      server/api/registry_handler_test.go
  66. 129 1
      server/api/release_handler.go
  67. 7 57
      server/api/template_handler.go
  68. 50 0
      server/router/router.go
  69. 1 0
      v.yml

+ 2 - 2
README.md

@@ -36,7 +36,7 @@ Run the following command to grab the latest binary:
 
 ```sh
 {
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name
@@ -57,7 +57,7 @@ Run the following command to grab the latest binary:
 
 ```sh
 {
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name

+ 1 - 1
dashboard/docker/dev.Dockerfile

@@ -5,7 +5,7 @@ WORKDIR /webpack
 
 COPY package*.json ./
 
-RUN npm i
+RUN npm install
 
 ENV NODE_ENV=development
 

+ 14 - 3
dashboard/package-lock.json

@@ -401,6 +401,12 @@
         "jest-diff": "^24.3.0"
       }
     },
+    "@types/js-base64": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/js-base64/-/js-base64-3.0.0.tgz",
+      "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
+      "dev": true
+    },
     "@types/js-yaml": {
       "version": "3.12.5",
       "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz",
@@ -4183,6 +4189,11 @@
       "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz",
       "integrity": "sha1-uEF7dQZho5K+4sJTfGiyqdSXfNU="
     },
+    "js-base64": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.6.0.tgz",
+      "integrity": "sha512-wVdUBYQeY2gY73RIlPrysvpYx+2vheGo8Y1SNQv/BzHToWpAZzJU7Z6uheKMAe+GLSBig5/Ps2nxg/8tRB73xg=="
+    },
     "js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6851,9 +6862,9 @@
       "dev": true
     },
     "typescript": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz",
-      "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==",
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz",
+      "integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
       "dev": true
     },
     "union-value": {

+ 3 - 1
dashboard/package.json

@@ -10,6 +10,7 @@
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",
+    "js-base64": "^3.6.0",
     "js-yaml": "^3.14.0",
     "markdown-to-jsx": "^7.0.1",
     "posthog-node": "^1.0.6",
@@ -31,6 +32,7 @@
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
     "@types/jest": "^24.0.0",
+    "@types/js-base64": "^3.0.0",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/react": "^16.9.49",
@@ -44,7 +46,7 @@
     "qs": "^6.9.4",
     "source-map-loader": "^1.1.0",
     "ts-loader": "^8.0.4",
-    "typescript": "^4.0.3",
+    "typescript": "^4.1.2",
     "webpack": "^4.44.2",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"

+ 4 - 0
dashboard/src/assets/edit.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.6643 21.9897H7.33488C5.88835 22.0796 4.46781 21.5781 3.3989 20.6011C2.4219 19.5312 1.92041 18.1107 2.01032 16.6652V7.33482C1.92041 5.88932 2.4209 4.46878 3.3979 3.39889C4.46781 2.42189 5.88835 1.92041 7.33488 2.01032H16.6643C18.1089 1.92041 19.5284 2.4209 20.5973 3.39789C21.5733 4.46878 22.0758 5.88832 21.9899 7.33482V16.6652C22.0788 18.1107 21.5783 19.5312 20.6013 20.6011C19.5314 21.5781 18.1109 22.0796 16.6643 21.9897Z" fill="white"/>
+<path d="M17.0545 10.3976L10.5018 16.9829C10.161 17.3146 9.7131 17.5 9.24574 17.5H6.95762C6.83105 17.5 6.71421 17.4512 6.62658 17.3634C6.53895 17.2756 6.5 17.1585 6.5 17.0317L6.55842 14.7195C6.56816 14.261 6.75315 13.8317 7.07446 13.5098L11.7189 8.8561C11.7967 8.77805 11.9331 8.77805 12.011 8.8561L13.6399 10.4785C13.747 10.5849 13.9028 10.6541 14.0683 10.6541C14.4286 10.6541 14.7109 10.3615 14.7109 10.0102C14.7109 9.83463 14.6428 9.67854 14.5357 9.56146C14.5065 9.52244 12.9554 7.97805 12.9554 7.97805C12.858 7.88049 12.858 7.71463 12.9554 7.61707L13.6078 6.95366C14.2114 6.34878 15.1851 6.34878 15.7888 6.95366L17.0545 8.22195C17.6485 8.81707 17.6485 9.79268 17.0545 10.3976Z" fill="white"/>
+</svg>

+ 4 - 0
dashboard/src/assets/plus.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.6667 2H7.33333C3.92889 2 2 3.92889 2 7.33333V16.6667C2 20.0622 3.92 22 7.33333 22H16.6667C20.0711 22 22 20.0622 22 16.6667V7.33333C22 3.92889 20.0711 2 16.6667 2Z" fill="white"/>
+<path d="M15.3205 12.7083H12.7495V15.257C12.7495 15.6673 12.4139 16 12 16C11.5861 16 11.2505 15.6673 11.2505 15.257V12.7083H8.67955C8.29342 12.6687 8 12.3461 8 11.9613C8 11.5765 8.29342 11.2539 8.67955 11.2143H11.2424V8.67365C11.2824 8.29088 11.6078 8 11.996 8C12.3842 8 12.7095 8.29088 12.7495 8.67365V11.2143H15.3205C15.7066 11.2539 16 11.5765 16 11.9613C16 12.3461 15.7066 12.6687 15.3205 12.7083Z" fill="white"/>
+</svg>

BIN
dashboard/src/assets/tag.png


+ 17 - 3
dashboard/src/components/SaveButton.tsx

@@ -8,6 +8,9 @@ type PropsType = {
   disabled?: boolean,
   status?: string | null,
   color?: string,
+
+  // Makes flush with corner if not within a modal
+  makeFlush?: boolean 
 };
 
 type StateType = {
@@ -41,7 +44,7 @@ export default class SaveButton extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <ButtonWrapper>
+      <ButtonWrapper makeFlush={this.props.makeFlush}>
         {this.renderStatus()}
         <Button 
           disabled={this.props.disabled}
@@ -93,8 +96,19 @@ const ButtonWrapper = styled.div`
   display: flex;
   align-items: center;
   position: absolute;
-  bottom: 25px;
-  right: 27px;
+  ${(props: { makeFlush: boolean }) => {
+    if (!props.makeFlush) {
+      return (`
+        bottom: 25px;
+        right: 27px;
+      `);
+    } 
+    return (`
+      bottom: 0;
+      right: 0;
+    `);
+  }}
+
 `;
 
 const Button = styled.button`

+ 108 - 0
dashboard/src/components/TabRegion.tsx

@@ -0,0 +1,108 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import TabSelector from './TabSelector';
+import Loading from './Loading';
+
+type PropsType = {
+  options: { label: string, value: string }[],
+  tabContents: any,
+  defaultTab?: string,
+  addendum?: any,
+  checkTabExists?: boolean, // Handles the currently selected tab disappearing
+  color?: string | null,
+};
+
+type StateType = {
+  currentTab: string
+};
+
+// Manages a tab selector and renders the associated view
+// TODO: consider rearchitecturing to support standard re-render
+export default class TabRegion extends Component<PropsType, StateType> {
+  state = {
+    currentTab: this.props.defaultTab
+  }
+
+  setDefaultTab = () => {
+    if (!this.props.defaultTab && this.props.options[0]) {
+      this.setState({ currentTab: this.props.options[0].value });
+    }
+  }
+
+  componentDidMount() {
+    this.setDefaultTab();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    let { options, checkTabExists } = this.props;
+    if (prevProps.options !== options && !this.state.currentTab) {
+      this.setDefaultTab();
+    } else if (prevProps.checkTabExists !== checkTabExists
+      && !options.some((e: any) => e.value === this.state.currentTab)) {
+      this.setDefaultTab();
+    }
+  }
+
+  renderTabContents = () => {
+    let found = this.props.tabContents.find((el: any) => el.value === this.state.currentTab);
+    if (found) {
+      return found.component;
+    }
+  }
+
+  renderContents = () => {
+    if (!this.state.currentTab) {
+      return (
+        <Loading />
+      );
+    }
+
+    return (
+      <Div>
+        <TabSelector
+          options={this.props.options}
+          color={this.props.color}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          addendum={this.props.addendum}
+        />
+        <Gap />
+        <TabContents>
+          {this.renderTabContents()}
+        </TabContents>
+      </Div>
+    );
+  }
+
+  render() {
+    return (
+      <StyledTabRegion>
+        {this.renderContents()}
+      </StyledTabRegion>
+    );
+  }
+}
+
+const Div = styled.div`
+  width: 100%;
+  height: 100%;
+  animation: fadeIn 0.25s 0s;
+`;
+
+const TabContents = styled.div`
+  height: calc(100% - 60px);
+`;
+
+const Gap = styled.div`
+  width: 100%;
+  background: none;
+  height: 30px;
+`;
+
+const StyledTabRegion = styled.div`
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow-y: auto;
+`;

+ 316 - 0
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -0,0 +1,316 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import info from '../../assets/info.svg';
+import edit from '../../assets/edit.svg';
+
+import api from '../../shared/api';
+import { getIntegrationIcon } from '../../shared/common';
+import { Context } from '../../shared/Context';
+import { ImageType } from '../../shared/types';
+
+import Loading from '../Loading';
+import TagList from './TagList';
+
+type PropsType = {
+  forceExpanded?: boolean,
+  selectedImageUrl: string | null,
+  setSelectedImageUrl: (x: string) => void
+};
+
+type StateType = {
+  isExpanded: boolean,
+  loading: boolean,
+  error: boolean,
+  images: ImageType[],
+  clickedImage: ImageType | null,
+};
+
+const dummyImages = [
+  {
+    kind: 'docker-hub',
+    source: 'index.docker.io/jusrhee/image1',
+  },
+  {
+    kind: 'docker-hub',
+    source: 'https://index.docker.io/jusrhee/image2',
+  },
+  {
+    kind: 'docker-hub',
+    source: 'https://index.docker.io/jusrhee/image3',
+  },
+  {
+    kind: 'gcr',
+    source: 'https://gcr.io/some-registry/image1',
+  },
+  {
+    kind: 'gcr',
+    source: 'https://gcr.io/some-registry/image2',
+  },
+  {
+    kind: 'ecr',
+    source: 'https://aws_account_id.dkr.ecr.region.amazonaws.com/smth/1',
+  },
+  {
+    kind: 'ecr',
+    source: 'https://aws_account_id.dkr.ecr.region.amazonaws.com/smth/2',
+  },
+];
+
+export default class ImageSelector extends Component<PropsType, StateType> {
+  state = {
+    isExpanded: this.props.forceExpanded,
+    loading: false,
+    error: false,
+    images: [] as ImageType[],
+    clickedImage: null as ImageType | null,
+  }
+
+  componentDidMount() {
+    this.setState({ images: dummyImages });
+  }
+
+  renderImageList = () => {
+    let { images, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !images) {
+      return <LoadingWrapper>Error loading repos</LoadingWrapper>
+    }
+
+    return images.map((image: ImageType, i: number) => {
+      let icon = getIntegrationIcon(image.kind);
+      return (
+        <ImageItem
+          key={i}
+          isSelected={image.source === this.props.selectedImageUrl}
+          lastItem={i === images.length - 1}
+          onClick={() => { 
+            this.props.setSelectedImageUrl(image.source);
+            this.setState({ clickedImage: image });
+          }}
+        >
+          <img src={icon && icon} />{image.source}
+        </ImageItem>
+      );
+    });
+  }
+
+  renderBackButton = () => {
+    let { setSelectedImageUrl } = this.props;
+    if (this.state.clickedImage) {
+      return (
+        <BackButton
+          width='175px'
+          onClick={() => {
+            setSelectedImageUrl('');
+            this.setState({ clickedImage: null });
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Image Repo
+        </BackButton>
+      );
+    }
+  }
+
+  renderExpanded = () => {
+    let { selectedImageUrl, setSelectedImageUrl } = this.props;
+    if (!this.state.clickedImage) {
+      return (
+        <div>
+          <ExpandedWrapper>
+            {this.renderImageList()}
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <ExpandedWrapper>
+            <TagList
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={setSelectedImageUrl}
+            />
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    }
+  }
+
+  renderSelected = () => {
+    let { selectedImageUrl, setSelectedImageUrl } = this.props;
+    let icon = info;
+    if (this.state.clickedImage) {
+      icon = getIntegrationIcon(this.state.clickedImage.kind);
+    } else if (selectedImageUrl && selectedImageUrl !== '') {
+      icon = edit;
+    }
+    return (
+      <Label>
+        <img src={icon} />
+        <Input
+          onClick={(e: any) => e.stopPropagation()}
+          value={selectedImageUrl}
+          onChange={(e: any) => { 
+            setSelectedImageUrl(e.target.value); 
+            this.setState({ clickedImage: null });
+          }}
+          placeholder='Enter or select your container image URL'
+        />
+      </Label>
+    );
+  }
+
+  handleClick = () => {
+    if (!this.props.forceExpanded) {
+      this.setState({ isExpanded: !this.state.isExpanded });
+    }
+  }
+
+  render() {
+    return (
+      <div>
+        <StyledImageSelector
+          onClick={this.handleClick}
+          isExpanded={this.state.isExpanded}
+          forceExpanded={this.props.forceExpanded}
+        >
+          {this.renderSelected()}
+          {this.props.forceExpanded ? null : <i className="material-icons">{this.state.isExpanded ? 'close' : 'build'}</i>}
+        </StyledImageSelector>
+
+        {this.state.isExpanded ? this.renderExpanded() : null}
+      </div>
+    );
+  }
+}
+
+ImageSelector.contextType = Context;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const Input = styled.input`
+  outline: 0;
+  background: none;
+  border: 0;
+  width: calc(100% - 60px);
+  color: white;
+`;
+
+const ImageItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem: boolean, isSelected: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected: boolean, lastItem: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    filter: grayscale(100%);
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  flex: 1;
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const StyledImageSelector = styled.div`
+  width: 100%;
+  border: 1px solid #ffffff55;
+  background: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.isExpanded ? '#ffffff11' : ''};
+  border-radius: 3px;
+  user-select: none;
+  height: 40px;
+  font-size: 13px;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};;
+  :hover {
+    background: #ffffff11;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    font-size: 16px;
+    color: #ffffff66;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 20px;
+    padding: 4px;
+  }
+`;

+ 142 - 0
dashboard/src/components/image-selector/TagList.tsx

@@ -0,0 +1,142 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import tag_icon from '../../assets/tag.png';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  setSelectedImageUrl: (x: string) => void,
+  selectedImageUrl: string
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  tags: string[],
+  currentTag: string | null,
+};
+
+export default class TagList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+    tags: [] as string[],
+    currentTag: null as string | null,
+  }
+
+  componentDidMount() {
+    this.setState({ tags: ['123', '456', '889', '5521', '5212'], loading: false });
+
+    /* Get branches
+    api.getTags('<token>', {}, {
+
+    }, (err: any, res: any) => {
+      if (err) {
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ tags: res.data, loading: false, error: false });
+      }
+    });
+    */
+  }
+
+  setTag = (tag: string) => {
+    let { selectedImageUrl, setSelectedImageUrl} = this.props;
+    let splits = selectedImageUrl.split(':');
+    if (splits[splits.length - 1] === this.state.currentTag) {
+      selectedImageUrl = splits.reduce((acc: string, curr: string) => {
+        if (curr !== this.state.currentTag) {
+          return acc + ':' + curr;
+        } else {
+          return acc;
+        }
+      });
+    }
+    setSelectedImageUrl(selectedImageUrl + ':' + tag);
+    this.setState({ currentTag: tag });
+  }
+
+  renderTagList = () => {
+    let { tags, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !tags) {
+      return <LoadingWrapper>Error loading tags</LoadingWrapper>
+    }
+
+    return tags.map((tag: string, i: number) => {
+      return (
+        <TagName
+          key={i}
+          isSelected={tag === this.state.currentTag}
+          lastItem={i === tags.length - 1}
+          onClick={() => this.setTag(tag)}
+        >
+          <img src={tag_icon} />{tag}
+        </TagName>
+      );
+    });
+  }
+
+  render() {
+    return (
+      <div>
+        <TagNameAlt>
+          <img src={info} /> Select Image Tag
+        </TagNameAlt>
+        {this.renderTagList()}
+      </div>
+    );
+  }
+}
+
+const TagName = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem?: boolean, isSelected?: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected?: boolean, lastItem?: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const TagNameAlt = styled(TagName)`
+  color: #ffffff55;
+  cursor: default;
+  :hover {
+    background: #ffffff11;
+    > i {
+      background: none;
+    }
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

+ 0 - 3
dashboard/src/components/repo-selector/BranchList.tsx

@@ -1,10 +1,8 @@
-import { stringify } from 'querystring';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import branch_icon from '../../assets/branch.png';
 
 import api from '../../shared/api';
-import { RepoType } from '../../shared/types';
 
 import Loading from '../Loading';
 
@@ -22,7 +20,6 @@ type StateType = {
 
 export default class BranchList extends Component<PropsType, StateType> {
   state = {
-    selectedBranch: '',
     loading: true,
     error: false,
     branches: [] as string[]

+ 14 - 0
dashboard/src/components/values-form/Heading.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Heading(props: { children: string }) {
+  return <StyledHeading>{props.children}</StyledHeading>;
+}
+
+const StyledHeading = styled.div`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-top: 30px;
+  margin-bottom: 5px;
+`;

+ 14 - 0
dashboard/src/components/values-form/Helper.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Helper(props: { children: string }) {
+  return <StyledHelper>{props.children}</StyledHelper>;
+}
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 18 - 6
dashboard/src/components/values-form/InputRow.tsx

@@ -5,7 +5,7 @@ type PropsType = {
   label?: string,
   type: string,
   value: string | number,
-  setValue: (x: string) => void,
+  setValue: (x: string | number) => void,
   unit?: string
   placeholder?: string
   width?: string
@@ -13,9 +13,22 @@ type PropsType = {
 };
 
 type StateType = {
+  readOnly: boolean
 };
 
 export default class InputRow extends Component<PropsType, StateType> {
+  state = {
+    readOnly: true
+  }
+
+  handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    if (this.props.type === 'number') {
+      this.props.setValue(parseInt(e.target.value));
+    } else {
+      this.props.setValue(e.target.value);
+    }
+  }
+  
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
@@ -23,14 +36,13 @@ export default class InputRow extends Component<PropsType, StateType> {
         <Label>{label}</Label>
         <InputWrapper>
           <Input
+            readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
             disabled={this.props.disabled}
             placeholder={placeholder}
             width={width}
             type={type}
-            value={value}
-            onChange={(e: ChangeEvent<HTMLInputElement>) =>
-              this.props.setValue(e.target.value)
-            }
+            value={value || ''}
+            onChange={this.handleChange}
           />
           <Unit>{unit}</Unit>
         </InputWrapper>
@@ -57,7 +69,7 @@ const Input = styled.input`
   border-radius: 3px;
   width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
   color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
-  padding: 5px 8px;
+  padding: 5px 10px;
   margin-right: 8px;
   height: 30px;
 `;

+ 64 - 0
dashboard/src/components/values-form/TextArea.tsx

@@ -0,0 +1,64 @@
+import React, { ChangeEvent, Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  value: string,
+  setValue: (x: string) => void,
+  placeholder?: string
+  width?: string
+  disabled?: boolean
+};
+
+type StateType = {
+};
+
+export default class TextArea extends Component<PropsType, StateType> {
+  handleChange = (e: any) => {
+    this.props.setValue(e.target.value);
+  }
+
+  render() {
+    let { label, value, placeholder, width } = this.props;
+    return (
+      <StyledTextArea>
+        <Label>{label}</Label>
+        <InputArea
+          disabled={this.props.disabled}
+          placeholder={placeholder}
+          width={width}
+          value={value || ''}
+          onChange={this.handleChange}
+        />
+      </StyledTextArea>
+    );
+  }
+}
+
+const InputArea = styled.textarea`
+  outline: none;
+  border: none;
+  resize: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
+  color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
+  padding: 5px 10px;
+  margin-right: 8px;
+  height: 8em;
+  line-height: 1.5em;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const StyledTextArea = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 52 - 48
dashboard/src/components/values-form/ValuesForm.tsx

@@ -1,32 +1,38 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import { FormYAML, Section, FormElement } from '../../shared/types';
+import { Section, FormElement } from '../../shared/types';
+import { Context } from '../../shared/Context';
+import api from '../../shared/api';
 
 import SaveButton from '../SaveButton';
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
 import SelectRow from './SelectRow';
+import Helper from './Helper';
+import Heading from './Heading';
 
 type PropsType = {
-  formData?: FormYAML
+  onSubmit: (formValues: any) => void,
+  sections?: Section[],
+  disabled?: boolean,
+  saveValuesStatus?: string | null,
 };
 
 type StateType = any;
 
 export default class ValuesForm extends Component<PropsType, StateType> {
 
-  // Initialize corresponding state fields for form blocks
-  componentDidMount() {
+  updateFormState() {
     let formState: any = {};
-    this.props.formData.Sections.forEach((section: Section, i: number) => {
-      section.Contents.forEach((item: FormElement, i: number) => {
+    this.props.sections.forEach((section: Section, i: number) => {
+      section.contents.forEach((item: FormElement, i: number) => {
 
         // If no name is assigned use values.yaml variable as identifier
-        let key = item.Name || item.Variable;
+        let key = item.name || item.variable;
         
-        let def = item.Settings.Default;
-        switch (item.Type) {
+        let def = item.settings && item.settings.default;
+        switch (item.type) {
           case 'checkbox':
             formState[key] = def ? def : false;
             break;
@@ -34,10 +40,10 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             formState[key] = def ? def : '';
             break;
           case 'number-input':
-            formState[key] = def ? def : '';
+            formState[key] = def.toString() ? def : '';
             break;
           case 'select':
-            formState[key] = def ? def : item.Settings.Options[0].Value;
+            formState[key] = def ? def : item.settings.options[0].value;
           default:
         }
       });
@@ -45,45 +51,56 @@ export default class ValuesForm extends Component<PropsType, StateType> {
     this.setState(formState);
   }
 
+  // Initialize corresponding state fields for form blocks
+  componentDidMount() {
+    this.updateFormState();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.sections !== prevProps.sections) {
+      this.updateFormState();
+    }
+  }
+
   renderSection = (section: Section) => {
-    return section.Contents.map((item: FormElement, i: number) => {
+    return section.contents.map((item: FormElement, i: number) => {
 
       // If no name is assigned use values.yaml variable as identifier
-      let key = item.Name || item.Variable;
-      switch (item.Type) {
+      let key = item.name || item.variable;
+      switch (item.type) {
         case 'heading':
-          return <Heading key={i}>{item.Label}</Heading>
+          return <Heading key={i}>{item.label}</Heading>
         case 'subtitle':
-          return <Helper key={i}>{item.Label}</Helper>
+          return <Helper key={i}>{item.label}</Helper>
         case 'checkbox':
           return (
             <CheckboxRow
               key={i}
               checked={this.state[key]}
               toggle={() => this.setState({ [key]: !this.state[key] })}
-              label={item.Label}
+              label={item.label}
             />
           );
         case 'string-input':
           return (
             <InputRow
               key={i}
-              type={'text'}
+              type='text'
               value={this.state[key]}
               setValue={(x: string) => this.setState({ [key]: x })}
-              label={item.Label}
-              unit={item.Settings ? item.Settings.Unit : null}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
             />
           );
         case 'number-input':
           return (
             <InputRow
               key={i}
-              type={'number'}
+              type='number'
               value={this.state[key]}
-              setValue={(x: string) => this.setState({ [key]: parseInt(x) })}
-              label={item.Label}
-              unit={item.Settings ? item.Settings.Unit : null}
+              setValue={(x: number) => this.setState({ [key]: x })}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
             />
           );
         case 'select':
@@ -92,9 +109,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               key={i}
               value={this.state[key]}
               setActiveValue={(val) => this.setState({ [key]: val })}
-              options={item.Settings.Options}
+              options={item.settings.options}
               dropdownLabel=''
-              label={item.Label}
+              label={item.label}
             />
           );
         default:
@@ -104,11 +121,10 @@ export default class ValuesForm extends Component<PropsType, StateType> {
 
   renderFormContents = () => {
     if (this.state) {
-      return this.props.formData.Sections.map((section: Section, i: number) => {
-
+      return this.props.sections.map((section: Section, i: number) => {
         // Hide collapsible section if deciding field is false
-        if (section.ShowIf) {
-          if (!this.state[section.ShowIf]) {
+        if (section.show_if) {
+          if (!this.state[section.show_if]) {
             return null;
           }
         }
@@ -130,15 +146,19 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           {this.renderFormContents()}
         </StyledValuesForm>
         <SaveButton
+          disabled={this.props.disabled}
           text='Deploy'
-          onClick={() => console.log(this.state)}
-          status={null}
+          onClick={() => this.props.onSubmit(this.state)}
+          status={this.props.saveValuesStatus}
+          makeFlush={true}
         />
       </Wrapper>
     );
   }
 }
 
+ValuesForm.contextType = Context;
+
 const DarkMatter = styled.div`
   margin-top: 0px;
 `;
@@ -148,22 +168,6 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const Helper = styled.div`
-  color: #aaaabb;
-  line-height: 1.6em;
-  font-size: 13px;
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;
-
-const Heading = styled.div`
-  color: white;
-  font-weight: 500;
-  font-size: 16px;
-  margin-top: 30px;
-  margin-bottom: 5px;
-`;
-
 const StyledValuesForm = styled.div`
   width: 100%;
   height: 100%;

+ 46 - 25
dashboard/src/main/home/Home.tsx

@@ -10,10 +10,11 @@ import Dashboard from './dashboard/Dashboard';
 import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
-import LaunchTemplateModal from './modals/LaunchTemplateModal';
+import Integrations from "./integrations/Integrations";
 import CreateProjectModal from './modals/CreateProjectModal';
 import UpdateProjectModal from './modals/UpdateProjectModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
+import IntegrationsModal from './modals/IntegrationsModal';
 
 type PropsType = {
   logOut: () => void
@@ -76,26 +77,24 @@ export default class Home extends Component<PropsType, StateType> {
         <ClusterDashboard
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       </DashboardWrapper>
     );
   }
 
   renderContents = () => {
-    if (this.state.currentView === 'cluster-dashboard') {
-      return (
-        <StyledDashboard>
-          {this.renderDashboard()}
-        </StyledDashboard>
-      );
-    } else if (this.state.currentView === 'dashboard') {
+    let { currentView } = this.state;
+    if (currentView === 'cluster-dashboard') {
+      return this.renderDashboard();
+    } else if (currentView === 'dashboard') {
       return (
-        <StyledDashboard>
-          <DashboardWrapper>
-            <Dashboard />
-          </DashboardWrapper>
-        </StyledDashboard>
+        <DashboardWrapper>
+          <Dashboard />
+        </DashboardWrapper>
       );
+    } else if (currentView === 'integrations') {
+      return <Integrations />;
     }
 
     return <Templates />;
@@ -105,14 +104,6 @@ export default class Home extends Component<PropsType, StateType> {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
       <StyledHome>
-        <ReactModal
-          isOpen={currentModal === 'LaunchTemplateModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={MediumModalStyles}
-          ariaHideApp={false}
-        >
-          <LaunchTemplateModal />
-        </ReactModal>
         <ReactModal
           isOpen={currentModal === 'CreateProjectModal'}
           onRequestClose={() => currentProject ? setCurrentModal(null, null) : null }
@@ -137,6 +128,14 @@ export default class Home extends Component<PropsType, StateType> {
         >
           <UpdateProjectModal />
         </ReactModal>
+        <ReactModal
+          isOpen={currentModal === 'IntegrationsModal'}
+          onRequestClose={() => setCurrentModal(null, null)}
+          style={SmallModalStyles}
+          ariaHideApp={false}
+        >
+          <IntegrationsModal />
+        </ReactModal>
 
         <Sidebar
           logOut={this.props.logOut}
@@ -145,8 +144,10 @@ export default class Home extends Component<PropsType, StateType> {
           setCurrentView={(x: string) => this.setState({ currentView: x })}
           currentView={this.state.currentView}
         />
-        
-        {this.renderContents()}
+
+        <ViewWrapper>
+          {this.renderContents()}
+        </ViewWrapper>
       </StyledHome>
     );
   }
@@ -173,6 +174,25 @@ const MediumModalStyles = {
   },
 };
 
+const SmallModalStyles = {
+  overlay: {
+    backgroundColor: 'rgba(0,0,0,0.6)',
+    zIndex: 2,
+  },
+  content: {
+    borderRadius: '7px',
+    border: 0,
+    width: '760px',
+    maxWidth: '80vw',
+    margin: '0 auto',
+    height: '425px',
+    top: 'calc(50% - 214px)',
+    backgroundColor: '#202227',
+    animation: 'floatInModal 0.5s 0s',
+    overflow: 'visible',
+  },
+};
+
 const ProjectModalStyles = {
   overlay: {
     backgroundColor: 'rgba(0,0,0,0.6)',
@@ -211,10 +231,10 @@ const TallModalStyles = {
   },
 };
 
-const StyledDashboard = styled.div`
+const ViewWrapper = styled.div`
   height: 100%;
   width: 100vw;
-  padding-top: 80px;
+  padding-top: 30px;
   overflow-y: auto;
   display: flex;
   flex: 1;
@@ -225,6 +245,7 @@ const StyledDashboard = styled.div`
 
 const DashboardWrapper = styled.div`
   width: 80%;
+  padding-top: 50px;
   min-width: 300px;
   padding-bottom: 120px;
 `;

+ 8 - 5
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -13,6 +13,7 @@ import ExpandedChart from './expanded-chart/ExpandedChart';
 type PropsType = {
   currentCluster: Cluster,
   setSidebar: (x: boolean) => void
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -108,8 +109,10 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
         
         <ControlRow>
-          <Button disabled={true}>
-            <i className="material-icons">add</i> Deploy a Chart
+          <Button
+            onClick={() => this.props.setCurrentView('templates')}
+          >
+            <i className="material-icons">add</i> Deploy Template
           </Button>
           <NamespaceSelector
             setNamespace={(namespace) => this.setState({ namespace })}
@@ -198,11 +201,11 @@ const Button = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
 
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
+  background: ${(props: { disabled?: boolean }) => props.disabled ? '#aaaabbee' : '#616FEEcc'};
   :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
+    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#505edddd'};
   }
 
   > i {

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

@@ -1,20 +1,40 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 
 type PropsType = {
   chart: ChartType,
-  setCurrentChart: (c: ChartType) => void
+  setCurrentChart: (c: ChartType) => void,
+  controllers: Record<string, any>,
 };
 
 type StateType = {
+  expand: boolean,
+  controllers: Record<string, boolean>,
+  update: any[],
+  getAvailability: Function,
 };
 
 export default class Chart extends Component<PropsType, StateType> {
+  getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return (c.status.availableReplicas == c.status.replicas)
+      case "statefulset":
+       return (c.status.readyReplicas == c.status.replicas)
+      case "daemonset":
+        return (c.status.numberAvailable == c.status.desiredNumberScheduled)
+      }
+  }
+
   state = {
     expand: false,
+    controllers: {} as Record<string, boolean>,
+    update: [] as any[],
+    getAvailability: this.getAvailability.bind(this),
   }
 
   renderIcon = () => {
@@ -34,9 +54,49 @@ export default class Chart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
+  setControllerStatus = (cs: Record<string, any>) => {
+    let controllers = {} as Record<string, boolean>;
+    for (var uid in cs) {
+      let value = cs[uid];
+      controllers[uid] = this.getAvailability(value.kind, value);
+    }
+    this.setState({ controllers });
+  }
+
+  getChartStatus = (chartStatus: string) => {
+    if (chartStatus === 'deployed') {
+      for (var uid in this.state.controllers) {
+        if (!this.state.controllers[uid]) {
+          return 'not ready'
+        }
+      }
+      return 'deployed'
+    }
+    return chartStatus
+  }
+
+  static getDerivedStateFromProps(nextProps: any, prevState: any) {
+    let controllers = {} as Record<string, boolean>;
+    
+    for (var uid in nextProps.controllers) {
+      let controller = nextProps.controllers[uid]
+      controllers[uid] = prevState.getAvailability(controller.kind, controller)
+    }
+
+    return {
+      controllers,
+    };
+  }
+
+  componentDidMount () {
+    const { chart, controllers } = this.props;
+    if (chart.info.status == 'failed') return;
+    this.setControllerStatus(controllers)
+  }
+
   render() {
     let { chart, setCurrentChart } = this.props;
-
+    let status = this.getChartStatus(chart.info.status)
     return ( 
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
@@ -54,8 +114,8 @@ export default class Chart extends Component<PropsType, StateType> {
         <BottomWrapper>
           <InfoWrapper>
             <StatusIndicator>
-              <StatusColor status={chart.info.status} />
-              {chart.info.status}
+              <StatusColor status={status} />
+              {status}
             </StatusIndicator>
 
             <LastDeployed>

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

@@ -16,26 +16,26 @@ type PropsType = {
 
 type StateType = {
   charts: ChartType[],
+  chartLookupTable: Record<string, string>,
+  controllers: Record<string, Record<string, any>>,
   loading: boolean,
-  error: boolean
+  error: boolean,
+  websockets: Record<string, any>,
 };
 
 export default class ChartList extends Component<PropsType, StateType> {
   state = {
     charts: [] as ChartType[],
+    chartLookupTable: {} as Record<string, string>,
+    controllers: {} as Record<string, Record<string, any>>,
     loading: false,
     error: false,
+    websockets : {} as Record<string, any>,
   }
 
-  updateCharts = () => {
-    let { currentCluster, currentProject } = this.context;
-
+  updateCharts = (callback: Function) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     this.setState({ loading: true });
-    setTimeout(() => {
-      if (this.state.loading) {
-        this.setState({ loading: false, error: true });
-      }
-    }, 3000);
 
     api.getCharts('<token>', {
       namespace: this.props.namespace,
@@ -45,25 +45,131 @@ export default class ChartList extends Component<PropsType, StateType> {
       limit: 20,
       skip: 0,
       byDate: false,
-      statusFilter: ['deployed']
+      statusFilter: ['deployed', 'uninstalled', 'pending', 'pending_upgrade',
+        'pending_rollback','superseded','failed']
     }, { id: currentProject.id }, (err: any, res: any) => {
-        if (err) {
+      if (err) {
         console.log(err)
-        // setCurrentError(JSON.stringify(err));
+        setCurrentError(JSON.stringify(err));
         this.setState({ loading: false, error: true });
       } else {
-        if (res.data) {
-          this.setState({ charts: res.data });
-        } else {
-          this.setState({ charts: [] });
-        }
-        this.setState({ loading: false, error: false });
+        let charts = res.data || [];
+        this.setState({ charts }, () => {
+          this.setState({ loading: false, error: false });
+        });
+        callback(charts)
       }
     });
   }
 
+  setupWebsocket = (kind: string) => {
+      let { currentCluster, currentProject } = this.context;
+      let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+      ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+  
+      ws.onmessage = (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data)
+        let object = event.Object
+        let chartKey = this.state.chartLookupTable[object.metadata.uid]
+
+        // ignore if updated object does not belong to any chart in the list.
+        if (!chartKey) {
+          return;
+        }
+
+        let chartControllers = this.state.controllers[chartKey]
+        chartControllers[object.metadata.uid] = object
+
+        this.setState({
+          controllers: {
+            ...this.state.controllers,
+            [chartKey] : chartControllers
+          }
+        })
+      }
+  
+      ws.onclose = () => {
+        console.log('closing websocket')
+      }
+  
+      ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+        ws.close()
+      }
+
+      return ws
+  }
+
+  setControllerWebsockets = (controllers: any[]) => {
+    let websockets = controllers.map((kind: string) => {
+      return this.setupWebsocket(kind)
+    })
+    this.setState({websockets})
+  }
+
+  getControllers = (charts: any[]) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    charts.forEach(async (chart: any) => {
+      // don't retrieve controllers for chart that failed to even deploy.
+      if (chart.info.status == 'failed') return;
+
+      await new Promise((next: (res?: any) => void) => {
+        api.getChartControllers('<token>', {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          service_account_id: currentCluster.service_account_id,
+          storage: StorageType.Secret
+        }, {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version
+        }, (err: any, res: any) => {
+          if (err) {
+            setCurrentError(JSON.stringify(err));
+            return
+          }
+          // transform controller array into hash table for easy lookup during updates.
+          let chartControllers = {} as Record<string, Record<string, any>>
+          res.data.forEach((c: any) => {
+            chartControllers[c.metadata.uid] = c
+          })
+
+          res.data.forEach(async (c: any) => {
+            await new Promise((nextController: (res?: any) => void) => {
+              this.setState({
+                chartLookupTable: {
+                  ...this.state.chartLookupTable,
+                  [c.metadata.uid] : `${chart.namespace}-${chart.name}`
+                },
+                controllers: {
+                  ...this.state.controllers,
+                  [`${chart.namespace}-${chart.name}`] : chartControllers
+                }
+              }, () => {
+                nextController();
+              })
+            })
+          })
+          next();
+        });
+      })
+    })
+  }
+
   componentDidMount() {
-    this.updateCharts();
+    this.updateCharts(this.getControllers);
+    this.setControllerWebsockets(["deployment", "statefulset", "daemonset", "replicaset"]);
+  }
+
+  async componentWillUnmount () {
+    if (this.state.websockets) {
+      this.state.websockets.forEach((ws: WebSocket) => {
+        ws.close()
+      })
+    }
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -71,7 +177,7 @@ export default class ChartList extends Component<PropsType, StateType> {
     // Ret2: Prevents reload when opening ClusterConfigModal
     if (prevProps.currentCluster !== this.props.currentCluster || 
       prevProps.namespace !== this.props.namespace) {
-      this.updateCharts();
+      this.updateCharts(this.getControllers);
     }
   }
 
@@ -94,12 +200,13 @@ export default class ChartList extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.charts.map((x: ChartType, i: number) => {
+    return this.state.charts.map((chart: ChartType, i: number) => {
       return (
         <Chart
-          key={i}
-          chart={x}
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
           setCurrentChart={this.props.setCurrentChart}
+          controllers={this.state.controllers[`${chart.namespace}-${chart.name}`] || {} as Record<string, any>}
         />
       )
     })

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

@@ -1,17 +1,19 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
+import yaml from 'js-yaml';
+import { Base64 } from 'js-base64';
 import close from '../../../../assets/close.png';
 
-import { ResourceType, ChartType, StorageType } from '../../../../shared/types';
+import { ResourceType, ChartType, StorageType, ChoiceType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 
-import TabSelector from '../../../../components/TabSelector';
+import TabRegion from '../../../../components/TabRegion';
 import RevisionSection from './RevisionSection';
 import ValuesYaml from './ValuesYaml';
 import GraphSection from './GraphSection';
 import ListSection from './ListSection';
-import LogSection from './log/LogSection';
+import StatusSection from './status/StatusSection';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import SettingsSection from './SettingsSection';
 
@@ -24,132 +26,38 @@ type PropsType = {
 
 type StateType = {
   showRevisions: boolean,
-  currentTab: string,
   components: ResourceType[],
   podSelectors: string[]
   revisionPreview: ChartType | null,
-  devOpsMode: boolean
+  devOpsMode: boolean,
+  tabOptions: ChoiceType[],
+  tabContents: any,
+  checkTabExists: boolean,
+  saveValuesStatus: string | null,
 };
 
-const tabOptions = [
-  { label: 'Chart Overview', value: 'graph' },
-  { label: 'Search Chart', value: 'list' },
-  { label: 'Raw Values', value: 'values' },
-  { label: 'Detailed Logs', value: 'detailed-logs' },
-  { label: 'Deploy', value: 'deploy' },
-  { label: 'Settings', value: 'settings' },
-];
-
-const basicOptions = [
-  { label: 'Environment', value: 'environment' },
-  { label: 'Logs', value: 'logs' },
-  { label: 'Deploy', value: 'deploy' },
-  { label: 'Settings', value: 'settings' },
-];
-
-// FormYAML represents a chart's values.yaml form abstraction
-export interface FormYAML {
-	Name?: string,  
-	Icon?: string,   
-	Description?: string,   
-	Tags?: string[],
-  Sections?: Section[]
-}
-
-export interface Section {
-  Name?: string,
-  ShowIf?: string,
-  Contents: FormElement[]
-}
-
-// FormElement represents a form element
-export interface FormElement {
-  Type: string,
-  Label: string,
-  Name?: string,
-  Variable?: string,
-  Settings?: {
-    Default?: number | string | boolean,
-    Options?: any[],
-    Unit?: string
-  }
-}
-
-const dummyForm = {
-  Sections: [
-    {
-      Name: 'main',
-      Contents: [
-        {
-          Type: 'heading',
-          Label: '⚡ Electric feel settings',
-          Settings: {}
-        },
-        {
-          Type: 'subtitle',
-          Label: 'Shock me like an electric eel',
-          Settings: {}
-        },
-        {
-          Type: 'number-input',
-          Name: 'voltage',
-          Variable: 'volts',
-          Label: 'Voltage',
-          Settings: {
-            Default: 200,
-            Unit: 'Volts'
-          }
-        },
-        {
-          Type: 'number-input',
-          Name: 'batteries',
-          Variable: 'batteries',
-          Label: 'Batteries',
-          Settings: {
-            Default: 4,
-            Unit: 'AA'
-          }
-        },
-        {
-          Type: 'checkbox',
-          Name: 'trivia-checkbox',
-          Label: 'Show a fun fact?',
-          Settings: {
-            Default: true
-          }
-        },
-      ]
-    },
-    {
-      Name: 'trivia',
-      ShowIf: 'trivia-checkbox',
-      Contents: [
-        {
-          Type: 'heading',
-          Label: '🌊 Ocean fact No. 11232',
-          Settings: {}
-        },
-        {
-          Type: 'subtitle',
-          Label: 'Electric eels can reach huge proportions, exceeding 8 feet in length and 44 pounds in weight.',
-          Settings: {}
-        }
-      ]
-    }
-  ]
-}
-
-const defaultTab = 'environment';
-
-// TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
+// Tabs not display when previewing an old revision
+const excludedTabs = ['status', 'settings', 'deploy'];
+
+/*
+  TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
+  In general, tab management for ExpandedChart should be refactored. Cases to handle:
+  - Hiding logs, deploy, and settings tabs when previewing old charts
+  - Toggling additional DevOps tabs
+  - Handling the currently selected tab becoming hidden (for both preview and DevOps)
+  As part of consolidating currentChart and revisionPreview, can add an isPreview bool.
+*/
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
     showRevisions: false,
-    currentTab: defaultTab,
     components: [] as ResourceType[],
     podSelectors: [] as string[],
     revisionPreview: null as (ChartType | null),
-    devOpsMode: false
+    devOpsMode: false,
+    tabOptions: [] as ChoiceType[],
+    tabContents: [] as any,
+    checkTabExists: false,
+    saveValuesStatus: null as (string | null),
   }
 
   updateResources = () => {
@@ -169,11 +77,137 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       if (err) {
         console.log(err)
       } else {
-        this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors });
+        this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors }, this.refreshTabs);
       }
     });
   }
 
+  getFormData = (): any => {
+    let { files } = this.props.currentChart.chart;
+    for (const file of files) { 
+      if (file.name === 'form.yaml') {
+        let formData = yaml.load(Base64.decode(file.data));
+        if (this.props.currentChart.config) {
+          console.log(formData)
+        }
+        return formData;
+      }
+    };
+    return null;
+  }
+
+  upgradeValues = (values: any) => {
+    let { currentProject, currentCluster, setCurrentError } = this.context;
+    values = yaml.dump(values);
+    api.upgradeChartValues('<token>', {
+      namespace: this.props.currentChart.namespace,
+      storage: StorageType.Secret,
+      values,
+    }, {
+      id: currentProject.id, 
+      name: this.props.currentChart.name,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+    }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(err);
+        this.setState({ saveValuesStatus: 'error' });
+      } else {
+        this.setState({ saveValuesStatus: 'successful' });
+        this.props.refreshChart();
+      }
+    });
+  }
+
+  refreshTabs = () => {
+    let formData = this.getFormData();
+    let tabOptions = [] as ChoiceType[];
+    let tabContents = [] as any;
+
+    // Generate form tabs if form.yaml exists
+    if (formData && formData.tabs) {
+      formData.tabs.map((tab: any, i: number) => {
+        tabOptions.push({ value: '@' + tab.name, label: tab.label });
+        tabContents.push({
+          value: '@' + tab.name,
+          component: (
+            <ValuesFormWrapper>
+              <ValuesForm 
+                sections={tab.sections} 
+                onSubmit={this.upgradeValues}
+                saveValuesStatus={this.state.saveValuesStatus}
+              />
+            </ValuesFormWrapper>
+          ),
+        });
+      });
+    }
+
+    // Append universal tabs
+    tabOptions.push(
+      { label: 'Status', value: 'status' },
+      { label: 'Deploy', value: 'deploy' },
+      { label: 'Settings', value: 'settings' },
+    );
+
+    if (this.state.devOpsMode) {
+      tabOptions.push(
+        { label: 'Chart Overview', value: 'graph' },
+        { label: 'Search Chart', value: 'list' },
+        { label: 'Raw Values', value: 'values' }
+      );
+    }
+
+    let { currentChart, refreshChart, setSidebar } = this.props;
+    let chart = this.state.revisionPreview || currentChart;
+    tabContents.push(
+      {
+        value: 'status', component: (
+          <StatusSection currentChart={chart} selectors={this.state.podSelectors} />
+        ),
+      },
+      {
+        value: 'deploy', component: (
+          <Unimplemented>Coming soon.</Unimplemented> 
+        ),
+      },
+      {
+        value: 'settings', component: (
+          <SettingsSection /> 
+        ),
+      },
+      {
+        value: 'graph', component: (
+          <GraphSection
+            components={this.state.components}
+            currentChart={chart}
+            setSidebar={setSidebar}
+
+            // Handle resize YAML wrapper
+            showRevisions={this.state.showRevisions}
+          />
+        ),
+      },
+      {
+        value: 'list', component: (
+          <ListSection
+            currentChart={chart}
+            components={this.state.components}
+          />
+        ),
+      },
+      {
+        value: 'values', component: (
+          <ValuesYaml
+            currentChart={chart}
+            refreshChart={refreshChart}
+          />
+        ),
+      },
+    );
+    this.setState({ tabOptions, tabContents });
+  }
+
   componentDidMount() {
     this.updateResources();
   }
@@ -186,7 +220,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   setRevisionPreview = (oldChart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
-    this.setState({ revisionPreview: oldChart });
+    this.setState({ revisionPreview: oldChart, checkTabExists: true });
 
     if (oldChart) {
       api.getChartComponents('<token>', {
@@ -202,26 +236,31 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         if (err) {
           console.log(err)
         } else {
-          this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors });
+          this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors }, this.refreshTabs);
         }
       });
-
-      // Handle preview old chart while logs tab is open
-      if (this.state.currentTab === 'logs') {
-        this.setState({ currentTab: defaultTab });
-      } else if (this.state.currentTab === 'detailed-logs') {
-        this.setState({ currentTab: 'graph' });
-      }
     } else {
+      this.setState({ checkTabExists: false });
       this.updateResources();
     }
   }
 
+  // TODO: consolidate with pop + push in refreshTabs
   toggleDevOpsMode = () => {
     if (this.state.devOpsMode) {
-      this.setState({ devOpsMode: false, currentTab: defaultTab });
+      let { tabOptions } = this.state;
+      tabOptions.pop();
+      tabOptions.pop();
+      tabOptions.pop();
+      this.setState({ devOpsMode: false, checkTabExists: true, tabOptions });
     } else {
-      this.setState({ devOpsMode: true, currentTab: 'graph' });
+      let { tabOptions } = this.state;
+      tabOptions.push(
+        { label: 'Chart Overview', value: 'graph' },
+        { label: 'Search Chart', value: 'list' },
+        { label: 'Raw Values', value: 'values' }
+      );
+      this.setState({ devOpsMode: true, tabOptions, checkTabExists: false });
     }
   }
 
@@ -242,76 +281,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
-  // Hide certain tabs when previewing old charts
-  getTabOptions = () => {
-    let options = basicOptions.slice();
-    if (this.state.devOpsMode) {
-      options = tabOptions.slice();
-    }
-
-    if (this.state.revisionPreview) {
-      options.pop();
-      options.pop();
-      options.pop();
-    }
-    return options;
-  }
-
-  renderTabContents = () => {
-    let { currentChart, refreshChart, setSidebar } = this.props;
-    let chart = currentChart;
-    if (this.state.revisionPreview) {
-      chart = this.state.revisionPreview;
-    }
-    
-    if (this.state.currentTab === 'graph') {
-      return (
-        <GraphSection
-          components={this.state.components}
-          currentChart={chart}
-          setSidebar={setSidebar}
-
-          // Handle resize YAML wrapper
-          showRevisions={this.state.showRevisions}
-        />
-      );
-    } else if (this.state.currentTab === 'list') {
-      return (
-        <ListSection
-          currentChart={chart}
-          components={this.state.components}
-        />
-      );
-    } else if (this.state.currentTab === 'values') {
-      return (
-        <ValuesYaml
-          currentChart={chart}
-          refreshChart={refreshChart}
-        />
-      );
-    } else if (this.state.currentTab === 'logs') {
-      return (
-        <LogSection 
-          selectors={this.state.podSelectors}
-        />
-      );
-    } else if (this.state.currentTab === 'values-form') {
-      return (
-        <ValuesFormWrapper>
-          <ValuesForm formData={dummyForm} />
-        </ValuesFormWrapper>
-      );
-    } else if (this.state.currentTab === 'settings') {
-      return (
-        <SettingsSection />
-      );
-    }
-
-    return (
-      <Unimplemented>(Unimplemented)</Unimplemented>
-    );
-  }
-
   render() {
     let { currentChart, setCurrentChart, refreshChart } = this.props;
     let chart = this.state.revisionPreview || currentChart;
@@ -323,27 +292,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           <HeaderWrapper>
             <TitleSection>
               <Title>
-                <IconWrapper>
-                  {this.renderIcon()}
-                </IconWrapper>
-                {chart.name}
+                <IconWrapper>{this.renderIcon()}</IconWrapper>{chart.name}
               </Title>
+
               <InfoWrapper>
                 <StatusIndicator>
-                  <StatusColor status={chart.info.status} />
-                  {chart.info.status}
+                  <StatusColor status={chart.info.status} />{chart.info.status}
                 </StatusIndicator>
-
                 <LastDeployed>
-                  <Dot>•</Dot>Last deployed {this.readableDate(chart.info.last_deployed)}
+                  <Dot>•</Dot>Last deployed 
+                  {' ' + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>
 
               <TagWrapper>
-                Namespace
-                <NamespaceTag>
-                  {chart.namespace}
-                </NamespaceTag>
+                Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
               </TagWrapper>
             </TitleSection>
 
@@ -358,25 +321,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               refreshChart={refreshChart}
               setRevisionPreview={this.setRevisionPreview}
             />
-
-            <TabSelectorWrapper>
-              <TabSelector
-                options={this.getTabOptions()}
-                color={this.state.revisionPreview ? '#f5cb42' : null}
-                currentTab={this.state.currentTab}
-                setCurrentTab={(value: string) => this.setState({ currentTab: value })}
-                addendum={
-                  <TabButton onClick={this.toggleDevOpsMode} devOpsMode={this.state.devOpsMode}>
-                    <i className="material-icons">offline_bolt</i> DevOps Mode
-                  </TabButton>
-                }
-              />
-            </TabSelectorWrapper>
-
           </HeaderWrapper>
-          <ContentSection>
-            {this.renderTabContents()}
-          </ContentSection>
+
+          <TabRegion
+            options={this.state.tabOptions.filter((opt: any) => {
+              return !this.state.revisionPreview || !excludedTabs.includes(opt.value);
+            })}
+            tabContents={this.state.tabContents}
+            checkTabExists={this.state.checkTabExists}
+            color={this.state.revisionPreview ? '#f5cb42' : null}
+            addendum={
+              <TabButton onClick={this.toggleDevOpsMode} devOpsMode={this.state.devOpsMode}>
+                <i className="material-icons">offline_bolt</i> DevOps Mode
+              </TabButton>
+            }
+          />
         </StyledExpandedChart>
       </div>
     );
@@ -387,8 +346,8 @@ ExpandedChart.contextType = Context;
 
 const ValuesFormWrapper = styled.div`
   width: 100%;
-  height: calc(100% - 60px);
-  margin-bottom: 60px;
+  height: 100%;
+  padding-bottom: 60px;
 `;
 
 const Unimplemented = styled.div`
@@ -396,9 +355,12 @@ const Unimplemented = styled.div`
   height: 100%;
   background: #ffffff11;
   padding-bottom: 20px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
   display: flex;
   align-items: center;
   justify-content: center;
+  border-radius: 5px;
 `;
 
 const TabButton = styled.div`
@@ -424,12 +386,6 @@ const TabButton = styled.div`
   }
 `;
 
-const TabSelectorWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-bottom: 7px;
-`;
-
 const CloseOverlay = styled.div`
   position: absolute;
   top: 0;
@@ -439,19 +395,6 @@ const CloseOverlay = styled.div`
 `;
 
 const HeaderWrapper = styled.div`
-  margin-bottom: 20px;
-`;
-
-const ContentSection = styled.div`
-  display: flex;
-  border-radius: 5px;
-  flex: 1;
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-size: 13px;
-  overflow-y: auto;
 `;
 
 const StatusColor = styled.div`
@@ -601,7 +544,6 @@ const StyledExpandedChart = styled.div`
   animation-fill-mode: forwards;
   padding: 25px; 
   display: flex;
-  overflow: hidden;
   flex-direction: column;
 
   @keyframes floatIn {

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

@@ -54,4 +54,5 @@ const StyledGraphSection = styled.div`
   width: 100%;
   height: 100%;
   background: #ffffff11;
+  font-size: 13px;
 `;

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx

@@ -1,9 +1,8 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import api from '../../../../shared/api';
 
 import { Context } from '../../../../shared/Context';
-import { ResourceType, StorageType, ChartType } from '../../../../shared/types';
+import { ResourceType, ChartType } from '../../../../shared/types';
 
 import ResourceItem from './ResourceItem';
 import Loading from '../../../../components/Loading';
@@ -70,4 +69,6 @@ const StyledListSection = styled.div`
   background: #ffffff11;
   display: flex;
   position: relative;
+  border-radius: 5px;
+  font-size: 13px;
 `;

+ 8 - 14
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -3,23 +3,19 @@ import styled from 'styled-components';
 
 import { RepoType } from '../../../../shared/types';
 
-import RepoSelector from '../../../../components/repo-selector/RepoSelector';
+import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import SaveButton from '../../../../components/SaveButton';
 
 type PropsType = {
 };
 
 type StateType = {
-  selectedRepo: RepoType | null,
-  selectedBranch: string,
-  subdirectory: string,
+  selectedImageUrl: string | null,
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    selectedRepo: null as RepoType | null,
-    selectedBranch: '',
-    subdirectory: '',
+    selectedImageUrl: '',
   }
 
   render() {
@@ -27,19 +23,17 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       <Wrapper>
         <StyledSettingsSection>
           <Subtitle>Connected source</Subtitle>
-          <RepoSelector
-            selectedRepo={this.state.selectedRepo}
-            selectedBranch={this.state.selectedBranch}
-            subdirectory={this.state.subdirectory}
-            setSelectedRepo={(selectedRepo: RepoType) => this.setState({ selectedRepo })}
-            setSelectedBranch={(selectedBranch: string) => this.setState({ selectedBranch })}
-            setSubdirectory={(subdirectory: string) => this.setState({ subdirectory })}
+          <ImageSelector
+            selectedImageUrl={this.state.selectedImageUrl}
+            setSelectedImageUrl={(x: string) => this.setState({ selectedImageUrl: x })}
+            forceExpanded={true}
           />
         </StyledSettingsSection>
         <SaveButton
           text='Save Settings'
           onClick={() => console.log(this.state)}
           status={null}
+          makeFlush={true}
         />
       </Wrapper>
     );

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

@@ -59,7 +59,7 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
       service_account_id: currentCluster.service_account_id,
     }, (err: any, res: any) => {
       if (err) {
-        setCurrentError(err.response.data);
+        console.log(err)
         this.setState({ saveValuesStatus: 'error' });
       } else {
         this.setState({ saveValuesStatus: 'successful' });
@@ -81,6 +81,7 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
           text='Update Values'
           onClick={this.handleSaveValues}
           status={this.state.saveValuesStatus}
+          makeFlush={true}
         />
       </StyledValuesYaml>
     );

+ 0 - 108
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx

@@ -1,108 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import api from '../../../../../shared/api';
-import { ResourceType, ChartType } from '../../../../../shared/types';
-import Logs from './Logs';
-import { Context } from '../../../../../shared/Context';
-
-type PropsType = {
-  selectors: string[],
-};
-
-type StateType = {
-  logs: string[]
-  pods: string[],
-  selectedPod: string,
-};
-
-export default class LogSection extends Component<PropsType, StateType> {
-  state = {
-    logs: [] as string[],
-    pods: [] as string[],
-    selectedPod: null as string,
-    matchingPods: [] as any[]
-  }
-
-  renderLogs = () => {
-    return <Logs key={this.state.selectedPod} selectedPod={this.state.selectedPod} />
-  }
-
-  renderPodTabs = () => {
-    return this.state.pods.map((pod, i) => {
-      return (
-        <Tab 
-          key={i}
-          selected={(this.state.selectedPod == pod)} 
-          onClick={() => {
-          this.setState({selectedPod: pod})
-          }
-        }>
-          {pod}
-        </Tab>
-      )
-    })
-  }
-
-  componentDidMount() {
-    const { selectors } = this.props;
-    let { currentCluster, currentProject } = this.context;
-
-    api.getMatchingPods('<token>', { 
-      cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
-      selectors,
-    }, {
-      id: currentProject.id
-    }, (err: any, res: any) => {
-      console.log("SELECTORS", selectors)
-      this.setState({pods: res.data, selectedPod: res.data[0]})
-    })
-  }
-
-  render() {
-    return (
-      <StyledLogSection>
-        <TabWrapper>
-          {this.renderPodTabs()}
-        </TabWrapper>
-        {this.renderLogs()}
-      </StyledLogSection>
-    );
-  }
-}
-
-LogSection.contextType = Context;
-
-const TabWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-  width: 30%;
-  float: left;
-`
-
-const Tab = styled.div`
-  align-items: center;
-  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
-  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : '##ffffff11'};
-  height: 100%;
-  justify-content: center;
-  font-size: 13px;
-  padding: 15px 13px;
-  margin-right: 10px;
-  border-radius: 5px;
-  text-shadow: 0px 0px 8px none;
-  cursor: pointer;
-  :hover {
-    color: white;
-    background: #ffffff18;
-  }
-`;
-
-const StyledLogSection = styled.span`
-  width: 100%;
-  height: 100%;
-  position: relative;
-  padding: 0px;
-  user-select: text;
-`;

+ 0 - 65
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx

@@ -1,65 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import { Context } from '../../../../../shared/Context';
-
-type PropsType = {
-  selectedPod: string,
-};
-
-type StateType = {
-  logs: string[]
-};
-
-export default class Logs extends Component<PropsType, StateType> {
-  
-  state = {
-    logs: [] as string[],
-  }
-
-  scrollRef = React.createRef<HTMLDivElement>()
-
-  scrollToBottom = () => {
-    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
-  }
-
-  renderLogs = () => {
-    return this.state.logs.map((log, i) => {
-        return <div key={i}>{log}</div>
-    })
-  }
-
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/default/pod/${this.props.selectedPod}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
-
-    ws.onopen = () => {
-      console.log('connected to websocket')
-    }
-
-    ws.onmessage = evt => {
-      this.setState({ logs: [...this.state.logs, evt.data] }, () => {
-        this.scrollToBottom()
-      })
-    }
-  }
-
-  render() {
-    return (
-      <LogStream ref={this.scrollRef}>
-        {this.renderLogs()}
-      </LogStream>
-    );
-  }
-}
-
-Logs.contextType = Context;
-
-const LogStream = styled.div`
-  width: 70%;
-  height: 100%;
-  background: #202227;
-  position: relative;
-  padding: 25px;
-  user-select: text;
-  overflow: auto;
-`;

+ 313 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -0,0 +1,313 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { kindToIcon } from '../../../../../shared/rosettaStone';
+import api from '../../../../../shared/api';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  controller: any,
+  selectedPod: any,
+  selectPod: Function,
+};
+
+type StateType = {
+  expanded: boolean,
+  pods: any[],
+  raw: any[],
+};
+
+// Controller tab in log section that displays list of pods on click.
+export default class ControllerTab extends Component<PropsType, StateType> {
+  state = {
+    expanded: false,
+    pods: [] as any[],
+    raw: [] as any[],
+  }
+
+  getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return [
+          c.status?.availableReplicas || c.status?.replicas - c.status?.unavailableReplicas, 
+          c.status?.replicas
+        ]
+      case "statefulset":
+       return [c.status?.readyReplicas, c.status?.replicas]
+      case "daemonset":
+        return [c.status?.numberAvailable, c.status?.desiredNumberScheduled]
+      }
+  }
+
+  renderIcon = (kind: string) => {
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  getPodStatus = (status: any) => {
+    if (status?.phase == 'Pending') {
+      return 'waiting'
+    }
+
+    if (status?.phase == 'Failed') {
+      return 'failed'
+    }
+
+    if (status?.phase == 'Running') {
+      let collatedStatus = 'running';
+
+      status.containerStatuses.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus = 'waiting'
+        } else if (s.state?.terminated) {
+          collatedStatus = 'failed'
+          throw {};
+        }
+      })
+      return collatedStatus;
+    }
+  }
+
+  renderExpanded = () => {
+    if (this.state.expanded) {
+      return (
+        <ExpandWrapper>
+            {
+              this.state.raw.map((pod) => {
+                let status = this.getPodStatus(pod.status)
+                return (
+                  <Tab 
+                    key={pod.metadata?.name}
+                    selected={(this.props.selectedPod?.metadata?.name === pod?.metadata?.name)}
+                    onClick={() => {this.props.selectPod(pod)}}
+                  > 
+                    {pod.metadata?.name}
+                    <Status>
+                      <StatusColor status={status} />
+                      {status}
+                    </Status>
+                  </Tab>)
+              })
+            }
+        </ExpandWrapper>
+      );
+    }
+  }
+
+  componentDidMount() {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let { controller } = this.props;
+
+    let selectors = [] as string[]
+    let ml = controller?.spec?.selector?.matchLabels || controller?.spec?.selector
+    let i = 1;
+    let selector = ''
+    for (var key in ml) {
+      selector += key + '=' + ml[key]
+      if (i != Object.keys(ml).length) {
+        selector += ','
+      }
+      i += 1;
+    }
+    selectors.push(selector)
+    
+    api.getMatchingPods('<token>', { 
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      selectors,
+    }, {
+      id: currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        setCurrentError(JSON.stringify(err))
+        return
+      }
+      let pods = res?.data?.map((pod: any) => {
+        return {
+          namespace: pod?.metadata?.namespace, 
+          name: pod?.metadata?.name,
+          phase: pod?.status?.phase,
+        }
+      })
+      console.log(res.data)
+      this.setState({ pods, raw: res.data })
+    })
+  }
+
+  render() {
+    let { controller } = this.props;
+    let [available, total] = this.getAvailability(controller.kind, controller);
+    let status = (available == total) ? 'running' : 'waiting'
+    return (
+      <StyledResourceItem>
+        <ResourceHeader
+          expanded={this.state.expanded}
+          onClick={() => this.setState({ expanded: !this.state.expanded })}
+        >
+          <DropdownIcon expanded={this.state.expanded}>
+            <i className="material-icons">arrow_right</i>
+          </DropdownIcon>
+          <Info>
+          <Metadata>
+            {this.renderIcon(controller.kind)}
+            {`${controller.kind}`}
+            <ResourceName
+              showKindLabels={true}
+            >
+              {controller.metadata.name}
+            </ResourceName>
+          </Metadata>
+          <Status>
+            <StatusColor status={status} />
+            {available}/{total}
+          </Status>
+          </Info>
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceItem>
+    );
+  }
+}
+
+ControllerTab.contextType = Context;
+
+const StyledResourceItem = styled.div`
+  width: 100%;
+`;
+
+const ExpandWrapper = styled.div`
+  overflow: hidden;
+`;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  color: #ffffff66;
+  padding: 8px 13px;
+  text-transform: capitalize;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+`;
+
+const Metadata = styled.div`
+  display: flex;
+  align-items: center;
+  width: 85%;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: 'Hind Siliguri', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-bottom: 1px;
+  margin-right: 5px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) => (props.status === 'running' ? '#4797ff' : props.status === 'failed' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
+  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : '##ffffff11'};
+  font-size: 13px;
+  padding: 20px 12px 20px 45px;
+  text-shadow: 0px 0px 8px none;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: ${(props: { showKindLabels: boolean }) => props.showKindLabels ? '10px' : ''};
+  text-transform: none;
+  max-width: 60%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    overflow: visible;
+  }
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 16px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-right: 13px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff18' : ''};
+    transform: ${(props: { expanded: boolean }) => props.expanded ? 'rotate(180deg)' : ''};
+    animation: ${(props: { expanded: boolean }) => props.expanded ? 'quarterTurn 0.3s' : ''};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from { transform: rotate(0deg) }
+      to { transform: rotate(90deg) }
+    }
+  }
+`;

+ 103 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -0,0 +1,103 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  selectedPod: any,
+};
+
+type StateType = {
+  logs: string[],
+  ws: any
+};
+
+export default class Logs extends Component<PropsType, StateType> {
+  
+  state = {
+    logs: [] as string[],
+    ws : null as any
+  }
+
+  scrollRef = React.createRef<HTMLDivElement>()
+
+  scrollToBottom = () => {
+    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
+  }
+
+  renderLogs = () => {
+    let { selectedPod } = this.props;
+    if (!selectedPod?.metadata?.name) {
+      return <Message>Please select a pod to view its logs.</Message>
+    }
+    if (this.state.logs.length == 0) {
+      return <Message>No logs to display from this pod.</Message>
+    }
+    return this.state.logs.map((log, i) => {
+        return <div key={i}>{log}</div>
+    })
+  }
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let { selectedPod } = this.props;
+    if (!selectedPod.metadata?.name) return
+
+    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+
+    this.setState({ ws }, () => {
+      if (!this.state.ws) return;
+  
+      this.state.ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+  
+      this.state.ws.onmessage = (evt: MessageEvent) => {
+        this.setState({ logs: [...this.state.logs, evt.data] }, () => {
+          this.scrollToBottom()
+        })
+      }
+  
+      this.state.ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+      }
+    })
+  }
+
+  componentWillUnmount() {
+    if (this.state.ws) {
+      this.state.ws.close()
+    }
+  }
+
+  render() {
+    return (
+      <LogStream ref={this.scrollRef}>
+        {this.renderLogs()}
+      </LogStream>
+    );
+  }
+}
+
+Logs.contextType = Context;
+
+const LogStream = styled.div`
+  overflow: auto;
+  width: 65%;
+  float: right;
+  height: 100%;
+  background: #202227;
+  padding: 25px;
+  user-select: text;
+  overflow: auto;
+  border-radius: 5px;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+`

+ 156 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -0,0 +1,156 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import api from '../../../../../shared/api';
+import Logs from './Logs';
+import ControllerTab from './ControllerTab';
+import { Context } from '../../../../../shared/Context';
+import { ChartType, StorageType } from '../../../../../shared/types';
+import Loading from '../../../../../components/Loading';
+
+type PropsType = {
+  selectors: string[],
+  currentChart: ChartType,
+};
+
+type StateType = {
+  logs: string[]
+  pods: any[],
+  selectedPod: any,
+  controllers: any[],
+  loading: boolean,
+};
+
+export default class StatusSection extends Component<PropsType, StateType> {
+  state = {
+    logs: [] as string[],
+    pods: [] as any[],
+    selectedPod: {} as any,
+    controllers: [] as any[],
+    loading: true,
+  }
+
+  renderLogs = () => {
+    return <Logs 
+      key={this.state.selectedPod?.metadata?.name} 
+      selectedPod={this.state.selectedPod} 
+    />
+  }
+
+  selectPod = (pod: any) => {
+    this.setState({
+      selectedPod: pod
+    })
+  }
+
+  renderTabs = () => {
+    return this.state.controllers.map((c) => {
+      return (
+        <ControllerTab 
+          key={c.metadata.uid} 
+          selectedPod={this.state.selectedPod} 
+          selectPod={this.selectPod.bind(this)}
+          controller={c}
+        />
+      )
+    })
+  }
+
+  renderStatusSection = () => {
+    if (this.state.loading) {
+      return (
+        <NoControllers> 
+          <Loading />
+        </NoControllers>
+      )
+    }
+    if (this.state.controllers.length > 0) {
+      return (
+        <Wrapper>
+          <TabWrapper>
+            {this.renderTabs()}
+          </TabWrapper>
+          {this.renderLogs()}
+        </Wrapper>
+      )
+    } else {
+      return (
+        <NoControllers> 
+          <i className="material-icons">category</i> 
+          No objects to display. This might happen while your app is still deploying.
+        </NoControllers>
+      )
+    }
+  }
+
+  componentDidMount() {
+    const { selectors, currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api.getChartControllers('<token>', {
+      namespace: currentChart.namespace,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      storage: StorageType.Secret
+    }, {
+      id: currentProject.id,
+      name: currentChart.name,
+      revision: currentChart.version
+    }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(JSON.stringify(err));
+        return
+      }
+      this.setState({ controllers: res.data, loading: false })
+    });
+  }
+
+  render() {
+    return (
+      <StyledStatusSection>
+        {this.renderStatusSection()}
+      </StyledStatusSection>
+    );
+  }
+}
+
+StatusSection.contextType = Context;
+
+const TabWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+  width: 35%;
+  float: left;
+  max-height: 100%;
+  background: #ffffff11;
+`
+
+const StyledStatusSection = styled.div`
+  width: 100%;
+  height: 100%;
+  position: relative;
+  font-size: 13px;
+  padding: 0px;
+  user-select: text;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const NoControllers = styled.div`
+  padding-top: 20%;
+  position: relative;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;

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

@@ -0,0 +1,119 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import { getIntegrationIcon } from '../../../shared/common';
+import api from '../../../shared/api';
+
+type PropsType = {
+  setCurrent: (x: any) => void,
+  integrations: any,
+  isCategory?: boolean
+};
+
+type StateType = {
+};
+
+export default class IntegrationList extends Component<PropsType, StateType> {
+  renderContents = () => {
+    let { integrations, setCurrent, isCategory } = this.props;
+    if (integrations) {
+      return integrations.map((integration: any, i: number) => {
+        let icon = getIntegrationIcon(integration.value);
+        let disabled = integration.value === 'repo';
+        return (
+          <Integration
+            key={i}
+            onClick={() => disabled ? null : setCurrent(integration)}
+            isCategory={isCategory}
+            disabled={disabled}
+          >
+            <Flex>
+              <Icon src={icon && icon} />
+              <Label>{integration.label}</Label>
+            </Flex>
+            <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
+          </Integration>
+        );
+      });
+    }
+    return (
+      <Placeholder>
+        You haven't set up any integrations yet.
+      </Placeholder>
+    );
+  }
+  
+  render() {
+    return ( 
+      <StyledIntegrationList>
+        {this.renderContents()}
+      </StyledIntegrationList>
+    );
+  }
+}
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Integration = styled.div`
+  height: 70px;
+  width: calc(100% + 4px);
+  margin-left: -2px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  background: #26282f;
+  cursor: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  :hover {
+    background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+
+    > i {
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: ${(props: { isCategory: boolean, disabled: boolean }) => props.isCategory ? '#616feecc' : '#ffffff44'};
+    margin-right: -7px;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 18px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 150px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  justify-content: center;
+  margin-top: 30px;
+  background: #ffffff11;
+  color: #ffffff44;
+  border-radius: 5px;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+`;

+ 247 - 0
dashboard/src/main/home/integrations/Integrations.tsx

@@ -0,0 +1,247 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { getIntegrationIcon } from '../../../shared/common';
+import { ChoiceType } from '../../../shared/types';
+
+import IntegrationList from './IntegrationList';
+import IntegrationForm from './integration-form/IntegrationForm';
+
+type PropsType = {
+};
+
+type StateType = {
+  currentCategory: ChoiceType | null,
+  currentIntegration: any | null,
+  currentOptions: any[],
+};
+
+const categories = [
+  {
+    value: 'kubernetes',
+    label: 'Kubernetes',
+    buttonText: 'Add a Cluster',
+  },
+  {
+    value: 'registry',
+    label: 'Docker Registry',
+    buttonText: 'Add a Registry',
+  },
+  {
+    value: 'repo',
+    label: 'Git Repository',
+    buttonText: 'Add a Repository',
+  },
+];
+
+export default class Integrations extends Component<PropsType, StateType> {
+  state = {
+    currentCategory: null as any | null,
+    currentIntegration: null as any | null,
+    currentOptions: [] as any[],
+  }
+
+  getIntegrations = (categoryType: string): any[] => {
+    switch (categoryType) {
+      case 'kubernetes':
+        return [
+          {
+            value: 'gke',
+            label: 'Google Kubernetes Engine (GKE)',
+          },
+          {
+            value: 'eks',
+            label: 'Amazon Elastic Kubernetes Service (EKS)',
+          },
+        ];
+      case 'registry':
+        return [
+          {
+            value: 'gcr',
+            label: 'Google Container Registry (GCR)',
+          },
+          {
+            value: 'ecr',
+            label: 'Elastic Container Registry (ECR)',
+          },
+          {
+            value: 'docker-hub',
+            label: 'Docker Hub',
+          },
+        ];
+      default:
+        return [];
+    }
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.currentCategory && this.state.currentCategory !== prevState.currentCategory) {
+      this.setState({ currentOptions: this.getIntegrations(this.state.currentCategory.value) });
+    }
+  }
+
+  renderContents = () => {
+    let { currentCategory, currentIntegration } = this.state;
+
+    if (currentIntegration) {
+      let icon = getIntegrationIcon(currentIntegration.value);
+      return (
+        <div>
+          <TitleSectionAlt>
+            <Flex>
+              <i className="material-icons" onClick={() => this.setState({ currentIntegration: null })}>
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{currentIntegration.label}</Title>
+            </Flex>
+          </TitleSectionAlt>
+
+          <IntegrationForm integrationName={currentIntegration.value} />
+          <Br />
+        </div>
+      );
+    } else if (currentCategory) {
+      let icon = getIntegrationIcon(currentCategory.value);
+      return (
+        <div>
+          <TitleSectionAlt>
+            <Flex>
+              <i className="material-icons" onClick={() => this.setState({ currentCategory: null })}>
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{currentCategory.label}</Title>
+            </Flex>
+
+            <Button 
+              onClick={() => this.context.setCurrentModal('IntegrationsModal', { 
+                integrations: this.state.currentOptions,
+                setCurrentIntegration: (x: any) => this.setState({ currentIntegration: x })
+              })}
+            >
+              <i className="material-icons">add</i>
+              {currentCategory.buttonText}
+            </Button>
+          </TitleSectionAlt>
+
+          <IntegrationList
+            integrations={this.state.currentOptions}
+            setCurrent={(x: any) => this.setState({ currentIntegration: x })}
+          />
+        </div>
+      );
+    }
+    return (
+      <div>
+        <TitleSection>
+          <Title>Integrations</Title>
+        </TitleSection>
+
+        <IntegrationList
+          integrations={categories}
+          setCurrent={(x: any) => this.setState({ currentCategory: x })}
+          isCategory={true}
+        />
+      </div>
+    );
+  }
+  
+  render() {
+    return ( 
+      <StyledIntegrations>
+        {this.renderContents()}
+      </StyledIntegrations>
+    );
+  }
+}
+
+Integrations.contextType = Context;
+
+const Br = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Button = styled.div`
+  height: 100%;
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: pointer;
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > img, i {
+    width: 20px;
+    height: 20px;
+    font-size: 16px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    justify-content: center;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  height: 40px;
+`;
+
+const TitleSectionAlt = styled(TitleSection)`
+  margin-left: -42px;
+  width: calc(100% + 42px);
+`;
+
+const StyledIntegrations = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  padding-top: 45px;
+`;

+ 84 - 0
dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx

@@ -0,0 +1,84 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import SaveButton from '../../../../components/SaveButton';
+
+type PropsType = {
+};
+
+type StateType = {
+  registryURL: string,
+  dockerEmail: string,
+  dockerUsername: string,
+  dockerPassword: string
+};
+
+export default class DockerHubForm extends Component<PropsType, StateType> {
+  state = {
+    registryURL: '',
+    dockerEmail: '',
+    dockerUsername: '',
+    dockerPassword: ''
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <InputRow
+            type='text'
+            value={this.state.registryURL}
+            setValue={(x: string) => this.setState({ registryURL: x })}
+            label='📦 Registry URL'
+            placeholder='ex: index.docker.io'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.dockerEmail}
+            setValue={(x: string) => this.setState({ dockerEmail: x })}
+            label='✉️ Docker Email'
+            placeholder='ex: captain@ahab.com'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.dockerUsername}
+            setValue={(x: string) => this.setState({ dockerUsername: x })}
+            label='👤 Docker Username'
+            placeholder='ex: whale_watcher_2000'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.dockerPassword}
+            setValue={(x: string) => this.setState({ dockerPassword: x })}
+            label='🔒 Docker Password'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 81 - 0
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx

@@ -0,0 +1,81 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  credentialsName: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class ECRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 101 - 0
dashboard/src/main/home/integrations/integration-form/EKSForm.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class EKSForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 70 - 0
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -0,0 +1,70 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  credentialsName: string,
+  serviceAccountKey: string,
+};
+
+export default class GCRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    serviceAccountKey: '',
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 90 - 0
dashboard/src/main/home/integrations/integration-form/GKEForm.tsx

@@ -0,0 +1,90 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  serviceAccountKey: string
+};
+
+export default class GKEForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    serviceAccountKey: ''
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 37 - 0
dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx

@@ -0,0 +1,37 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import DockerHubForm from './DockerHubForm';
+import GKEForm from './GKEForm';
+import EKSForm from './EKSForm';
+import GCRForm from './GCRForm';
+import ECRForm from './ECRForm';
+
+type PropsType = {
+  integrationName: string
+};
+
+type StateType = {
+};
+
+export default class IntegrationForm extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    switch (this.props.integrationName) {
+      case 'docker-hub':
+        return <DockerHubForm />;
+      case 'gke':
+        return <GKEForm />;
+      case 'eks':
+        return <EKSForm />;
+      case 'ecr':
+        return <ECRForm />;
+      case 'gcr':
+        return <GCRForm />;
+      default:
+        return null;
+    }
+  }
+}

+ 154 - 0
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -0,0 +1,154 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../assets/close.png';
+
+import { Context } from '../../../shared/Context';
+import { getIntegrationIcon } from '../../../shared/common';
+
+type PropsType = {
+};
+
+type StateType = {
+};
+
+export default class IntegrationsModal extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderIntegrationsCatalog = () => {
+    if (this.context.currentModalData) {
+      let { integrations, setCurrentIntegration } = this.context.currentModalData;
+      
+      return integrations.map((integration: any, i: number) => {
+        let icon = getIntegrationIcon(integration.value);
+        return (
+          <IntegrationOption 
+            key={i}
+            onClick={() => {
+              setCurrentIntegration(integration);
+              this.context.setCurrentModal(null, null);
+            }}
+          >
+            <Icon src={icon && icon} />
+            <Label>{integration.label}</Label>
+          </IntegrationOption>
+        );
+      });
+    }
+  }
+ 
+  render() {
+    return (
+      <StyledIntegrationsModal>
+        <CloseButton onClick={() => {
+          this.context.setCurrentModal(null, null);
+        }}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Add a New Integration</ModalTitle>
+        <Subtitle>Select the service you would like to connect to.</Subtitle>
+       
+        <IntegrationsCatalog>
+          {this.renderIntegrationsCatalog()}
+        </IntegrationsCatalog>
+      </StyledIntegrationsModal>
+    );
+  }
+}
+
+IntegrationsModal.contextType = Context;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 15px;
+`;
+
+const IntegrationOption = styled.div`
+  height: 60px;
+  user-select: none;
+  width: 100%;
+  border-bottom: 1px solid #ffffff44;
+  display: flex;
+  align-items: center;
+  padding: 20px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const IntegrationsCatalog = styled.div`
+  width: 100%;
+  margin-top: 17px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  background: #ffffff11;
+  height: calc(100% - 100px);
+  overflow-y: auto;
+`;
+
+const Subtitle = styled.div`
+  padding: 10px 0px;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+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 StyledIntegrationsModal= styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 32px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 0 - 294
dashboard/src/main/home/modals/LaunchTemplateModal.tsx

@@ -1,294 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import close from '../../../assets/close.png';
-
-import api from '../../../shared/api';
-import { Context } from '../../../shared/Context';
-import { Cluster, RepoType } from '../../../shared/types';
-
-import SaveButton from '../../../components/SaveButton';
-import Selector from '../../../components/Selector';
-import RepoSelector from '../../../components/repo-selector/RepoSelector';
-import ValuesForm from '../../../components/values-form/ValuesForm';
-
-type PropsType = {
-};
-
-type StateType = {
-  currentView: string,
-  clusterOptions: { label: string, value: string }[],
-  selectedCluster: string,
-  selectedRepo: RepoType | null,
-  selectedBranch: string,
-  subdirectory: string,
-};
-
-export default class LaunchTemplateModal extends Component<PropsType, StateType> {
-  state = {
-    currentView: 'repo',
-    clusterOptions: [] as { label: string, value: string }[],
-    selectedCluster: this.context.currentCluster.name,
-    selectedRepo: null as RepoType | null,
-    selectedBranch: '',
-    subdirectory: '',
-  };
-  
-  componentDidMount() {
-    let { currentProject } = this.context;
-
-    // TODO: query with selected filter once implemented
-    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        // console.log(err)
-      } else if (res.data) {
-        let clusterOptions = res.data.map((x: Cluster) => { return { label: x.name, value: x.name } });
-        if (res.data.length > 0) {
-          this.setState({ clusterOptions });
-        }
-      }
-    });
-  }
-
-  renderIcon = (icon: string) => {
-    if (icon) {
-      return <Icon src={icon} />
-    }
-
-    return (
-      <Polymer><i className="material-icons">layers</i></Polymer>
-    );
-  }
-
-  renderContents = () => {
-    if (this.state.currentView === 'repo') {
-      return (
-        <div>
-          <Subtitle>Select the source and branch you would like to use</Subtitle>
-          <RepoSelector
-            forceExpanded={true}
-            selectedRepo={this.state.selectedRepo}
-            selectedBranch={this.state.selectedBranch}
-            subdirectory={this.state.subdirectory}
-            setSelectedRepo={(selectedRepo: RepoType) => this.setState({ selectedRepo })}
-            setSelectedBranch={(selectedBranch: string) => this.setState({ selectedBranch })}
-            setSubdirectory={(subdirectory: string) => this.setState({ subdirectory })}
-          />
-          <SaveButton
-            disabled={this.state.selectedBranch === ''}
-            text='Continue'
-            onClick={() => this.setState({ currentView: 'values'})}
-          />
-        </div>
-      );
-    }
-
-    let subdir = this.state.subdirectory === '' ? '' : '/' + this.state.subdirectory;
-    return (
-      <Div>
-        <Subtitle>Optionally edit default settings for this template</Subtitle>
-        <ValuesFormWrapper>
-          <ValuesForm
-            formData={this.context.currentModalData.template.Form}
-          />
-        </ValuesFormWrapper>
-        <RepoButton onClick={() => this.setState({ currentView: 'repo' })}>
-          <i className="material-icons">keyboard_backspace</i>
-          {this.state.selectedRepo.FullName + subdir}
-        </RepoButton>
-      </Div>
-    );
-  }
-
-  render() {
-    let { currentModalData } = this.context;
-    if (currentModalData) {
-      let { Name, Icon, Description } = currentModalData.template.Form;
-      let name = Name ? Name : currentModalData.template.Name;
-
-      return (
-        <StyledClusterConfigModal>
-          <CloseButton onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-
-          <ModalTitle>Launch Template</ModalTitle>
-          <ClusterSection>
-            <Template>
-              {Icon ? this.renderIcon(Icon) : this.renderIcon(currentModalData.template.Icon)}
-              {name}
-            </Template>
-            <i className="material-icons">arrow_right_alt</i>
-            <ClusterLabel>
-              <i className="material-icons">device_hub</i>Cluster
-            </ClusterLabel>
-            <Selector
-              activeValue={this.state.selectedCluster}
-              setActiveValue={(cluster: string) => this.setState({ selectedCluster: cluster })}
-              options={this.state.clusterOptions}
-              width='250px'
-              dropdownWidth='335px'
-              closeOverlay={true}
-            />
-          </ClusterSection>
-          {this.renderContents()}
-        </StyledClusterConfigModal>
-      );
-    }
-    return null;
-  }
-}
-
-LaunchTemplateModal.contextType = Context;
-
-const RepoButton = styled.div`
-  height: 40px;
-  font-size: 13px;
-  padding: 6px 20px 7px 13px;
-  border-radius: 5px;
-  background: #ffffff11;
-  color: #ffffff;
-  border: 1px solid #ffffff55;
-  cursor: pointer;
-  user-select: none;
-  display: flex;
-  align-items: center;
-  position: absolute;
-  bottom: 25px;
-  left: 30px;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    font-size: 16px;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-`;
-
-const Div = styled.div`
-  width: calc(100% + 64px);
-  margin-left: -32px;
-  height: calc(100% - 50px);
-  position: relative;
-  padding: 0 32px;
-`;
-
-const ValuesFormWrapper = styled.div`
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: 100%;
-  height: calc(100% - 149px);
-`;
-
-const ClusterLabel = styled.div`
-  margin-right: 10px;
-  display: flex;
-  align-items: center;
-  > i {
-    font-size: 16px;
-    margin-right: 6px;
-  }
-`;
-
-const Icon = styled.img`
-  width: 21px;
-  margin-right: 10px;
-`;
-
-
-const Polymer = styled.div`
-  margin-bottom: -3px;
-
-  > i {
-    color: ${props => props.theme.containerIcon};
-    font-size: 18px;
-    margin-right: 10px;
-  }
-`;
-
-const Template = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 13px;
-`;
-
-const ClusterSection = styled.div`
-  display: flex;
-  align-items: center;
-  color: #ffffff;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 14px;
-  font-weight: 500;
-  margin-top: 20px;
-
-  > i {
-    font-size: 25px;
-    color: #ffffff44;
-    margin-right: 13px;
-  }
-`;
-
-const Subtitle = styled.div`
-  padding: 17px 0px 25px;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  margin-top: 3px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-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 StyledClusterConfigModal= styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;

+ 4 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -123,7 +123,10 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <img src={filter} />
             Templates
           </NavButton>
-          <NavButton disabled={true}>
+          <NavButton
+            onClick={() => this.props.setCurrentView('integrations')}
+            selected={this.props.currentView === 'integrations'}
+          >
             <img src={integrations} />
             Integrations
           </NavButton>

+ 6 - 22
dashboard/src/main/home/templates/Templates.tsx

@@ -75,15 +75,15 @@ export default class Templates extends Component<PropsType, StateType> {
     }
 
     return this.state.porterCharts.map((template: PorterChart, i: number) => {
-      let { Name, Icon, Description } = template.Form;
+      let { name, icon, description } = template.form;
       return (
         <TemplateBlock key={i} onClick={() => this.setState({ currentTemplate: template })}>
-          {Icon ? this.renderIcon(Icon) : this.renderIcon(template.Icon)}
+          {icon ? this.renderIcon(icon) : this.renderIcon(template.icon)}
           <TemplateTitle>
-            {Name ? Name : template.Name}
+            {name ? name : template.name}
           </TemplateTitle>
           <TemplateDescription>
-            {Description ? Description : template.Description}
+            {description ? description : template.description}
           </TemplateDescription>
         </TemplateBlock>
       )
@@ -118,11 +118,7 @@ export default class Templates extends Component<PropsType, StateType> {
   }
   
   render() {
-    return ( 
-      <StyledTemplates>
-        {this.renderContents()}
-      </StyledTemplates>
-    );
+    return this.renderContents();
   }
 }
 
@@ -186,7 +182,6 @@ const TemplateTitle = styled.div`
 `;
 
 const TemplateBlock = styled.div`
-  background: none;
   border: 1px solid #ffffff00;
   align-items: center;
   user-select: none;
@@ -242,19 +237,8 @@ const TitleSection = styled.div`
   align-items: center;
 `;
 
-const StyledTemplates = styled.div`
-  height: 100%;
-  width: 100vw;
-  padding-top: 45px;
-  overflow-y: auto;
-  display: flex;
-  flex: 1;
-  justify-content: center;
-  position: relative;
-`;
-
 const TemplatesWrapper = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  padding-top: 30px;
+  padding-top: 50px;
 `;

+ 1 - 1
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx

@@ -51,5 +51,5 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 const StyledExpandedTemplate = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  padding-top: 30px;
+  padding-top: 50px;
 `;

+ 100 - 11
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -4,8 +4,11 @@ import styled from 'styled-components';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 
-import { PorterChart, RepoType, Cluster } from '../../../../shared/types';
+import { PorterChart, ChoiceType, Cluster, StorageType } from '../../../../shared/types';
 import Selector from '../../../../components/Selector';
+import ImageSelector from '../../../../components/image-selector/ImageSelector';
+import TabRegion from '../../../../components/TabRegion';
+import ValuesForm from '../../../../components/values-form/ValuesForm';
 
 type PropsType = {
   currentTemplate: PorterChart,
@@ -16,9 +19,9 @@ type StateType = {
   currentView: string,
   clusterOptions: { label: string, value: string }[],
   selectedCluster: string,
-  selectedRepo: RepoType | null,
-  selectedBranch: string,
-  subdirectory: string,
+  selectedImageUrl: string | null,
+  tabOptions: ChoiceType[],
+  tabContents: any
 };
 
 export default class LaunchTemplate extends Component<PropsType, StateType> {
@@ -26,15 +29,57 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     currentView: 'repo',
     clusterOptions: [] as { label: string, value: string }[],
     selectedCluster: this.context.currentCluster.name,
-    selectedRepo: null as RepoType | null,
-    selectedBranch: '',
-    subdirectory: '',
+    selectedImageUrl: '' as string | null,
+    tabOptions: [] as ChoiceType[],
+    tabContents: [] as any,
   };
 
+  onSubmit = (formValues: any) => {
+    let { currentCluster, currentProject } = this.context;
+    api.deployTemplate('<token>', {
+      templateName: this.props.currentTemplate.name,
+      imageURL: "index.docker.io/bitnami/redis",
+      storage: StorageType.Secret,
+      formValues,
+    }, {
+      id: currentProject.id,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        console.log(res.data)
+      }
+    });
+  }
+
+  refreshTabs = () => {
+    // Generate settings tabs from the provided form
+    let tabOptions = [] as ChoiceType[];
+    let tabContents = [] as any;
+    this.props.currentTemplate.form.tabs.map((tab: any, i: number) => {
+      tabOptions.push({ value: tab.name, label: tab.label });
+      tabContents.push({
+        value: tab.name, component: (
+          <ValuesFormWrapper>
+            <ValuesForm 
+              sections={tab.sections} 
+              onSubmit={this.onSubmit}
+              disabled={!this.state.selectedImageUrl || this.state.selectedImageUrl === ''}
+            />
+          </ValuesFormWrapper>
+        ),
+      });
+    });
+    this.setState({ tabOptions, tabContents });
+  }
+
   componentDidMount() {
-    let { currentProject } = this.context;
+    this.refreshTabs();
 
     // TODO: query with selected filter once implemented
+    let { currentProject } = this.context;
     api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         // console.log(err)
@@ -47,6 +92,12 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     });
   }
 
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.selectedImageUrl != prevState.selectedImageUrl) {
+      this.refreshTabs();
+    }
+  }
+
   renderIcon = (icon: string) => {
     if (icon) {
       return <Icon src={icon} />
@@ -58,9 +109,9 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   }
 
   render() {
-    let { Name, Icon, Description } = this.props.currentTemplate.Form;
+    let { name, icon, description } = this.props.currentTemplate.form;
     let { currentTemplate } = this.props;
-    let name = Name ? Name : currentTemplate.Name;
+    name = name ? name : currentTemplate.name;
 
     return (
       <StyledLaunchTemplate>
@@ -74,7 +125,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         </TitleSection>
         <ClusterSection>
           <Template>
-            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentTemplate.Icon)}
+            {icon ? this.renderIcon(icon) : this.renderIcon(currentTemplate.icon)}
             {name}
           </Template>
           <i className="material-icons">arrow_right_alt</i>
@@ -90,6 +141,21 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
+
+        <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
+        <Br />
+        <ImageSelector
+          selectedImageUrl={this.state.selectedImageUrl}
+          setSelectedImageUrl={(x: string) => this.setState({ selectedImageUrl: x })}
+          forceExpanded={true}
+        />
+
+        <br />
+        <Subtitle>Configure additional settings for this template (optional).</Subtitle>
+        <TabRegion
+          options={this.state.tabOptions}
+          tabContents={this.state.tabContents}
+        />
       </StyledLaunchTemplate>
     );
   }
@@ -97,6 +163,27 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 LaunchTemplate.contextType = Context;
 
+const ValuesFormWrapper = styled.div`
+  width: 100%;
+  height: calc(100% + 65px);
+  padding-bottom: 65px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 7px;
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 20px;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
 const ClusterLabel = styled.div`
   margin-right: 10px;
   display: flex;
@@ -137,6 +224,7 @@ const ClusterSection = styled.div`
   font-size: 14px;
   font-weight: 500;
   margin-top: 20px;
+  margin-bottom: 15px;
 
   > i {
     font-size: 25px;
@@ -182,4 +270,5 @@ const TitleSection = styled.div`
 
 const StyledLaunchTemplate = styled.div`
   width: 100%;
+  padding-bottom: 150px;
 `;

+ 22 - 14
dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx

@@ -28,7 +28,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
   }
 
   renderTagList = () => {
-    return this.props.currentTemplate.Form.Tags.map((tag: string, i: number) => {
+    return this.props.currentTemplate.form.tags.map((tag: string, i: number) => {
       return (
         <Tag key={i}>{tag}</Tag>
       )
@@ -37,22 +37,33 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
 
   renderMarkdown = () => {
     let { currentTemplate } = this.props;
-    if (currentTemplate.Markdown) {
+    if (currentTemplate.markdown) {
       return (
-        <Markdown>{currentTemplate.Markdown}</Markdown>
+        <Markdown>{currentTemplate.markdown}</Markdown>
       );
-    } else if (currentTemplate.Form.Description) {
-      return currentTemplate.Form.Description;
+    } else if (currentTemplate.form.description) {
+      return currentTemplate.form.description;
     }
 
-    return currentTemplate.Description;
+    return currentTemplate.description;
+  }
+
+  renderTagSection = () => {
+    if (this.props.currentTemplate.form.tags) {
+      return (
+        <TagSection>
+          <i className="material-icons">local_offer</i>
+          {this.renderTagList()}
+        </TagSection>
+      );
+    }
   }
 
   render() {
     let { currentCluster } = this.context;
-    let { Name, Icon } = this.props.currentTemplate.Form;
+    let { name, icon } = this.props.currentTemplate.form;
     let { currentTemplate } = this.props;
-    let name = Name ? Name : currentTemplate.Name;
+    name = name ? name : currentTemplate.name;
     return (
       <StyledExpandedTemplate>
         <TitleSection>
@@ -60,7 +71,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
             <i className="material-icons" onClick={() => this.props.setCurrentTemplate(null)}>
               keyboard_backspace
             </i>
-            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentTemplate.Icon)}
+            {icon ? this.renderIcon(icon) : this.renderIcon(currentTemplate.icon)}
             <Title>{name}</Title>
           </Flex>
           <Button
@@ -71,10 +82,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
             Launch Template
           </Button>
         </TitleSection>
-        <TagSection>
-          <i className="material-icons">local_offer</i>
-          {this.renderTagList()}
-        </TagSection>
+        {this.renderTagSection()}
         <ContentSection>
           {this.renderMarkdown()}
         </ContentSection>
@@ -143,7 +151,7 @@ const Button = styled.div`
   font-size: 13px;
   padding: 10px 15px;
   border-radius: 3px;
-  cursor: ${(props: { isDisabled: boolean }) => (!props.isDisabled ? 'pointer' : 'default')};;
+  cursor: ${(props: { isDisabled: boolean }) => (!props.isDisabled ? 'pointer' : 'default')};
   box-shadow: 0 5px 8px 0px #00000010;
   display: flex;
   flex-direction: row;

+ 22 - 1
dashboard/src/shared/api.tsx

@@ -71,6 +71,15 @@ const getChartComponents = baseApi<{
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
+const getChartControllers = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  service_account_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
+});
+
 const getNamespaces = baseApi<{
   cluster_id: number,
   service_account_id: number,
@@ -153,6 +162,16 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
+const deployTemplate = baseApi<{
+  templateName: string,
+  imageURL: string,
+  formValues: any,
+  storage: StorageType,
+}, { id: number, cluster_id: number, service_account_id: number }>('POST', pathParams => {
+  let {id, cluster_id, service_account_id} = pathParams;
+  return `/api/projects/${id}/deploy?cluster_id=${cluster_id}&service_account_id=${service_account_id}`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -166,6 +185,7 @@ export default {
   getCharts,
   getChart,
   getChartComponents,
+  getChartControllers,
   getNamespaces,
   getMatchingPods,
   getRevisions,
@@ -176,5 +196,6 @@ export default {
   getBranchContents,
   getProjects,
   createProject,
-  deleteProject
+  deleteProject,
+  deployTemplate
 }

+ 30 - 0
dashboard/src/shared/common.tsx

@@ -0,0 +1,30 @@
+export const getIntegrationIcon = (kind: string) => {
+  switch (kind) {
+    case 'gke':
+      return 'https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png';
+    case 'eks':
+      return 'https://img.stackshare.io/service/7991/amazon-eks.png';
+    case 'kubeconfig':
+      return 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png';
+    case 'docker-hub':
+      return 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png';
+    case 'gcr':
+      return 'https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640';
+    case 'ecr':
+      return 'https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4';
+    case 'kubernetes':
+      return 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png';
+    case 'repo':
+      return 'https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png';
+    case 'registry':
+      return 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png';
+    default:
+      return null
+  }
+}
+
+export const getIgnoreCase = (object: any, key: string) => {
+  return object[Object.keys(object)
+    .find(k => k.toLowerCase() === key.toLowerCase())
+  ];
+}

+ 39 - 21
dashboard/src/shared/types.tsx

@@ -23,6 +23,10 @@ export interface ChartType {
       icon: string,
       apiVersion: string
     },
+    files?: {
+      data: string,
+      name: string,
+    }[],
   },
   config: string,
   version: number,
@@ -64,38 +68,42 @@ export enum StorageType {
 
 // PorterChart represents a bundled Porter template
 export interface PorterChart {
-	Name: string,
-	Description: string,
-	Icon: string,
-  Form: FormYAML,
-  Markdown?: string,
+	name: string,
+	description: string,
+	icon: string,
+  form: FormYAML,
+  markdown?: string,
 }
 
 // FormYAML represents a chart's values.yaml form abstraction
 export interface FormYAML {
-	Name?: string,  
-	Icon?: string,   
-	Description?: string,   
-	Tags?: string[],
-  Sections?: Section[]
+	name?: string,  
+	icon?: string,   
+	description?: string,   
+  tags?: string[],
+  tabs?: {
+    name: string,
+    label: string,
+    sections?: Section[]
+  }[]
 }
 
 export interface Section {
-  Name?: string,
-  ShowIf?: string,
-  Contents: FormElement[]
+  name?: string,
+  show_if?: string,
+  contents: FormElement[]
 }
 
 // FormElement represents a form element
 export interface FormElement {
-  Type: string,
-  Label: string,
-  Name?: string,
-  Variable?: string,
-  Settings?: {
-    Default?: number | string | boolean,
-    Options?: any[],
-    Unit?: string
+  type: string,
+  label: string,
+  name?: string,
+  variable?: string,
+  settings?: {
+    default?: number | string | boolean,
+    options?: any[],
+    unit?: string
   }
 }
 
@@ -118,4 +126,14 @@ export interface ProjectType {
     user_id: number,
     project_id: number
   }[]
+}
+
+export interface ChoiceType {
+  value: string,
+  label: string
+}
+
+export interface ImageType {
+  kind: string,
+  source: string,
 }

+ 3 - 3
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
-	github.com/aws/aws-sdk-go v1.30.0
+	github.com/aws/aws-sdk-go v1.31.6
 	github.com/containerd/containerd v1.4.1
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
@@ -25,6 +25,7 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.1
+	github.com/google/go-containerregistry v0.1.4
 	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-github/v32 v32.1.0
 	github.com/gorilla/securecookie v1.1.1
@@ -46,14 +47,13 @@ require (
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
-	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
+	golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
 	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	google.golang.org/api v0.30.0
 	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
 	google.golang.org/grpc v1.33.0 // indirect
-	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2

+ 42 - 0
go.sum

@@ -52,6 +52,8 @@ github.com/Azure/azure-sdk-for-go v29.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
 github.com/Azure/azure-sdk-for-go v30.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go v32.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go v35.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go v38.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go v42.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-service-bus-go v0.9.1/go.mod h1:yzBx6/BUGfjfeqbRZny9AQIbIe3AcV9WZbAdpkoXOa0=
 github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
@@ -64,6 +66,7 @@ github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSW
 github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
 github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0=
 github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
+github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 github.com/Azure/go-autorest/autorest v0.11.1 h1:eVvIXUKiTgv++6YnWb42DUA1YL7qDugnKP0HljexdnQ=
 github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
@@ -72,6 +75,8 @@ github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMl
 github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
 github.com/Azure/go-autorest/autorest/adal v0.9.0 h1:SigMbuFNuKgc1xcGhaeapbh+8fgsu+GxgDRFyg7f5lM=
 github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
+github.com/Azure/go-autorest/autorest/adal v0.9.5 h1:Y3bBUV4rTuxenJJs41HU3qmqsb+auo+a3Lz+PlJPpL0=
+github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM=
 github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw=
 github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
@@ -82,8 +87,11 @@ github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxB
 github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
 github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM=
 github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
+github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
 github.com/Azure/go-autorest/autorest/to v0.2.0/go.mod h1:GunWKJp1AEqgMaGLV+iocmRAJWqST1wQYhyyjXJ3SJc=
+github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA=
 github.com/Azure/go-autorest/autorest/validation v0.1.0/go.mod h1:Ha3z/SqBeaalWQvokg3NZAlQTalVMtOIAs1aGK7G6u8=
+github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI=
 github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
 github.com/Azure/go-autorest/logger v0.2.0 h1:e4RVHVZKC5p6UANLJHkM4OfR1UKZPj8Wt8Pcx+3oqrE=
 github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
@@ -119,6 +127,7 @@ github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo
 github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
 github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
+github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
 github.com/Microsoft/hcsshim v0.0.0-20190417211021-672e52e9209d/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
@@ -184,6 +193,8 @@ github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN
 github.com/aws/aws-sdk-go v1.28.2/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.30.0 h1:7NDwnnQrI1Ivk0bXLzMmuX5ozzOwteHOsAs4druW7gI=
 github.com/aws/aws-sdk-go v1.30.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
+github.com/aws/aws-sdk-go v1.31.6 h1:nKjQbpXhdImctBh1e0iLg9iQW/X297LPPuY/9f92R2k=
+github.com/aws/aws-sdk-go v1.31.6/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
 github.com/aws/aws-sdk-go v1.35.4 h1:GG0sdhmzQSe4/UcF9iuQP9i+58bPRyU4OpujyzMlVjo=
 github.com/aws/aws-sdk-go v1.35.4/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
@@ -261,6 +272,7 @@ github.com/containerd/console v0.0.0-20170925154832-84eeaae905fa/go.mod h1:Tj/on
 github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
 github.com/containerd/containerd v1.0.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.4.1 h1:pASeJT3R3YyVn+94qEPk0SnU1OQ20Jd/T+SPKy9xehY=
@@ -321,12 +333,14 @@ github.com/digitalocean/godo v1.19.0/go.mod h1:AAPQ+tiM4st79QHlEBTg8LM7JQNre4SAQ
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
 github.com/dlespiau/kube-test-harness v0.0.0-20190930170435-ec3f93e1a754/go.mod h1:rTr8X4qZPRmQKsyAjhECPi+zPnmlcmv5W9s1F11oBSo=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
+github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v0.0.0-20191216044856-a8371794149d/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
 github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce h1:KXS1Jg+ddGcWA8e1N7cupxaHHZhit5rB9tfDU+mfjyY=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ=
@@ -385,6 +399,8 @@ github.com/fluxcd/flux/pkg/install v0.0.0-20201001122558-cb08da1b356a/go.mod h1:
 github.com/fluxcd/go-git-providers v0.0.3/go.mod h1:iaXf3nEq8MB/LzxfbNcCl48sAtIReUU7jqjJ7CEnfFQ=
 github.com/fluxcd/helm-operator/pkg/install v0.0.0-20200729150005-1467489f7ee4/go.mod h1:ijsiZLK3c4Qu4sFqHu5pJdwjmMEjvKpwivq3uAdffBk=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
@@ -594,6 +610,8 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-containerregistry v0.1.4 h1:fZm+V2pYnvb8NMPM1YOsyxr31XKfpHTun5oVTRnG8qc=
+github.com/google/go-containerregistry v0.1.4/go.mod h1:6EGiuQp36pL82lX6rFN0s9AJOVL0Mlgx/DAsYZW5X3s=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
 github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM=
@@ -637,6 +655,8 @@ github.com/googleapis/gnostic v0.1.0 h1:rVsPeBmXbYv4If/cumu1AzZPwV58q433hvONV1UE
 github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g=
 github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
+github.com/googleapis/gnostic v0.2.2 h1:DcFegQ7+ECdmkJMfVwWlC+89I4esJ7p8nkGt9ainGDk=
+github.com/googleapis/gnostic v0.2.2/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/gookit/color v1.2.4/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=
 github.com/gophercloud/gophercloud v0.0.0-20180807015416-4ea085781bae/go.mod h1:3WdhXV3rUYy9p6AUW8d94kr+HS62Y4VL9mBnFxsD8q4=
 github.com/gophercloud/gophercloud v0.0.0-20190216224116-dcc6e84aef1b/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
@@ -705,6 +725,8 @@ github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47/go.mod h1:/m3
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk=
+github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 github.com/hashicorp/hcl v0.0.0-20160711231752-d8c773c4cba1/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -810,6 +832,7 @@ github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
 github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5 h1:lrdPtrORjGv1HbbEvKWDUAy97mPpFm4B8hp77tcCUJY=
 github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
+github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppWLm4yqBwZsKJlAayUu5FGozhrF3ETSM=
 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHur0g8VplbLOv5vXmDzacSaH9Z7XhcgsSh1xciU=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
@@ -856,6 +879,7 @@ github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06 h1:vN4d3jSss3ExzUn2cE0WctxztfOgiKvMKnDrydBsg00=
 github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06/go.mod h1:++9BgZujZd4v0ZTZCb5iPsaomXdZWyxotIAh1IiDm44=
@@ -940,6 +964,7 @@ github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb44
 github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY=
 github.com/mesos/mesos-go v0.0.9/go.mod h1:kPYCMQ9gsOXVAle1OsoY4I1+9kPu8GHkf88aV59fDr4=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY=
@@ -1005,6 +1030,7 @@ github.com/nbutton23/zxcvbn-go v0.0.0-20160627004424-a22cb81b2ecd/go.mod h1:o96d
 github.com/nbutton23/zxcvbn-go v0.0.0-20171102151520-eafdab6b0663/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU=
 github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU=
 github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
@@ -1161,6 +1187,7 @@ github.com/sanathkr/yaml v0.0.0-20170819201035-0056894fa522 h1:fOCp11H0yuyAt2wql
 github.com/sanathkr/yaml v0.0.0-20170819201035-0056894fa522/go.mod h1:tQTYKOQgxoH3v6dEmdHiz4JG+nbxWwM5fgPQUpSZqVQ=
 github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
 github.com/securego/gosec v0.0.0-20200103095621-79fbf3af8d83/go.mod h1:vvbZ2Ae7AzSq3/kywjUDxSNq2SJ27RxCz2un0H3ePqE=
@@ -1296,6 +1323,7 @@ github.com/valyala/fasthttp v1.2.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk
 github.com/valyala/quicktemplate v1.1.1/go.mod h1:EH+4AkTd43SvgIbQHYu59/cJyxDoOVRUAfrukLPuGJ4=
 github.com/valyala/quicktemplate v1.2.0/go.mod h1:EH+4AkTd43SvgIbQHYu59/cJyxDoOVRUAfrukLPuGJ4=
 github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
+github.com/vdemeester/k8s-pkg-credentialprovider v1.18.1-0.20201019120933-f1d16962a4db/go.mod h1:grWy0bkr1XO6hqbaaCKaPXqkBVlMGHYG6PGykktwbJc=
 github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
 github.com/vektra/mockery v0.0.0-20181123154057-e78b021dcbb5/go.mod h1:ppEjwdhyy7Y31EnHRDm1JkChoC7LXIJ7Ex0VYLWtZtQ=
 github.com/vishvananda/netlink v0.0.0-20171020171820-b2de5d10e38e/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
@@ -1350,6 +1378,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
 github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
 github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
@@ -1420,6 +1449,8 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
 golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
+golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1560,6 +1591,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190620070143-6f217b454f45/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190812172437-4e8604ab3aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1610,6 +1642,7 @@ golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20170915040203-e531a2a1c15f/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1646,6 +1679,7 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw
 golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190706070813-72ffa07ba3db/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
 golang.org/x/tools v0.0.0-20190719005602-e377ae9d6386/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
 golang.org/x/tools v0.0.0-20190812233024-afc3694995b6/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -1696,6 +1730,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1767,6 +1802,7 @@ google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfG
 google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
 google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
 google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
@@ -1811,6 +1847,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
@@ -1857,8 +1894,10 @@ gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpA
 grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
 helm.sh/helm v2.16.12+incompatible h1:nQfifk10KcpAGD1RJaNZVW/fWiqluV0JMuuDwdba4rw=
 helm.sh/helm v2.16.12+incompatible/go.mod h1:0Xbc6ErzwWH9qC55X1+hE3ZwhM3atbhCm/NbFZw5i+4=
+helm.sh/helm v2.17.0+incompatible h1:cSe3FaQOpRWLDXvTObQNj0P7WI98IG5yloU6tQVls2k=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
 helm.sh/helm/v3 v3.3.4/go.mod h1:CyCGQa53/k1JFxXvXveGwtfJ4cuB9zkaBSGa5rnAiHU=
+helm.sh/helm/v3 v3.4.1 h1:NIdlBGKFRTAkhz0ooYKw1VBbmTldxNAZRY1nH6Glk6I=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1876,6 +1915,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.19.4 h1:+ZoddM7nbzrDCp0T3SWnyxqf8cbWPT2fkZImoyvHUG0=
 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=
@@ -1887,6 +1927,7 @@ k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwn
 k8s.io/cloud-provider v0.18.8/go.mod h1:cn9AlzMPVIXA4HHLVbgGUigaQlZyHSZ7WAwDEFNrQSs=
 k8s.io/cluster-bootstrap v0.18.8/go.mod h1:guq0Uc+QwazHgpS1yAw5Z7yUlBCtGppbgWQkbN3lxIY=
 k8s.io/code-generator v0.16.8/go.mod h1:wFdrXdVi/UC+xIfLi+4l9elsTT/uEF61IfcN2wOLULQ=
+k8s.io/code-generator v0.17.2/go.mod h1:DVmfPQgxQENqDIzVR2ddLXMH34qeszkKSdH/N+s+38s=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/component-base v0.16.8/go.mod h1:Q8UWOWShpP3MZZny4n/15gOncfaaVtc9SbCdkM5MhUE=
 k8s.io/component-base v0.18.8 h1:BW5CORobxb6q5mb+YvdwQlyXXS6NVH5fDXWbU7tf2L8=
@@ -1914,6 +1955,7 @@ k8s.io/kube-aggregator v0.18.8/go.mod h1:CyLoGZB+io8eEwnn+6RbV7QWJQhj8a3TBH8ZM8s
 k8s.io/kube-controller-manager v0.18.8/go.mod h1:IYZteddXJFD1TVgAw8eRP3c9OOA2WtHdXdE8aH6gXnc=
 k8s.io/kube-openapi v0.0.0-20180509051136-39cb288412c4/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc=
 k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
+k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
 k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6 h1:Oh3Mzx5pJ+yIumsAD0MOECPVeXsVot0UkiaCGVyfGQY=
 k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
 k8s.io/kube-proxy v0.18.8/go.mod h1:u4E8OsUpUzfZ9CEFf9rdLsbYiusZr8utbtF4WQrX+qs=

+ 13 - 0
internal/forms/release.go

@@ -113,3 +113,16 @@ type UpgradeReleaseForm struct {
 	Name   string `json:"name" form:"required"`
 	Values string `json:"values" form:"required"`
 }
+
+// ChartTemplateForm represents the accepted values for installing a new chart from a template.
+type ChartTemplateForm struct {
+	TemplateName string                 `json:"templateName" form:"required"`
+	ImageURL     string                 `json:"imageURL" form:"required"`
+	FormValues   map[string]interface{} `json:"formValues"`
+}
+
+// InstallChartTemplateForm represents the accepted values for installing a new chart from a template.
+type InstallChartTemplateForm struct {
+	*ReleaseForm
+	*ChartTemplateForm
+}

+ 56 - 0
internal/helm/agent.go

@@ -3,7 +3,10 @@ package helm
 import (
 	"fmt"
 
+	"github.com/pkg/errors"
 	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/release"
 	"k8s.io/helm/pkg/chartutil"
 )
@@ -77,6 +80,47 @@ func (a *Agent) UpgradeRelease(
 	return res, nil
 }
 
+// InstallChart installs a new chart by URL, absolute or relative filepaths.
+// Equivalent to `helm install [CHART_NAME] [cp]` where cp is one of the following:
+//  1) Absolute URL: https://example.com/charts/nginx-1.2.3.tgz
+//  2) path to packaged chart ./nginx-1.2.3.tgz
+//  3) path to unpacked chart ./nginx
+func (a *Agent) InstallChart(
+	cp string,
+	values []byte,
+) (*release.Release, error) {
+	cmd := action.NewInstall(a.ActionConfig)
+	valuesYaml, err := chartutil.ReadValues(values)
+
+	if err != nil {
+		return nil, fmt.Errorf("Values could not be parsed: %v", err)
+	}
+
+	// Only supports filepaths for now, URL option WIP.
+	// Check chart dependencies to make sure all are present in /charts
+	chartRequested, err := loader.Load(cp)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := checkIfInstallable(chartRequested); err != nil {
+		return nil, err
+	}
+
+	if chartRequested.Metadata.Deprecated {
+		return nil, fmt.Errorf("This chart is deprecated")
+	}
+
+	if req := chartRequested.Metadata.Dependencies; req != nil {
+		if err := action.CheckDependencies(chartRequested, req); err != nil {
+			// TODO: Handle dependency updates.
+			return nil, err
+		}
+	}
+
+	return cmd.Run(chartRequested, valuesYaml)
+}
+
 // RollbackRelease rolls a release back to a specified revision/version
 func (a *Agent) RollbackRelease(
 	name string,
@@ -86,3 +130,15 @@ func (a *Agent) RollbackRelease(
 	cmd.Version = version
 	return cmd.Run(name)
 }
+
+// ------------------------ Helm agent helper functions ------------------------ //
+
+// checkIfInstallable validates if a chart can be installed
+// Application chart type is only installable
+func checkIfInstallable(ch *chart.Chart) error {
+	switch ch.Metadata.Type {
+	case "", "application":
+		return nil
+	}
+	return errors.Errorf("%s charts are not installable", ch.Metadata.Type)
+}

+ 41 - 0
internal/helm/grapher/object.go

@@ -51,3 +51,44 @@ func ParseObjs(objs []map[string]interface{}) []Object {
 	}
 	return objArr
 }
+
+// ParseControllers parses a k8s object from a single-document yaml
+// and returns an array of controllers.
+func ParseControllers(objs []map[string]interface{}) []Object {
+	objArr := []Object{}
+
+	for i, obj := range objs {
+		kind := getField(obj, "kind")
+
+		// ignore block comments
+		if kind == nil {
+			continue
+		}
+
+		switch kind.(string) {
+		// Parse for all possible controller types
+		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
+			name := getField(obj, "metadata", "name")
+			namespace := getField(obj, "metadata", "namespace")
+
+			if namespace == nil {
+				namespace = "default"
+			}
+
+			if name == nil {
+				name = ""
+			}
+
+			// First add the object that appears on the YAML
+			parsedObj := Object{
+				ID:        i,
+				Kind:      kind.(string),
+				Name:      name.(string),
+				Namespace: namespace.(string),
+			}
+			objArr = append(objArr, parsedObj)
+		}
+
+	}
+	return objArr
+}

+ 151 - 8
internal/kubernetes/agent.go

@@ -5,12 +5,17 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"strings"
 
 	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/internal/helm/grapher"
+	appsv1 "k8s.io/api/apps/v1"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/cache"
 )
 
 // Agent is a Kubernetes agent for performing operations that interact with the
@@ -20,6 +25,16 @@ type Agent struct {
 	Clientset        kubernetes.Interface
 }
 
+type Message struct {
+	EventType string
+	Object    interface{}
+	Kind      string
+}
+
+type ListOptions struct {
+	FieldSelector string
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(
@@ -28,6 +43,42 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// GetDeployment gets the depployment given the name and namespace
+func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetStatefulSet gets the statefulset given the name and namespace
+func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
+	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetReplicaSet gets the replicaset given the name and namespace
+func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
+	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDaemonSet gets the daemonset by name and namespace
+func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
+	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
 // GetPodsByLabel retrieves pods with matching labels
 func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
 	// Search in all namespaces for matching pods
@@ -55,18 +106,110 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	defer podLogs.Close()
 
 	r := bufio.NewReader(podLogs)
-	for {
-		bytes, err := r.ReadBytes('\n')
+	errorchan := make(chan error)
 
-		if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
-			return writeErr
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				conn.Close()
+				errorchan <- nil
+				fmt.Println("Successfully closed log stream")
+				return
+			}
 		}
+	}()
 
-		if err != nil {
-			if err != io.EOF {
-				return err
+	go func() {
+		for {
+			select {
+			case <-errorchan:
+				defer close(errorchan)
+				return
+			default:
+			}
+			bytes, err := r.ReadBytes('\n')
+			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+				errorchan <- writeErr
+				return
 			}
-			return nil
+			if err != nil {
+				if err != io.EOF {
+					errorchan <- err
+					return
+				}
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err = <-errorchan:
+			return err
+		}
+	}
+}
+
+// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
+// TODO: Support Jobs
+func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error {
+	factory := informers.NewSharedInformerFactory(
+		a.Clientset,
+		10,
+	)
+	var informer cache.SharedInformer
+
+	// Spins up an informer depending on kind. Convert to lowercase for robustness
+	switch strings.ToLower(kind) {
+	case "deployment":
+		informer = factory.Apps().V1().Deployments().Informer()
+	case "statefulset":
+		informer = factory.Apps().V1().StatefulSets().Informer()
+	case "replicaset":
+		informer = factory.Apps().V1().ReplicaSets().Informer()
+	case "daemonset":
+		informer = factory.Apps().V1().DaemonSets().Informer()
+	}
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    newObj,
+				Kind:      strings.ToLower(kind),
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				defer close(stopper)
+				defer fmt.Println("Successfully closed controller status stream")
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
 		}
 	}
 }

+ 57 - 0
internal/models/templates.go

@@ -0,0 +1,57 @@
+package models
+
+// IndexYAML represents a chart repo's index.yaml
+type IndexYAML struct {
+	APIVersion string                    `yaml:"apiVersion"`
+	Generated  string                    `yaml:"generated"`
+	Entries    map[interface{}]ChartYAML `yaml:"entries"`
+}
+
+// ChartYAML represents the data for chart in index.yaml
+type ChartYAML []struct {
+	APIVersion  string   `yaml:"apiVersion"`
+	AppVersion  string   `yaml:"appVersion"`
+	Created     string   `yaml:"created"`
+	Description string   `yaml:"description"`
+	Digest      string   `yaml:"digest"`
+	Icon        string   `yaml:"icon"`
+	Name        string   `yaml:"name"`
+	Type        string   `yaml:"type"`
+	Urls        []string `yaml:"urls"`
+	Version     string   `yaml:"version"`
+}
+
+// PorterChart represents a bundled Porter template
+type PorterChart struct {
+	Name        string   `json:"name"`
+	Description string   `json:"description"`
+	Icon        string   `json:"icon"`
+	Form        FormYAML `json:"form"`
+	Markdown    string   `json:"markdown"`
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+type FormYAML struct {
+	Name        string   `yaml:"name" json:"name"`
+	Icon        string   `yaml:"icon" json:"icon"`
+	Description string   `yaml:"description" json:"description"`
+	Tags        []string `yaml:"tags" json:"tags"`
+	Tabs        []struct {
+		Name     string `yaml:"name" json:"name"`
+		Label    string `yaml:"label" json:"label"`
+		Sections []struct {
+			Name     string `yaml:"name" json:"name"`
+			ShowIf   string `yaml:"show_if" json:"show_if"`
+			Contents []struct {
+				Type     string `yaml:"type" json:"type"`
+				Label    string `yaml:"label" json:"label"`
+				Name     string `yaml:"name,omitempty" json:"name,omitempty"`
+				Variable string `yaml:"variable,omitempty" json:"variable,omitempty"`
+				Settings struct {
+					Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
+					Unit    interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
+				} `yaml:"settings,omitempty" json:"settings,omitempty"`
+			} `yaml:"contents" json:"contents,omitempty"`
+		} `yaml:"sections" json:"sections,omitempty"`
+	} `yaml:"tabs" json:"tabs,omitempty"`
+}

+ 21 - 0
node_modules/@types/js-base64/LICENSE

@@ -0,0 +1,21 @@
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE

+ 16 - 0
node_modules/@types/js-base64/README.md

@@ -0,0 +1,16 @@
+# Installation
+> `npm install --save @types/js-base64`
+
+# Summary
+This package contains type definitions for js-base64 (https://github.com/dankogai/js-base64).
+
+# Details
+Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/js-base64.
+
+### Additional Details
+ * Last updated: Sat, 18 Jul 2020 15:47:12 GMT
+ * Dependencies: none
+ * Global values: `Base64`
+
+# Credits
+These definitions were written by [Denis Carriere](https://github.com/DenisCarriere), [Tommy Lent](https://github.com/tlent), and [JounQin](https://github.com/JounQin).

+ 82 - 0
node_modules/@types/js-base64/index.d.ts

@@ -0,0 +1,82 @@
+// Type definitions for js-base64 3.0
+// Project: https://github.com/dankogai/js-base64
+// Definitions by: Denis Carriere <https://github.com/DenisCarriere>
+//                 Tommy Lent <https://github.com/tlent>
+//                 JounQin <https://github.com/JounQin>
+// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
+
+export interface Base64 {
+    VERSION: string;
+    encode(s: string, uriSafe?: boolean): string;
+    encodeURI(s: string): string;
+    encodeURL: Base64['encodeURI'];
+    decode(base64: string): string;
+    atob(base64: string): string;
+    btoa(s: string): string;
+    fromBase64(base64: string): string;
+    toBase64(s: string, uriSafe?: boolean): string;
+    btou(s: string): string;
+    utob(s: string): string;
+    fromUint8Array(uint8Array: Uint8Array, uriSafe?: boolean): string;
+    toUint8Array(s: string): Uint8Array;
+    extendString(): void;
+    extendUint8Array(): void;
+    extendBuiltins(): void;
+}
+
+export const Base64: Base64;
+
+export const VERSION: string;
+
+export const encode: Base64['encode'];
+
+export const encodeURI: Base64['encodeURI'];
+
+export const encodeURL: Base64['encodeURL'];
+
+export const decode: Base64['decode'];
+
+export const atob: Base64['atob'];
+
+export const btoa: Base64['btoa'];
+
+export const fromBase64: Base64['fromBase64'];
+
+export const toBase64: Base64['toBase64'];
+
+export const btou: Base64['btou'];
+
+export const utob: Base64['utob'];
+
+export const fromUint8Array: Base64['fromUint8Array'];
+
+export const toUint8Array: Base64['toUint8Array'];
+
+export const extendString: Base64['extendString'];
+
+export const extendUint8Array: Base64['extendUint8Array'];
+
+export const extendBuiltins: Base64['extendBuiltins'];
+
+/**
+ * only for global usage, not available in esm actually
+ */
+export function noConflict(): Base64;
+
+export as namespace Base64;
+
+declare global {
+    interface String {
+        fromBase64(): string;
+        toBase64(uriSafe?: boolean): string;
+        toBase64URI(): string;
+        toBase64URL(): string;
+        toUint8Array(): Uint8Array;
+    }
+
+    interface Uint8Array {
+        toBase64(uriSafe?: boolean): string;
+        toBase64URI(): string;
+        toBase64URL(): string;
+    }
+}

+ 62 - 0
node_modules/@types/js-base64/package.json

@@ -0,0 +1,62 @@
+{
+  "_from": "@types/js-base64",
+  "_id": "@types/js-base64@3.0.0",
+  "_inBundle": false,
+  "_integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
+  "_location": "/@types/js-base64",
+  "_phantomChildren": {},
+  "_requested": {
+    "type": "tag",
+    "registry": true,
+    "raw": "@types/js-base64",
+    "name": "@types/js-base64",
+    "escapedName": "@types%2fjs-base64",
+    "scope": "@types",
+    "rawSpec": "",
+    "saveSpec": null,
+    "fetchSpec": "latest"
+  },
+  "_requiredBy": [
+    "#USER",
+    "/"
+  ],
+  "_resolved": "https://registry.npmjs.org/@types/js-base64/-/js-base64-3.0.0.tgz",
+  "_shasum": "b7b4c130facefefd5c57ba82664c41e2995f91be",
+  "_spec": "@types/js-base64",
+  "_where": "/Users/trevorshim/Development/porter-dev/porter",
+  "bugs": {
+    "url": "https://github.com/DefinitelyTyped/DefinitelyTyped/issues"
+  },
+  "bundleDependencies": false,
+  "contributors": [
+    {
+      "name": "Denis Carriere",
+      "url": "https://github.com/DenisCarriere"
+    },
+    {
+      "name": "Tommy Lent",
+      "url": "https://github.com/tlent"
+    },
+    {
+      "name": "JounQin",
+      "url": "https://github.com/JounQin"
+    }
+  ],
+  "dependencies": {},
+  "deprecated": false,
+  "description": "TypeScript definitions for js-base64",
+  "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped#readme",
+  "license": "MIT",
+  "main": "",
+  "name": "@types/js-base64",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/DefinitelyTyped/DefinitelyTyped.git",
+    "directory": "types/js-base64"
+  },
+  "scripts": {},
+  "typeScriptVersion": "3.0",
+  "types": "index.d.ts",
+  "typesPublisherContentHash": "4b5afb34917caed330bdb1d07cae9ec4f28c8f27affcb5551a4412b3f9d082eb",
+  "version": "3.0.0"
+}

+ 221 - 0
server/api/deploy_handler.go

@@ -0,0 +1,221 @@
+package api
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/models"
+	"gopkg.in/yaml.v2"
+)
+
+// HandleDeployTemplate triggers a chart deployment from a template
+func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	form := &forms.InstallChartTemplateForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo: app.repo,
+			},
+		},
+		ChartTemplateForm: &forms.ChartTemplateForm{},
+	}
+
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.repo.Cluster,
+	)
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	if err != nil {
+		return
+	}
+
+	baseURL := "https://porter-dev.github.io/chart-repo/"
+	values, err := getDefaultValues(form.ChartTemplateForm.TemplateName, baseURL)
+	if err != nil {
+		return
+	}
+
+	// Set image URL
+	(*values)["image"].(map[interface{}]interface{})["repository"] = form.ChartTemplateForm.ImageURL
+
+	// Loop through form params to override
+	for k := range form.ChartTemplateForm.FormValues {
+		switch v := interface{}(k).(type) {
+		case string:
+			splits := strings.Split(v, ".")
+
+			// Validate that the field to override exists
+			currentLoc := *values
+			for s := range splits {
+				key := splits[s]
+				val := currentLoc[key]
+				if val == nil {
+					fmt.Printf("No such field: %v\n", key)
+				} else if s == len(splits)-1 {
+					newValue := form.ChartTemplateForm.FormValues[v]
+					fmt.Printf("Overriding default %v with %v\n", val, newValue)
+					currentLoc[key] = newValue
+				} else {
+					fmt.Println("Traversing...")
+					currentLoc = val.(map[interface{}]interface{})
+				}
+			}
+		default:
+			fmt.Println("Non-string type")
+		}
+	}
+
+	v, err := yaml.Marshal(values)
+
+	if err != nil {
+		return
+	}
+
+	// Output values.yaml string
+	_, err = agent.InstallChart(baseURL+"react-0.1.5.tgz", v)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error installing a new chart" + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// ------------------------ Deploy handler helper functions ------------------------ //
+
+func getDefaultValues(templateName string, baseURL string) (*map[interface{}]interface{}, error) {
+	resp, err := http.Get(baseURL + "index.yaml")
+	if err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+
+	form := models.IndexYAML{}
+	if err := yaml.Unmarshal([]byte(body), &form); err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	// Loop over charts in index.yaml
+	for k := range form.Entries {
+		indexChart := form.Entries[k][0]
+		tarURL := indexChart.Urls[0]
+		splits := strings.Split(tarURL, "-")
+
+		strAcc := splits[0]
+		for i := 1; i < len(splits)-1; i++ {
+			strAcc += "-" + splits[i]
+		}
+
+		// Unpack the target chart and retrieve values.yaml
+		if strAcc == templateName {
+			tgtURL := baseURL + tarURL
+			values, err := processValues(tgtURL)
+			if err != nil {
+				fmt.Println(err)
+				return nil, err
+			}
+			return values, nil
+		}
+	}
+	return nil, errors.New("no values.yaml found")
+}
+
+func processValues(tgtURL string) (*map[interface{}]interface{}, error) {
+	resp, err := http.Get(tgtURL)
+	if err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	buf := bytes.NewBuffer(body)
+
+	gzf, err := gzip.NewReader(buf)
+	if err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	// Process tarball to generate FormYAML and retrieve markdown
+	tarReader := tar.NewReader(gzf)
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			fmt.Println(err)
+			return nil, err
+		}
+
+		name := header.Name
+		switch header.Typeflag {
+		case tar.TypeDir:
+			continue
+		case tar.TypeReg:
+
+			// Handle values.yaml located in archive
+			if strings.Contains(name, "values.yaml") {
+				bufForm := new(bytes.Buffer)
+
+				_, err := io.Copy(bufForm, tarReader)
+				if err != nil {
+					fmt.Println(err)
+					return nil, err
+				}
+
+				// Unmarshal yaml byte buffer
+				form := make(map[interface{}]interface{})
+				if err := yaml.Unmarshal(bufForm.Bytes(), &form); err != nil {
+					fmt.Println(err)
+					return nil, err
+				}
+				return &form, nil
+			}
+		default:
+			fmt.Printf("%s : %c %s %s\n",
+				"Unknown type",
+				header.Typeflag,
+				"in file",
+				name,
+			)
+		}
+	}
+	return nil, errors.New("no values.yaml found")
+}

+ 113 - 0
server/api/deploy_handler_test.go

@@ -0,0 +1,113 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type deployTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *deployTest, tester *tester, t *testing.T)
+}
+
+func testDeployRequests(t *testing.T, tests []*deployTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+// var newDeployTests = []*deployTest{
+// 	&deployTest{
+// 		initializers: []func(tester *tester){
+// 			initDefaultDeploy,
+// 		},
+// 		msg:       "Deploy template",
+// 		method:    "POST",
+// 		endpoint:  "/api/projects/1/deploy",
+// 		body:      "",
+// 		expStatus: http.StatusOK,
+// 		expBody:   "unimplemented",
+// 		useCookie: true,
+// 		validators: []func(c *deployTest, tester *tester, t *testing.T){
+// 			deployValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleDeployTemplate(t *testing.T) {
+// 	testDeployRequests(t, newDeployTests, true)
+// }
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultDeploy(tester *tester) {
+	initUserDefault(tester)
+
+	agent := kubernetes.GetAgentTesting(defaultObjects...)
+
+	// overwrite the test agent with new resources
+	tester.app.TestAgents.K8sAgent = agent
+}
+
+func deployValidator(c *deployTest, tester *tester, t *testing.T) {
+	var gotBody map[string]interface{}
+	var expBody map[string]interface{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if string(tester.rr.Body.Bytes()) != c.expBody {
+		t.Errorf("Mismatch")
+	}
+}

+ 65 - 2
server/api/k8s_handler.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	v1 "k8s.io/api/core/v1"
 
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
@@ -176,7 +177,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
-	pods := []string{}
+	pods := []v1.Pod{}
 	for _, selector := range vals["selectors"] {
 		podsList, err := agent.GetPodsByLabel(selector)
 
@@ -186,7 +187,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		}
 
 		for _, pod := range podsList.Items {
-			pods = append(pods, pod.ObjectMeta.Name)
+			pods = append(pods, pod)
 		}
 	}
 
@@ -195,3 +196,65 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleStreamControllerStatus test calls
+// TODO: Refactor repeated calls.
+func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
+
+	// get session to retrieve correct kubeconfig
+	_, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	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,
+		},
+	}
+
+	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.testing {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
+	err = agent.StreamControllerStatus(conn, kind)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}

+ 36 - 0
server/api/registry_handler.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"strconv"
 
@@ -10,6 +11,10 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/google/go-containerregistry/pkg/authn"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
 )
 
 // HandleCreateRegistry creates a new registry
@@ -129,3 +134,34 @@ func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleListImages retrieves a list of repo names
+func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
+	ref, err := name.ParseReference("gcr.io/google-containers/pause")
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	img, err := remote.Image(ref)
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	fmt.Println(img.Size())
+
+	ctx := r.Context()
+	reg, err := name.NewRegistry("index.docker.io")
+	if err != nil {
+		fmt.Println("fuk")
+		fmt.Println(err)
+		return
+	}
+
+	stuff, err := remote.Catalog(ctx, reg, remote.WithAuthFromKeychain(authn.DefaultKeychain))
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	fmt.Println(stuff[0])
+}

+ 96 - 0
server/api/registry_handler_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -24,6 +25,18 @@ type regTest struct {
 	validators   []func(c *regTest, tester *tester, t *testing.T)
 }
 
+type imagesTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *imagesTest, tester *tester, t *testing.T)
+}
+
 func testRegistryRequests(t *testing.T, tests []*regTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
@@ -66,6 +79,48 @@ func testRegistryRequests(t *testing.T, tests []*regTest, canQuery bool) {
 	}
 }
 
+func testImagesRequests(t *testing.T, tests []*imagesTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
 var createRegistryTests = []*regTest{
@@ -111,10 +166,32 @@ var listRegistryTests = []*regTest{
 	},
 }
 
+var listImagesTests = []*imagesTest{
+	&imagesTest{
+		initializers: []func(tester *tester){
+			initDefaultImages,
+		},
+		msg:       "List images",
+		method:    "GET",
+		endpoint:  "/api/projects/1/images",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   "unimplemented",
+		useCookie: true,
+		validators: []func(c *imagesTest, tester *tester, t *testing.T){
+			imagesListValidator,
+		},
+	},
+}
+
 func TestHandleListRegistries(t *testing.T) {
 	testRegistryRequests(t, listRegistryTests, true)
 }
 
+func TestHandleListImages(t *testing.T) {
+	testImagesRequests(t, listImagesTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func initRegistry(tester *tester) {
@@ -146,6 +223,25 @@ func regsBodyValidator(c *regTest, tester *tester, t *testing.T) {
 	gotBody := make([]*models.Registry, 0)
 	expBody := make([]*models.Registry, 0)
 
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func initDefaultImages(tester *tester) {
+	initUserDefault(tester)
+
+	agent := kubernetes.GetAgentTesting(defaultObjects...)
+
+	// overwrite the test agent with new resources
+	tester.app.TestAgents.K8sAgent = agent
+}
+
+func imagesListValidator(c *imagesTest, tester *tester, t *testing.T) {
+	var gotBody map[string]interface{}
+	var expBody map[string]interface{}
+
 	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 

+ 129 - 1
server/api/release_handler.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/grapher"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -103,7 +104,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleGetReleaseComponents retrieves a single release based on a name and revision
+// HandleGetReleaseComponents retrieves kubernetes objects listed in a release identified by name and revision
 func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
@@ -158,6 +159,133 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleGetReleaseControllers retrieves controllers that belong to a release.
+// Used to display status of charts.
+func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo: app.repo,
+			},
+		},
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo: app.repo,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.testing {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
+	controllers := grapher.ParseControllers(yamlArr)
+	retrievedControllers := []interface{}{}
+
+	// get current status of each controller
+	// TODO: refactor with type assertion
+	for _, c := range controllers {
+		switch c.Kind {
+		case "Deployment":
+			rc, err := k8sAgent.GetDeployment(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "StatefulSet":
+			rc, err := k8sAgent.GetStatefulSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "DaemonSet":
+			rc, err := k8sAgent.GetDaemonSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "ReplicaSet":
+			rc, err := k8sAgent.GetReplicaSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(retrievedControllers); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 7 - 57
server/api/template_handler.go

@@ -12,66 +12,16 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/models"
+
 	"gopkg.in/yaml.v2"
 )
 
-// IndexYAML represents a chart repo's index.yaml
-type IndexYAML struct {
-	APIVersion string                    `yaml:"apiVersion"`
-	Generated  string                    `yaml:"generated"`
-	Entries    map[interface{}]ChartYAML `yaml:"entries"`
-}
-
-// ChartYAML represents the data for chart in index.yaml
-type ChartYAML []struct {
-	APIVersion  string   `yaml:"apiVersion"`
-	AppVersion  string   `yaml:"appVersion"`
-	Created     string   `yaml:"created"`
-	Description string   `yaml:"description"`
-	Digest      string   `yaml:"digest"`
-	Icon        string   `yaml:"icon"`
-	Name        string   `yaml:"name"`
-	Type        string   `yaml:"type"`
-	Urls        []string `yaml:"urls"`
-	Version     string   `yaml:"version"`
-}
-
-// PorterChart represents a bundled Porter template
-type PorterChart struct {
-	Name        string
-	Description string
-	Icon        string
-	Form        FormYAML
-	Markdown    string
-}
-
-// FormYAML represents a chart's values.yaml form abstraction
-type FormYAML struct {
-	Name        string   `yaml:"name"`
-	Icon        string   `yaml:"icon"`
-	Description string   `yaml:"description"`
-	Tags        []string `yaml:"tags"`
-	Sections    []struct {
-		Name     string `yaml:"name"`
-		ShowIf   string `yaml:"show_if"`
-		Contents []struct {
-			Type     string `yaml:"type"`
-			Label    string `yaml:"label"`
-			Name     string `yaml:"name,omitempty"`
-			Variable string `yaml:"variable,omitempty"`
-			Settings struct {
-				Default interface{}
-			} `yaml:"settings,omitempty"`
-		} `yaml:"contents"`
-	} `yaml:"sections"`
-}
-
 // HandleListTemplates retrieves a list of Porter templates
 // TODO: test and reduce fragility (handle untar/parse error for individual charts)
 // TODO: separate markdown retrieval into its own query if necessary
 func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
 	baseURL := "https://porter-dev.github.io/chart-repo/"
-
 	resp, err := http.Get(baseURL + "index.yaml")
 	if err != nil {
 		fmt.Println(err)
@@ -81,14 +31,14 @@ func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
 	defer resp.Body.Close()
 	body, _ := ioutil.ReadAll(resp.Body)
 
-	form := IndexYAML{}
+	form := models.IndexYAML{}
 	if err := yaml.Unmarshal([]byte(body), &form); err != nil {
 		fmt.Println(err)
 		return
 	}
 
 	// Loop over charts in index.yaml
-	porterCharts := []PorterChart{}
+	porterCharts := []models.PorterChart{}
 	for k := range form.Entries {
 		indexChart := form.Entries[k][0]
 		tarURL := indexChart.Urls[0]
@@ -102,7 +52,7 @@ func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		porterChart := PorterChart{}
+		porterChart := models.PorterChart{}
 		porterChart.Name = indexChart.Name
 		porterChart.Description = indexChart.Description
 		porterChart.Icon = indexChart.Icon
@@ -117,7 +67,7 @@ func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(porterCharts)
 }
 
-func processTarball(tarURL string) (*FormYAML, string, error) {
+func processTarball(tarURL string) (*models.FormYAML, string, error) {
 	resp, err := http.Get(tarURL)
 	if err != nil {
 		fmt.Println(err)
@@ -176,7 +126,7 @@ func processTarball(tarURL string) (*FormYAML, string, error) {
 				}
 
 				// Unmarshal yaml byte buffer
-				form := FormYAML{}
+				form := models.FormYAML{}
 				if err := yaml.Unmarshal(bufForm.Bytes(), &form); err != nil {
 					fmt.Println(err)
 					return nil, "", err

+ 50 - 0
server/router/router.go

@@ -244,6 +244,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/controllers",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetReleaseControllers, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/releases/{name}/history",
@@ -331,6 +345,28 @@ func New(
 		// 	),
 		// )
 
+		// /api/projects/{project_id}/images routes
+		// TODO: add back project access check
+		r.Method(
+			"GET",
+			"/projects/{project_id}/images",
+			auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListImages, l)),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/deploy",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDeployTemplate, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/templates routes
 		r.Method(
 			"GET",
@@ -369,6 +405,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/{kind}/status",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleStreamControllerStatus, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/pods",

+ 1 - 0
v.yml

@@ -0,0 +1 @@
+ok: true