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

basic chart repo integration + read in templates w/ form.yaml on frontend

jusrhee 5 лет назад
Родитель
Сommit
a54454cd44

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

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

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

@@ -8,6 +8,7 @@ import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import ClusterConfigModal from './modals/ClusterConfigModal';
 import Loading from '../../components/Loading';
+import Templates from './templates/Templates';
 
 type PropsType = {
   logOut: () => void
@@ -15,13 +16,15 @@ type PropsType = {
 
 type StateType = {
   forceSidebar: boolean,
-  showWelcome: boolean
+  showWelcome: boolean,
+  currentView: string,
 };
 
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
-    showWelcome: false
+    showWelcome: false,
+    currentView: 'templates'
   }
 
   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() {
     return (
       <StyledHome>
@@ -73,10 +88,10 @@ export default class Home extends Component<PropsType, StateType> {
           logOut={this.props.logOut}
           forceSidebar={this.state.forceSidebar}
           setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
-        <StyledDashboard>
-          {this.renderDashboard()}
-        </StyledDashboard>
+        
+        {this.renderContents()}
       </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
     }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
       if (err) {
-        console.log(err)
+        console.log(err);
       } else {
         this.setState({ currentChart: res.data });
       }

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

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

+ 7 - 35
dashboard/src/main/home/dashboard/expanded-chart/values-form/ValuesForm.tsx

@@ -12,44 +12,16 @@ type PropsType = {
 type StateType = any;
 
 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: {
-      label: 'Some stuff',
+      label: 'Default StorageClass for WordPress',
       options: [
-        { label: 'volcano', value: 'A' },
-        { label: 'typhon', value: 'B' },
-        { label: 'intergalactic', value: 'C' }
+        { label: 'Standard', value: 'A' },
+        { label: 'Custom Storage Class', value: 'B' },
       ]
     }
   },

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

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

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

@@ -1,6 +1,9 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 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 { Context } from '../../../shared/Context';
@@ -10,7 +13,8 @@ import ClusterSection from './ClusterSection';
 type PropsType = {
   logOut: () => void,
   forceSidebar: boolean,
-  setWelcome: (x: boolean) => void
+  setWelcome: (x: boolean) => void,
+  setCurrentView: (x: string) => void
 };
 
 type StateType = {
@@ -120,11 +124,28 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <UserName>{this.context.user.email}</UserName>
           </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>
           <ClusterSection 
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
+            setCurrentView={this.props.setCurrentView}
           />
 
           <BottomSection>
@@ -150,10 +171,10 @@ const NavButton = styled.div`
   font-size: 14px;
   font-family: 'Hind Siliguri', sans-serif;
   color: #ffffff;
-  cursor: pointer;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed': 'pointer'};
 
   :hover {
     background: #ffffff0f;
@@ -165,10 +186,20 @@ const NavButton = styled.div`
     height: 20px;
     width: 20px;
     border-radius: 3px;
-    font-size: 12px;
+    font-size: 18px;
     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 {
     background: none;
     display: flex;
+    font-size: 12px;
+    top: 11px;
     align-items: center;
     justify-content: center;
     color: #ffffffaa;

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

@@ -0,0 +1,203 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+
+import TabSelector from '../../../components/TabSelector';
+import { AnyNaptrRecord } from 'dns';
+
+const tabOptions = [
+  { label: 'Community Templates', value: 'community' }
+];
+
+type PropsType = {
+};
+
+type StateType = {
+  currentTab: string,
+  porterCharts: any[]
+};
+
+export default class Templates extends Component<PropsType, StateType> {
+  state = {
+    currentTab: 'community',
+    porterCharts: [] as any[]
+  }
+
+  componentDidMount() {
+    // Get templates
+    api.getTemplates('<token>', {}, {}, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.setState({ porterCharts: res.data });
+      }
+    });
+  }
+
+  renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />
+    }
+
+    return (
+        <Polymer><i className="material-icons">layers</i></Polymer>
+    )
+  }
+
+  renderStackList = () => {
+    return this.state.porterCharts.map((template, i) => {
+      console.log(template)
+      return (
+        <TemplateBlock key={i}>
+          {this.renderIcon(template.Icon)}
+          <TemplateTitle>
+            {template.Form.Name ? template.Form.Name : template.Name}
+          </TemplateTitle>
+          <TemplateDescription>
+            {template.Form.Description ? template.Form.Description : template.Description}
+          </TemplateDescription>
+        </TemplateBlock>
+      )
+    })
+  }
+  
+  render() {
+    return ( 
+      <StyledTemplates>
+        <TemplatesWrapper>
+        <TitleSection>
+          <Title>Template Manager</Title>
+        </TitleSection>
+        <TabSelector
+          options={tabOptions}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+        />
+        <TemplateList>
+          {this.renderStackList()}
+        </TemplateList>
+        </TemplatesWrapper>
+      </StyledTemplates>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+
+const Icon = styled.img`
+  height: 50px;
+  margin-top: 28px;
+  margin-bottom: 15px;
+`;
+
+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 CenterWrap = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  padding-bottom: 15px;
+`;
+
+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: #ffffff08;
+  }
+`;
+
+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;
+`;

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

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

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

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

+ 1 - 1
dashboard/webpack.config.js

@@ -33,7 +33,7 @@ module.exports = () => {
         },
         { 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: [
             {
               loader: 'file-loader',

+ 175 - 0
server/api/template_handler.go

@@ -0,0 +1,175 @@
+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"`
+	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
+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("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
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})