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

Merge pull request #93 from porter-dev/frontend-paas

Frontend paas
jusrhee 5 лет назад
Родитель
Сommit
2eb63f837e

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

@@ -0,0 +1,4 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M20.0943 2.5H24.3268C26.0796 2.5 27.4999 3.93231 27.4999 5.69995V9.96816C27.4999 11.7358 26.0796 13.1681 24.3268 13.1681H20.0943C18.3415 13.1681 16.9211 11.7358 16.9211 9.96816V5.69995C16.9211 3.93231 18.3415 2.5 20.0943 2.5Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5.67316 2.5H9.90561C11.6584 2.5 13.0788 3.93231 13.0788 5.69995V9.96816C13.0788 11.7358 11.6584 13.1681 9.90561 13.1681H5.67316C3.92032 13.1681 2.5 11.7358 2.5 9.96816V5.69995C2.5 3.93231 3.92032 2.5 5.67316 2.5ZM5.67316 16.8319H9.90561C11.6584 16.8319 13.0788 18.2642 13.0788 20.0318V24.3C13.0788 26.0665 11.6584 27.5 9.90561 27.5H5.67316C3.92032 27.5 2.5 26.0665 2.5 24.3V20.0318C2.5 18.2642 3.92032 16.8319 5.67316 16.8319ZM24.3268 16.8319H20.0944C18.3415 16.8319 16.9212 18.2642 16.9212 20.0318V24.3C16.9212 26.0665 18.3415 27.5 20.0944 27.5H24.3268C26.0797 27.5 27.5 26.0665 27.5 24.3V20.0318C27.5 18.2642 26.0797 16.8319 24.3268 16.8319Z" fill="white"/>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.6909 6.94442C12.7546 7.07401 12.7967 7.21278 12.8155 7.35544L13.1635 12.5303L13.3363 15.1314C13.3381 15.3988 13.38 15.6646 13.4608 15.9201C13.6695 16.4157 14.1715 16.7307 14.7176 16.7088L23.0392 16.1644C23.3995 16.1585 23.7475 16.2933 24.0065 16.5391C24.2224 16.744 24.3618 17.012 24.4057 17.3002L24.4204 17.4752C24.0761 22.2436 20.5739 26.2208 15.8154 27.2475C11.0569 28.2742 6.17733 26.1054 3.82589 21.9186C3.14798 20.7023 2.72456 19.3653 2.58048 17.9862C2.52029 17.578 2.49378 17.1656 2.50122 16.7532C2.49379 11.6409 6.13434 7.22121 11.2304 6.15572C11.8438 6.06022 12.445 6.38492 12.6909 6.94442Z" fill="white"/>
+<path opacity="0.4" d="M16.0875 2.50102C21.7873 2.64603 26.5779 6.74474 27.5 12.2654L27.4912 12.3061L27.466 12.3653L27.4695 12.528C27.4564 12.7434 27.3733 12.9507 27.2299 13.1181C27.0806 13.2925 26.8766 13.4113 26.652 13.4574L26.515 13.4762L16.914 14.0983C16.5946 14.1298 16.2766 14.0268 16.0391 13.8149C15.8412 13.6384 15.7147 13.4001 15.679 13.1433L15.0345 3.55633C15.0233 3.52391 15.0233 3.48877 15.0345 3.45635C15.0433 3.19209 15.1597 2.9423 15.3575 2.76279C15.5554 2.58327 15.8183 2.489 16.0875 2.50102Z" fill="white"/>
+</svg>

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

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M22 12.0048C22 17.5137 17.5116 22 12 22C6.48842 22 2 17.5137 2 12.0048C2 6.48625 6.48842 2 12 2C17.5116 2 22 6.48625 22 12.0048Z" fill="white"/>
+<path d="M16 12.0049C16 12.2576 15.9205 12.5113 15.7614 12.7145C15.7315 12.7543 15.5923 12.9186 15.483 13.0255L15.4233 13.0838C14.5881 13.9694 12.5099 15.3011 11.456 15.7278C11.456 15.7375 10.8295 15.9913 10.5312 16H10.4915C10.0341 16 9.60653 15.7482 9.38778 15.34C9.26847 15.1154 9.15909 14.4642 9.14915 14.4554C9.05966 13.8712 9 12.9769 9 11.9951C9 10.9657 9.05966 10.0316 9.16903 9.45808C9.16903 9.44836 9.27841 8.92345 9.34801 8.74848C9.45739 8.49672 9.65625 8.2819 9.90483 8.14581C10.1037 8.04957 10.3125 8 10.5312 8C10.7599 8.01069 11.1875 8.15553 11.3565 8.22357C12.4702 8.65128 14.598 10.051 15.4134 10.9064C15.5526 11.0425 15.7017 11.2087 15.7415 11.2467C15.9105 11.4605 16 11.723 16 12.0049Z" fill="white"/>
+</svg>

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

@@ -0,0 +1,6 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M8.38194 16.1132C7.7368 16.1132 7.21454 16.6432 7.21454 17.2979L6.8927 23.0213C6.8927 23.8557 7.55979 24.5312 8.38194 24.5312C9.20409 24.5312 9.86972 23.8557 9.86972 23.0213L9.54934 17.2979C9.54934 16.6432 9.02708 16.1132 8.38194 16.1132Z" fill="white"/>
+<path d="M9.97546 4.59181C9.97546 4.59181 9.64045 4.24737 9.43272 4.09741C9.13136 3.8658 8.75978 3.75 8.38967 3.75C7.9742 3.75 7.58799 3.88065 7.27347 4.12711C7.21642 4.18501 6.97357 4.40326 6.77315 4.60666C5.51505 5.79588 3.45674 8.90033 2.82769 10.526C2.72821 10.7725 2.51317 11.3961 2.5 11.7301C2.5 12.0478 2.57022 12.3537 2.71358 12.6432C2.914 13.0054 3.22853 13.2964 3.60011 13.4553C3.85758 13.5577 4.62853 13.7166 4.64316 13.7166C5.48726 13.8769 6.85947 13.9631 8.37504 13.9631C9.81893 13.9631 11.1341 13.8769 11.9913 13.7463C12.006 13.7314 12.9627 13.5726 13.2919 13.3974C13.8917 13.0782 14.2647 12.4546 14.2647 11.788V11.7301C14.2501 11.2951 13.877 10.3805 13.8639 10.3805C13.2348 8.84242 11.276 5.81072 9.97546 4.59181Z" fill="white"/>
+<path opacity="0.4" d="M21.6184 13.887C22.2635 13.887 22.7858 13.357 22.7858 12.7022L23.1062 6.97869C23.1062 6.14429 22.4405 5.46875 21.6184 5.46875C20.7962 5.46875 20.1292 6.14429 20.1292 6.97869L20.451 12.7022C20.451 13.357 20.9732 13.887 21.6184 13.887Z" fill="white"/>
+<path d="M27.2865 17.3566C27.0861 16.9944 26.7715 16.7048 26.3999 16.5445C26.1425 16.4421 25.3701 16.2832 25.3569 16.2832C24.5128 16.1228 23.1406 16.0367 21.625 16.0367C20.1811 16.0367 18.866 16.1228 18.0087 16.2535C17.9941 16.2683 17.0373 16.4287 16.7082 16.6024C16.1069 16.9216 15.7354 17.5452 15.7354 18.2133V18.2712C15.75 18.7062 16.1216 19.6193 16.1362 19.6193C16.7652 21.1575 18.7226 24.1907 20.0246 25.4082C20.0246 25.4082 20.3596 25.7526 20.5673 25.9011C20.8672 26.1342 21.2388 26.25 21.6119 26.25C22.0259 26.25 22.4106 26.1194 22.7266 25.8729C22.7836 25.815 23.0265 25.5967 23.2269 25.3948C24.4835 24.2041 26.5433 21.0996 27.1709 19.4753C27.2718 19.2288 27.4869 18.6038 27.5001 18.2712C27.5001 17.952 27.4298 17.6461 27.2865 17.3566Z" fill="white"/>
+</svg>  

+ 8 - 21
dashboard/src/components/TabSelector.tsx

@@ -10,7 +10,8 @@ type PropsType = {
   currentTab: string,
   currentTab: string,
   options: selectOption[],
   options: selectOption[],
   setCurrentTab: (value: string) => void,
   setCurrentTab: (value: string) => void,
-  addendum?: any
+  addendum?: any,
+  color?: string
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -22,6 +23,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
   }
 
 
   renderTabList = () => {
   renderTabList = () => {
+    let color = this.props.color || '#949effcc';
     return (
     return (
       this.props.options.map((option: selectOption, i: number) => {
       this.props.options.map((option: selectOption, i: number) => {
         return (
         return (
@@ -29,7 +31,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
             key={i}
             key={i}
             onClick={() => this.handleTabClick(option.value)}
             onClick={() => this.handleTabClick(option.value)}
             lastItem={i === this.props.options.length - 1}
             lastItem={i === this.props.options.length - 1}
-            highlight={option.value === this.props.currentTab}
+            highlight={option.value === this.props.currentTab ? color : null}
           >
           >
             {option.label}
             {option.label}
           </Tab>
           </Tab>
@@ -48,29 +50,14 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
   }
 }
 }
 
 
-const Highlight = styled.div`
-  width: 80%;
-  height: 1px;
-  margin-top: 5px;
-  background: #949EFFcc00;
-
-  opacity: 0;
-  animation: lineEnter 0.5s 0s;
-  animation-fill-mode: forwards;
-  @keyframes lineEnter {
-    from { width: 0%; opacity: 0; }
-    to   { width: 80%; opacity: 1; }
-  }
-`; 
-
 const Tab = styled.div`
 const Tab = styled.div`
   height: 30px;
   height: 30px;
-  margin-right: ${(props: { lastItem: boolean, highlight: boolean }) => props.lastItem ? '' : '30px'};
+  margin-right: ${(props: { lastItem: boolean, highlight: string }) => props.lastItem ? '' : '30px'};
   display: flex;
   display: flex;
   font-family: 'Work Sans', sans-serif;
   font-family: 'Work Sans', sans-serif;
   font-size: 13px;
   font-size: 13px;
   user-select: none;
   user-select: none;
-  color: ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '#949effcc' : '#aaaabb55'};
+  color: ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? props.highlight : '#aaaabb55'};
   flex-direction: column;
   flex-direction: column;
   padding-top: 7px;
   padding-top: 7px;
   padding-bottom: 2px;
   padding-bottom: 2px;
@@ -78,9 +65,9 @@ const Tab = styled.div`
   align-items: center;
   align-items: center;
   cursor: pointer;
   cursor: pointer;
   white-space: nowrap;
   white-space: nowrap;
-  border-bottom: 1px solid ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '#949effcc' : 'none'};
+  border-bottom: 1px solid ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? props.highlight : 'none'};
   :hover {
   :hover {
-    color: ${(props: { lastItem: boolean, highlight: boolean }) => props.highlight ? '' : '#aaaabb'};
+    color: ${(props: { lastItem: boolean, highlight: string }) => props.highlight ? '' : '#aaaabb'};
   }
   }
 `;
 `;
 
 

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

@@ -8,6 +8,7 @@ import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import Dashboard from './dashboard/Dashboard';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 import Loading from '../../components/Loading';
 import Loading from '../../components/Loading';
+import Templates from './templates/Templates';
 
 
 type PropsType = {
 type PropsType = {
   logOut: () => void
   logOut: () => void
@@ -15,13 +16,15 @@ type PropsType = {
 
 
 type StateType = {
 type StateType = {
   forceSidebar: boolean,
   forceSidebar: boolean,
-  showWelcome: boolean
+  showWelcome: boolean,
+  currentView: string,
 };
 };
 
 
 export default class Home extends Component<PropsType, StateType> {
 export default class Home extends Component<PropsType, StateType> {
   state = {
   state = {
     forceSidebar: true,
     forceSidebar: true,
-    showWelcome: false
+    showWelcome: false,
+    currentView: 'dashboard'
   }
   }
 
 
   renderDashboard = () => {
   renderDashboard = () => {
@@ -57,6 +60,18 @@ export default class Home extends Component<PropsType, StateType> {
     );
     );
   }
   }
 
 
+  renderContents = () => {
+    if (this.state.currentView === 'dashboard') {
+      return (
+        <StyledDashboard>
+          {this.renderDashboard()}
+        </StyledDashboard>
+      );
+    }
+
+    return <Templates />
+  }
+
   render() {
   render() {
     return (
     return (
       <StyledHome>
       <StyledHome>
@@ -73,10 +88,10 @@ export default class Home extends Component<PropsType, StateType> {
           logOut={this.props.logOut}
           logOut={this.props.logOut}
           forceSidebar={this.state.forceSidebar}
           forceSidebar={this.state.forceSidebar}
           setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
           setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
         />
-        <StyledDashboard>
-          {this.renderDashboard()}
-        </StyledDashboard>
+        
+        {this.renderContents()}
       </StyledHome>
       </StyledHome>
     );
     );
   }
   }

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -43,7 +43,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
       storage: StorageType.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);
       } else {
       } else {
         this.setState({ currentChart: res.data });
         this.setState({ currentChart: res.data });
       }
       }

+ 43 - 14
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -33,13 +33,17 @@ const tabOptions = [
   { label: 'Chart Overview', value: 'graph' },
   { label: 'Chart Overview', value: 'graph' },
   { label: 'Search Chart', value: 'list' },
   { label: 'Search Chart', value: 'list' },
   { label: 'Raw Values', value: 'values' },
   { label: 'Raw Values', value: 'values' },
-  { label: 'Logs', value: 'logs' },
+  { label: 'Detailed Logs', value: 'detailed-logs' },
+  { label: 'Deploy', value: 'deploy' },
+  { label: 'Settings', value: 'settings' },
 ];
 ];
 
 
 const basicOptions = [
 const basicOptions = [
-  { label: 'Update Values', value: 'values-form' },
+  { label: 'Values', value: 'values-form' },
   { label: 'Environment', value: 'environment' },
   { label: 'Environment', value: 'environment' },
   { label: 'Logs', value: 'logs' },
   { label: 'Logs', value: 'logs' },
+  { label: 'Deploy', value: 'deploy' },
+  { label: 'Settings', value: 'settings' },
 ];
 ];
 
 
 // TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
 // TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
@@ -83,17 +87,26 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     let { currentCluster } = this.context;
     let { currentCluster } = this.context;
     this.setState({ revisionPreview: oldChart });
     this.setState({ revisionPreview: oldChart });
 
 
-    api.getChartComponents('<token>', {
-      namespace: oldChart.namespace,
-      context: currentCluster,
-      storage: StorageType.Secret
-    }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else {
-        this.setState({ components: res.data });
+    if (oldChart) {
+      api.getChartComponents('<token>', {
+        namespace: oldChart.namespace,
+        context: currentCluster,
+        storage: StorageType.Secret
+      }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
+        if (err) {
+          console.log(err)
+        } else {
+          this.setState({ components: res.data });
+        }
+      });
+
+      // Handle preview old chart while logs tab is open
+      if (this.state.currentTab === 'logs') {
+        this.setState({ currentTab: 'values-form' });
+      } else if (this.state.currentTab === 'detailed-logs') {
+        this.setState({ currentTab: 'graph' });
       }
       }
-    });
+    }
   }
   }
 
 
   toggleDevOpsMode = () => {
   toggleDevOpsMode = () => {
@@ -121,6 +134,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
     return `${time} on ${date}`;
   }
   }
 
 
+  // Hide certain tabs when previewing old charts
+  getTabOptions = () => {
+    let options = basicOptions.slice();
+    if (this.state.devOpsMode) {
+      options = tabOptions.slice();
+    }
+
+    if (this.state.revisionPreview) {
+      options.pop();
+      options.pop();
+      options.pop();
+    }
+    return options;
+  }
+
   renderTabContents = () => {
   renderTabContents = () => {
     let { currentChart, refreshChart, setSidebar } = this.props;
     let { currentChart, refreshChart, setSidebar } = this.props;
     let chart = currentChart;
     let chart = currentChart;
@@ -199,7 +227,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
               <TagWrapper>
               <TagWrapper>
                 Namespace
                 Namespace
-              <NamespaceTag>
+                <NamespaceTag>
                   {chart.namespace}
                   {chart.namespace}
                 </NamespaceTag>
                 </NamespaceTag>
               </TagWrapper>
               </TagWrapper>
@@ -219,7 +247,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
             <TabSelectorWrapper>
             <TabSelectorWrapper>
               <TabSelector
               <TabSelector
-                options={this.state.devOpsMode ? tabOptions : basicOptions}
+                options={this.getTabOptions()}
+                color={this.state.revisionPreview ? '#f5cb42' : null}
                 currentTab={this.state.currentTab}
                 currentTab={this.state.currentTab}
                 setCurrentTab={(value: string) => this.setState({ currentTab: value })}
                 setCurrentTab={(value: string) => this.setState({ currentTab: value })}
                 addendum={
                 addendum={

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

@@ -21,6 +21,7 @@ type StateType = {
   maxVersion: number
   maxVersion: number
 };
 };
 
 
+// TODO: handle refresh when new revision is generated from an old revision
 export default class RevisionSection extends Component<PropsType, StateType> {
 export default class RevisionSection extends Component<PropsType, StateType> {
   state = {
   state = {
     revisions: [] as ChartType[],
     revisions: [] as ChartType[],
@@ -89,13 +90,22 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     });
     });
   }
   }
 
 
+  handleClickRevision = (revision: ChartType) => {
+    let isCurrent = revision.version === this.state.maxVersion;
+    if (isCurrent) {
+      this.props.setRevisionPreview(null);
+    } else {
+      this.props.setRevisionPreview(revision);
+    }
+  }
+
   renderRevisionList = () => {
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
       let isCurrent = revision.version === this.state.maxVersion;
       return (
       return (
         <Tr
         <Tr
           key={i}
           key={i}
-          onClick={() => this.props.setRevisionPreview(revision)}
+          onClick={() => this.handleClickRevision(revision)}
           selected={this.props.chart.version === revision.version}
           selected={this.props.chart.version === revision.version}
         >
         >
           <Td>{revision.version}</Td>
           <Td>{revision.version}</Td>
@@ -172,6 +182,7 @@ export default class RevisionSection extends Component<PropsType, StateType> {
       <div>
       <div>
         <RevisionHeader
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           showRevisions={this.props.showRevisions}
+          isCurrent={isCurrent}
           onClick={this.props.toggleShowRevisions}
           onClick={this.props.toggleShowRevisions}
         >
         >
           {isCurrent ? `Current Revision` : `Previewing Revision (Not Deployed)`} - <Revision>No. {this.props.chart.version}</Revision>
           {isCurrent ? `Current Revision` : `Previewing Revision (Not Deployed)`} - <Revision>No. {this.props.chart.version}</Revision>
@@ -343,7 +354,7 @@ const Revision = styled.div`
 `;
 `;
 
 
 const RevisionHeader = styled.div`
 const RevisionHeader = styled.div`
-  color: #ffffff66;
+  color: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.isCurrent ? '#ffffff66' : '#f5cb42'};
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   height: 40px;
   height: 40px;
@@ -351,7 +362,7 @@ const RevisionHeader = styled.div`
   width: 100%;
   width: 100%;
   padding-left: 15px;
   padding-left: 15px;
   cursor: pointer;
   cursor: pointer;
-  background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff11' : ''};
+  background: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? '#ffffff11' : ''};
   :hover {
   :hover {
     background: #ffffff18;
     background: #ffffff18;
     > i {
     > i {
@@ -364,8 +375,8 @@ const RevisionHeader = styled.div`
     font-size: 20px;
     font-size: 20px;
     cursor: pointer;
     cursor: pointer;
     border-radius: 20px;
     border-radius: 20px;
-    background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff18' : ''};
-    transform: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
+    background: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? '#ffffff18' : ''};
+    transform: ${(props: { showRevisions: boolean, isCurrent: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
   }
   }
 `;
 `;
 
 

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

@@ -19,6 +19,7 @@ type StateType = {
   saveValuesStatus: string | null
   saveValuesStatus: string | null
 };
 };
 
 
+// TODO: handle zoom out
 export default class ValuesYaml extends Component<PropsType, StateType> {
 export default class ValuesYaml extends Component<PropsType, StateType> {
   state = {
   state = {
     values: '',
     values: '',

+ 13 - 36
dashboard/src/main/home/dashboard/expanded-chart/values-form/ValuesForm.tsx

@@ -12,44 +12,16 @@ type PropsType = {
 type StateType = any;
 type StateType = any;
 
 
 const naiveFormArray = [
 const naiveFormArray = [
-  { type: 'heading', data: '🍦 Dessert' },
-  { type: 'helper', data: 'Select your favorite dessert' },
-  {
-    field: 'dessert', type: 'select', data: {
-      label: 'Base flavor',
-      options: [
-        { label: 'vanilla', value: 'A' },
-        { label: 'chocolate', value: 'B' },
-        { label: 'wasabi', value: 'C' }
-      ]
-    }
-  },
-  {
-    field: 'topping', type: 'select', data: {
-      label: 'Topping',
-      options: [
-        { label: 'sprinkles', value: 'A' },
-        { label: 'gummy-worms', value: 'B' },
-        { label: 'salt', value: 'C' }
-      ]
-    }
-  },
-  { type: 'heading', data: '⚡ Resources' },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
-  { field: 'arguable', type: 'checkbox', data: { label: 'Use a persistent volume' } },
-  { field: 'horizon', type: 'checkbox', data: { label: 'Use a refurbished Telecaster' } },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
-  { field: 'name', type: 'input', data: { type: 'string', label: 'Resource name' } },
-  { field: 'oof', type: 'checkbox', data: { label: 'Use a perspective vortex' } },
-  { field: 'memory', type: 'input', data: { type: 'number', label: 'Memory', unit: 'Mi' } },
-  { type: 'helper', data: 'Update computing resources and memory for certain resources.' },
+  { type: 'heading', data: '⚡ Wordpress Settings' },
+  { type: 'helper', data: 'Enable persistent volume for WordPress' },
+  { field: 'pv-enabled', type: 'checkbox', data: { label: 'Persistent volume enabled' } },
+  { field: 'name', type: 'input', data: { type: 'number', label: 'WordPress volume size', unit: 'Gi' } },
   {
   {
     field: 'ocean', type: 'select', data: {
     field: 'ocean', type: 'select', data: {
-      label: 'Some stuff',
+      label: 'Default StorageClass for WordPress',
       options: [
       options: [
-        { label: 'volcano', value: 'A' },
-        { label: 'typhon', value: 'B' },
-        { label: 'intergalactic', value: 'C' }
+        { label: 'Standard', value: 'A' },
+        { label: 'Custom Storage Class', value: 'B' },
       ]
       ]
     }
     }
   },
   },
@@ -125,6 +97,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
     return (
     return (
       <Wrapper>
       <Wrapper>
         <StyledValuesForm>
         <StyledValuesForm>
+          <DarkMatter />
           {this.renderFormContents()}
           {this.renderFormContents()}
         </StyledValuesForm>
         </StyledValuesForm>
         <SaveButton
         <SaveButton
@@ -137,6 +110,10 @@ export default class ValuesForm extends Component<PropsType, StateType> {
   }
   }
 }
 }
 
 
+const DarkMatter = styled.div`
+  margin-top: -5px;
+`;
+
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
@@ -153,7 +130,7 @@ const Heading = styled.div`
   color: white;
   color: white;
   font-weight: 500;
   font-weight: 500;
   font-size: 16px;
   font-size: 16px;
-  margin-top: 30px;
+  margin-top: 35px;
   margin-bottom: 5px;
   margin-bottom: 5px;
 `;
 `;
 
 

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

@@ -11,7 +11,8 @@ import Drawer from './Drawer';
 type PropsType = {
 type PropsType = {
   forceCloseDrawer: boolean,
   forceCloseDrawer: boolean,
   releaseDrawer: () => void,
   releaseDrawer: () => void,
-  setWelcome: (x: boolean) => void
+  setWelcome: (x: boolean) => void,
+  setCurrentView: (x: string) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -104,7 +105,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
     if (kubeContexts.length > 0) {
     if (kubeContexts.length > 0) {
       return (
       return (
         <ClusterSelector showDrawer={showDrawer}>
         <ClusterSelector showDrawer={showDrawer}>
-          <LinkWrapper>
+          <LinkWrapper onClick={() => this.props.setCurrentView('dashboard')}>
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
             <ClusterName>{currentCluster}</ClusterName>
             <ClusterName>{currentCluster}</ClusterName>
           </LinkWrapper>
           </LinkWrapper>

+ 38 - 5
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -1,6 +1,9 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 import gradient from '../../../assets/gradient.jpg';
+import category from '../../../assets/category.svg';
+import pipelines from '../../../assets/pipelines.svg';
+import integrations from '../../../assets/integrations.svg';
 
 
 import api from '../../../shared/api';
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
@@ -10,7 +13,8 @@ import ClusterSection from './ClusterSection';
 type PropsType = {
 type PropsType = {
   logOut: () => void,
   logOut: () => void,
   forceSidebar: boolean,
   forceSidebar: boolean,
-  setWelcome: (x: boolean) => void
+  setWelcome: (x: boolean) => void,
+  setCurrentView: (x: string) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -120,11 +124,28 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <UserName>{this.context.user.email}</UserName>
             <UserName>{this.context.user.email}</UserName>
           </UserSection>
           </UserSection>
 
 
+          <SidebarLabel>Home</SidebarLabel>
+          <NavButton onClick={() => this.props.setCurrentView('templates')}>
+            <img src={category} />
+            Templates
+          </NavButton>
+          <NavButton disabled={true}>
+            <img src={pipelines} />
+            Pipelines
+          </NavButton>
+          <NavButton disabled={true}>
+            <img src={integrations} />
+            Integrations
+          </NavButton>
+
+          <br />
+
           <SidebarLabel>Current Cluster</SidebarLabel>
           <SidebarLabel>Current Cluster</SidebarLabel>
           <ClusterSection 
           <ClusterSection 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             setWelcome={this.props.setWelcome}
+            setCurrentView={this.props.setCurrentView}
           />
           />
 
 
           <BottomSection>
           <BottomSection>
@@ -150,10 +171,10 @@ const NavButton = styled.div`
   font-size: 14px;
   font-size: 14px;
   font-family: 'Hind Siliguri', sans-serif;
   font-family: 'Hind Siliguri', sans-serif;
   color: #ffffff;
   color: #ffffff;
-  cursor: pointer;
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed': 'pointer'};
 
 
   :hover {
   :hover {
     background: #ffffff0f;
     background: #ffffff0f;
@@ -165,10 +186,20 @@ const NavButton = styled.div`
     height: 20px;
     height: 20px;
     width: 20px;
     width: 20px;
     border-radius: 3px;
     border-radius: 3px;
-    font-size: 12px;
+    font-size: 18px;
     position: absolute;
     position: absolute;
-    left: 21px;
-    top: 11px;
+    left: 19px;
+    top: 8px;
+  }
+
+  > img {
+    padding: 4px 4px;
+    height: 23px;
+    width: 23px;
+    border-radius: 3px;
+    position: absolute;
+    left: 20px;
+    top: 9px;
   }
   }
 `;
 `;
 
 
@@ -188,6 +219,8 @@ const LogOutButton = styled(NavButton)`
   > i {
   > i {
     background: none;
     background: none;
     display: flex;
     display: flex;
+    font-size: 12px;
+    top: 11px;
     align-items: center;
     align-items: center;
     justify-content: center;
     justify-content: center;
     color: #ffffffaa;
     color: #ffffffaa;

+ 261 - 0
dashboard/src/main/home/templates/Templates.tsx

@@ -0,0 +1,261 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { PorterChart } from '../../../shared/types';
+
+import TabSelector from '../../../components/TabSelector';
+import ExpandedTemplate from './expanded-template/ExpandedTemplate';
+import Loading from '../../../components/Loading';
+
+const tabOptions = [
+  { label: 'Community Templates', value: 'community' }
+];
+
+type PropsType = {
+};
+
+type StateType = {
+  currentChart: PorterChart | null,
+  currentTab: string,
+  porterCharts: PorterChart[],
+  loading: boolean,
+  error: boolean
+};
+
+export default class Templates extends Component<PropsType, StateType> {
+  state = {
+    currentChart: null as (PorterChart | null),
+    currentTab: 'community',
+    porterCharts: [] as PorterChart[],
+    loading: false,
+    error: false,
+  }
+
+  componentDidMount() {
+
+    // Get templates
+    this.setState({ loading: true });
+    api.getTemplates('<token>', {}, {}, (err: any, res: any) => {
+      if (err) {
+        this.setState({ loading: false, error: true });
+      } else {
+        this.setState({ porterCharts: res.data, loading: false, error: false });
+      }
+    });
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer><i className="material-icons">layers</i></Polymer>
+    );
+  }
+
+  renderTemplateList = () => {
+    let { loading, error, porterCharts } = this.state;
+
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error retrieving templates.
+        </Placeholder>
+      );
+    } else if (porterCharts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No templates found.
+        </Placeholder>
+      );
+    }
+
+    return this.state.porterCharts.map((template: PorterChart, i: number) => {
+      let { Name, Icon, Description } = template.Form;
+      return (
+        <TemplateBlock key={i} onClick={() => this.setState({ currentChart: template })}>
+          {Icon ? this.renderIcon(Icon) : this.renderIcon(template.Icon)}
+          <TemplateTitle>
+            {Name ? Name : template.Name}
+          </TemplateTitle>
+          <TemplateDescription>
+            {Description ? Description : template.Description}
+          </TemplateDescription>
+        </TemplateBlock>
+      )
+    });
+  }
+
+  renderContents = () => {
+    if (this.state.currentChart) {
+      return (
+        <ExpandedTemplate
+          currentChart={this.state.currentChart}
+          setCurrentChart={(currentChart: PorterChart) => this.setState({ currentChart })}
+        />
+      );
+    }
+
+    return (
+      <TemplatesWrapper>
+        <TitleSection>
+          <Title>Template Explorer</Title>
+        </TitleSection>
+        <TabSelector
+          options={tabOptions}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+        />
+        <TemplateList>
+          {this.renderTemplateList()}
+        </TemplateList>
+      </TemplatesWrapper>
+    );
+  }
+  
+  render() {
+    return ( 
+      <StyledTemplates>
+        {this.renderContents()}
+      </StyledTemplates>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+const Placeholder = styled.div`
+  padding-top: 100px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 300px;
+`;
+
+const Icon = styled.img`
+  height: 42px;
+  margin-top: 35px;
+  margin-bottom: 13px;
+`;
+
+const Polymer = styled.div`
+  > i {
+    font-size: 34px;
+    margin-top: 38px;
+    margin-bottom: 20px;
+  }
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 26px;
+  color: #ffffff55;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;  
+`;
+
+const TemplateTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  background: none;
+  border: 1px solid #ffffff44;
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  color: #ffffff;
+  ma: 'Work Sans', sans-serif;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 200px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  :hover {
+    background: #ffffff11;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const TemplateList = styled.div`
+  overflow-y: auto;
+  margin-top: 35px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 15px;
+  grid-row-gap: 15px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+`;
+
+const StyledTemplates = styled.div`
+  height: 100%;
+  width: 100vw;
+  padding-top: 45px;
+  overflow-y: auto;
+  display: flex;
+  flex: 1;
+  justify-content: center;
+  position: relative;
+`;
+
+const TemplatesWrapper = styled.div`
+  width: calc(90% - 30px);
+  min-width: 300px;
+  padding-top: 20px;
+`;

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

@@ -0,0 +1,195 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import launch from '../../../../assets/launch.svg';
+
+import { PorterChart } from '../../../../shared/types';
+
+type PropsType = {
+  currentChart: PorterChart,
+  setCurrentChart: (x: PorterChart) => void
+};
+
+type StateType = {
+};
+
+export default class ExpandedTemplate extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />
+    }
+
+    return (
+      <Polymer><i className="material-icons">layers</i></Polymer>
+    );
+  }
+
+  renderTagList = () => {
+    return this.props.currentChart.Form.Tags.map((tag: string, i: number) => {
+      return (
+        <Tag key={i}>{tag}</Tag>
+      )
+    });
+  }
+
+  render() {
+    let { Name, Icon, Description } = this.props.currentChart.Form;
+    let { currentChart } = this.props;
+
+    return (
+      <StyledExpandedTemplate>
+        <TitleSection>
+          <Flex>
+            <i className="material-icons" onClick={() => this.props.setCurrentChart(null)}>
+              keyboard_backspace
+            </i>
+            {Icon ? this.renderIcon(Icon) : this.renderIcon(currentChart.Icon)}
+            <Title>{Name ? Name : currentChart.Name}</Title>
+          </Flex>
+          <Button>
+            <img src={launch} />
+            Launch Template
+          </Button>
+        </TitleSection>
+        <TagSection>
+          <i className="material-icons">local_offer</i>
+          {this.renderTagList()}
+        </TagSection>
+        <ContentSection>
+          <br />
+          [Add Markdown Support] <br /><br />
+          Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. <br /><br />
+          Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. <br /><br />
+          Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. <br /><br />
+        </ContentSection>
+      </StyledExpandedTemplate>
+    );
+  }
+}
+
+const ContentSection = styled.div`
+  margin-top: 20px;
+  font-size: 14px;
+  line-height: 1.8em;
+`;
+
+const Tag = styled.div`
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  display: flex;
+  margin-right: 7px;
+  align-items: center;
+  padding: 5px 10px;
+`;
+
+const TagSection = styled.div`
+  margin-top: 20px;
+  display: flex;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  align-items: center;
+
+  > i {
+    font-size: 20px;
+    margin-right: 10px;
+    color: #aaaabb;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Button = styled.div`
+  height: 100%;
+  background: #616FEEcc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: not-allowed;
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > img {
+    width: 20px;
+    height: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    justify-content: center;
+  }
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-left: 14px;
+  margin-right: 4px;
+  margin-bottom: -1px;
+`;
+
+
+const Polymer = styled.div`
+  margin-bottom: -3px;
+
+  > i {
+    color: ${props => props.theme.containerIcon};
+    font-size: 24px;
+    margin-left: 12px;
+    margin-right: 3px;
+  }
+`;
+
+const Description = styled.div`
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 30px;
+  width: calc(100% - 60px);
+  height: 4em;
+  border-radius: 2px;
+  color: #aaaabb;
+  padding: 5px 10px;
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 10px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const TitleSection = styled.div`
+  display: flex;
+  margin-left: -42px;
+  flex-direction: row;
+  justify-content: space-between;
+  width: calc(100% + 42px);
+  align-items: center;
+`;
+
+const StyledExpandedTemplate = styled.div`
+  width: calc(90% - 70px);
+  padding-top: 20px;
+`;

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

@@ -96,6 +96,8 @@ const upgradeChartValues = baseApi<{
   return `/api/releases/${pathParams.name}/upgrade`;
   return `/api/releases/${pathParams.name}/upgrade`;
 });
 });
 
 
+const getTemplates = baseApi('GET', '/api/templates');
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -111,5 +113,6 @@ export default {
   getNamespaces,
   getNamespaces,
   getRevisions,
   getRevisions,
   rollbackChart,
   rollbackChart,
-  upgradeChartValues
+  upgradeChartValues,
+  getTemplates
 }
 }

+ 5 - 0
dashboard/src/shared/images.d.ts

@@ -11,4 +11,9 @@ declare module "*.jpg" {
 declare module "*.png" {
 declare module "*.png" {
   const value: any;
   const value: any;
   export = value;
   export = value;
+}
+
+declare module "*.svg" {
+  const value: any;
+  export = value;
 }
 }

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

@@ -57,9 +57,39 @@ export interface EdgeType {
   target: number
   target: number
 }
 }
 
 
-
 export enum StorageType {
 export enum StorageType {
   Secret = 'secret',
   Secret = 'secret',
   ConfigMap = 'configmap',
   ConfigMap = 'configmap',
   Memory = 'memory'
   Memory = 'memory'
 }
 }
+
+// PorterChart represents a bundled Porter template
+export interface PorterChart {
+	Name: string,
+	Description: string,
+	Icon: string,
+	Form: FormYAML
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+export interface FormYAML {
+	Name: string,  
+	Icon: string,   
+	Description: string,   
+	Tags: string[],
+  Sections: {
+    Name: string,
+    Contents: FormElement[]
+  }[]
+}
+
+// FormElement represents a form element
+export interface FormElement {
+  Type: string,
+  Label: string,
+  Name: string,
+  Variable: string,
+  Settings: {
+    Default: number
+  }
+}

+ 1 - 1
dashboard/webpack.config.js

@@ -33,7 +33,7 @@ module.exports = () => {
         },
         },
         { test: /\.css$/, use: [ 'css-loader' ] },
         { test: /\.css$/, use: [ 'css-loader' ] },
         {
         {
-          test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
+          test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
           use: [
           use: [
             {
             {
               loader: 'file-loader',
               loader: 'file-loader',

+ 177 - 0
server/api/template_handler.go

@@ -0,0 +1,177 @@
+package api
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"gopkg.in/yaml.v2"
+)
+
+var baseURL string = "https://porter-dev.github.io/chart-repo/"
+
+// IndexYAML represents a chart repo's index.yaml
+type IndexYAML struct {
+	APIVersion string                    `yaml:"apiVersion"`
+	Generated  string                    `yaml:"generated"`
+	Entries    map[interface{}]ChartYAML `yaml:"entries"`
+}
+
+// ChartYAML represents the data for chart in index.yaml
+type ChartYAML []struct {
+	APIVersion  string   `yaml:"apiVersion"`
+	AppVersion  string   `yaml:"appVersion"`
+	Created     string   `yaml:"created"`
+	Description string   `yaml:"description"`
+	Digest      string   `yaml:"digest"`
+	Icon        string   `yaml:"icon"`
+	Name        string   `yaml:"name"`
+	Type        string   `yaml:"type"`
+	Urls        []string `yaml:"urls"`
+	Version     string   `yaml:"version"`
+}
+
+// PorterChart represents a bundled Porter template
+type PorterChart struct {
+	Name        string
+	Description string
+	Icon        string
+	Form        FormYAML
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+type FormYAML struct {
+	Name        string   `yaml:"name"`
+	Icon        string   `yaml:"icon"`
+	Description string   `yaml:"description"`
+	Tags        []string `yaml:"tags"`
+	Sections    []struct {
+		Name     string `yaml:"name"`
+		Contents []struct {
+			Type     string `yaml:"type"`
+			Label    string `yaml:"label"`
+			Name     string `yaml:"name,omitempty"`
+			Variable string `yaml:"variable,omitempty"`
+			Settings struct {
+				Default int `yaml:"default"`
+			} `yaml:"settings,omitempty"`
+		} `yaml:"contents"`
+	} `yaml:"sections"`
+}
+
+// HandleListTemplates retrieves a list of Porter templates
+// TODO: test and reduce fragility (handle untar/parse error for individual charts)
+func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
+	resp, err := http.Get(baseURL + "index.yaml")
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+
+	form := IndexYAML{}
+	if err := yaml.Unmarshal([]byte(body), &form); err != nil {
+		fmt.Println(err)
+		return
+	}
+
+	// Loop over charts in index.yaml
+	porterCharts := []PorterChart{}
+	for k := range form.Entries {
+		indexChart := form.Entries[k][0]
+		tarURL := indexChart.Urls[0]
+		if !strings.Contains(tarURL, "http://") {
+			tarURL = baseURL + tarURL
+		}
+
+		formData, err := getFormData(tarURL)
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+
+		porterChart := PorterChart{}
+		porterChart.Name = indexChart.Name
+		porterChart.Description = indexChart.Description
+		porterChart.Icon = indexChart.Icon
+		porterChart.Form = *formData
+
+		porterCharts = append(porterCharts, porterChart)
+	}
+
+	json.NewEncoder(w).Encode(porterCharts)
+}
+
+func getFormData(tarURL string) (*FormYAML, error) {
+	resp, err := http.Get(tarURL)
+	if err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	buf := bytes.NewBuffer(body)
+
+	gzf, err := gzip.NewReader(buf)
+	if err != nil {
+		fmt.Println(err)
+		return nil, err
+	}
+
+	// Process tarball to generate FormYAML
+	tarReader := tar.NewReader(gzf)
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			fmt.Println(err)
+			return nil, err
+		}
+
+		name := header.Name
+		switch header.Typeflag {
+		case tar.TypeDir:
+			continue
+		case tar.TypeReg:
+
+			// Handle form.yaml located in archive
+			if strings.Contains(name, "form.yaml") {
+				bufForm := new(bytes.Buffer)
+
+				_, err := io.Copy(bufForm, tarReader)
+				if err != nil {
+					fmt.Println(err)
+					return nil, err
+				}
+
+				// Unmarshal yaml byte buffer
+				form := FormYAML{}
+				if err := yaml.Unmarshal(bufForm.Bytes(), &form); err != nil {
+					fmt.Println(err)
+					return nil, err
+				}
+
+				return &form, nil
+			}
+		default:
+			fmt.Printf("%s : %c %s %s\n",
+				"Unknown type",
+				header.Typeflag,
+				"in file",
+				name,
+			)
+		}
+	}
+	return nil, errors.New("no form.yaml found")
+}

+ 113 - 0
server/api/template_handler_test.go

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

+ 3 - 0
server/router/router.go

@@ -43,6 +43,9 @@ func New(
 		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)))
 		r.Method("POST", "/releases/{name}/rollback", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackRelease, l)))
 		r.Method("POST", "/releases/{name}/rollback", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackRelease, l)))
 
 
+		// /api/templates routes
+		r.Method("GET", "/templates", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListTemplates, l)))
+
 		// /api/k8s routes
 		// /api/k8s routes
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})
 	})