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

Merge branch 'beta.3.stream-chart-status'

sunguroku 5 лет назад
Родитель
Сommit
d2ecfd8a3d
45 измененных файлов с 2736 добавлено и 689 удалено
  1. 4 0
      dashboard/src/assets/plus.svg
  2. 17 3
      dashboard/src/components/SaveButton.tsx
  3. 108 0
      dashboard/src/components/TabRegion.tsx
  4. 239 0
      dashboard/src/components/image-selector/ImageSelector.tsx
  5. 2 2
      dashboard/src/components/values-form/InputRow.tsx
  6. 41 11
      dashboard/src/components/values-form/ValuesForm.tsx
  7. 45 25
      dashboard/src/main/home/Home.tsx
  8. 65 7
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  9. 128 22
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  10. 8 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  11. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  12. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  13. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  14. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  15. 0 108
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx
  16. 0 66
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx
  17. 313 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  18. 103 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  19. 156 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  20. 131 0
      dashboard/src/main/home/integrations/IntegrationList.tsx
  21. 150 0
      dashboard/src/main/home/integrations/Integrations.tsx
  22. 84 0
      dashboard/src/main/home/integrations/integration-forms/DockerHubForm.tsx
  23. 165 0
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  24. 0 294
      dashboard/src/main/home/modals/LaunchTemplateModal.tsx
  25. 4 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  26. 2 18
      dashboard/src/main/home/templates/Templates.tsx
  27. 1 1
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  28. 64 8
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  29. 13 5
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  30. 16 1
      dashboard/src/shared/api.tsx
  31. 12 0
      dashboard/src/shared/common.tsx
  32. 11 2
      dashboard/src/shared/types.tsx
  33. 3 3
      go.mod
  34. 38 1
      go.sum
  35. 41 0
      internal/helm/grapher/object.go
  36. 140 32
      internal/kubernetes/agent.go
  37. 56 0
      internal/models/templates.go
  38. 130 0
      server/api/deploy_handler.go
  39. 113 0
      server/api/deploy_handler_test.go
  40. 8 5
      server/api/k8s_handler.go
  41. 41 0
      server/api/registry_handler.go
  42. 113 0
      server/api/registry_handler_test.go
  43. 128 1
      server/api/release_handler.go
  44. 7 57
      server/api/template_handler.go
  45. 30 2
      server/router/router.go

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

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

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

@@ -0,0 +1,239 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+import { getRegistryIcon } from '../../shared/common';
+import { Context } from '../../shared/Context';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  forceExpanded?: boolean,
+  selectedImageUrl: string | null,
+  setSelectedImageUrl: (x: string) => void
+};
+
+type StateType = {
+  isExpanded: boolean,
+  loading: boolean,
+  error: boolean,
+  images: any[]
+};
+
+const dummyImages = [
+  {
+    kind: 'docker-hub',
+    source: 'https://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 any[]
+  }
+
+  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: any, i: number) => {
+      let icon = getRegistryIcon(image.kind);
+      return (
+        <ImageItem
+          key={i}
+          isSelected={image.source === this.props.selectedImageUrl}
+          lastItem={i === images.length - 1}
+          onClick={() => this.props.setSelectedImageUrl(image.source)}
+        >
+          <img src={icon && icon} />{image.source}
+        </ImageItem>
+      );
+    });
+  }
+
+  renderExpanded = () => {
+    return (
+      <ExpandedWrapper>
+        {this.renderImageList()}
+      </ExpandedWrapper>
+    );
+  }
+
+  renderSelected = () => {
+    let { selectedImageUrl, setSelectedImageUrl } = this.props;
+    let icon = info;
+    return (
+      <Label>
+        <img src={icon} />
+        <Input
+          onClick={(e: any) => e.stopPropagation()}
+          value={selectedImageUrl}
+          onChange={(e: any) => setSelectedImageUrl(e.value)}
+          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 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;
+  }
+`;

+ 2 - 2
dashboard/src/components/values-form/InputRow.tsx

@@ -27,7 +27,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             placeholder={placeholder}
             width={width}
             type={type}
-            value={value}
+            value={value || ''}
             onChange={(e: ChangeEvent<HTMLInputElement>) =>
               this.props.setValue(e.target.value)
             }
@@ -57,7 +57,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;
 `;

+ 41 - 11
dashboard/src/components/values-form/ValuesForm.tsx

@@ -1,7 +1,9 @@
 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';
@@ -9,17 +11,16 @@ import InputRow from './InputRow';
 import SelectRow from './SelectRow';
 
 type PropsType = {
-  formData?: FormYAML
+  sections?: Section[]
 };
 
 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) => {
+    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
@@ -34,7 +35,7 @@ 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.toString() : '';
             break;
           case 'select':
             formState[key] = def ? def : item.Settings.Options[0].Value;
@@ -45,6 +46,32 @@ 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();
+    }
+  }
+
+  handleDeploy = () => {
+    console.log(this.state);
+    let { currentProject } = this.context;
+
+    api.deployTemplate('<token>', {}, {
+      id: currentProject.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else {
+        // console.log(res.data)
+      }
+    });
+  }
+
   renderSection = (section: Section) => {
     return section.Contents.map((item: FormElement, i: number) => {
 
@@ -68,7 +95,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <InputRow
               key={i}
-              type={'text'}
+              type='text'
               value={this.state[key]}
               setValue={(x: string) => this.setState({ [key]: x })}
               label={item.Label}
@@ -79,9 +106,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <InputRow
               key={i}
-              type={'number'}
+              type='number'
               value={this.state[key]}
-              setValue={(x: string) => this.setState({ [key]: parseInt(x) })}
+              setValue={(x: string) => this.setState({ [key]: x })}
               label={item.Label}
               unit={item.Settings ? item.Settings.Unit : null}
             />
@@ -104,7 +131,7 @@ 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) {
@@ -131,14 +158,17 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         </StyledValuesForm>
         <SaveButton
           text='Deploy'
-          onClick={() => console.log(this.state)}
+          onClick={this.handleDeploy}
           status={null}
+          makeFlush={true}
         />
       </Wrapper>
     );
   }
 }
 
+ValuesForm.contextType = Context;
+
 const DarkMatter = styled.div`
   margin-top: 0px;
 `;

+ 45 - 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
@@ -82,20 +83,17 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    if (this.state.currentView === 'cluster-dashboard') {
+    let { currentView } = this.state;
+    if (currentView === 'cluster-dashboard') {
+      return this.renderDashboard();
+    } else if (currentView === 'dashboard') {
       return (
-        <StyledDashboard>
-          {this.renderDashboard()}
-        </StyledDashboard>
-      );
-    } else if (this.state.currentView === 'dashboard') {
-      return (
-        <StyledDashboard>
-          <DashboardWrapper>
-            <Dashboard />
-          </DashboardWrapper>
-        </StyledDashboard>
+        <DashboardWrapper>
+          <Dashboard />
+        </DashboardWrapper>
       );
+    } else if (currentView === 'integrations') {
+      return <Integrations />;
     }
 
     return <Templates />;
@@ -105,14 +103,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 +127,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 +143,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 +173,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 +230,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 +244,7 @@ const StyledDashboard = styled.div`
 
 const DashboardWrapper = styled.div`
   width: 80%;
+  padding-top: 50px;
   min-width: 300px;
   padding-bottom: 120px;
 `;

+ 65 - 7
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,11 +54,49 @@ export default class Chart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
-  render() {
-    let { chart, setCurrentChart } = this.props;
+  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,
+    };
+  }
 
-    console.log(chart)
+  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 })}
@@ -56,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>

+ 128 - 22
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,
@@ -48,23 +48,128 @@ export default class ChartList extends Component<PropsType, StateType> {
       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 => {
+        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 => {
+              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) {
@@ -72,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);
     }
   }
 
@@ -95,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>}
         />
       )
     })

Разница между файлами не показана из-за своего большого размера
+ 8 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx


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

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

@@ -40,6 +40,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
           text='Save Settings'
           onClick={() => console.log(this.state)}
           status={null}
+          makeFlush={true}
         />
       </Wrapper>
     );

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

@@ -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 - 66
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx

@@ -1,66 +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}`)
-    // let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/deployment/status?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;
+  }
+`;

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

@@ -0,0 +1,131 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import { getRegistryIcon } from '../../../shared/common';
+import api from '../../../shared/api';
+
+type PropsType = {
+  setCurrentIntegration: (x: any) => void
+};
+
+type StateType = {
+  integrations: any[]
+};
+
+const dummyIntegrations = [
+  {
+    name: 'docker-hub',
+    label: 'Docker Hub',
+  },
+  {
+    name: 'gcr',
+    label: 'Google Container Registry (GCR)',
+  },
+  {
+    name: 'ecr',
+    label: 'Amazon Elastic Container Registry (ECR)',
+  },
+];
+
+export default class IntegrationList extends Component<PropsType, StateType> {
+  state = {
+    integrations: [] as any[]
+  }
+
+  componentDidMount() {
+    this.setState({ integrations: dummyIntegrations });
+  }
+
+  renderContents = () => {
+    if (this.state.integrations) {
+      return this.state.integrations.map((integration: any, i: number) => {
+        let icon = getRegistryIcon(integration.name);
+        return (
+          <Integration
+            key={i}
+            onClick={() => this.props.setCurrentIntegration(integration)}
+          >
+            <Flex>
+              <Icon src={icon && icon} />
+              <Label>{integration.label}</Label>
+            </Flex>
+            <i className="material-icons">launch</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;
+  cursor: pointer;
+  background: #26282f;
+  cursor: pointer;
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    font-size: 18px;
+    color: #616feecc;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 15px;
+`;
+
+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;
+`;

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

@@ -0,0 +1,150 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { getRegistryIcon } from '../../../shared/common';
+
+import IntegrationList from './IntegrationList';
+import DockerHubForm from './integration-forms/DockerHubForm';
+
+type PropsType = {
+};
+
+type StateType = {
+  currentIntegration: null | any
+};
+
+export default class Integrations extends Component<PropsType, StateType> {
+  state = {
+    currentIntegration: null as null | any,
+  }
+
+  renderContents = () => {
+    let { currentIntegration } = this.state;
+    if (currentIntegration) {
+      let icon = getRegistryIcon(currentIntegration.name);
+      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>
+
+          <DockerHubForm />
+        </div>
+      );
+    }
+    return (
+      <div>
+        <TitleSection>
+          <Title>Integrations</Title>
+          <Button onClick={() => this.context.setCurrentModal('IntegrationsModal', {})}>
+            <i className="material-icons">add</i>
+            Add Integration
+          </Button>
+        </TitleSection>
+
+        <IntegrationList
+          setCurrentIntegration={(x: any) => this.setState({ currentIntegration: x })}
+        />
+      </div>
+    );
+  }
+  
+  render() {
+    return ( 
+      <StyledIntegrations>
+        {this.renderContents()}
+      </StyledIntegrations>
+    );
+  }
+}
+
+Integrations.contextType = Context;
+
+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-forms/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 ( 
+      <StyledDockerHubForm>
+        <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 Changes'
+          makeFlush={true}
+          onClick={() => console.log('unimplemented')}
+        />
+      </StyledDockerHubForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 10px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledDockerHubForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

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

@@ -0,0 +1,165 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../assets/close.png';
+
+import { Context } from '../../../shared/Context';
+import { getRegistryIcon } from '../../../shared/common';
+
+type PropsType = {
+};
+
+type StateType = {
+  integrations: any[]
+};
+
+const dummyIntegrations = [
+  {
+    name: 'docker-hub',
+    label: 'Docker Hub',
+  },
+  {
+    name: 'gcr',
+    label: 'Google Container Registry (GCR)',
+  },
+  {
+    name: 'ecr',
+    label: 'Amazon Elastic Container Registry (ECR)',
+  },
+];
+
+export default class IntegrationsModal extends Component<PropsType, StateType> {
+  state = {
+    currentTab: 'mac',
+    integrations: [] as any[]
+  }
+
+  componentDidMount() {
+    this.setState({ integrations: dummyIntegrations });
+  }
+
+  renderIntegrationsCatalog = () => {
+    return this.state.integrations.map((integration: any, i: number) => {
+      let icon = getRegistryIcon(integration.name);
+      return (
+        <IntegrationOption key={i}>
+          <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;
+  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>

+ 2 - 18
dashboard/src/main/home/templates/Templates.tsx

@@ -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;
 `;

+ 64 - 8
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 } 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,30 @@ 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: '',
+    tabOptions: [] as ChoiceType[],
+    tabContents: [] as any,
   };
 
   componentDidMount() {
-    let { currentProject } = this.context;
+
+    // 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} />
+          </ValuesFormWrapper>
+        ),
+      });
+    });
+    this.setState({ tabOptions, tabContents });
 
     // 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)
@@ -90,6 +108,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 +130,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 +191,7 @@ const ClusterSection = styled.div`
   font-size: 14px;
   font-weight: 500;
   margin-top: 20px;
+  margin-bottom: 15px;
 
   > i {
     font-size: 25px;
@@ -182,4 +237,5 @@ const TitleSection = styled.div`
 
 const StyledLaunchTemplate = styled.div`
   width: 100%;
+  padding-bottom: 150px;
 `;

+ 13 - 5
dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx

@@ -48,6 +48,17 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
     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;
@@ -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;

+ 16 - 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,10 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
+const deployTemplate = baseApi<{}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/deploy`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -166,6 +179,7 @@ export default {
   getCharts,
   getChart,
   getChartComponents,
+  getChartControllers,
   getNamespaces,
   getMatchingPods,
   getRevisions,
@@ -176,5 +190,6 @@ export default {
   getBranchContents,
   getProjects,
   createProject,
-  deleteProject
+  deleteProject,
+  deployTemplate
 }

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

@@ -0,0 +1,12 @@
+export const getRegistryIcon = (kind: string) => {
+  switch (kind) {
+    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';
+    default:
+      return null
+  }
+}

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

@@ -76,8 +76,12 @@ export interface FormYAML {
 	Name?: string,  
 	Icon?: string,   
 	Description?: string,   
-	Tags?: string[],
-  Sections?: Section[]
+  Tags?: string[],
+  Tabs?: {
+    Name: string,
+    Label: string,
+    Sections?: Section[]
+  }[]
 }
 
 export interface Section {
@@ -118,4 +122,9 @@ export interface ProjectType {
     user_id: number,
     project_id: number
   }[]
+}
+
+export interface ChoiceType {
+  value: string,
+  label: 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
@@ -45,14 +46,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

+ 38 - 1
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/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
@@ -63,7 +65,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.11.1 h1:eVvIXUKiTgv++6YnWb42DUA1YL7qDugnKP0HljexdnQ=
+github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 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=
 github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc=
@@ -71,6 +73,7 @@ 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/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=
@@ -81,8 +84,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=
@@ -118,6 +124,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=
@@ -182,6 +189,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=
@@ -259,6 +268,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=
@@ -319,12 +329,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=
@@ -383,6 +395,7 @@ 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/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=
@@ -592,6 +605,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=
@@ -635,6 +650,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=
@@ -699,6 +716,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=
@@ -802,6 +821,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=
@@ -848,6 +868,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=
@@ -932,6 +953,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=
@@ -996,6 +1018,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=
@@ -1152,6 +1175,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=
@@ -1287,6 +1311,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=
@@ -1341,6 +1366,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=
@@ -1411,6 +1437,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=
@@ -1551,6 +1579,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=
@@ -1601,6 +1630,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=
@@ -1637,6 +1667,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=
@@ -1687,6 +1718,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=
@@ -1758,6 +1790,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=
@@ -1802,6 +1835,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=
@@ -1867,6 +1901,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=
@@ -1878,6 +1913,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=
@@ -1905,6 +1941,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=

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

+ 140 - 32
internal/kubernetes/agent.go

@@ -5,8 +5,10 @@ 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"
@@ -23,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(
@@ -31,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,53 +103,113 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	if err != nil {
 		return fmt.Errorf("Cannot open log stream for pod %s", name)
 	}
+	defer podLogs.Close()
 
 	r := bufio.NewReader(podLogs)
-	for {
-		bytes, err := r.ReadBytes('\n')
-		fmt.Println(bytes)
-		if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
-			return writeErr
+	errorchan := make(chan error)
+
+	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
+			}
+			if err != nil {
+				if err != io.EOF {
+					errorchan <- err
+					return
+				}
+				errorchan <- nil
+				return
 			}
-			return nil
+		}
+	}()
+
+	for {
+		select {
+		case err = <-errorchan:
+			return err
 		}
 	}
 }
 
-// StreamDeploymentStatus streams deployment status.
-func (a *Agent) StreamDeploymentStatus(conn *websocket.Conn) error {
-	fmt.Println("===========================streaming dep status============================")
+// 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()
+	}
 
-	factory := informers.NewSharedInformerFactory(a.Clientset, 0)
-	informer := factory.Apps().V1().Deployments().Informer()
 	stopper := make(chan struct{})
-	defer close(stopper)
-	defer fmt.Println("closing...")
+	errorchan := make(chan error)
+	defer close(errorchan)
 
 	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		AddFunc: func(obj interface{}) {
-			d := obj.(*appsv1.Deployment)
-			fmt.Printf("adding deployment %s\n", d.Name)
-			fmt.Println(d.Status.Replicas == d.Status.AvailableReplicas)
-		},
 		UpdateFunc: func(oldObj, newObj interface{}) {
-			d := newObj.(*appsv1.Deployment)
-			fmt.Printf("updating deployment %s\n", d.Name)
-			fmt.Println(d.Status.Replicas == d.Status.AvailableReplicas)
-			fmt.Println(d.Status.Conditions[0].Message)
-		},
-		DeleteFunc: func(obj interface{}) {
-			d := obj.(*appsv1.Deployment)
-			fmt.Printf("deleting deployment %s\n", d.Name)
-			fmt.Println(d.Status.Replicas == d.Status.AvailableReplicas)
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    newObj,
+				Kind:      strings.ToLower(kind),
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
 		},
 	})
 
-	informer.Run(stopper)
-	return nil
+	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
+		}
+	}
 }

+ 56 - 0
internal/models/templates.go

@@ -0,0 +1,56 @@
+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
+	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"`
+	Tabs        []struct {
+		Name     string `yaml:"name"`
+		Label    string `yaml:"label"`
+		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"`
+	} `yaml:"tabs"`
+}

+ 130 - 0
server/api/deploy_handler.go

@@ -0,0 +1,130 @@
+package api
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"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) {
+	tgt := "hello-porter"
+
+	baseURL := "https://porter-dev.github.io/chart-repo/"
+	resp, err := http.Get(baseURL + "index.yaml")
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	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
+	}
+
+	// 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 == tgt {
+			tgtURL := baseURL + tarURL
+			values, err := processValues(tgtURL)
+			if err != nil {
+				fmt.Println(err)
+				return
+			}
+
+			defaultValues := *values
+			defaultValues["replicaCount"] = 87
+			fmt.Println(defaultValues["replicaCount"])
+			for k := range *values {
+				fmt.Println(k)
+			}
+		}
+	}
+}
+
+func processValues(tgtURL string) (*map[string]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[string]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")
+	}
+}

+ 8 - 5
server/api/k8s_handler.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/api/core/v1"
 
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
@@ -189,7 +190,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)
 
@@ -199,7 +200,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)
 		}
 	}
 
@@ -209,9 +210,9 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleStreamDeployment test calls
+// HandleStreamControllerStatus test calls
 // TODO: Refactor repeated calls.
-func (app *App) HandleStreamDeployment(w http.ResponseWriter, r *http.Request) {
+func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
 
 	// get session to retrieve correct kubeconfig
 	_, err := app.store.Get(r, app.cookieName)
@@ -261,7 +262,9 @@ func (app *App) HandleStreamDeployment(w http.ResponseWriter, r *http.Request) {
 		app.handleErrorUpgradeWebsocket(err, w)
 	}
 
-	err = agent.StreamDeploymentStatus(conn)
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
+	err = agent.StreamControllerStatus(conn, kind)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)

+ 41 - 0
server/api/registry_handler.go

@@ -0,0 +1,41 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-containerregistry/pkg/authn"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+)
+
+// 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])
+}

+ 113 - 0
server/api/registry_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 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 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 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 TestHandleListImages(t *testing.T) {
+	testImagesRequests(t, listImagesTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+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)
+
+	if string(tester.rr.Body.Bytes()) != c.expBody {
+		t.Errorf("Mismatch")
+	}
+}

+ 128 - 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,132 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleGetReleaseControllers retrieves a single release based on a name and revision
+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{
+				UpdateTokenCache: app.updateTokenCache,
+			},
+		},
+		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{
+			UpdateTokenCache: app.updateTokenCache,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+
+	// 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

+ 30 - 2
server/router/router.go

@@ -163,6 +163,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/controllers",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetReleaseControllers, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/releases/{name}/history",
@@ -250,6 +264,20 @@ 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.BasicAuthenticate(requestlog.NewHandler(a.HandleDeployTemplate, l)),
+		)
+
 		// /api/templates routes
 		r.Method(
 			"GET",
@@ -290,10 +318,10 @@ func New(
 
 		r.Method(
 			"GET",
-			"/projects/{project_id}/k8s/deployment/status",
+			"/projects/{project_id}/k8s/{kind}/status",
 			auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveServiceAccountAccess(
-					requestlog.NewHandler(a.HandleStreamDeployment, l),
+					requestlog.NewHandler(a.HandleStreamControllerStatus, l),
 					mw.URLParam,
 					mw.QueryParam,
 				),

Некоторые файлы не были показаны из-за большого количества измененных файлов