Przeglądaj źródła

Merge branch 'staging' of https://github.com/porter-dev/porter

jusrhee 5 lat temu
rodzic
commit
e56baed886
39 zmienionych plików z 3252 dodań i 112 usunięć
  1. 4 2
      dashboard/package-lock.json
  2. 2 0
      dashboard/package.json
  3. 3 1
      dashboard/src/components/Loading.tsx
  4. 2 6
      dashboard/src/components/TabSelector.tsx
  5. 12 0
      dashboard/src/main/Login.tsx
  6. 2 1
      dashboard/src/main/Main.tsx
  7. 12 0
      dashboard/src/main/Register.tsx
  8. 20 3
      dashboard/src/main/home/Home.tsx
  9. 0 2
      dashboard/src/main/home/Toolbar.tsx
  10. 7 4
      dashboard/src/main/home/dashboard/Dashboard.tsx
  11. 3 3
      dashboard/src/main/home/dashboard/chart/ChartList.tsx
  12. 104 68
      dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx
  13. 55 0
      dashboard/src/main/home/dashboard/expanded-chart/GraphSection.tsx
  14. 73 0
      dashboard/src/main/home/dashboard/expanded-chart/ListSection.tsx
  15. 164 0
      dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx
  16. 3 3
      dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx
  17. 2 2
      dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx
  18. 95 0
      dashboard/src/main/home/dashboard/expanded-chart/graph/Edge.tsx
  19. 492 0
      dashboard/src/main/home/dashboard/expanded-chart/graph/GraphDisplay.tsx
  20. 154 0
      dashboard/src/main/home/dashboard/expanded-chart/graph/InfoPanel.tsx
  21. 121 0
      dashboard/src/main/home/dashboard/expanded-chart/graph/Node.tsx
  22. 60 0
      dashboard/src/main/home/dashboard/expanded-chart/graph/SelectRegion.tsx
  23. 7 6
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  24. 7 3
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  25. 12 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  26. 14 5
      dashboard/src/shared/api.tsx
  27. 14 0
      dashboard/src/shared/rosettaStone.tsx
  28. 28 1
      dashboard/src/shared/types.tsx
  29. 43 0
      internal/helm/grapher/object.go
  30. 151 0
      internal/helm/grapher/object_test.go
  31. 70 0
      internal/helm/grapher/parser.go
  32. 350 0
      internal/helm/grapher/relation.go
  33. 274 0
      internal/helm/grapher/relation_test.go
  34. 237 0
      internal/helm/grapher/test_yaml/cassandra.yaml
  35. 119 0
      internal/helm/grapher/test_yaml/ingress.yaml
  36. 446 0
      internal/helm/grapher/test_yaml/kafka.yaml
  37. 35 0
      internal/helm/grapher/test_yaml/volumes.yaml
  38. 54 0
      server/api/release_handler.go
  39. 1 0
      server/router/router.go

+ 4 - 2
dashboard/package-lock.json

@@ -428,7 +428,8 @@
     "@types/qs": {
     "@types/qs": {
       "version": "6.9.5",
       "version": "6.9.5",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz",
-      "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ=="
+      "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==",
+      "dev": true
     },
     },
     "@types/react": {
     "@types/react": {
       "version": "16.9.49",
       "version": "16.9.49",
@@ -5321,7 +5322,8 @@
     "qs": {
     "qs": {
       "version": "6.9.4",
       "version": "6.9.4",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
-      "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
+      "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==",
+      "dev": true
     },
     },
     "querystring": {
     "querystring": {
       "version": "0.2.0",
       "version": "0.2.0",

+ 2 - 0
dashboard/package.json

@@ -28,6 +28,7 @@
     "@testing-library/user-event": "^7.1.2",
     "@testing-library/user-event": "^7.1.2",
     "@types/jest": "^24.0.0",
     "@types/jest": "^24.0.0",
     "@types/node": "^12.12.62",
     "@types/node": "^12.12.62",
+    "@types/qs": "^6.9.5",
     "@types/react": "^16.9.49",
     "@types/react": "^16.9.49",
     "@types/react-dom": "^16.9.8",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-modal": "^3.10.6",
@@ -36,6 +37,7 @@
     "@types/styled-components": "^5.1.3",
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "html-webpack-plugin": "^4.5.0",
+    "qs": "^6.9.4",
     "source-map-loader": "^1.1.0",
     "source-map-loader": "^1.1.0",
     "ts-loader": "^8.0.4",
     "ts-loader": "^8.0.4",
     "typescript": "^4.0.3",
     "typescript": "^4.0.3",

+ 3 - 1
dashboard/src/components/Loading.tsx

@@ -3,6 +3,7 @@ import styled from 'styled-components';
 import loading from '../assets/loading.gif';
 import loading from '../assets/loading.gif';
 
 
 type PropsType = {
 type PropsType = {
+  offset?: string
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -14,7 +15,7 @@ export default class Loading extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     return (
     return (
-      <StyledLoading>
+      <StyledLoading offset={this.props.offset}>
         <Spinner src={loading} />
         <Spinner src={loading} />
       </StyledLoading>
       </StyledLoading>
     );
     );
@@ -31,4 +32,5 @@ const StyledLoading = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
+  margin-top: ${(props: { offset?: string }) => props.offset};
 `;
 `;

+ 2 - 6
dashboard/src/components/TabSelector.tsx

@@ -7,28 +7,24 @@ export interface selectOption {
 }
 }
 
 
 type PropsType = {
 type PropsType = {
+  currentTab: string,
   options: selectOption[],
   options: selectOption[],
   setCurrentTab: (value: string) => void,
   setCurrentTab: (value: string) => void,
   tabWidth?: string  
   tabWidth?: string  
 };
 };
 
 
 type StateType = {
 type StateType = {
-  currentTab: string
 };
 };
 
 
 export default class TabSelector extends Component<PropsType, StateType> {
 export default class TabSelector extends Component<PropsType, StateType> {
-  state = {
-    currentTab: 'overview', 
-  }
 
 
   renderLine = (tab: string): JSX.Element | undefined => {
   renderLine = (tab: string): JSX.Element | undefined => {
-    if (this.state.currentTab === tab) {
+    if (this.props.currentTab === tab) {
       return <Highlight />
       return <Highlight />
     }
     }
   };
   };
 
 
   handleTabClick = (value: string) => {
   handleTabClick = (value: string) => {
-    this.setState({ currentTab: value });
     this.props.setCurrentTab(value);
     this.props.setCurrentTab(value);
   }
   }
 
 

+ 12 - 0
dashboard/src/main/Login.tsx

@@ -25,6 +25,18 @@ export default class Login extends Component<PropsType, StateType> {
     credentialError: false
     credentialError: false
   }
   }
 
 
+  handleKeyDown = (e: any) => {
+    e.key === 'Enter' ? this.handleLogin() : null;
+  }
+
+  componentDidMount() {
+    document.addEventListener("keydown", this.handleKeyDown);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this.handleKeyDown);
+  }
+
   handleLogin = (): void => {
   handleLogin = (): void => {
     let { email, password } = this.state;
     let { email, password } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;

+ 2 - 1
dashboard/src/main/Main.tsx

@@ -32,7 +32,7 @@ export default class Main extends Component<PropsType, StateType> {
   componentDidMount() {
   componentDidMount() {
     let { setUserId } = this.context;
     let { setUserId } = this.context;
     api.checkAuth('', {}, {}, (err: any, res: any) => {
     api.checkAuth('', {}, {}, (err: any, res: any) => {
-      if (res.data) {
+      if (res?.data) {
         setUserId(res.data.id);
         setUserId(res.data.id);
         this.setState({ isLoggedIn: true, initialized: true, loading: false });
         this.setState({ isLoggedIn: true, initialized: true, loading: false });
       } else {
       } else {
@@ -116,6 +116,7 @@ const GlobalStyle = createGlobalStyle`
   }
   }
   body {
   body {
     background: #202227;
     background: #202227;
+    overscroll-behavior-x: none;
   }
   }
 `;
 `;
 
 

+ 12 - 0
dashboard/src/main/Register.tsx

@@ -27,6 +27,18 @@ export default class Register extends Component<PropsType, StateType> {
     confirmPasswordError: false
     confirmPasswordError: false
   }
   }
 
 
+  handleKeyDown = (e: any) => {
+    e.key === 'Enter' ? this.handleRegister() : null;
+  }
+
+  componentDidMount() {
+    document.addEventListener("keydown", this.handleKeyDown);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keydown", this.handleKeyDown);
+  }
+
   handleRegister = (): void => {
   handleRegister = (): void => {
     let { email, password, confirmPassword } = this.state;
     let { email, password, confirmPassword } = this.state;
     let { authenticate } = this.props;
     let { authenticate } = this.props;

+ 20 - 3
dashboard/src/main/home/Home.tsx

@@ -14,14 +14,20 @@ type PropsType = {
 };
 };
 
 
 type StateType = {
 type StateType = {
+  forceSidebar: boolean,
+  showWelcome: boolean
 };
 };
 
 
 export default class Home extends Component<PropsType, StateType> {
 export default class Home extends Component<PropsType, StateType> {
+  state = {
+    forceSidebar: true,
+    showWelcome: false
+  }
 
 
   renderDashboard = () => {
   renderDashboard = () => {
     let { currentCluster, setCurrentModal, setCurrentModalData } = this.context;
     let { currentCluster, setCurrentModal, setCurrentModalData } = this.context;
 
 
-    if (currentCluster === '') {
+    if (currentCluster === '' || this.state.showWelcome) {
       return (
       return (
         <DashboardWrapper>
         <DashboardWrapper>
           <Placeholder>
           <Placeholder>
@@ -41,7 +47,14 @@ export default class Home extends Component<PropsType, StateType> {
       return <Loading />
       return <Loading />
     }
     }
 
 
-    return <DashboardWrapper><Dashboard currentCluster={currentCluster} /></DashboardWrapper>
+    return (
+      <DashboardWrapper>
+        <Dashboard
+          currentCluster={currentCluster}
+          setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+        />
+      </DashboardWrapper>
+    );
   }
   }
 
 
   render() {
   render() {
@@ -56,7 +69,11 @@ export default class Home extends Component<PropsType, StateType> {
           <ClusterConfigModal />
           <ClusterConfigModal />
         </ReactModal>
         </ReactModal>
 
 
-        <Sidebar logOut={this.props.logOut} />
+        <Sidebar
+          logOut={this.props.logOut}
+          forceSidebar={this.state.forceSidebar}
+          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+        />
         <StyledDashboard>
         <StyledDashboard>
           {this.renderDashboard()}
           {this.renderDashboard()}
         </StyledDashboard>
         </StyledDashboard>

+ 0 - 2
dashboard/src/main/home/Toolbar.tsx

@@ -4,7 +4,6 @@ import ReactModal from 'react-modal';
 
 
 import { Context } from '../../shared/Context';
 import { Context } from '../../shared/Context';
 
 
-import Sidebar from './sidebar/Sidebar';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 
 
 type PropsType = {
 type PropsType = {
@@ -27,7 +26,6 @@ export default class Home extends Component<PropsType, StateType> {
           <ClusterConfigModal />
           <ClusterConfigModal />
         </ReactModal>
         </ReactModal>
 
 
-        <Sidebar logOut={this.props.logOut} />
         <DummyDashboard>
         <DummyDashboard>
           🏗️🏗️🏗️🏗️🏗️
           🏗️🏗️🏗️🏗️🏗️
         </DummyDashboard>
         </DummyDashboard>

+ 7 - 4
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 import gradient from '../../../assets/gradient.jpg';
 
 
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
-import { ChartType } from '../../../shared/types';
+import { ChartType, StorageType } from '../../../shared/types';
 import api from '../../../shared/api';
 import api from '../../../shared/api';
 
 
 import ChartList from './chart/ChartList';
 import ChartList from './chart/ChartList';
@@ -11,7 +11,8 @@ import NamespaceSelector from './NamespaceSelector';
 import ExpandedChart from './expanded-chart/ExpandedChart';
 import ExpandedChart from './expanded-chart/ExpandedChart';
 
 
 type PropsType = {
 type PropsType = {
-  currentCluster: string
+  currentCluster: string,
+  setSidebar: (x: boolean) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -36,10 +37,11 @@ export default class Dashboard extends Component<PropsType, StateType> {
   // Allows rollback to update the top-level chart
   // Allows rollback to update the top-level chart
   refreshChart = () => {
   refreshChart = () => {
     let { currentCluster } = this.props;
     let { currentCluster } = this.props;
+    console.log(currentCluster)
     api.getChart('<token>', {
     api.getChart('<token>', {
       namespace: this.state.namespace,
       namespace: this.state.namespace,
       context: currentCluster,
       context: currentCluster,
-      storage: 'secret'
+      storage: StorageType.Secret
     }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
     }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
@@ -50,7 +52,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
   }
   }
 
 
   renderContents = () => {
   renderContents = () => {
-    let { currentCluster } = this.props;
+    let { currentCluster, setSidebar } = this.props;
 
 
     if (this.state.currentChart) {
     if (this.state.currentChart) {
       return (
       return (
@@ -58,6 +60,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
           currentChart={this.state.currentChart}
           currentChart={this.state.currentChart}
           refreshChart={this.refreshChart}
           refreshChart={this.refreshChart}
           setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
           setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
+          setSidebar={setSidebar}
         />
         />
       );
       );
     }
     }

+ 3 - 3
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 
 
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 import api from '../../../../shared/api';
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 
 
 import Chart from './Chart';
 import Chart from './Chart';
 import Loading from '../../../../components/Loading';
 import Loading from '../../../../components/Loading';
@@ -35,12 +35,12 @@ export default class ChartList extends Component<PropsType, StateType> {
       if (this.state.loading) {
       if (this.state.loading) {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       }
       }
-    }, 1000);
+    }, 2000);
 
 
     api.getCharts('<token>', {
     api.getCharts('<token>', {
       namespace: this.props.namespace,
       namespace: this.props.namespace,
       context: currentCluster,
       context: currentCluster,
-      storage: 'secret',
+      storage: StorageType.Secret,
       limit: 20,
       limit: 20,
       skip: 0,
       skip: 0,
       byDate: false,
       byDate: false,

+ 104 - 68
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -2,33 +2,57 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import close from '../../../../assets/close.png';
 import close from '../../../../assets/close.png';
 
 
-import { ChartType } from '../../../../shared/types';
+import { ResourceType, ChartType, StorageType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
 
 
 import TabSelector from '../../../../components/TabSelector';
 import TabSelector from '../../../../components/TabSelector';
 import RevisionSection from './RevisionSection';
 import RevisionSection from './RevisionSection';
 import ValuesYaml from './ValuesYaml';
 import ValuesYaml from './ValuesYaml';
+import GraphSection from './GraphSection';
+import ListSection from './ListSection';
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType,
   currentChart: ChartType,
   setCurrentChart: (x: ChartType | null) => void,
   setCurrentChart: (x: ChartType | null) => void,
-  refreshChart: () => void
+  refreshChart: () => void,
+  setSidebar: (x: boolean) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
   showRevisions: boolean,
   showRevisions: boolean,
-  currentTab: string
+  currentTab: string,
+  components: ResourceType[]
 };
 };
 
 
 const tabOptions = [
 const tabOptions = [
-  { label: 'Chart Overview', value: 'overview' },
+  { label: 'Chart Overview', value: 'graph' },
+  { label: 'Search Chart', value: 'list' },
   { label: 'Values Editor', value: 'values' }
   { label: 'Values Editor', value: 'values' }
 ]
 ]
 
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
   state = {
     showRevisions: false,
     showRevisions: false,
-    currentTab: 'overview'
+    currentTab: 'graph',
+    components: [] as ResourceType[]
+  }
+
+  componentDidMount() {
+    let { currentCluster, setCurrentError } = this.context;
+    let { currentChart } = this.props;
+
+    api.getChartComponents('<token>', {
+      namespace: currentChart.namespace,
+      context: currentCluster,
+      storage: StorageType.Secret
+    }, { name: currentChart.name, revision: 0 }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ components: res.data });
+      }
+    });
   }
   }
 
 
   renderIcon = () => {
   renderIcon = () => {
@@ -49,13 +73,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
   }
 
 
   renderTabContents = () => {
   renderTabContents = () => {
-    let { currentChart, refreshChart } = this.props;
-
-    if (this.state.currentTab === 'overview') {
+    let { currentChart, refreshChart, setSidebar} = this.props;
+    if (this.state.currentTab === 'graph') {
+      return (
+        <GraphSection
+          components={this.state.components}
+          currentChartName={currentChart.name}
+          setSidebar={setSidebar}
+        />
+      );
+    } else if (this.state.currentTab === 'list') {
       return (
       return (
-        <Wrapper>
-          <Placeholder>(Under construction)</Placeholder>
-        </Wrapper>
+        <ListSection
+          currentChart={currentChart}
+          components={this.state.components}
+        />
       );
       );
     }
     }
 
 
@@ -72,76 +104,79 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     let chart = currentChart;
     let chart = currentChart;
 
 
     return ( 
     return ( 
-      <StyledExpandedChart>
-        <TitleSection>
-          <Title>
-            <IconWrapper>
-              {this.renderIcon()}
-            </IconWrapper>
-            {chart.name}
-          </Title>
-          <InfoWrapper>
-            <StatusIndicator>
-              <StatusColor status={chart.info.status} />
-              {chart.info.status}
-            </StatusIndicator>
-
-            <LastDeployed>
-              <Dot>•</Dot>Last deployed {this.readableDate(chart.info.last_deployed)}
-            </LastDeployed>
-          </InfoWrapper>
-
-          <TagWrapper>
-            Namespace
-            <NamespaceTag>
-              {chart.namespace}
-            </NamespaceTag>
-          </TagWrapper>
-        </TitleSection>
-
-        <CloseButton onClick={() => setCurrentChart(null)}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <RevisionSection
-          showRevisions={this.state.showRevisions}
-          toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
-          chart={chart}
-          refreshChart={refreshChart}
-        />
-
-        <TabSelector
-          options={tabOptions}
-          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
-          tabWidth='120px'
-        />
-        <ContentSection>
-          {this.renderTabContents()}
-        </ContentSection>
-      </StyledExpandedChart>
+      <div>
+        <CloseOverlay onClick={() => setCurrentChart(null)}/>
+        <StyledExpandedChart>
+          <HeaderWrapper>
+            <TitleSection>
+              <Title>
+                <IconWrapper>
+                  {this.renderIcon()}
+                </IconWrapper>
+                {chart.name}
+              </Title>
+              <InfoWrapper>
+                <StatusIndicator>
+                  <StatusColor status={chart.info.status} />
+                  {chart.info.status}
+                </StatusIndicator>
+
+                <LastDeployed>
+                  <Dot>•</Dot>Last deployed {this.readableDate(chart.info.last_deployed)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace
+              <NamespaceTag>
+                  {chart.namespace}
+                </NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            <CloseButton onClick={() => setCurrentChart(null)}>
+              <CloseButtonImg src={close} />
+            </CloseButton>
+
+            <RevisionSection
+              showRevisions={this.state.showRevisions}
+              toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
+              chart={chart}
+              refreshChart={refreshChart}
+            />
+
+            <TabSelector
+              options={tabOptions}
+              currentTab={this.state.currentTab}
+              setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+              tabWidth='120px'
+            />
+          </HeaderWrapper>
+          <ContentSection>
+            {this.renderTabContents()}
+          </ContentSection>
+        </StyledExpandedChart>
+      </div>
     );
     );
   }
   }
 }
 }
 
 
 ExpandedChart.contextType = Context;
 ExpandedChart.contextType = Context;
 
 
-const Wrapper = styled.div`
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
-  background: #ffffff11;
-  display: flex;
-  align-items: center;
-  justify-content: center;
 `;
 `;
 
 
-const Placeholder = styled.div`
-  color: #ffffff66;
-  padding-bottom: 30px;
+const HeaderWrapper = styled.div`
+  margin-bottom: 20px;
 `;
 `;
 
 
 const ContentSection = styled.div`
 const ContentSection = styled.div`
   display: flex;
   display: flex;
-  margin-top: 20px;
   border-radius: 5px;
   border-radius: 5px;
   flex: 1;
   flex: 1;
   width: 100%;
   width: 100%;
@@ -194,13 +229,14 @@ const TagWrapper = styled.div`
   border: 1px solid #ffffff44;
   border: 1px solid #ffffff44;
   border-radius: 3px;
   border-radius: 3px;
   padding-left: 5px;
   padding-left: 5px;
+  background: #26282E;
 `;
 `;
 
 
 const NamespaceTag = styled.div`
 const NamespaceTag = styled.div`
   height: 20px;
   height: 20px;
   margin-left: 6px;
   margin-left: 6px;
   color: #aaaabb;
   color: #aaaabb;
-  background: #ffffff22;
+  background: #43454A;
   border-radius: 3px;
   border-radius: 3px;
   font-size: 12px;
   font-size: 12px;
   display: flex;
   display: flex;

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

@@ -0,0 +1,55 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import { ResourceType } from '../../../../shared/types';
+
+import GraphDisplay from './graph/GraphDisplay';
+import Loading from '../../../../components/Loading';
+
+type PropsType = {
+  components: ResourceType[],
+  currentChartName: string,
+  setSidebar: (x: boolean) => void
+};
+
+type StateType = {
+  isExpanded: boolean
+};
+
+export default class GraphSection extends Component<PropsType, StateType> {
+  state = {
+    isExpanded: false
+  }
+
+  renderContents = () => {
+    if (this.props.components && this.props.components.length > 0) {
+      return (
+        <GraphDisplay
+          setSidebar={this.props.setSidebar}
+          components={this.props.components}
+          isExpanded={this.state.isExpanded}
+          currentChartName={this.props.currentChartName}
+        />
+      );
+    }
+
+    return <Loading offset='-30px' />;
+  }
+
+  render() {
+    return (
+      <StyledGraphSection>
+        {this.renderContents()}
+      </StyledGraphSection>
+    );
+  }
+}
+
+GraphSection.contextType = Context;
+
+const StyledGraphSection = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+`;

+ 73 - 0
dashboard/src/main/home/dashboard/expanded-chart/ListSection.tsx

@@ -0,0 +1,73 @@
+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 ResourceItem from './ResourceItem';
+import Loading from '../../../../components/Loading';
+
+type PropsType = {
+  currentChart: ChartType,
+  components: ResourceType[]
+};
+
+type StateType = {
+  showKindLabels: boolean
+};
+
+export default class ListSection extends Component<PropsType, StateType> {
+  state = {
+    showKindLabels: true
+  }
+
+  renderResourceList = () => {
+    return this.props.components.map((resource: ResourceType, i: number) => {
+      return (
+        <ResourceItem
+          key={i}
+          resource={resource}
+          toggleKindLabels={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
+          showKindLabels={this.state.showKindLabels}
+        />
+      );
+    });
+  }
+
+  renderContents = () => {
+    if (this.props.components && this.props.components.length > 0) {
+      return (
+        <ResourceList>
+          {this.renderResourceList()}
+        </ResourceList>
+      );
+    }
+
+    return <Loading offset='-30px' />;
+  }
+
+  render() {
+    return (
+      <StyledListSection>
+        {this.renderContents()}
+      </StyledListSection>
+    );
+  }
+}
+
+ListSection.contextType = Context;
+
+const ResourceList = styled.div`
+  width: 100%;
+  overflow-y: auto;
+  padding-bottom: 150px;
+`;
+
+const StyledListSection = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+  display: flex;
+  position: relative;
+`;

+ 164 - 0
dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx

@@ -0,0 +1,164 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import yaml from 'js-yaml';
+
+import { kindToIcon } from '../../../../shared/rosettaStone';
+
+import { ResourceType } from '../../../../shared/types';
+import YamlEditor from '../../../../components/YamlEditor';
+
+
+type PropsType = {
+  resource: ResourceType,
+  toggleKindLabels: () => void,
+  showKindLabels: boolean
+};
+
+type StateType = {
+  expanded: boolean,
+  RawYAML: string
+};
+
+// A single resource block in the expanded chart list view
+export default class ResourceItem extends Component<PropsType, StateType> {
+  state = {
+    expanded: false,
+    RawYAML: yaml.dump(this.props.resource.RawYAML)
+  }
+
+  renderIcon = (kind: string) => {
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  renderExpanded = () => {
+    if (this.state.expanded) {
+      return (
+        <ExpandWrapper>
+          <YamlEditor
+            value={this.state.RawYAML}
+            onChange={(e: any) => this.setState({ RawYAML: e })}
+            height='300px'
+          />
+        </ExpandWrapper>
+      );
+    }
+  }
+
+  render() {
+    let { resource, showKindLabels, toggleKindLabels } = this.props;
+    return (
+      <StyledResourceItem>
+        <ResourceHeader
+          expanded={this.state.expanded}
+          onClick={() => this.setState({ expanded: !this.state.expanded })}
+        >
+          <i className="material-icons">arrow_right</i>
+
+          <ClickWrapper onClick={toggleKindLabels}>
+            {this.renderIcon(resource.Kind)}
+            {showKindLabels ? `${resource.Kind}` : null}
+          </ClickWrapper>
+
+          <ResourceName
+            showKindLabels={showKindLabels}
+          >
+            {resource.Name}
+          </ResourceName>
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceItem>
+    );
+  }
+}
+
+const ExpandWrapper = styled.div`
+  padding: 12px;
+  animation: expandResource 0.3s;
+  animation-timing-function: ease-out;
+  overflow: hidden;
+  @keyframes expandResource {
+    from { height: 0px }
+    to { height: 300px }
+  }
+`;
+
+const StyledResourceItem = styled.div`
+  border-bottom: 1px solid #606166;
+`;
+
+const BigPlaceholder = styled.div`
+  height: 200px;
+  width: 100%;
+  background: blue;
+`;
+
+const ClickWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: ${(props: { showKindLabels: boolean }) => props.showKindLabels ? '10px' : ''};
+  text-transform: none;
+`;
+
+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 ResourceHeader = styled.div`
+  width: 100%;
+  height: 60px;
+  display: flex;
+  color: #ffffff66;
+  align-items: center;
+  padding: 15px 13px;
+  text-transform: capitalize;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > 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) }
+    }
+  }
+`;

+ 3 - 3
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx

@@ -4,7 +4,7 @@ import loading from '../../../../assets/loading.gif';
 
 
 import api from '../../../../shared/api';
 import api from '../../../../shared/api';
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 import Chart from '../chart/Chart';
 import Chart from '../chart/Chart';
 
 
 type PropsType = {
 type PropsType = {
@@ -33,7 +33,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     api.getRevisions('<token>', {
     api.getRevisions('<token>', {
       namespace: chart.namespace,
       namespace: chart.namespace,
       context: this.context.currentCluster,
       context: this.context.currentCluster,
-      storage: 'secret'
+      storage: StorageType.Secret
     }, { name: chart.name }, (err: any, res: any) => {
     }, { name: chart.name }, (err: any, res: any) => {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
@@ -70,7 +70,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     api.rollbackChart('<token>', {
     api.rollbackChart('<token>', {
       namespace: this.props.chart.namespace,
       namespace: this.props.chart.namespace,
       context: currentCluster,
       context: currentCluster,
-      storage: 'secret',
+      storage: StorageType.Secret,
       revision: revisionNumber
       revision: revisionNumber
     }, {
     }, {
       name: this.props.chart.name
       name: this.props.chart.name

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

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import yaml from 'js-yaml';
 import yaml from 'js-yaml';
 
 
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 import api from '../../../../shared/api';
 import api from '../../../../shared/api';
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
 
 
@@ -47,7 +47,7 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
     api.upgradeChartValues('<token>', {
     api.upgradeChartValues('<token>', {
       namespace: this.props.currentChart.namespace,
       namespace: this.props.currentChart.namespace,
       context: currentCluster,
       context: currentCluster,
-      storage: 'secret',
+      storage: StorageType.Secret,
       values: this.state.values
       values: this.state.values
     }, { name: this.props.currentChart.name }, (err: any, res: any) => {
     }, { name: this.props.currentChart.name }, (err: any, res: any) => {
       if (err) {
       if (err) {

+ 95 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/Edge.tsx

@@ -0,0 +1,95 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { edgeColors } from '../../../../../shared/rosettaStone';
+import { EdgeType } from '../../../../../shared/types';
+
+const thickness = 12;
+
+type PropsType = {
+  x1: number,
+  y1: number,
+  x2: number,
+  y2: number,
+  originX: number,
+  originY: number,
+  edge: EdgeType,
+  setCurrentEdge: (edge: EdgeType) => void
+};
+
+type StateType = {
+  showArrowHead: boolean
+};
+
+export default class Edge extends Component<PropsType, StateType> {
+  state = {
+    showArrowHead: true
+  }
+
+  render() {
+    let { originX, originY, edge, setCurrentEdge } = this.props;
+    let x1 = Math.round(originX + this.props.x1);
+    let x2 = Math.round(originX + this.props.x2);
+    let y1 = Math.round(originY - this.props.y1);
+    let y2 = Math.round(originY - this.props.y2);
+    
+    var length = Math.sqrt(((x2-x1) * (x2-x1)) + ((y2-y1) * (y2-y1)));
+    // center
+    var cx = ((x1 + x2) / 2) - (length / 2);
+    var cy = ((y1 + y2) / 2) - (thickness / 2);
+    // angle
+    var angle = Math.atan2((y1 - y2), (x1 - x2)) * (180 / Math.PI);
+
+    return (
+      <StyledEdge
+        length={length}
+        cx={cx}
+        cy={cy}
+        angle={angle}
+        onMouseEnter={() => setCurrentEdge(edge)}
+        onMouseLeave={() => setCurrentEdge(null)}
+        type={edge.type}
+      >
+        {this.state.showArrowHead ? <ArrowHead color={edgeColors[edge.type]} /> : null}
+        <VisibleLine color={edgeColors[edge.type]} />
+      </StyledEdge>
+    );
+  }
+}
+
+const ArrowHead = styled.div`
+  width: 0; 
+  height: 0;
+  margin-left: 20px;
+  border-top: 5px solid transparent;
+  border-bottom: 5px solid transparent; 
+  border-right: 10px solid ${(props: { color: string }) => props.color ? props.color : '#ffffff66'};
+`;
+
+const VisibleLine = styled.section`
+  height: 2px;
+  width: 100%;
+  background: ${(props: { color: string }) => props.color ? props.color : '#ffffff66'};
+`;
+
+const StyledEdge: any = styled.div.attrs((props: any) => ({
+  style: {
+    top: props.cy + 'px',
+    left: props.cx + 'px',
+    transform: 'rotate(' + props.angle + 'deg)',
+    width: props.length + 'px'
+  },
+}))`
+  position: absolute;
+  height: ${thickness}px;
+  cursor: pointer;
+  z-index: ${(props: { type: string, color: string }) => props.type == 'ControlRel' ? '1' : '0'};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    > section {
+      box-shadow: 0 0 10px #ffffff;
+    }
+  }
+`;

+ 492 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -0,0 +1,492 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ResourceType, NodeType, EdgeType } from '../../../../../shared/types';
+
+import Node from './Node';
+import Edge from './Edge';
+import InfoPanel from './InfoPanel';
+import SelectRegion from './SelectRegion';
+
+const zoomConstant = 0.01;
+const panConstant = 0.8;
+
+type PropsType = {
+  components: ResourceType[],
+  isExpanded: boolean,
+  setSidebar: (x: boolean) => void,
+  currentChartName: string
+};
+
+type StateType = {
+  nodes: NodeType[],
+  edges: EdgeType[],
+  activeIds: number[], // IDs of all currently selected nodes
+  originX: number | null, 
+  originY: number | null,
+  cursorX: number | null,
+  cursorY: number | null,
+  deltaX: number | null, // Dragging bg x-displacement
+  deltaY: number | null, // Dragging y-displacement
+  panX: number | null, // Two-finger pan x-displacement 
+  panY: number | null, // Two-finger pan y-displacement
+  anchorX: number | null, // Initial cursorX during region select
+  anchorY: number | null, // Initial cursorY during region select
+  dragBg: boolean, // Boolean to track if all nodes should move with mouse (bg drag)
+  preventBgDrag: boolean, // Prevents bg drag when moving selected with mouse down
+  relocateAllowed: boolean, // Suppresses movement of selected when drawing select region
+  scale: number,
+  showKindLabels: boolean,
+  currentNode: NodeType | null,
+  currentEdge: EdgeType | null,
+  isExpanded: boolean
+};
+
+// TODO: region-based unselect, shift-click, multi-region
+export default class GraphDisplay extends Component<PropsType, StateType> {
+  state = {
+    nodes: [] as NodeType[],
+    edges: [] as EdgeType[],
+    activeIds: [] as number[],
+    originX: null as (number | null),
+    originY: null as (number | null),
+    cursorX: null as (number | null),
+    cursorY: null as (number | null),
+    deltaX: null as (number | null),
+    deltaY: null as (number | null),
+    panX: null as (number | null),
+    panY: null as (number | null),
+    anchorX: null as (number | null),
+    anchorY: null as (number | null),
+    dragBg: false,
+    preventBgDrag: false,
+    scale: 0.5,
+    showKindLabels: true,
+    currentNode: null as (NodeType | null),
+    currentEdge: null as (EdgeType | null),
+    relocateAllowed: false,
+    isExpanded: false
+  }
+
+  spaceRef: any = React.createRef();
+
+  getRandomIntBetweenRange = (min: number, max: number) => {
+    min = Math.ceil(min);
+    max = Math.floor(max);
+    return Math.floor(Math.random() * (max - min) + min);  
+  }
+
+  componentDidMount() {
+    let { components } = this.props;
+
+    // Initialize origin
+    let height = this.spaceRef.offsetHeight;
+    let width = this.spaceRef.offsetWidth;
+    this.setState({
+      originX: Math.round(width / 2),
+      originY: Math.round(height / 2)
+    });
+
+    // Suppress trackpad gestures
+    this.spaceRef.addEventListener("touchmove", (e: any) => e.preventDefault());
+    this.spaceRef.addEventListener("mousewheel", (e: any) => e.preventDefault());
+
+    let graph = localStorage.getItem(`charts.${this.props.currentChartName}`)
+    let nodes = [] as NodeType[]
+    let edges = [] as EdgeType[]
+
+    if (!graph) {
+      nodes = this.createNodes(components)
+      edges = this.createEdges(components)
+      this.setState({ nodes, edges });
+    } else {
+      let storedState = JSON.parse(localStorage.getItem(`charts.${this.props.currentChartName}`))
+      this.setState(storedState)
+    }
+
+    document.addEventListener("keydown", this.handleKeyDown);
+    document.addEventListener("keyup", this.handleKeyUp);
+  }
+
+  createNodes = (components: ResourceType[]) => {
+    return components.map((c: ResourceType) => {
+      switch(c.Kind) {
+        case "ClusterRoleBinding":
+        case "ClusterRole":
+        case "RoleBinding":
+        case "Role":
+          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-1000, 0), y: this.getRandomIntBetweenRange(0, 500), w: 40, h: 40 };
+        case "Deployment":
+        case "StatefulSet":
+        case "Pod":
+        case "ServiceAccount":
+          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 1000), y: this.getRandomIntBetweenRange(0, 500), w: 40, h: 40 };
+        case "Service":
+        case "Ingress":
+        case "ServiceAccount":
+            return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 1000), y: this.getRandomIntBetweenRange(-500, 0), w: 40, h: 40 };
+        default:
+          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-700, 0), y: this.getRandomIntBetweenRange(-500, 0), w: 40, h: 40 };
+        }
+    });
+  }
+
+  createEdges = (components: ResourceType[]) => {
+    let edges = [] as EdgeType[]
+    components.map((c: ResourceType) => {
+      c.Relations?.ControlRels?.map((rel: any) => {
+        if (rel.Source == c.ID) {
+          edges.push({ type: "ControlRel", source: rel.Source, target: rel.Target });
+        }
+      })
+      c.Relations?.LabelRels?.map((rel: any) => {
+        if (rel.Source == c.ID) {
+          edges.push({ type: "LabelRel", source: rel.Source, target: rel.Target });
+        }
+      })
+      c.Relations?.SpecRels?.map((rel: any) => {
+        if (rel.Source == c.ID) {
+          edges.push({ type: "SpecRel", source: rel.Source, target: rel.Target });
+        }
+      })
+    });
+    return edges
+  }
+
+  componentWillUnmount() {
+    let graph = this.state;
+    console.log("unmounting...", graph)
+    // flush non-persistent data
+    graph.activeIds = [];
+    graph.currentNode = null;
+    graph.currentEdge = null;
+    graph.isExpanded = false;
+
+    localStorage.setItem(`charts.${this.props.currentChartName}`, JSON.stringify(graph))
+    this.spaceRef.removeEventListener("touchmove", (e: any) => e.preventDefault());
+    this.spaceRef.removeEventListener("mousewheel", (e: any) => e.preventDefault());
+    document.removeEventListener("keydown", this.handleKeyDown);
+    document.removeEventListener("keyup", this.handleKeyUp);
+  }
+
+  // Handle shift key for multi-select
+  handleKeyDown = (e: any) => {
+    if (e.key === 'Shift') {
+      this.setState({
+        anchorX: this.state.cursorX,
+        anchorY: this.state.cursorY,
+        relocateAllowed: false
+      });
+    }
+  }
+
+  handleKeyUp = (e: any) => {
+    if (e.key === 'Shift') {
+      this.setState({ anchorX: null, anchorY: null });
+    }
+  }
+
+  // Push to activeIds if not already present
+  handleClickNode = (clickedId: number) => {
+    let holding = this.state.activeIds;
+    if (!holding.includes(clickedId)) {
+      holding.push(clickedId);
+    }
+
+    // Track and store offset to grab node from anywhere (must store)
+    this.state.nodes.forEach((node: NodeType) => {
+      if (this.state.activeIds.includes(node.id)) {
+        if (!node.toCursorX && !node.toCursorY) {
+          node.toCursorX = node.x - this.state.cursorX;
+          node.toCursorY = node.y - this.state.cursorY;
+        } else {
+          node.toCursorX = 0;
+          node.toCursorY = 0;
+        }
+      }
+    });
+
+    this.setState({ activeIds: holding, preventBgDrag: true, relocateAllowed: true });
+  }
+
+  handleReleaseNode = () => {
+    this.setState({ activeIds: [], preventBgDrag: false });
+
+    // Only update dot position state on release for all active
+    let { activeIds, nodes} = this.state;
+    for (var i=0; i < activeIds.length; i++) {
+      var a = activeIds[i];
+      nodes[a].toCursorX = 0;
+      nodes[a].toCursorY = 0;
+    }
+  }
+
+  handleMouseMove = (e: any) => {
+    let { originX, originY, dragBg, preventBgDrag, scale, panX, panY, anchorX, anchorY, nodes, activeIds, relocateAllowed } = this.state;
+    
+    // Suppress navigation gestures
+    if (scale !== 1 || panX !== 0 || panY !== 0) {
+      this.setState({ scale: 1, panX: 0, panY: 0 });
+    }
+
+    // Update origin-centered cursor coordinates
+    let bounds = this.spaceRef.getBoundingClientRect();
+    let cursorX = e.clientX - bounds.left - originX;
+    let cursorY = -(e.clientY - bounds.top - originY);
+    this.setState({ cursorX, cursorY });
+
+    // Track delta for dragging background
+    if (dragBg && !preventBgDrag) {
+      this.setState({ deltaX: e.movementX, deltaY: e.movementY });
+    }
+
+    // Check if within select region 
+    if (anchorX && anchorY) {
+      nodes.forEach((node: NodeType) => {
+        if (node.x > Math.min(anchorX, cursorX) && node.x < Math.max(anchorX, cursorX)
+          && node.y > Math.min(anchorY, cursorY) && node.y < Math.max(anchorY, cursorY)
+        ) {
+          activeIds.push(node.id);
+          this.setState({ activeIds });
+        }
+      });
+    } 
+  }
+
+  // Handle pan XOR zoom (two-finger gestures count as onWheel)
+  handleWheel = (e: any) => {
+
+    // Pinch/zoom sets e.ctrlKey to true
+    if (e.ctrlKey) {
+      var scale = 1;
+      scale -= e.deltaY * zoomConstant;
+      this.setState({ scale, panX: 0, panY: 0 });
+    } else {
+      this.setState({ panX: e.deltaX, panY: e.deltaY, scale: 1 });
+    }
+  };
+
+  toggleExpanded = () => {
+    this.setState({ isExpanded: !this.state.isExpanded }, () => {
+      this.props.setSidebar(!this.state.isExpanded);
+
+      // Update origin on expand/collapse
+      let height = this.spaceRef.offsetHeight;
+      let width = this.spaceRef.offsetWidth;
+      let nudge = 0;
+      if (!this.state.isExpanded) {
+        nudge = 100;
+      }
+      this.setState({
+        originX: Math.round(width / 2) - nudge,
+        originY: Math.round(height / 2)
+      });  
+    });
+  }
+
+  // Pass origin to node for offset
+  renderNodes = () => {
+    let { activeIds, originX, originY, cursorX, cursorY, scale, panX, panY, anchorX, anchorY, relocateAllowed } = this.state;
+
+    return this.state.nodes.map((node: NodeType, i: number) => {
+
+      // Update position if not highlighting and active
+      if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) {
+        node.x = cursorX + node.toCursorX;
+        node.y = cursorY + node.toCursorY;
+      }
+
+      // Apply movement from dragging background
+      if (this.state.dragBg && !this.state.preventBgDrag) {
+        node.x += this.state.deltaX;
+        node.y -= this.state.deltaY;
+      }
+
+      // Apply cursor-centered zoom
+      if (this.state.scale !== 1) {
+        node.x = cursorX + scale * (node.x - cursorX);
+        node.y = cursorY + scale * (node.y - cursorY);
+      }
+
+      // Apply pan 
+      if (this.state.panX !== 0 || this.state.panY !== 0) {
+        node.x -= panConstant * panX;
+        node.y += panConstant * panY;
+      }
+      
+      return (
+        <Node
+          key={i}
+          node={node}
+          originX={originX}
+          originY={originY}
+          nodeMouseDown={() => this.handleClickNode(node.id)}
+          nodeMouseUp={this.handleReleaseNode}
+          isActive={activeIds.includes(node.id)}
+          showKindLabels={this.state.showKindLabels}
+          setCurrentNode={(node: NodeType) => this.setState({ currentNode: node })}
+        />
+      );
+    });
+  }
+
+  renderEdges = () => {
+    return this.state.edges.map((edge: EdgeType, i: number) => {
+      return (
+        <Edge
+          key={i}
+          originX={this.state.originX}
+          originY={this.state.originY}
+          x1={this.state.nodes[edge.source].x}
+          y1={this.state.nodes[edge.source].y}
+          x2={this.state.nodes[edge.target].x}
+          y2={this.state.nodes[edge.target].y}
+          edge={edge}
+          setCurrentEdge={(edge: EdgeType) => this.setState({ currentEdge: edge })}
+        />
+      );
+    });
+  }
+
+  renderSelectRegion = () => {
+    if (this.state.anchorX && this.state.anchorY) {
+      return (
+        <SelectRegion
+          anchorX={this.state.anchorX}
+          anchorY={this.state.anchorY}
+          originX={this.state.originX}
+          originY={this.state.originY}
+          cursorX={this.state.cursorX}
+          cursorY={this.state.cursorY}
+        />
+      );
+    }
+  }
+
+  render() {
+    return (
+      <StyledGraphDisplay
+        isExpanded={this.state.isExpanded}
+        ref={element => this.spaceRef = element}
+        onMouseMove={this.handleMouseMove}
+        onMouseDown={() => this.setState({
+          dragBg: true,
+
+          // Suppress drifting on repeated click
+          deltaX: null,
+          deltaY: null,
+          panX: null,
+          panY: null,
+          scale: 1
+        })}
+        onMouseUp={() => this.setState({ dragBg: false, activeIds: [] })}
+        onWheel={this.handleWheel}
+      >
+        {this.renderNodes()}
+        {this.renderEdges()}
+        {this.renderSelectRegion()}
+
+        <ButtonSection>
+          <ToggleLabel
+            onClick={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
+          >
+            <Checkbox checked={this.state.showKindLabels}>
+                <i className="material-icons">done</i>
+            </Checkbox>
+            Show Type
+          </ToggleLabel>
+          <ExpandButton
+            onClick={this.toggleExpanded}
+          >
+            <i className="material-icons">
+              {this.state.isExpanded ? 'close_fullscreen' : 'open_in_full'}
+            </i>
+          </ExpandButton>
+        </ButtonSection>
+        <InfoPanel
+          currentNode={this.state.currentNode}
+          currentEdge={this.state.currentEdge}
+        />
+      </StyledGraphDisplay>
+    );
+  }
+}
+
+const Checkbox = styled.div`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff44;
+  margin: 0px 8px 0px 3px;
+  border-radius: 3px;
+  background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : ''};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
+  }
+`;
+
+const ToggleLabel = styled.div`
+  font: 12px 'Work Sans';
+  color: #ffffff;
+  position: relative;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-radius: 3px;
+  padding-right: 5px;
+  cursor: pointer;
+  border: 1px solid #ffffff44;
+  :hover {
+    background: #ffffff22;
+
+    > div {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const ButtonSection = styled.div`
+  position: absolute;
+  top: 17px;
+  right: 15px;
+  display: flex;
+  align-items: center;
+`;
+
+const ExpandButton = styled.div`
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  margin-left: 10px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+
+  :hover {
+    background: #ffffff44; 
+  }
+
+  > i {
+    font-size: 14px;
+  }
+`;
+
+const StyledGraphDisplay = styled.div`
+  overflow: hidden;
+  cursor: move;
+  width: ${(props: { isExpanded: boolean }) => props.isExpanded ? '100vw' : '100%'};
+  height: ${(props: { isExpanded: boolean }) => props.isExpanded ? '100vh' : '100%'};
+  background: #202227;
+  position: ${(props: { isExpanded: boolean }) => props.isExpanded ? 'fixed' : 'relative'};
+  top: ${(props: { isExpanded: boolean }) => props.isExpanded ? '-25px' : ''};
+  right: ${(props: { isExpanded: boolean }) => props.isExpanded ? '-25px' : ''};
+`;

+ 154 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/InfoPanel.tsx

@@ -0,0 +1,154 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { kindToIcon, edgeColors } from '../../../../../shared/rosettaStone';
+import { NodeType, EdgeType} from '../../../../../shared/types';
+import Edge from './Edge';
+
+type PropsType = {
+  currentNode: NodeType,
+  currentEdge: EdgeType
+};
+
+type StateType = {
+};
+
+export default class InfoPanel extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderIcon = (kind: string) => {
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  renderColorBlock = (type: string) => {
+    return <ColorBlock color={edgeColors[type]} />;
+  }
+
+  renderContents = () => {
+    let { currentNode, currentEdge } = this.props;
+    if (currentNode) {
+      return (
+        <Div>
+          {this.renderIcon(currentNode.kind)}
+          {currentNode.kind}
+          <ResourceName>
+            {currentNode.name}
+          </ResourceName>
+        </Div>
+      );
+    } else if (currentEdge) {
+      return (
+        <EdgeInfo>
+          {this.renderColorBlock(currentEdge.type)}
+          {this.renderEdgeMessage(currentEdge)}
+        </EdgeInfo>
+      )
+    }
+
+    return (
+      <Div>
+        <IconWrapper>
+          <i className="material-icons">info</i>
+        </IconWrapper>
+        Hover over a node or edge to display info.
+      </Div>
+    )
+  }
+
+  renderEdgeMessage = (edge: EdgeType) => {
+    // TODO: render more information about edges (labels, spec property field)
+    switch(edge.type) {
+      case "ControlRel":
+        return "Controller Relation"
+      case "LabelRel":
+        return "Label Relation"
+      case "SpecRel":
+        return "Spec Relation"
+    }
+  }
+
+  render() {
+    return (
+      <StyledInfoPanel>
+        {this.renderContents()}
+      </StyledInfoPanel>
+    );
+  }
+}
+
+const ColorBlock = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 3px;
+  margin-left: -2px;
+  margin-right: 13px;
+  background: ${(props: { color: string }) => props.color ? props.color : '#ffffff66'};
+`;
+
+const Div = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const EdgeInfo = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: 10px;
+  text-transform: none;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+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 StyledInfoPanel = styled.div`
+  position: absolute;
+  right: 15px;
+  bottom: 15px;
+  color: #ffffff66;
+  height: 40px;
+  width: 400px;
+  max-width: 600px;
+  background: #44444699;
+  border-radius: 3px;
+  padding-left: 20px;
+  display: inline-block;
+  z-index: 999;
+  padding-top: 7px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  padding-right: 13px;
+`;

+ 121 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/Node.tsx

@@ -0,0 +1,121 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { kindToIcon } from '../../../../../shared/rosettaStone';
+import { NodeType } from '../../../../../shared/types';
+
+type PropsType = {
+  node: NodeType,
+  originX: number,
+  originY: number,
+  nodeMouseDown: () => void,
+  nodeMouseUp: () => void,
+  isActive: boolean,
+  showKindLabels: boolean,
+  setCurrentNode: (node: NodeType) => void,
+};
+
+type StateType = {
+};
+
+export default class Node extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    let { x, y, w, h, name, kind } = this.props.node;
+    let { originX, originY, nodeMouseDown, nodeMouseUp, isActive } = this.props;
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+
+    return (
+      <StyledNode
+        x={Math.round(originX + x - (w / 2))}
+        y={Math.round(originY - y - (h / 2))}
+        w={Math.round(w)}
+        h={Math.round(h)}
+        isActive={isActive}
+      >
+        <Kind>
+          {this.props.showKindLabels ? kind : null}
+        </Kind>
+        <NodeBlock 
+          onMouseDown={nodeMouseDown}
+          onMouseUp={nodeMouseUp}
+          onMouseEnter={() => this.props.setCurrentNode(this.props.node)}
+          onMouseLeave={() => this.props.setCurrentNode(null)}
+        >
+          <i className="material-icons">{icon}</i>
+        </NodeBlock>
+        <NodeLabel>
+          {name}
+        </NodeLabel>
+      </StyledNode>
+    );
+  }
+}
+
+const Kind = styled.div`
+  color: #ffffff33;
+  position: absolute;
+  top: -25px;
+  width: 140px;
+  left: -50px;
+  text-align: center;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const NodeLabel = styled.div`
+  position: absolute;
+  bottom: -25px;
+  color: #aaaabb;
+  width: 140px;
+  left: -50px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const NodeBlock = styled.div`
+  background: #444446;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 100px;
+  cursor: grab;
+  :hover {
+    background: #555556;
+  }
+
+  > i {
+    color: white;
+    font-size: 18px;
+  }
+`;
+
+const StyledNode: any = styled.div.attrs((props: NodeType) => ({
+  style: {
+    top: props.y + 'px',
+    left: props.x + 'px',
+    },
+}))`
+  position: absolute;
+  width: ${(props: NodeType) => props.w + 'px'};;
+  height: ${(props: NodeType) => props.h + 'px'};;
+  box-shadow: ${(props: any) => props.isActive ? '0 0 10px #ffffff66' : '0px 0px 10px 2px #00000022'};
+  color: #ffffff22;
+  border-radius: 100px;
+  z-index: 3;
+`;

+ 60 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/SelectRegion.tsx

@@ -0,0 +1,60 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  anchorX: number,
+  anchorY: number,
+  originX: number,
+  originY: number,
+  cursorX: number,
+  cursorY: number
+};
+
+type StateType = {
+};
+
+export default class SelectRegion extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    let { cursorX, cursorY, anchorX, anchorY, originX, originY } = this.props;
+    
+    var x, y, w, h;
+    if (cursorY < anchorY) {
+      y = anchorY;
+    } else {
+      y = cursorY;
+    }
+    if (cursorX < anchorX) {
+      x = cursorX;
+    } else {
+      x = anchorX;
+    }
+
+    w = Math.abs(cursorX - anchorX);
+    h = Math.abs(cursorY - anchorY);
+
+    return (
+      <StyledSelectRegion
+        x={Math.round(originX + x)}
+        y={Math.round(originY - y)}
+        w={w}
+        h={h}
+      />
+    );
+  }
+}
+
+const StyledSelectRegion: any = styled.div.attrs((props: { x: number, y: number, w: number, h: number }) => ({
+  style: {
+    top: props.y + 'px',
+    left: props.x + 'px',
+    width: props.w + 'px',
+    height: props.h + 'px'
+    },
+}))`
+  position: absolute;
+  background: #ffffff22;
+  z-index: 1;
+`;

+ 7 - 6
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -41,7 +41,7 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
     // Parse kubeconfig to retrieve all possible clusters
     // Parse kubeconfig to retrieve all possible clusters
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
       if (err) {
-        setCurrentError(JSON.stringify(err));
+        // setCurrentError(JSON.stringify(err));
       } else {
       } else {
         this.setState({ kubeContexts: res.data });
         this.setState({ kubeContexts: res.data });
       }
       }
@@ -57,7 +57,7 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
 
 
     api.getUser('<token>', {}, { id: userId }, (err: any, res: any) => {
     api.getUser('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
       if (err) {
-        setCurrentError(JSON.stringify(err));
+        // setCurrentError(JSON.stringify(err));
       } else if (res.data.rawKubeConfig !== '') {
       } else if (res.data.rawKubeConfig !== '') {
         this.setState({ rawKubeconfig: res.data.rawKubeConfig });
         this.setState({ rawKubeconfig: res.data.rawKubeConfig });
       }
       }
@@ -207,6 +207,7 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
         </Header>
         </Header>
         <ModalTitle>Connect from Kubeconfig</ModalTitle>
         <ModalTitle>Connect from Kubeconfig</ModalTitle>
         <TabSelector
         <TabSelector
+          currentTab={this.state.currentTab}
           options={tabOptions}
           options={tabOptions}
           setCurrentTab={(value: string) => this.setState({ currentTab: value })}
           setCurrentTab={(value: string) => this.setState({ currentTab: value })}
           tabWidth='120px'
           tabWidth='120px'
@@ -250,10 +251,10 @@ const UploadButton = styled.button`
 `;
 `;
 
 
 const Checkbox = styled.div`
 const Checkbox = styled.div`
-  width: 15px;
-  height: 15px;
+  width: 16px;
+  height: 16px;
   border: 1px solid #ffffff44;
   border: 1px solid #ffffff44;
-  margin: 0px 15px 0px 12px;
+  margin: 1px 15px 0px 12px;
   border-radius: 3px;
   border-radius: 3px;
   background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : ''};
   background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : ''};
   display: flex;
   display: flex;
@@ -262,7 +263,7 @@ const Checkbox = styled.div`
 
 
   > i {
   > i {
     font-size: 12px;
     font-size: 12px;
-    padding-left: 1px;
+    padding-left: 0px;
     display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
     display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
   }
   }
 `;
 `;

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

@@ -10,7 +10,8 @@ import Drawer from './Drawer';
 
 
 type PropsType = {
 type PropsType = {
   forceCloseDrawer: boolean,
   forceCloseDrawer: boolean,
-  releaseDrawer: () => void
+  releaseDrawer: () => void,
+  setWelcome: (x: boolean) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -34,9 +35,12 @@ export default class ClusterSection extends Component<PropsType, StateType> {
     // TODO: query with selected filter once implemented
     // TODO: query with selected filter once implemented
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
     api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
       if (err) {
-        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
-      } else {
 
 
+        // Assume intializing if no contexts
+        this.props.setWelcome(true);
+      } else {
+        this.props.setWelcome(false);
+        
         // TODO: handle uninitialized kubeconfig
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
         if (res.data) {
 
 

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

@@ -8,7 +8,9 @@ import { Context } from '../../../shared/Context';
 import ClusterSection from './ClusterSection';
 import ClusterSection from './ClusterSection';
 
 
 type PropsType = {
 type PropsType = {
-  logOut: () => void
+  logOut: () => void,
+  forceSidebar: boolean,
+  setWelcome: (x: boolean) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -40,6 +42,13 @@ export default class Sidebar extends Component<PropsType, StateType> {
     document.removeEventListener('keyup', this.handleKeyUp);
     document.removeEventListener('keyup', this.handleKeyUp);
   }
   }
 
 
+  // Need to override showDrawer when the sidebar is closed
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.forceSidebar !== this.props.forceSidebar) {
+      this.setState({ showSidebar: this.props.forceSidebar });
+    }
+  }  
+
   handleKeyDown = (e: KeyboardEvent): void => {
   handleKeyDown = (e: KeyboardEvent): void => {
     if (e.key === 'Meta' || e.key === 'Control') {
     if (e.key === 'Meta' || e.key === 'Control') {
       this.setState({ pressingCtrl: true });
       this.setState({ pressingCtrl: true });
@@ -115,6 +124,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
           <ClusterSection 
           <ClusterSection 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
+            setWelcome={this.props.setWelcome}
           />
           />
 
 
           <BottomSection>
           <BottomSection>
@@ -165,7 +175,7 @@ const NavButton = styled.div`
 const BottomSection = styled.div`
 const BottomSection = styled.div`
   position: absolute;
   position: absolute;
   width: 100%;
   width: 100%;
-  bottom: 12px;
+  bottom: 10px;
 `;
 `;
 
 
 const LogOutButton = styled(NavButton)`
 const LogOutButton = styled(NavButton)`

+ 14 - 5
dashboard/src/shared/api.tsx

@@ -43,7 +43,7 @@ const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
 const getCharts = baseApi<{
 const getCharts = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
-  storage: string
+  storage: StorageType,
   limit: number,
   limit: number,
   skip: number,
   skip: number,
   byDate: boolean,
   byDate: boolean,
@@ -53,11 +53,19 @@ const getCharts = baseApi<{
 const getChart = baseApi<{
 const getChart = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
-  storage: string
+  storage: StorageType
 }, { name: string, revision: number }>('GET', pathParams => {
 }, { name: string, revision: number }>('GET', pathParams => {
   return `/api/releases/${pathParams.name}/${pathParams.revision}`;
   return `/api/releases/${pathParams.name}/${pathParams.revision}`;
 });
 });
 
 
+const getChartComponents = baseApi<{
+  namespace: string,
+  context: string,
+  storage: StorageType
+}, { name: string, revision: number }>('GET', pathParams => {
+  return `/api/releases/${pathParams.name}/${pathParams.revision}/components`;
+});
+
 const getNamespaces = baseApi<{
 const getNamespaces = baseApi<{
   context: string
   context: string
 }>('GET', '/api/k8s/namespaces');
 }>('GET', '/api/k8s/namespaces');
@@ -65,7 +73,7 @@ const getNamespaces = baseApi<{
 const getRevisions = baseApi<{
 const getRevisions = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
-  storage: string
+  storage: StorageType
 }, { name: string }>('GET', pathParams => {
 }, { name: string }>('GET', pathParams => {
   return `/api/releases/${pathParams.name}/history`;
   return `/api/releases/${pathParams.name}/history`;
 });
 });
@@ -73,7 +81,7 @@ const getRevisions = baseApi<{
 const rollbackChart = baseApi<{
 const rollbackChart = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
-  storage: string,
+  storage: StorageType,
   revision: number
   revision: number
 }, { name: string }>('POST', pathParams => {
 }, { name: string }>('POST', pathParams => {
   return `/api/releases/${pathParams.name}/rollback`;
   return `/api/releases/${pathParams.name}/rollback`;
@@ -82,7 +90,7 @@ const rollbackChart = baseApi<{
 const upgradeChartValues = baseApi<{
 const upgradeChartValues = baseApi<{
   namespace: string,
   namespace: string,
   context: string,
   context: string,
-  storage: string,
+  storage: StorageType,
   values: string
   values: string
 }, { name: string }>('POST', pathParams => {
 }, { name: string }>('POST', pathParams => {
   return `/api/releases/${pathParams.name}/upgrade`;
   return `/api/releases/${pathParams.name}/upgrade`;
@@ -99,6 +107,7 @@ export default {
   getContexts,
   getContexts,
   getCharts,
   getCharts,
   getChart,
   getChart,
+  getChartComponents,
   getNamespaces,
   getNamespaces,
   getRevisions,
   getRevisions,
   rollbackChart,
   rollbackChart,

+ 14 - 0
dashboard/src/shared/rosettaStone.tsx

@@ -0,0 +1,14 @@
+export const kindToIcon: any = {
+  'Deployment': 'category',
+  'Pod': 'fiber_manual_record',
+  'Service': 'alt_route',
+  'Ingress': 'sensor_door',
+  'StatefulSet': 'location_city',
+  'Secret': 'vpn_key'
+}
+
+export const edgeColors: any = {
+  'LabelRel': '#949EFF',
+  'ControlRel': '#fcb603',
+  'SpecRel': '#32a852'
+};

+ 28 - 1
dashboard/src/shared/types.tsx

@@ -30,8 +30,35 @@ export interface ChartType {
   namespace: string
   namespace: string
 }
 }
 
 
+export interface ResourceType {
+  ID: number,
+  Kind: string,
+  Name: string,
+  RawYAML: Object,
+  Relations: any
+}
+
+export interface NodeType {
+  id: number,
+  name: string,
+  kind: string,
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+  toCursorX?: number,
+  toCursorY?: number
+}
+
+export interface EdgeType {
+  type: string,
+  source: number,
+  target: number
+}
+
+
 export enum StorageType {
 export enum StorageType {
   Secret = 'secret',
   Secret = 'secret',
   ConfigMap = 'configmap',
   ConfigMap = 'configmap',
   Memory = 'memory'
   Memory = 'memory'
-}
+}

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

@@ -0,0 +1,43 @@
+package grapher
+
+// Object contains information about each k8s component in the chart.
+type Object struct {
+	ID        int
+	Kind      string
+	Name      string
+	Namespace string
+	RawYAML   map[string]interface{}
+	Relations Relations
+}
+
+// ParseObjs parses a k8s object from a single-document yaml
+// and returns an array of objects that includes its children.
+func ParseObjs(objs []map[string]interface{}) []Object {
+	objArr := []Object{}
+
+	for i, obj := range objs {
+		kind := getField(obj, "kind").(string)
+		name := getField(obj, "metadata", "name").(string)
+
+		namespace := getField(obj, "metadata", "namespace")
+
+		if namespace == nil {
+			namespace = ""
+		}
+
+		// First add the object that appears on the YAML
+		parsedObj := Object{
+			ID:        i,
+			Kind:      kind,
+			Name:      name,
+			Namespace: namespace.(string),
+			RawYAML:   obj,
+			Relations: Relations{
+				ControlRels: []ControlRel{},
+				LabelRels:   []LabelRel{},
+			},
+		}
+		objArr = append(objArr, parsedObj)
+	}
+	return objArr
+}

+ 151 - 0
internal/helm/grapher/object_test.go

@@ -0,0 +1,151 @@
+package grapher_test
+
+import (
+	"io/ioutil"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/helm/grapher"
+)
+
+// Expected objects for helm Cassandra chart
+var c1 = grapher.Object{
+	Kind: "Secret",
+	Name: "my-release-cassandra",
+}
+
+var c2 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-cassandra-headless",
+	Relations: grapher.Relations{
+		LabelRels: []grapher.LabelRel{
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 1,
+					Target: 4,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 1,
+					Target: 5,
+				},
+			},
+		},
+	},
+}
+
+var c3 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-cassandra",
+	Relations: grapher.Relations{
+		LabelRels: []grapher.LabelRel{
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 2,
+					Target: 4,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 2,
+					Target: 5,
+				},
+			},
+		},
+	},
+}
+
+var c4 = grapher.Object{
+	Kind: "StatefulSet",
+	Name: "my-release-cassandra",
+}
+
+// Expected objects for helm Cassandra chart
+var k1 = grapher.Object{
+	Kind: "ServiceAccount",
+	Name: "my-release-kafka",
+}
+
+var k2 = grapher.Object{
+	Kind: "ConfigMap",
+	Name: "my-release-kafka-scripts",
+}
+
+var k3 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-zookeeper-headless",
+}
+
+var k4 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-zookeeper",
+}
+
+var k5 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-kafka-headless",
+}
+
+var k6 = grapher.Object{
+	Kind: "Service",
+	Name: "my-release-kafka",
+}
+
+var k7 = grapher.Object{
+	Kind: "StatefulSet",
+	Name: "my-release-zookeeper",
+}
+
+var k8 = grapher.Object{
+	Kind: "StatefulSet",
+	Name: "my-release-kafka",
+}
+
+var expObjs1 = []grapher.Object{
+	c1, c2, c3, c4,
+}
+
+var expObjs2 = []grapher.Object{
+	k1, k2, k3, k4,
+	k5, k6, k7, k8,
+}
+
+type k8sObj struct {
+	Expected []grapher.Object
+	FilePath string
+}
+
+func TestParseObj(t *testing.T) {
+	k8sObjs := []k8sObj{
+		k8sObj{
+			Expected: expObjs1,
+			FilePath: "./test_yaml/cassandra.yaml",
+		},
+		k8sObj{
+			Expected: expObjs2,
+			FilePath: "./test_yaml/kafka.yaml",
+		},
+	}
+
+	for _, k8sObj := range k8sObjs {
+		// Load in yaml from test files
+		file, err := ioutil.ReadFile(k8sObj.FilePath)
+
+		if err != nil {
+			t.Errorf("Error reading file %s", k8sObj.FilePath)
+		}
+
+		yamlArr := grapher.ImportMultiDocYAML(file)
+		objects := grapher.ParseObjs(yamlArr)
+
+		for i, o := range objects {
+			if k8sObj.Expected[i].Kind != o.Kind {
+				t.Errorf("Object Kinds are different at position %d. Expected %s, Got %s\n", i, k8sObj.Expected[i].Kind, o.Kind)
+			}
+
+			if k8sObj.Expected[i].Name != o.Name {
+				t.Errorf("Object names are different at position %d. Expected %s, Got %s\n", i, k8sObj.Expected[i].Name, o.Name)
+			}
+		}
+	}
+}

+ 70 - 0
internal/helm/grapher/parser.go

@@ -0,0 +1,70 @@
+package grapher
+
+import (
+	"bytes"
+	"fmt"
+
+	"gopkg.in/yaml.v2"
+)
+
+// ImportMultiDocYAML is a helper function that goes through a yaml file with multiple documents (or objects)
+// separated by '---' or '...' and returns an array of yamls.
+func ImportMultiDocYAML(source []byte) (arr []map[string]interface{}) {
+	dec := yaml.NewDecoder(bytes.NewReader(source))
+
+	for {
+		doc := make(map[interface{}]interface{})
+		if dec.Decode(&doc) != nil {
+			return arr
+		}
+		strmap := recursiveConv(doc).(map[string]interface{})
+		arr = append(arr, strmap)
+	}
+}
+
+// recursive helper function that type asserts each layer of nested interfaces to
+// retrieve child value. Every call of GetField must be accompanied with a type assertion.
+func getField(yaml map[string]interface{}, keys ...string) interface{} {
+	if yaml[keys[0]] == nil {
+		return nil
+	}
+
+	if len(keys) == 1 {
+		return yaml[keys[0]]
+	}
+
+	return getField(yaml[keys[0]].(map[string]interface{}), keys[1:len(keys)]...)
+}
+
+// recursively convert all key values in generic interface{} format into strings.
+// i.e. map[interface{}]interface{} --> map[string]interface{}
+func recursiveConv(m interface{}) interface{} {
+	switch o := m.(type) {
+
+	// quickly skip if object already has strings as keys
+	case map[string]interface{}:
+		for k, v := range o {
+			o[k] = recursiveConv(v)
+		}
+
+	case map[interface{}]interface{}:
+		res := make(map[string]interface{})
+		for k, v := range o {
+			// ✨ sprinkle of efficiency by skipping string keys
+			switch kt := k.(type) {
+			case string:
+				res[kt] = recursiveConv(v)
+			default:
+				res[fmt.Sprint(kt)] = recursiveConv(v)
+			}
+		}
+		m = res
+
+	case []interface{}:
+		for i, v := range o {
+			o[i] = recursiveConv(v)
+		}
+	}
+
+	return m
+}

+ 350 - 0
internal/helm/grapher/relation.go

@@ -0,0 +1,350 @@
+package grapher
+
+import (
+	"strconv"
+)
+
+// Relation describes the relationship between k8s components. Type is one of CostrolRel, LabelRel, AnnotationsRel, SpecRel.
+// Source and Target contains the ID of the k8s component that is either the giver or recipient of a relationship.
+// All relations are bi-directional in that each object contains both the incoming and outbound relationships.
+type Relation struct {
+	Source int
+	Target int
+}
+
+// ControlRel describes the relationship between a controller and its children pod.
+type ControlRel struct {
+	Relation
+	Replicas int
+	Template map[string]interface{}
+}
+
+// LabelRel connects objects with spec.selector with pods that have corresponding metadata.labels.
+type LabelRel struct {
+	Relation
+}
+
+// SpecRel connects objects via various spec properties.
+type SpecRel struct {
+	Relation
+}
+
+// ParsedObjs has methods GetControlRel and GetLabelRel that updates its objects array.
+type ParsedObjs struct {
+	Objects []Object
+}
+
+// Relations is embedded into the Object struct and contains arrays of the three types of relationships.
+type Relations struct {
+	ControlRels []ControlRel
+	LabelRels   []LabelRel
+	SpecRels    []SpecRel
+}
+
+// MatchLabel is used to match Equality label selector.
+type MatchLabel struct {
+	key   string
+	value string
+}
+
+// MatchExpression is used to match Set-based label selectors.
+type MatchExpression struct {
+	key      string
+	operator string // In, NotIn, Exists, DoesNotExist are valid
+	values   []string
+}
+
+// =============== helpers for parsing relationships from YAML ===============
+
+// GetControlRel generates relationships and children objects for common k8s controller types.
+// Note that this only includes controllers whose children are 1) pods and 2) do not have its own YAML.
+// i.e. Children relies entirely on the parent's template. Controllers like CronJob are excluded because its children are not pods.
+func (parsed *ParsedObjs) GetControlRel() {
+	// First collect all children (Pods) that are not included in the yaml as top-level object.
+	children := []Object{}
+	for i, obj := range parsed.Objects {
+		yaml := obj.RawYAML
+
+		switch kind := getField(yaml, "kind").(string); kind {
+		// Parse for all possible controller types
+		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
+			rs := getField(yaml, "spec", "replicas")
+
+			if rs != nil && rs.(int) > 0 {
+				// Add Pods for controller objects
+				template := getField(yaml, "spec", "template").(map[string]interface{})
+				for j := 0; j < rs.(int); j++ {
+					cid := len(parsed.Objects) + len(children)
+					crel := ControlRel{
+						Relation: Relation{
+							Source: obj.ID,
+							Target: cid,
+						},
+						Replicas: rs.(int),
+					}
+
+					pod := Object{
+						ID:        cid,
+						Kind:      "Pod",
+						Name:      obj.Name + "-" + strconv.Itoa(j), // tentative name pre-deploy
+						Namespace: obj.Namespace,
+						RawYAML:   template,
+						Relations: Relations{
+							ControlRels: []ControlRel{
+								crel,
+							},
+						},
+					}
+
+					children = append(children, pod)
+					obj.Relations.ControlRels = append(obj.Relations.ControlRels, crel)
+					parsed.Objects[i] = obj
+				}
+			}
+		}
+	}
+
+	// add children to the objects array at the end.
+	parsed.Objects = append(parsed.Objects, children...)
+}
+
+// GetLabelRel is generates relationships between objects connected by selector-label.
+// It supports both Equality-based and Set-based operators with MatchLabels and MatchExpressions, respectively.
+func (parsed *ParsedObjs) GetLabelRel() {
+	for i, o := range parsed.Objects {
+		// Skip Pods
+		yaml := o.RawYAML
+		matchLabels := []MatchLabel{}
+		matchExpressions := []MatchExpression{}
+
+		// First check for the outdated syntax (matchLabels were added in recent k8s version)
+		if l := getField(yaml, "spec", "selector"); l != nil {
+			simple := true
+			if ml := getField(yaml, "spec", "selector", "matchLabels"); ml != nil {
+				matchLabels = addMatchLabels(matchLabels, ml.(map[string]interface{}))
+				simple = false
+			}
+
+			if me := getField(yaml, "spec", "selector", "matchExpressions"); me != nil {
+				for _, o := range me.([]interface{}) {
+					ot := o.(map[string]interface{})
+					values := []string{}
+					for _, arg := range ot["values"].([]interface{}) {
+						values = append(values, arg.(string))
+					}
+					matchExpressions = append(matchExpressions, MatchExpression{
+						key:      ot["key"].(string),
+						operator: ot["operator"].(string),
+						values:   values,
+					})
+				}
+				simple = false
+			}
+
+			if simple {
+				matchLabels = addMatchLabels(matchLabels, l.(map[string]interface{}))
+			}
+		}
+
+		// Find ID's of targets that match the label selector
+		targetID := parsed.findLabelsBySelector(o.ID, matchLabels, matchExpressions)
+		lrels := o.Relations.LabelRels
+		for _, tid := range targetID {
+			newrel := LabelRel{
+				Relation{
+					Source: o.ID,
+					Target: tid,
+				},
+			}
+			lrels = append(lrels, newrel)
+		}
+
+		parsed.Objects[i].Relations.LabelRels = lrels
+	}
+}
+
+// GetSpecRel draws relationships between two objects that are tied via various fields in their spec.
+func (parsed *ParsedObjs) GetSpecRel() {
+	for i, o := range parsed.Objects {
+		tid := []int{}
+		switch o.Kind {
+		case "ClusterRoleBinding", "RoleBinding":
+			tid = parsed.findRBACTargets(o.ID, o.RawYAML)
+		case "Ingress":
+			rules := getField(o.RawYAML, "spec", "rules")
+			if rules == nil {
+				rules = []interface{}{}
+			}
+			for _, r := range rules.([]interface{}) {
+				paths := getField(r.(map[string]interface{}), "http", "paths")
+				if paths == nil {
+					paths = []interface{}{}
+				}
+				for _, p := range paths.([]interface{}) {
+					// service and resource are mutually exclusive backend types.
+					name := getField(p.(map[string]interface{}), "backend", "serviceName")
+					kind := "Service"
+					if name == nil {
+						name = getField(p.(map[string]interface{}), "backend", "service", "name")
+					}
+					if name == nil {
+						name = getField(p.(map[string]interface{}), "backend", "resource", "name")
+						kind = getField(p.(map[string]interface{}), "backend", "resource", "kind").(string)
+					}
+					tid = parsed.findObjectByNameAndKind(o.ID, name, kind)
+				}
+			}
+		case "StatefulSet":
+			serviceName := getField(o.RawYAML, "spec", "serviceName")
+			tid = append(tid, parsed.findObjectByNameAndKind(o.ID, serviceName, "Service")...)
+		case "Pod":
+			volume := getField(o.RawYAML, "spec", "volumes")
+			imageSecrets := getField(o.RawYAML, "spec", "ImagePullSecrets")
+			serviceAccount := getField(o.RawYAML, "spec", "serviceAccountName")
+
+			if imageSecrets == nil {
+				imageSecrets = []interface{}{}
+			}
+
+			if volume == nil {
+				volume = []interface{}{}
+			}
+
+			for _, sec := range imageSecrets.([]interface{}) {
+				tid = append(tid, parsed.findObjectByNameAndKind(o.ID, sec, "Secret")...)
+			}
+			tid = append(tid, parsed.findObjectByNameAndKind(o.ID, serviceAccount, "ServiceAccount")...)
+
+			for _, v := range volume.([]interface{}) {
+				vt := v.(map[string]interface{})
+				configMap := getField(vt, "configMap", "name")
+				pvc := getField(vt, "persistentVolumeClaim", "claimName")
+				secret := getField(vt, "secret", "secretName")
+
+				tid = append(tid, parsed.findObjectByNameAndKind(o.ID, configMap, "ConfigMap")...)
+				tid = append(tid, parsed.findObjectByNameAndKind(o.ID, pvc, "PersistentVolumeClaim")...)
+				tid = append(tid, parsed.findObjectByNameAndKind(o.ID, secret, "Secret")...)
+			}
+		}
+
+		// Add edges to parent
+		rels := o.Relations.SpecRels
+		for _, id := range tid {
+			newrel := SpecRel{
+				Relation{
+					Source: o.ID,
+					Target: id,
+				},
+			}
+			rels = append(rels, newrel)
+		}
+		parsed.Objects[i].Relations.SpecRels = rels
+
+	}
+}
+
+// SpecRel helpers
+func (parsed *ParsedObjs) findObjectByNameAndKind(parentID int, name interface{}, kind string) []int {
+	targets := []int{}
+
+	if name == nil {
+		return targets
+	}
+
+	name = name.(string)
+	for i, o := range parsed.Objects {
+		newrel := SpecRel{
+			Relation{
+				Source: parentID,
+				Target: o.ID,
+			},
+		}
+
+		if o.Name == name && o.Kind == kind {
+			// Add bidirectional link from children as well.
+			parsed.Objects[i].Relations.SpecRels = append(parsed.Objects[i].Relations.SpecRels, newrel)
+			targets = append(targets, o.ID)
+			return targets
+		}
+	}
+	return targets
+}
+
+func (parsed *ParsedObjs) findRBACTargets(parentID int, yaml map[string]interface{}) []int {
+	roleRef := getField(yaml, "roleRef")
+	subjects := getField(yaml, "subjects")
+	rules := append(subjects.([]interface{}), roleRef)
+	targets := []int{}
+	for i, o := range parsed.Objects {
+		for _, r := range rules {
+			tr := r.(map[string]interface{})
+
+			newrel := SpecRel{
+				Relation{
+					Source: parentID,
+					Target: o.ID,
+				},
+			}
+
+			// first consider case of targets added via subjects, which are namespace scoped.
+			if tr["namespace"] != nil && o.Kind == tr["kind"] &&
+				o.Name == tr["name"] && o.Namespace == tr["namespace"] {
+
+				// Add bidirectional link from children as well.
+				parsed.Objects[i].Relations.SpecRels = append(parsed.Objects[i].Relations.SpecRels, newrel)
+				targets = append(targets, o.ID)
+
+			} else if tr["namespace"] == nil && o.Kind == tr["kind"] && o.Name == tr["name"] {
+				parsed.Objects[i].Relations.SpecRels = append(parsed.Objects[i].Relations.SpecRels, newrel)
+				targets = append(targets, o.ID)
+			}
+		}
+	}
+	return targets
+}
+
+func addMatchLabels(matchLabels []MatchLabel, ml map[string]interface{}) []MatchLabel {
+	for k, v := range ml {
+		matchLabels = append(matchLabels, MatchLabel{
+			key:   k,
+			value: v.(string),
+		})
+	}
+	return matchLabels
+}
+
+// TODO: Implement MatchExpression for set based operations.
+func (parsed *ParsedObjs) findLabelsBySelector(parentID int, ml []MatchLabel, me []MatchExpression) []int {
+	matchedObjs := []int{}
+	for i, o := range parsed.Objects {
+
+		// Only Pods can be selected by spec.selector
+		if o.Kind != "Pod" {
+			continue
+		}
+
+		// find Pods that match labels
+		labels := getField(o.RawYAML, "metadata", "labels")
+		match := 0
+		for _, l := range ml {
+			if labels.(map[string]interface{})[l.key] == l.value {
+				match++
+			}
+		}
+
+		// Returns only if labels meet all conditions of the selector.
+		if match == len(ml) && match > 0 {
+			newrel := LabelRel{
+				Relation{
+					Source: parentID,
+					Target: o.ID,
+				},
+			}
+
+			// Add bidirectional link from children as well.
+			parsed.Objects[i].Relations.LabelRels = append(parsed.Objects[i].Relations.LabelRels, newrel)
+			matchedObjs = append(matchedObjs, o.ID)
+		}
+	}
+	return matchedObjs
+}

+ 274 - 0
internal/helm/grapher/relation_test.go

@@ -0,0 +1,274 @@
+package grapher_test
+
+import (
+	"io/ioutil"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/helm/grapher"
+)
+
+var c7 = grapher.Object{
+	Kind: "StatefulSet",
+	Relations: grapher.Relations{
+		ControlRels: []grapher.ControlRel{
+			grapher.ControlRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 4,
+				},
+				Replicas: 2,
+			},
+			grapher.ControlRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 5,
+				},
+				Replicas: 2,
+			},
+		},
+		LabelRels: []grapher.LabelRel{
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 4,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 5,
+				},
+			},
+		},
+	},
+}
+
+var c5 = grapher.Object{
+	Kind: "Pod",
+	Relations: grapher.Relations{
+		ControlRels: []grapher.ControlRel{
+			grapher.ControlRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 4,
+				},
+				Replicas: 2,
+			},
+		},
+		LabelRels: []grapher.LabelRel{
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 1,
+					Target: 4,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 2,
+					Target: 4,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 4,
+				},
+			},
+		},
+	},
+}
+
+var c6 = grapher.Object{
+	Kind: "Pod",
+	Relations: grapher.Relations{
+		ControlRels: []grapher.ControlRel{
+			grapher.ControlRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 5,
+				},
+				Replicas: 2,
+			},
+		},
+		LabelRels: []grapher.LabelRel{
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 1,
+					Target: 5,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 2,
+					Target: 5,
+				},
+			},
+			grapher.LabelRel{
+				Relation: grapher.Relation{
+					Source: 3,
+					Target: 5,
+				},
+			},
+		},
+	},
+}
+
+var expControlRels1 = []grapher.Object{
+	c1, c2, c3, c7, c5, c6,
+}
+
+type test struct {
+	Expected []grapher.Object
+	FilePath string
+}
+
+func TestControlRels(t *testing.T) {
+	ts := []test{
+		test{
+			Expected: expControlRels1,
+			FilePath: "./test_yaml/cassandra.yaml",
+		},
+	}
+
+	for _, r := range ts {
+		// Load in yaml from test files
+		file, err := ioutil.ReadFile(r.FilePath)
+
+		if err != nil {
+			t.Errorf("Error reading file %s", r.FilePath)
+		}
+
+		yamlArr := grapher.ImportMultiDocYAML(file)
+		objects := grapher.ParseObjs(yamlArr)
+		parsed := grapher.ParsedObjs{
+			Objects: objects,
+		}
+
+		parsed.GetControlRel()
+
+		for i, o := range parsed.Objects {
+			e := r.Expected[i]
+			if len(e.Relations.ControlRels) != len(o.Relations.ControlRels) {
+				t.Errorf("Number of ControlRel differs for %s of type %s. Expected %d. Got %d",
+					e.Name, e.Kind, len(e.Relations.ControlRels), len(o.Relations.ControlRels))
+			}
+
+			for j, crel := range o.Relations.ControlRels {
+				expCrel := e.Relations.ControlRels[j]
+
+				if expCrel.Relation.Source != crel.Relation.Source {
+					t.Errorf("Source in ControlRel differs for %s of type %s. Expected %d. Got %d",
+						o.Name, o.Kind, expCrel.Relation.Source, crel.Relation.Source)
+				}
+
+				if expCrel.Relation.Target != crel.Relation.Target {
+					t.Errorf("Target in ControlRel differs for %s of type %s. Expected %d. Got %d",
+						o.Name, o.Kind, expCrel.Relation.Target, crel.Relation.Target)
+				}
+
+				if expCrel.Replicas != crel.Replicas {
+					t.Errorf("Number of replicas in ControlRel differs for %s of type %s. Expected %d. Got %d",
+						o.Name, o.Kind, expCrel.Replicas, crel.Replicas)
+				}
+			}
+		}
+	}
+}
+
+func TestLabelRels(t *testing.T) {
+	ts := []test{
+		test{
+			Expected: expControlRels1,
+			FilePath: "./test_yaml/cassandra.yaml",
+		},
+	}
+
+	for _, r := range ts {
+		// Load in yaml from test files
+		file, err := ioutil.ReadFile(r.FilePath)
+
+		if err != nil {
+			t.Errorf("Error reading file %s", r.FilePath)
+		}
+
+		yamlArr := grapher.ImportMultiDocYAML(file)
+		objects := grapher.ParseObjs(yamlArr)
+		parsed := grapher.ParsedObjs{
+			Objects: objects,
+		}
+
+		parsed.GetControlRel()
+		parsed.GetLabelRel()
+
+		for i, o := range parsed.Objects {
+			e := r.Expected[i]
+			if len(e.Relations.LabelRels) != len(o.Relations.LabelRels) {
+				t.Errorf("Number of LabelRel differs for %s of type %s. Expected %d. Got %d",
+					e.Name, e.Kind, len(e.Relations.LabelRels), len(o.Relations.LabelRels))
+			}
+
+			for j, rrel := range o.Relations.LabelRels {
+				expRrel := e.Relations.LabelRels[j]
+
+				if expRrel.Relation.Source != rrel.Relation.Source {
+					t.Errorf("Source in ControlRel differs for %s of type %s. Expected %d. Got %d",
+						o.Name, o.Kind, expRrel.Relation.Source, rrel.Relation.Source)
+				}
+
+				if expRrel.Relation.Target != rrel.Relation.Target {
+					t.Errorf("Target in ControlRel differs for %s of type %s. Expected %d. Got %d",
+						o.Name, o.Kind, expRrel.Relation.Target, rrel.Relation.Target)
+				}
+			}
+		}
+	}
+}
+
+func TestSpecRels(t *testing.T) {
+	ts := []test{
+		test{
+			Expected: expControlRels1,
+			FilePath: "./test_yaml/ingress.yaml",
+		},
+	}
+
+	for _, r := range ts {
+		// Load in yaml from test files
+		file, err := ioutil.ReadFile(r.FilePath)
+
+		if err != nil {
+			t.Errorf("Error reading file %s", r.FilePath)
+		}
+
+		yamlArr := grapher.ImportMultiDocYAML(file)
+		objects := grapher.ParseObjs(yamlArr)
+		parsed := grapher.ParsedObjs{
+			Objects: objects,
+		}
+
+		parsed.GetControlRel()
+		parsed.GetSpecRel()
+
+		// for i, o := range parsed.Objects {
+		// e := r.Expected[i]
+		// if len(e.Relations.SpecRels) != len(o.Relations.SpecRels) {
+		// 	t.Errorf("Number of SpecRel differs for %s of type %s. Expected %d. Got %d",
+		// 		e.Name, e.Kind, len(e.Relations.SpecRels), len(o.Relations.SpecRels))
+		// }
+
+		// for j, rrel := range o.Relations.SpecRels {
+		// 	expRrel := e.Relations.SpecRels[j]
+
+		// 	if expRrel.Relation.Source != rrel.Relation.Source {
+		// 		t.Errorf("Source in ControlRel differs for %s of type %s. Expected %d. Got %d",
+		// 			o.Name, o.Kind, expRrel.Relation.Source, rrel.Relation.Source)
+		// 	}
+
+		// 	if expRrel.Relation.Target != rrel.Relation.Target {
+		// 		t.Errorf("Target in ControlRel differs for %s of type %s. Expected %d. Got %d",
+		// 			o.Name, o.Kind, expRrel.Relation.Target, rrel.Relation.Target)
+		// 	}
+		// }
+		// }
+	}
+}

+ 237 - 0
internal/helm/grapher/test_yaml/cassandra.yaml

@@ -0,0 +1,237 @@
+---
+# Source: cassandra/templates/cassandra-secret.yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: my-release-cassandra
+  namespace: default
+  labels:
+    app.kubernetes.io/name: cassandra
+    helm.sh/chart: cassandra-6.0.1
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+type: Opaque
+data:
+  cassandra-password: "SFVZRzR2VU4zaQ=="
+---
+# Source: cassandra/templates/headless-svc.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-cassandra-headless
+  namespace: default
+  labels:
+    app.kubernetes.io/name: cassandra
+    helm.sh/chart: cassandra-6.0.1
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+spec:
+  clusterIP: None
+  publishNotReadyAddresses: true
+  ports:
+    - name: intra
+      port: 7000
+      targetPort: intra
+    - name: tls
+      port: 7001
+      targetPort: tls
+    - name: jmx
+      port: 7199
+      targetPort: jmx
+    - name: cql
+      port: 9042
+      targetPort: cql
+    - name: thrift
+      port: 9160
+      targetPort: thrift
+  selector:
+    app.kubernetes.io/name: cassandra
+    app.kubernetes.io/instance: my-release
+---
+# Source: cassandra/templates/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-cassandra
+  namespace: default
+  labels:
+    app.kubernetes.io/name: cassandra
+    helm.sh/chart: cassandra-6.0.1
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+spec:
+  type: ClusterIP
+  ports:
+    - name: cql
+      port: 9042
+      targetPort: cql
+      nodePort: null
+    - name: thrift
+      port: 9160
+      targetPort: thrift
+      nodePort: null
+    - name: metrics
+      port: 8080
+      nodePort: null
+  selector:
+    app.kubernetes.io/name: cassandra
+    app.kubernetes.io/instance: my-release
+---
+# Source: cassandra/templates/statefulset.yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-release-cassandra
+  namespace: default
+  labels:
+    app.kubernetes.io/name: cassandra
+    helm.sh/chart: cassandra-6.0.1
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+spec:
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: cassandra
+      app.kubernetes.io/instance: my-release
+    matchExpressions:
+      - {key: tier, operator: In, values: [cache]}
+      - {key: environment, operator: NotIn, values: [dev]}
+  serviceName: my-release-cassandra-headless
+  podManagementPolicy: OrderedReady
+  replicas: 2
+  updateStrategy:
+    type: RollingUpdate
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: cassandra
+        helm.sh/chart: cassandra-6.0.1
+        app.kubernetes.io/instance: my-release
+        app.kubernetes.io/managed-by: Helm
+    spec:
+      
+      affinity:
+        podAffinity:
+          
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+            - podAffinityTerm:
+                labelSelector:
+                  matchLabels:
+                    app.kubernetes.io/name: cassandra
+                    app.kubernetes.io/instance: my-release
+                namespaces:
+                  - default
+                topologyKey: kubernetes.io/hostname
+              weight: 1
+        nodeAffinity:
+          
+      securityContext:
+        fsGroup: 1001
+      containers:
+        - name: cassandra
+          command:
+            - bash
+            - -ec
+            - |
+              # Node 0 is the password seeder
+              if [[ $HOSTNAME =~ (.*)-0$ ]]; then
+                  echo "Setting node as password seeder"
+                  export CASSANDRA_PASSWORD_SEEDER=yes
+              else
+                  # Only node 0 will execute the startup initdb scripts
+                  export CASSANDRA_IGNORE_INITDB_SCRIPTS=1
+              fi
+              /opt/bitnami/scripts/cassandra/entrypoint.sh /opt/bitnami/scripts/cassandra/run.sh
+          image: docker.io/bitnami/cassandra:3.11.8-debian-10-r20
+          imagePullPolicy: "IfNotPresent"
+          securityContext:
+            runAsUser: 1001
+          env:
+            - name: BITNAMI_DEBUG
+              value: "false"
+            - name: CASSANDRA_CLUSTER_NAME
+              value: cassandra
+            - name: CASSANDRA_SEEDS
+              value: "my-release-cassandra-0.my-release-cassandra-headless.default.svc.cluster.local"
+            - name: CASSANDRA_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  name: my-release-cassandra
+                  key: cassandra-password
+            - name: POD_IP
+              valueFrom:
+                fieldRef:
+                  fieldPath: status.podIP
+            - name: CASSANDRA_USER
+              value: "cassandra"
+            - name: CASSANDRA_NUM_TOKENS
+              value: "256"
+            - name: CASSANDRA_DATACENTER
+              value: dc1
+            - name: CASSANDRA_ENDPOINT_SNITCH
+              value: SimpleSnitch
+            - name: CASSANDRA_RACK
+              value: rack1
+            - name: CASSANDRA_ENABLE_RPC
+              value: "true"
+          envFrom:
+          livenessProbe:
+            exec:
+              command:
+                - /bin/bash
+                - -ec
+                - |
+                  nodetool status
+            initialDelaySeconds: 60
+            periodSeconds: 30
+            timeoutSeconds: 5
+            successThreshold: 1
+            failureThreshold: 5
+          readinessProbe:
+            exec:
+              command:
+                - /bin/bash
+                - -ec
+                - |
+                  nodetool status | grep -E "^UN\\s+${POD_IP}"
+            initialDelaySeconds: 60
+            periodSeconds: 10
+            timeoutSeconds: 5
+            successThreshold: 1
+            failureThreshold: 5
+          ports:
+            - name: intra
+              containerPort: 7000
+            - name: tls
+              containerPort: 7001
+            - name: jmx
+              containerPort: 7199
+            - name: cql
+              containerPort: 9042
+            - name: thrift
+              containerPort: 9160
+          resources: 
+            limits: {}
+            requests: {}
+          volumeMounts:
+            - name: data
+              mountPath: /bitnami/cassandra
+            
+      volumes:
+      - name: config-volume
+        configMap:
+          name: config-example
+  volumeClaimTemplates:
+    - metadata:
+        name: data
+        labels:
+          app.kubernetes.io/name: cassandra
+          app.kubernetes.io/instance: my-release
+      spec:
+        accessModes:
+          - "ReadWriteOnce"
+        resources:
+          requests:
+            storage: "8Gi"
+

+ 119 - 0
internal/helm/grapher/test_yaml/ingress.yaml

@@ -0,0 +1,119 @@
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: service-ingress
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+  rules:
+  - http:
+      paths:
+      - path: /testpath
+        pathType: Prefix
+        backend:
+          service:
+            name: test
+            port:
+              number: 80
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: test
+spec:
+  type: NodePort
+  selector:
+    app: foo
+  ports:
+  - protocol: TCP
+    port: 80
+    targetPort: 80
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: nginx
+spec:
+  type: NodePort
+  selector:
+    app: foo
+  ports:
+  - protocol: TCP
+    port: 80
+    targetPort: 80
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: resource-ingress
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+  rules:
+  - http:
+      paths:
+      - path: /testpath
+        pathType: Prefix
+        backend:
+          resource:
+            name: resource-test
+            kind: StatefulSet
+            port:
+              number: 80
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: resource-test
+spec:
+  serviceName: "nginx"
+  replicas: 2
+  selector:
+    matchLabels:
+      app: nginx
+  template:
+    metadata:
+      labels:
+        app: nginx
+    spec:
+      volumes:
+        - name: config-vol
+          configMap:
+            name: log-config
+            items:
+              - key: log_level
+                path: log_level
+      containers:
+      - name: nginx
+        image: k8s.gcr.io/nginx-slim:0.8
+        ports:
+        - containerPort: 80
+          name: web
+        volumeMounts:
+        - name: www
+          mountPath: /usr/share/nginx/html
+  volumeClaimTemplates:
+  - metadata:
+      name: www
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 1Gi
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  creationTimestamp: 2016-02-18T18:52:05Z
+  name: log-config
+  namespace: default
+  resourceVersion: "516"
+  uid: b4952dc3-d670-11e5-8cd0-68f728db1985
+data:
+  game.properties: |
+    enemies=aliens
+    lives=3
+    secret.code.lives=30
+  ui.properties: |
+    color.good=purple

+ 446 - 0
internal/helm/grapher/test_yaml/kafka.yaml

@@ -0,0 +1,446 @@
+---
+# Source: kafka/templates/serviceaccount.yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: my-release-kafka
+  labels:
+    app.kubernetes.io/name: kafka
+    helm.sh/chart: kafka-11.8.6
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: kafka
+---
+# Source: kafka/templates/scripts-configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: my-release-kafka-scripts
+  labels:
+    app.kubernetes.io/name: kafka
+    helm.sh/chart: kafka-11.8.6
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+data:
+  setup.sh: |-
+    #!/bin/bash
+
+    ID="${MY_POD_NAME#"my-release-kafka-"}"
+    export KAFKA_CFG_BROKER_ID="$ID"
+
+    exec /entrypoint.sh /run.sh
+---
+# Source: kafka/charts/zookeeper/templates/svc-headless.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-zookeeper-headless
+  namespace: default
+  labels:
+    app.kubernetes.io/name: zookeeper
+    helm.sh/chart: zookeeper-5.21.9
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: zookeeper
+spec:
+  type: ClusterIP
+  clusterIP: None
+  publishNotReadyAddresses: true
+  ports:
+    
+    - name: tcp-client
+      port: 2181
+      targetPort: client
+    
+    
+    - name: follower
+      port: 2888
+      targetPort: follower
+    - name: tcp-election
+      port: 3888
+      targetPort: election
+  selector:
+    app.kubernetes.io/name: zookeeper
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/component: zookeeper
+---
+# Source: kafka/charts/zookeeper/templates/svc.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-zookeeper
+  namespace: default
+  labels:
+    app.kubernetes.io/name: zookeeper
+    helm.sh/chart: zookeeper-5.21.9
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: zookeeper
+spec:
+  type: ClusterIP
+  ports:
+    
+    - name: tcp-client
+      port: 2181
+      targetPort: client
+    
+    
+    - name: follower
+      port: 2888
+      targetPort: follower
+    - name: tcp-election
+      port: 3888
+      targetPort: election
+  selector:
+    app.kubernetes.io/name: zookeeper
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/component: zookeeper
+---
+# Source: kafka/templates/svc-headless.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-kafka-headless
+  labels:
+    app.kubernetes.io/name: kafka
+    helm.sh/chart: kafka-11.8.6
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: kafka
+spec:
+  type: ClusterIP
+  clusterIP: None
+  ports:
+    - name: tcp-client
+      port: 9092
+      protocol: TCP
+      targetPort: kafka-client
+    - name: tcp-internal
+      port: 9093
+      protocol: TCP
+      targetPort: kafka-internal
+  selector:
+    app.kubernetes.io/name: kafka
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/component: kafka
+---
+# Source: kafka/templates/svc.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: my-release-kafka
+  labels:
+    app.kubernetes.io/name: kafka
+    helm.sh/chart: kafka-11.8.6
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: kafka
+spec:
+  type: ClusterIP
+  ports:
+    - name: tcp-client
+      port: 9092
+      protocol: TCP
+      targetPort: kafka-client
+      nodePort: null
+  selector:
+    app.kubernetes.io/name: kafka
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/component: kafka
+---
+# Source: kafka/charts/zookeeper/templates/statefulset.yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-release-zookeeper
+  namespace: default
+  labels:
+    app.kubernetes.io/name: zookeeper
+    helm.sh/chart: zookeeper-5.21.9
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: zookeeper
+    role: zookeeper
+spec:
+  serviceName: my-release-zookeeper-headless
+  replicas: 1
+  podManagementPolicy: Parallel
+  updateStrategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: zookeeper
+      app.kubernetes.io/instance: my-release
+      app.kubernetes.io/component: zookeeper
+  template:
+    metadata:
+      name: my-release-zookeeper
+      labels:
+        app.kubernetes.io/name: zookeeper
+        helm.sh/chart: zookeeper-5.21.9
+        app.kubernetes.io/instance: my-release
+        app.kubernetes.io/managed-by: Helm
+        app.kubernetes.io/component: zookeeper
+    spec:
+      
+      serviceAccountName: default
+      securityContext:
+        fsGroup: 1001
+      containers:
+        - name: zookeeper
+          image: docker.io/bitnami/zookeeper:3.6.2-debian-10-r10
+          imagePullPolicy: "IfNotPresent"
+          securityContext:
+            runAsUser: 1001
+          command:
+            - bash
+            - -ec
+            - |
+                # Execute entrypoint as usual after obtaining ZOO_SERVER_ID based on POD hostname
+                HOSTNAME=`hostname -s`
+                if [[ $HOSTNAME =~ (.*)-([0-9]+)$ ]]; then
+                  ORD=${BASH_REMATCH[2]}
+                  export ZOO_SERVER_ID=$((ORD+1))
+                else
+                  echo "Failed to get index from hostname $HOST"
+                  exit 1
+                fi
+                exec /entrypoint.sh /run.sh
+          resources:
+            requests:
+              cpu: 250m
+              memory: 256Mi
+          env:
+            - name: ZOO_DATA_LOG_DIR
+              value: ""
+            - name: ZOO_PORT_NUMBER
+              value: "2181"
+            - name: ZOO_TICK_TIME
+              value: "2000"
+            - name: ZOO_INIT_LIMIT
+              value: "10"
+            - name: ZOO_SYNC_LIMIT
+              value: "5"
+            - name: ZOO_MAX_CLIENT_CNXNS
+              value: "60"
+            - name: ZOO_4LW_COMMANDS_WHITELIST
+              value: "srvr, mntr, ruok"
+            - name: ZOO_LISTEN_ALLIPS_ENABLED
+              value: "no"
+            - name: ZOO_AUTOPURGE_INTERVAL
+              value: "0"
+            - name: ZOO_AUTOPURGE_RETAIN_COUNT
+              value: "3"
+            - name: ZOO_MAX_SESSION_TIMEOUT
+              value: "40000"
+            - name: ZOO_SERVERS
+              value: my-release-zookeeper-0.my-release-zookeeper-headless.default.svc.cluster.local:2888:3888 
+            - name: ZOO_ENABLE_AUTH
+              value: "no"
+            - name: ZOO_HEAP_SIZE
+              value: "1024"
+            - name: ZOO_LOG_LEVEL
+              value: "ERROR"
+            - name: ALLOW_ANONYMOUS_LOGIN
+              value: "yes"
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  apiVersion: v1
+                  fieldPath: metadata.name
+          ports:
+            
+            - name: client
+              containerPort: 2181
+            
+            
+            - name: follower
+              containerPort: 2888
+            - name: election
+              containerPort: 3888
+          livenessProbe:
+            exec:
+              command: ['/bin/bash', '-c', 'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok']
+            initialDelaySeconds: 30
+            periodSeconds: 10
+            timeoutSeconds: 5
+            successThreshold: 1
+            failureThreshold: 6
+          readinessProbe:
+            exec:
+              command: ['/bin/bash', '-c', 'echo "ruok" | timeout 2 nc -w 2 localhost 2181 | grep imok']
+            initialDelaySeconds: 5
+            periodSeconds: 10
+            timeoutSeconds: 5
+            successThreshold: 1
+            failureThreshold: 6
+          volumeMounts:
+            - name: data
+              mountPath: /bitnami/zookeeper
+      volumes:
+  volumeClaimTemplates:
+    - metadata:
+        name: data
+        annotations:
+      spec:
+        accessModes:
+          - "ReadWriteOnce"
+        resources:
+          requests:
+            storage: "8Gi"
+---
+# Source: kafka/templates/statefulset.yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: my-release-kafka
+  labels:
+    app.kubernetes.io/name: kafka
+    helm.sh/chart: kafka-11.8.6
+    app.kubernetes.io/instance: my-release
+    app.kubernetes.io/managed-by: Helm
+    app.kubernetes.io/component: kafka
+spec:
+  podManagementPolicy: Parallel
+  replicas: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: kafka
+      app.kubernetes.io/instance: my-release
+      app.kubernetes.io/component: kafka
+  serviceName: my-release-kafka-headless
+  updateStrategy:
+    type: "RollingUpdate"
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: kafka
+        helm.sh/chart: kafka-11.8.6
+        app.kubernetes.io/instance: my-release
+        app.kubernetes.io/managed-by: Helm
+        app.kubernetes.io/component: kafka
+    spec:      
+      securityContext:
+        fsGroup: 1001
+        runAsUser: 1001
+      serviceAccountName: my-release-kafka
+      containers:
+        - name: kafka
+          image: docker.io/bitnami/kafka:2.6.0-debian-10-r30
+          imagePullPolicy: "IfNotPresent"
+          command:
+            - /scripts/setup.sh
+          env:
+            - name: BITNAMI_DEBUG
+              value: "false"
+            - name: MY_POD_IP
+              valueFrom:
+                fieldRef:
+                  fieldPath: status.podIP
+            - name: MY_POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+            - name: KAFKA_CFG_ZOOKEEPER_CONNECT
+              value: "my-release-zookeeper"
+            - name: KAFKA_INTER_BROKER_LISTENER_NAME
+              value: "INTERNAL"
+            - name: KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP
+              value: "INTERNAL:PLAINTEXT,CLIENT:PLAINTEXT"
+            - name: KAFKA_CFG_LISTENERS
+              value: "INTERNAL://:9093,CLIENT://:9092"
+            - name: KAFKA_CFG_ADVERTISED_LISTENERS
+              value: "INTERNAL://$(MY_POD_NAME).my-release-kafka-headless.default.svc.cluster.local:9093,CLIENT://$(MY_POD_NAME).my-release-kafka-headless.default.svc.cluster.local:9092"
+            - name: ALLOW_PLAINTEXT_LISTENER
+              value: "yes"
+            - name: KAFKA_CFG_DELETE_TOPIC_ENABLE
+              value: "false"
+            - name: KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE
+              value: "true"
+            - name: KAFKA_HEAP_OPTS
+              value: "-Xmx1024m -Xms1024m"
+            - name: KAFKA_CFG_LOG_FLUSH_INTERVAL_MESSAGES
+              value: "10000"
+            - name: KAFKA_CFG_LOG_FLUSH_INTERVAL_MS
+              value: "1000"
+            - name: KAFKA_CFG_LOG_RETENTION_BYTES
+              value: "1073741824"
+            - name: KAFKA_CFG_LOG_RETENTION_CHECK_INTERVALS_MS
+              value: "300000"
+            - name: KAFKA_CFG_LOG_RETENTION_HOURS
+              value: "168"
+            - name: KAFKA_CFG_MESSAGE_MAX_BYTES
+              value: "1000012"
+            - name: KAFKA_CFG_LOG_SEGMENT_BYTES
+              value: "1073741824"
+            - name: KAFKA_CFG_LOG_DIRS
+              value: "/bitnami/kafka/data"
+            - name: KAFKA_CFG_DEFAULT_REPLICATION_FACTOR
+              value: "1"
+            - name: KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR
+              value: "1"
+            - name: KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR
+              value: "1"
+            - name: KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR
+              value: "1"
+            - name: KAFKA_CFG_NUM_IO_THREADS
+              value: "8"
+            - name: KAFKA_CFG_NUM_NETWORK_THREADS
+              value: "3"
+            - name: KAFKA_CFG_NUM_PARTITIONS
+              value: "1"
+            - name: KAFKA_CFG_NUM_RECOVERY_THREADS_PER_DATA_DIR
+              value: "1"
+            - name: KAFKA_CFG_SOCKET_RECEIVE_BUFFER_BYTES
+              value: "102400"
+            - name: KAFKA_CFG_SOCKET_REQUEST_MAX_BYTES
+              value: "104857600"
+            - name: KAFKA_CFG_SOCKET_SEND_BUFFER_BYTES
+              value: "102400"
+            - name: KAFKA_CFG_ZOOKEEPER_CONNECTION_TIMEOUT_MS
+              value: "6000"
+          ports:
+            - name: kafka-client
+              containerPort: 9092
+            - name: kafka-internal
+              containerPort: 9093
+          livenessProbe:
+            tcpSocket:
+              port: kafka-client
+            initialDelaySeconds: 10
+            timeoutSeconds: 5
+            failureThreshold: 
+            periodSeconds: 
+            successThreshold: 
+          readinessProbe:
+            tcpSocket:
+              port: kafka-client
+            initialDelaySeconds: 5
+            timeoutSeconds: 5
+            failureThreshold: 6
+            periodSeconds: 
+            successThreshold: 
+          resources:
+            limits: {}
+            requests: {}
+          volumeMounts:
+            - name: data
+              mountPath: /bitnami/kafka
+            - name: scripts
+              mountPath: /scripts/setup.sh
+              subPath: setup.sh
+      volumes:
+        - name: scripts
+          configMap:
+            name: my-release-kafka-scripts
+            defaultMode: 0755
+  volumeClaimTemplates:
+    - metadata:
+        name: data
+      spec:
+        accessModes:
+          - "ReadWriteOnce"
+        resources:
+          requests:
+            storage: "8Gi"
+

+ 35 - 0
internal/helm/grapher/test_yaml/volumes.yaml

@@ -0,0 +1,35 @@
+---
+apiVersion: v1
+kind: Pod
+metadata:
+  name: configmap-pod
+spec:
+  containers:
+    - name: test
+      image: busybox
+      volumeMounts:
+        - name: config-vol
+          mountPath: /etc/config
+  volumes:
+    - name: config-vol
+      configMap:
+        name: log-config
+        items:
+          - key: log_level
+            path: log_level
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  creationTimestamp: 2016-02-18T18:52:05Z
+  name: log-config
+  namespace: default
+  resourceVersion: "516"
+  uid: b4952dc3-d670-11e5-8cd0-68f728db1985
+data:
+  game.properties: |
+    enemies=aliens
+    lives=3
+    secret.code.lives=30
+  ui.properties: |
+    color.good=purple

+ 54 - 0
server/api/release_handler.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/grapher"
 )
 )
 
 
 // Enumeration of release API error codes, represented as int64
 // Enumeration of release API error codes, represented as int64
@@ -97,6 +98,59 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
+// HandleGetReleaseComponents retrieves a single release based on a 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)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		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
+	}
+
+	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
+	objects := grapher.ParseObjs(yamlArr)
+
+	parsed := grapher.ParsedObjs{
+		Objects: objects,
+	}
+
+	parsed.GetControlRel()
+	parsed.GetLabelRel()
+	parsed.GetSpecRel()
+
+	if err := json.NewEncoder(w).Encode(parsed.Objects); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")

+ 1 - 0
server/router/router.go

@@ -29,6 +29,7 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 
 
 		// /api/releases routes
 		// /api/releases routes
 		r.Method("GET", "/releases", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleases, l)))
 		r.Method("GET", "/releases", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleases, l)))
+		r.Method("GET", "/releases/{name}/{revision}/components", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetReleaseComponents, l)))
 		r.Method("GET", "/releases/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleaseHistory, l)))
 		r.Method("GET", "/releases/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleaseHistory, l)))
 		r.Method("POST", "/releases/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeRelease, l)))
 		r.Method("POST", "/releases/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeRelease, l)))
 		r.Method("GET", "/releases/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetRelease, l)))
 		r.Method("GET", "/releases/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetRelease, l)))