Browse Source

Simplified view (#2858)

* install github app button

* general error component

* auto-generate cluster name

* fix button text color

* infra tab for porter user

* simplified view feature flag

* standard clickable

* namespace placeholder

* currenterror

* currenterror

* placeholder simplified grid view

* grid list toggle

* grid list toggle

* grid list toggle

* fuzzy search

* addon placeholder and bring back dashboard

* proper top-level add-on view

* new add-on route w listed templates

* expand template stub

* nav back

* settings placeholder

* add-ons integrated

* app deployment flow stub

* app deploy stub

* new application form work

* porter app table

* adding build settings to form

* adding more to the deployment form

* select component

* add-on list placeholder

* ui cleanup

* adding github action modal

* better launch spacing for feroze

* addon template

* change modal copy

* Env Changes

* File Read-in

* stacks stubs

* FileReader

* New Contents

* cached

* actionconf to FC

* laying groundwork for github action

* gha

* Dectect Contents

* configured endpoint and finished handler

* progressing the api

* call stack create and add service modal stub

* moving things around

* IT WORKS HOLY SHIT

* by golly it works

* some cleanups good night

* expandable section

* BuildPackSettings

* basic service tabs

* BuildPackStack

* BuildPackStack

* NewAppFlow

* build settings prop fix

* application services write to form state

* start adding create repo handlers

* add schema

* more boilerplate

* Add porter yaml url (#2948)

* PorterYaml Changes

* PorterYaml Changes

* FrontEnd Read In

* FrontEnd Read In

* Input Casing

* Input Casing

* query to create stack in db

* hide alert

* autodetecting services

* list endpoint for stacks

* start command is grayed out if it is specified in porter yaml

* implement get stack endpoint

* tab stubs

* add tests

---------

Co-authored-by: Feroze Mohideen <feroze@porter.run>
Co-authored-by: Soham Dessai <sd5we@virginia.edu>
Co-authored-by: Justin Rhee <jusrhee@Justins-MacBook-Air.local>
Co-authored-by: sdess09 <37374498+sdess09@users.noreply.github.com>
jusrhee 3 năm trước cách đây
mục cha
commit
9906dc74c6
100 tập tin đã thay đổi với 6595 bổ sung291 xóa
  1. 82 0
      api/server/handlers/gitinstallation/get_porter_yaml.go
  2. 62 0
      api/server/handlers/stacks/create_porter_app.go
  3. 144 0
      api/server/handlers/stacks/create_secret_and_open_pr.go
  4. 45 0
      api/server/handlers/stacks/get_porter_app.go
  5. 37 0
      api/server/router/git_installation.go
  6. 89 0
      api/server/router/stack.go
  7. 6 6
      api/server/shared/config/loader/loader.go
  8. 4 0
      api/types/git_installation.go
  9. 22 0
      api/types/porter_app.go
  10. 2 0
      api/types/project.go
  11. 1 0
      api/types/request.go
  12. 12 0
      api/types/stack.go
  13. 13 0
      api/types/stacks.go
  14. 18 0
      dashboard/package-lock.json
  15. 1 0
      dashboard/package.json
  16. 0 2
      dashboard/src/App.tsx
  17. BIN
      dashboard/src/assets/add-ons-bold.png
  18. BIN
      dashboard/src/assets/add-ons.png
  19. BIN
      dashboard/src/assets/grid.png
  20. BIN
      dashboard/src/assets/integrations-bold.png
  21. BIN
      dashboard/src/assets/integrations.png
  22. BIN
      dashboard/src/assets/list.png
  23. BIN
      dashboard/src/assets/not-found.png
  24. BIN
      dashboard/src/assets/search.png
  25. BIN
      dashboard/src/assets/settings-bold.png
  26. BIN
      dashboard/src/assets/settings.png
  27. BIN
      dashboard/src/assets/status-healthy.png
  28. BIN
      dashboard/src/assets/time.png
  29. 2 2
      dashboard/src/components/SaveButton.tsx
  30. 2 1
      dashboard/src/components/YamlEditor.tsx
  31. 0 1
      dashboard/src/components/form-components/Heading.tsx
  32. 14 2
      dashboard/src/components/porter-form/PorterForm.tsx
  33. 3 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  34. 73 0
      dashboard/src/components/porter/Back.tsx
  35. 17 4
      dashboard/src/components/porter/Button.tsx
  36. 5 0
      dashboard/src/components/porter/Container.tsx
  37. 1 1
      dashboard/src/components/porter/Error.tsx
  38. 5 8
      dashboard/src/components/porter/ExpandableSection.tsx
  39. 1 1
      dashboard/src/components/porter/Fieldset.tsx
  40. 12 3
      dashboard/src/components/porter/Modal.tsx
  41. 126 0
      dashboard/src/components/porter/SearchBar.tsx
  42. 128 0
      dashboard/src/components/porter/Select.tsx
  43. 54 0
      dashboard/src/components/porter/Toggle.tsx
  44. 95 0
      dashboard/src/components/porter/VerticalSteps.tsx
  45. 120 0
      dashboard/src/components/repo-selector/ActionConfBranchSelector.tsx
  46. 110 0
      dashboard/src/components/repo-selector/ActionConfEditorStack.tsx
  47. 577 0
      dashboard/src/components/repo-selector/BuildpackStack.tsx
  48. 544 0
      dashboard/src/components/repo-selector/DetectContentsList.tsx
  49. 172 143
      dashboard/src/main/home/Home.tsx
  50. 335 0
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  51. 271 0
      dashboard/src/main/home/add-on-dashboard/ConfigureTemplate.tsx
  52. 160 0
      dashboard/src/main/home/add-on-dashboard/ExpandedTemplate.tsx
  53. 182 0
      dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx
  54. 269 0
      dashboard/src/main/home/app-dashboard/AppDashboard.tsx
  55. 164 0
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  56. 282 0
      dashboard/src/main/home/app-dashboard/new-app-flow/AdvancedBuildSettings.tsx
  57. 146 0
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx
  58. 99 0
      dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx
  59. 421 0
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  60. 176 0
      dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx
  61. 177 0
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  62. 118 0
      dashboard/src/main/home/app-dashboard/new-app-flow/SourceSelector.tsx
  63. 193 0
      dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx
  64. 154 0
      dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx
  65. 131 0
      dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx
  66. 41 0
      dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx
  67. 92 0
      dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts
  68. 14 9
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  69. 1 1
      dashboard/src/main/home/cluster-dashboard/apps/AppDashboard.tsx
  70. 5 5
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  71. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  72. 4 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  73. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  74. 2 2
      dashboard/src/main/home/cluster-dashboard/jobs/JobDashboard.tsx
  75. 1 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  76. 1 1
      dashboard/src/main/home/integrations/Integrations.tsx
  77. 0 1
      dashboard/src/main/home/launch/Launch.tsx
  78. 4 1
      dashboard/src/main/home/launch/TemplateList.tsx
  79. 1 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  80. 1 1
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  81. 10 5
      dashboard/src/main/home/sidebar/Clusters.tsx
  82. 63 6
      dashboard/src/main/home/sidebar/Sidebar.tsx
  83. 164 61
      dashboard/src/shared/api.tsx
  84. 13 4
      dashboard/src/shared/hardcodedNameDict.tsx
  85. 4 0
      dashboard/src/shared/routing.tsx
  86. 3 0
      dashboard/src/shared/themes/midnight.ts
  87. 2 1
      dashboard/src/shared/themes/standard.ts
  88. 14 6
      dashboard/src/shared/types.tsx
  89. 1 0
      ee/billing/client.go
  90. 1 0
      ee/billing/types.go
  91. 3 3
      internal/integrations/ci/actions/actions.go
  92. 1 1
      internal/integrations/ci/actions/preview.go
  93. 121 0
      internal/integrations/ci/actions/stack.go
  94. 23 0
      internal/integrations/ci/actions/steps.go
  95. 47 0
      internal/models/porter_app.go
  96. 2 0
      internal/models/project.go
  97. 1 0
      internal/repository/gorm/migrate.go
  98. 55 0
      internal/repository/gorm/porter_app.go
  99. 6 0
      internal/repository/gorm/repository.go
  100. 13 0
      internal/repository/porter_app.go

+ 82 - 0
api/server/handlers/gitinstallation/get_porter_yaml.go

@@ -0,0 +1,82 @@
+package gitinstallation
+
+import (
+	"context"
+	b64 "encoding/base64"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubGetPorterYamlHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetPorterYamlHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetPorterYamlHandler {
+	return &GithubGetPorterYamlHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPorterYamlRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := commonutils.GetBranchParam(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, _, _, err := client.Repositories.GetContents(
+		context.Background(),
+		owner,
+		name,
+		request.Path,
+		&github.RepositoryContentGetOptions{
+			Ref: branch,
+		},
+	)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	fileData, err := resp.GetContent()
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+	data := b64.StdEncoding.EncodeToString([]byte(fileData))
+
+	c.WriteResult(w, r, data)
+}

+ 62 - 0
api/server/handlers/stacks/create_porter_app.go

@@ -0,0 +1,62 @@
+package stacks
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreatePorterAppHandler {
+	return &CreatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreatePorterAppRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	app := &models.PorterApp{
+		Name:      request.Name,
+		ClusterID: cluster.ID,
+		ProjectID: project.ID,
+		RepoName:  request.RepoName,
+		GitBranch: request.GitBranch,
+
+		BuildContext: request.BuildContext,
+		Builder:      request.Builder,
+		Buildpacks:   request.Buildpacks,
+		Dockerfile:   request.Dockerfile,
+	}
+
+	_, err := c.Repo().PorterApp().CreatePorterApp(app)
+
+	if err != nil {
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+}

+ 144 - 0
api/server/handlers/stacks/create_secret_and_open_pr.go

@@ -0,0 +1,144 @@
+package stacks
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type OpenStackPRHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOpenStackPRHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OpenStackPRHandler {
+	return &OpenStackPRHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
+	}
+
+	request := &types.CreateSecretAndOpenGHPRRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	client, err := getGithubClient(c.Config(), request.GithubAppInstallationID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// generate porter jwt token
+	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
+		return
+	}
+	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
+		return
+	}
+
+	// create porter secret
+	secretName := fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
+	err = actions.CreateGithubSecret(
+		client,
+		secretName,
+		encoded,
+		request.GithubRepoOwner,
+		request.GithubRepoName,
+	)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
+		return
+	}
+
+	var pr *github.PullRequest
+	if request.OpenPr {
+		pr, err = actions.OpenGithubPR(&actions.GithubPROpts{
+			Client:        client,
+			GitRepoOwner:  request.GithubRepoOwner,
+			GitRepoName:   request.GithubRepoName,
+			StackName:     stackName,
+			ProjectID:     project.ID,
+			ClusterID:     cluster.ID,
+			ServerURL:     c.Config().ServerConf.ServerURL,
+			DefaultBranch: request.Branch,
+			SecretName:    secretName,
+		})
+	}
+
+	if err != nil {
+		unwrappedErr := errors.Unwrap(err)
+
+		if unwrappedErr != nil {
+			if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+			} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
+			}
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error setting up application in the github "+
+				"repo: %w", err)))
+			return
+		}
+	}
+
+	var resp types.CreateSecretAndOpenGHPRResponse
+	if pr != nil {
+		resp = types.CreateSecretAndOpenGHPRResponse{
+			URL: pr.GetHTMLURL(),
+		}
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, resp)
+}
+
+func getGithubClient(config *config.Config, gitInstallationId int64) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+	if err != nil {
+		return nil, fmt.Errorf("malformed GITHUB_APP_ID in server configuration: %w", err)
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(ghAppId),
+		gitInstallationId,
+		config.ServerConf.GithubAppSecret,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error in creating github client for stack: %w", err)
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

+ 45 - 0
api/server/handlers/stacks/get_porter_app.go

@@ -0,0 +1,45 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPorterAppHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetPorterAppHandler {
+	return &GetPorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GetPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+
+	app, err := c.Repo().PorterApp().ReadPorterApp(cluster.ID, name)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	fmt.Println("got here", app)
+
+	c.WriteResult(w, r, app.ToPorterAppType())
+}

+ 37 - 0
api/server/router/git_installation.go

@@ -588,6 +588,43 @@ func getGitInstallationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/porteryaml ->
+	// gitinstallation.NewGithubGetProcfileHandler
+	getPorterYamlEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/repos/{%s}/{%s}/{%s}/{%s}/porteryaml",
+					relPath,
+					types.URLParamGitKind,
+					types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName,
+					types.URLParamGitBranch,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+			},
+		},
+	)
+
+	getPorterYamlHandler := gitinstallation.NewGithubGetPorterYamlHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterYamlEndpoint,
+		Handler:  getPorterYamlHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/procfile ->
 	// gitinstallation.NewGithubGetProcfileHandler
 	getProcfileEndpoint := factory.NewAPIEndpoint(

+ 89 - 0
api/server/router/stack.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/stacks"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -53,6 +55,63 @@ func getStackRoutes(
 
 	var routes []*router.Route
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name} -> stacks.NewPorterAppGetHandler
+	getPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPorterAppHandler := stacks.NewGetPorterAppHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterAppEndpoint,
+		Handler:  getPorterAppHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/update_config -> stacks.NewCreateStackHandler
+	createPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update_config",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createPorterAppHandler := stacks.NewCreatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createPorterAppEndpoint,
+		Handler:  createPorterAppHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks -> stacks.NewCreateStackHandler
 	createEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -110,5 +169,35 @@ func getStackRoutes(
 		Handler:  updateHandler,
 		Router:   r,
 	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/pr -> stacks.NewOpenStackPRHandler
+	createSecretAndOpenGitHubPullRequestEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/pr", relPath, types.URLParamStackName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createSecretAndOpenGitHubPullRequestHandler := stacks.NewOpenStackPRHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createSecretAndOpenGitHubPullRequestEndpoint,
+		Handler:  createSecretAndOpenGitHubPullRequestHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 6 - 6
api/server/shared/config/loader/loader.go

@@ -83,13 +83,13 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	res.Logger.Info().Msg("Loaded MetadataFromConf")
 	res.DB = InstanceDB
 
-	// res.Logger.Info().Msg("Starting gorm automigrate")
-	// err = gorm.AutoMigrate(InstanceDB, sc.Debug)
+	res.Logger.Info().Msg("Starting gorm automigrate")
+	err = gorm.AutoMigrate(InstanceDB, sc.Debug)
 
-	// if err != nil {
-	// 	return nil, err
-	// }
-	// res.Logger.Info().Msg("Completed gorm automigrate")
+	if err != nil {
+		return nil, err
+	}
+	res.Logger.Info().Msg("Completed gorm automigrate")
 
 	var key [32]byte
 

+ 4 - 0
api/types/git_installation.go

@@ -51,6 +51,10 @@ type GithubDirectoryItem struct {
 
 type GetContentsResponse []GithubDirectoryItem
 
+type GetPorterYamlRequest struct {
+	Path string `schema:"path" form:"required"`
+}
+
 type GetProcfileRequest struct {
 	Path string `schema:"path" form:"required"`
 }

+ 22 - 0
api/types/porter_app.go

@@ -0,0 +1,22 @@
+package types
+
+type PorterApp struct {
+	ID        uint `json:"id"`
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	Name string `json:"name"`
+
+	ImageRepoURI string `json:"image_repo_uri,omitempty"`
+
+	// Git repo information (optional)
+	GitRepoID uint   `json:"git_repo_id,omitempty"`
+	RepoName  string `json:"repo_name,omitempty"`
+	GitBranch string `json:"git_branch,omitempty"`
+
+	// Build settings (optional)
+	BuildContext string `json:"build_context,omitempty"`
+	Builder      string `json:"builder,omitempty"`
+	Buildpacks   string `json:"build_packs,omitempty"`
+	Dockerfile   string `json:"dockerfile,omitempty"`
+}

+ 2 - 0
api/types/project.go

@@ -10,6 +10,7 @@ type Project struct {
 	APITokensEnabled       bool    `json:"api_tokens_enabled"`
 	StacksEnabled          bool    `json:"stacks_enabled"`
 	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
+	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 }
 
 type FeatureFlags struct {
@@ -18,6 +19,7 @@ type FeatureFlags struct {
 	StacksEnabled              string `json:"stacks_enabled,omitempty"`
 	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
 	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
+	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
 }
 
 type CreateProjectRequest struct {

+ 1 - 0
api/types/request.go

@@ -48,6 +48,7 @@ const (
 	URLParamWildcard              URLParam = "*"
 	URLParamIntegrationID         URLParam = "integration_id"
 	URLParamAPIContractRevisionID URLParam = "contract_revision_id"
+	URLParamStackName             URLParam = "stack_name"
 )
 
 type Path struct {

+ 12 - 0
api/types/stack.go

@@ -10,3 +10,15 @@ type ImageInfo struct {
 	Repository string `json:"repository"`
 	Tag        string `json:"tag"`
 }
+
+type CreateSecretAndOpenGHPRRequest struct {
+	GithubAppInstallationID int64  `json:"github_app_installation_id" form:"required"`
+	GithubRepoOwner         string `json:"github_repo_owner" form:"required"`
+	GithubRepoName          string `json:"github_repo_name" form:"required"`
+	OpenPr                  bool   `json:"open_pr"`
+	Branch                  string `json:"branch"`
+}
+
+type CreateSecretAndOpenGHPRResponse struct {
+	URL string `json:"url"`
+}

+ 13 - 0
api/types/stacks.go

@@ -22,6 +22,19 @@ type CreateStackRequest struct {
 	EnvGroups []*CreateStackEnvGroupRequest `json:"env_groups,omitempty" form:"required,dive,required"`
 }
 
+// swagger:model
+type CreatePorterAppRequest struct {
+	Name         string `json:"name" form:"required"`
+	ClusterID    uint   `json:"cluster_id"`
+	ProjectID    uint   `json:"project_id"`
+	RepoName     string `json:"repo_name" form:"required"`
+	GitBranch    string `json:"git_branch" form:"required"`
+	BuildContext string `json:"build_context" form:"required"`
+	Builder      string `json:"builder"`
+	Buildpacks   string `json:"buildpacks"`
+	Dockerfile   string `json:"dockerfile"`
+}
+
 // swagger:model
 type PutStackSourceConfigRequest struct {
 	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`

+ 18 - 0
dashboard/package-lock.json

@@ -51,6 +51,7 @@
         "random-word-slugs": "^0.1.6",
         "react": "^18.0.0",
         "react-ace": "^8.0.0",
+        "react-animate-height": "^3.1.1",
         "react-color": "^2.19.3",
         "react-datepicker": "^4.8.0",
         "react-dom": "^18.0.0",
@@ -10600,6 +10601,18 @@
         "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0"
       }
     },
+    "node_modules/react-animate-height": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.1.1.tgz",
+      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ==",
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
     "node_modules/react-color": {
       "version": "2.19.3",
       "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
@@ -23037,6 +23050,11 @@
         "prop-types": "^15.7.2"
       }
     },
+    "react-animate-height": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-3.1.1.tgz",
+      "integrity": "sha512-UkC6+V3ZlCneBRaSM7aUctDJ+PRP6ztcGtxvU7MTeoMMWPhz8BQNaX7QWaZrkzp1ih1G8uZZ+DI9nfLvtD6OdQ=="
+    },
     "react-color": {
       "version": "2.19.3",
       "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",

+ 1 - 0
dashboard/package.json

@@ -46,6 +46,7 @@
     "random-word-slugs": "^0.1.6",
     "react": "^18.0.0",
     "react-ace": "^8.0.0",
+    "react-animate-height": "^3.1.1",
     "react-color": "^2.19.3",
     "react-datepicker": "^4.8.0",
     "react-dom": "^18.0.0",

+ 0 - 2
dashboard/src/App.tsx

@@ -37,7 +37,6 @@ const GlobalStyle = createGlobalStyle`
   }
   
   body {
-    background: #202227;
     overscroll-behavior-x: none;
   }
 
@@ -57,6 +56,5 @@ const StyledMain = styled.div`
   position: fixed;
   top: 0;
   left: 0;
-  background: #202227;
   color: white;
 `;

BIN
dashboard/src/assets/add-ons-bold.png


BIN
dashboard/src/assets/add-ons.png


BIN
dashboard/src/assets/grid.png


BIN
dashboard/src/assets/integrations-bold.png


BIN
dashboard/src/assets/integrations.png


BIN
dashboard/src/assets/list.png


BIN
dashboard/src/assets/not-found.png


BIN
dashboard/src/assets/search.png


BIN
dashboard/src/assets/settings-bold.png


BIN
dashboard/src/assets/settings.png


BIN
dashboard/src/assets/status-healthy.png


BIN
dashboard/src/assets/time.png


+ 2 - 2
dashboard/src/components/SaveButton.tsx

@@ -80,7 +80,7 @@ const SaveButton: React.FC<Props> = (props) => {
         rounded={props.rounded}
         disabled={props.disabled}
         onClick={props.onClick}
-        color={props.color || "#5561C0"}
+        color={props.color}
       >
         {props.children || props.text}
       </Button>
@@ -199,7 +199,7 @@ const Button = styled.button<{
   text-align: left;
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
-  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  background: ${(props) => (!props.disabled ? (props.color || props.theme.button) : "#aaaabb")};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
   user-select: none;
   :focus {

+ 2 - 1
dashboard/src/components/YamlEditor.tsx

@@ -44,7 +44,7 @@ class YamlEditor extends Component<PropsType, StateType> {
       <Holder>
         <Editor onSubmit={this.handleSubmit} border={this.props.border}>
           <AceEditor
-            mode="yaml"
+            // mode="yaml"
             value={this.props.value}
             theme="porter"
             onChange={this.props.onChange}
@@ -79,6 +79,7 @@ const Holder = styled.div`
   }
   .ace_editor,
   .ace_editor * {
+    color: #aaaabb;
     font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
       monospace !important;
     font-size: 12px !important;

+ 0 - 1
dashboard/src/components/form-components/Heading.tsx

@@ -21,7 +21,6 @@ export default function Heading(props: {
 const StyledHeading = styled.div<{ isAtTop: boolean }>`
   color: white;
   margin-top: ${props => props.isAtTop ? "" : "40px"};
-  font-weight: 500;
   font-size: 16px;
   margin-bottom: 5px;
   display: flex;

+ 14 - 2
dashboard/src/components/porter-form/PorterForm.tsx

@@ -31,6 +31,7 @@ import VeleroForm from "./field-components/VeleroForm";
 import CronInput from "./field-components/CronInput";
 import TextAreaInput from "./field-components/TextAreaInput";
 import UrlLink from "./field-components/UrlLink";
+import Button from "components/porter/Button";
 
 interface Props {
   leftTabOptions?: TabOption[];
@@ -44,6 +45,7 @@ interface Props {
   isInModal?: boolean;
   color?: string;
   addendum?: any;
+  buttonStatus?: React.ReactNode;
   saveValuesStatus?: string;
   showStateDebugger?: boolean;
   currentTab: string;
@@ -223,9 +225,9 @@ const PorterForm: React.FC<Props> = (props) => {
         {renderTab()}
       </TabRegion>
       <br />
-      {showSaveButton() && (
+      {(showSaveButton() && props.buttonStatus === undefined) && (
         <SaveButton
-          text={props.saveButtonText || "Deploy app"}
+          text={props.saveButtonText || "Deploy application"}
           onClick={submit}
           absoluteSave={props.absoluteSave}
           clearPosition={true}
@@ -237,6 +239,16 @@ const PorterForm: React.FC<Props> = (props) => {
           disabled={isDisabled()}
         />
       )}
+      {/* TODO: change button when deploying */}
+      {(props.buttonStatus !== undefined) && (
+        <Button
+          onClick={submit}
+          status={props.buttonStatus}
+          disabled={isDisabled()}
+        >
+          Deploy application
+        </Button>
+      )}
       {props.showStateDebugger && (
         <Pre>{JSON.stringify(formState, undefined, 2)}</Pre>
       )}

+ 3 - 0
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   isInModal?: boolean;
   color?: string;
   addendum?: any;
+  buttonStatus?: React.ReactNode;
   saveValuesStatus?: string;
   showStateDebugger?: boolean;
   isLaunch?: boolean;
@@ -41,6 +42,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   isInModal,
   color,
   addendum,
+  buttonStatus,
   saveValuesStatus,
   showStateDebugger,
   isLaunch,
@@ -99,6 +101,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
         <PorterForm
           showStateDebugger={showStateDebugger}
           addendum={addendum}
+          buttonStatus={buttonStatus}
           isReadOnly={isReadOnly}
           leftTabOptions={leftTabOptions}
           rightTabOptions={rightTabOptions}

+ 73 - 0
dashboard/src/components/porter/Back.tsx

@@ -0,0 +1,73 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import leftArrow from "assets/left-arrow.svg";
+import Text from "./Text";
+import Container from "./Container";
+import { Link } from "react-router-dom";
+
+type Props = {
+  to?: string;
+  onClick?: () => void;
+};
+
+const Back: React.FC<Props> = ({
+  to,
+  onClick,
+}) => {
+  return (
+    <Container row>
+      {to ? (
+        <BackLink to={to}>
+          <ArrowIcon src={leftArrow} />
+          Back
+        </BackLink>
+      ) : (
+        <StyledBack onClick={onClick}>
+          <ArrowIcon src={leftArrow} />
+          Back
+        </StyledBack>
+      )}
+    </Container>
+  );
+};
+
+export default Back;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const BackLink = styled(Link)`
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const StyledBack = styled.div`
+color: #aaaabb88;
+font-size: 13px;
+margin-bottom: 15px;
+display: flex;
+margin-top: -10px;
+z-index: 999;
+padding: 5px;
+padding-right: 7px;
+border-radius: 5px;
+cursor: pointer;
+:hover {
+  background: #ffffff11;
+}
+`;

+ 17 - 4
dashboard/src/components/porter/Button.tsx

@@ -16,6 +16,8 @@ type Props = {
   height?: string;
   color?: string;
   withBorder?: boolean;
+  rounded?: boolean;
+  alt?: boolean;
 };
 
 const Button: React.FC<Props> = ({
@@ -31,6 +33,8 @@ const Button: React.FC<Props> = ({
   height,
   color,
   withBorder,
+  rounded,
+  alt,
 }) => {
   const renderStatus = () => {
     switch (status) {
@@ -67,7 +71,9 @@ const Button: React.FC<Props> = ({
         width={width}
         height={height}
         color={color}
-        withBorder={withBorder}
+        withBorder={withBorder || alt}
+        rounded={rounded || alt}
+        alt={alt}
       >
         <Text>{children}</Text>
       </StyledButton>
@@ -119,7 +125,6 @@ const StatusWrapper = styled.div<{
 
 const Wrapper = styled.div`
   display: flex;
-  align-items: center;
 `;
 
 const Text = styled.div`
@@ -134,20 +139,28 @@ const StyledButton = styled.button<{
   height: string;
   color: string;
   withBorder: boolean;
+  rounded: boolean;
+  alt: boolean;
 }>`
   height: ${props => props.height || "35px"};
   width: ${props => props.width || "auto"};
+  min-width: ${props => props.width || ""};
   font-size: 13px;
   cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
   padding: 15px;
   border: none;
   outline: none;
   color: white;
-  background: ${props => (props.disabled && !props.color) ? "#aaaabb" : (props.color || "#5561C0")};
+  background: ${props => {
+    if (props.alt) {
+      return props.theme.fg;
+    }
+    return (props.disabled && !props.color) ? "#aaaabb" : (props.color || props.theme.button);
+  }};
   display: flex;
   ailgn-items: center;
   justify-content: center;
-  border-radius: 5px;
+  border-radius: ${props => props.rounded ? "50px" : "5px"};
   border: ${props => props.withBorder ? "1px solid #494b4f" : "none"};
 
   :hover {

+ 5 - 0
dashboard/src/components/porter/Container.tsx

@@ -4,16 +4,19 @@ import styled from "styled-components";
 type Props = {
   children: React.ReactNode;
   row?: boolean;
+  spaced?: boolean;
 };
 
 const Container: React.FC<Props> = ({
   children,
   row,
+  spaced,
 }) => {
   const [isExpanded, setIsExpanded] = useState(false);
 
   return (
     <StyledContainer
+      spaced={spaced}
       row={row}
     >
       {children}
@@ -25,7 +28,9 @@ export default Container;
 
 const StyledContainer = styled.div<{
   row: boolean;
+  spaced: boolean;
 }>`
   display: ${props => props.row ? "flex" : "block"};
   align-items: center;
+  justify-content: ${props => props.spaced ? "space-between" : "flex-start"};
 `;

+ 1 - 1
dashboard/src/components/porter/Error.tsx

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 
-import expand from "assets/expand.png";
+import Spacer from "./Spacer";
 import Modal from "./Modal";
 
 type Props = {

+ 5 - 8
dashboard/src/components/porter/ExpandableSection.tsx

@@ -25,13 +25,10 @@ const ExpandableSection: React.FC<Props> = ({
   collapseText,
   maxHeight,
 }) => {
-  const [isExpanded, setIsExpanded] = useState(false);
-  useEffect(() => {
-    setIsExpanded(isInitiallyExpanded);
-  }, [isInitiallyExpanded]);
+  const [isExpanded, setIsExpanded] = useState(isInitiallyExpanded ?? false);
 
   return (
-    <StyledExpandableSection 
+    <StyledExpandableSection
       isExpanded={isExpanded}
       background={background}
       noWrapper={noWrapper}
@@ -44,7 +41,7 @@ const ExpandableSection: React.FC<Props> = ({
           </ExpandButton>
         </Container>
       ) : (
-        <HeaderRow 
+        <HeaderRow
           isExpanded={isExpanded}
           onClick={() => setIsExpanded(!isExpanded)}
           color={color}
@@ -71,7 +68,7 @@ const ExpandButton = styled.div`
   font-size: 13px;
 `;
 
-const HeaderRow = styled.div<{ 
+const HeaderRow = styled.div<{
   isExpanded: boolean;
   color?: string;
 }>`
@@ -97,7 +94,7 @@ const HeaderRow = styled.div<{
   }
 `;
 
-const StyledExpandableSection = styled.div<{ 
+const StyledExpandableSection = styled.div<{
   isExpanded: boolean;
   background?: string;
   noWrapper?: boolean;

+ 1 - 1
dashboard/src/components/porter/Fieldset.tsx

@@ -33,7 +33,7 @@ const StyledFieldset = styled.div<{
   position: relative;
   padding: 25px;
   border-radius: 5px;
-  background: ${props => props.background || "#26292e"};
+  background: ${props => props.background || props.theme.fg};
   border: 1px solid #494b4f;
   font-size: 13px;
 `;

+ 12 - 3
dashboard/src/components/porter/Modal.tsx

@@ -81,18 +81,27 @@ const ModalBg = styled.div`
   width: 100vw;
   height: 100vh;
   background-color: rgba(0, 0, 0, 0.6);
+  animation: fadeInModal 0.5s 0s;
+  @keyframes fadeInModal {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
 `;
 
 const StyledModal = styled.div`
   position: relative;
   padding: 25px;
-  padding-bottom: 30px;
+  padding-bottom: 35px;
   border-radius: 10px;
   border: 1px solid #494b4f;
   font-size: 13px;
   width: 600px;
-  background: #42444944;
-  backdrop-filter: saturate(150%) blur(10px);
+  background: #42444933;
+  backdrop-filter: saturate(150%) blur(8px);
 
   animation: floatInModal 0.5s 0s;
   @keyframes floatInModal {

+ 126 - 0
dashboard/src/components/porter/SearchBar.tsx

@@ -0,0 +1,126 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import search from "assets/search.png";
+
+type Props = {
+  placeholder: string;
+  width?: string;
+  value: string;
+  setValue: (value: string) => void;
+  label?: string | React.ReactNode;
+  height?: string;
+  type?: string;
+  error?: string;
+  children?: React.ReactNode;
+};
+
+const SearchBar: React.FC<Props> = ({
+  placeholder,
+  width,
+  value,
+  setValue,
+  label,
+  height,
+  type,
+  error,
+  children,
+}) => {
+  return (
+    <Block width={width}>
+      {
+        label && (
+          <Label>{label}</Label>
+        )
+      }
+      <StyledSearchBar
+        width={width}
+        height={height}
+        hasError={(error && true) || (error === "")}
+      >
+        <Icon src={search} />
+        <Input
+          value={value}
+          onChange={e => setValue(e.target.value)}
+          placeholder={placeholder}
+          type={type || "text"}
+        />
+        {
+          error && (
+            <Error>
+              <i className="material-icons">error</i>
+              {error}
+            </Error>
+          )
+        }
+      </StyledSearchBar>
+      {children}
+    </Block>
+  );
+};
+
+export default SearchBar;
+
+const Icon = styled.img`
+  position: absolute;
+  left: 12px;
+  top: 50%;
+  opacity: 0.6;
+  transform: translateY(-50%);
+  height: 11px;
+`;
+
+const Block = styled.div<{
+  width: string;
+}>`
+  display: block;
+  position: relative;
+  width: ${props => props.width || "200px"};
+`;
+
+const Label = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+  margin-bottom: 10px;
+`;
+
+const Error = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ff3b62;
+  margin-top: 10px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const StyledSearchBar = styled.div<{
+  width: string;
+  height: string;
+  hasError: boolean;
+}>`
+  height: ${props => props.height || "30px"};
+  padding: 5px 10px;
+  width: ${props => props.width || "200px"};
+  color: #ffffff;
+  font-size: 13px;
+  border-radius: 5px;
+  background: ${props => props.theme.fg};
+
+  border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
+  :hover {
+    border: 1px solid ${props => props.hasError ? "#ff3b62" : "#7a7b80"};
+  }
+`;
+
+const Input = styled.input`
+  outline: none;
+  background: #00000000;
+  border: none;
+  width: 100%;
+  height: 100%;
+  padding-left: 23px;
+`;

+ 128 - 0
dashboard/src/components/porter/Select.tsx

@@ -0,0 +1,128 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  width?: string;
+  options: { label: string; value: string }[];
+  label?: string | React.ReactNode;
+  height?: string;
+  error?: string;
+  children?: React.ReactNode;
+  disabled?: boolean;
+  value?: string;
+  setValue?: (value: string) => void;
+};
+
+const Select: React.FC<Props> = ({
+  width,
+  options,
+  label,
+  height,
+  error,
+  children,
+  disabled,
+  value,
+  setValue,
+}) => {
+  return (
+    <Block width={width}>
+      {
+        label && (
+          <Label>{label}</Label>
+        )
+      }
+      <SelectWrapper>
+        <i className="material-icons">arrow_drop_down</i>
+        <StyledSelect
+          onChange={e => {
+            setValue(e.target.value);
+          }}
+          width={width}
+          height={height}
+          hasError={(error && true) || (error === "")}
+          disabled={disabled ? disabled : false}
+        >
+          {options.map((option, i) => {
+            return <option value={option.value} key={i}>{option.label}</option>;
+          })}
+        </StyledSelect>
+      </SelectWrapper>
+      {
+        error && (
+          <Error>
+            <i className="material-icons">error</i>
+            {error}
+          </Error>
+        )
+      }
+      {children}
+    </Block>
+  );
+};
+
+export default Select;
+
+const Block = styled.div<{
+  width: string;
+}>`
+  display: block;
+  position: relative;
+  width: ${props => props.width || "200px"};
+`;
+
+const Label = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+  margin-bottom: 10px;
+`;
+
+const Error = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ff3b62;
+  margin-top: 10px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const SelectWrapper = styled.div`
+  position: relative;
+  background: #26292e;
+  z-index: 0;
+  border-radius: 5px;
+  overflow: hidden;
+  > i {
+    font-size: 18px;
+    position: absolute;
+    right: 7px;
+    top: calc(50% - 9px);
+    z-index: -1;
+  }
+`;
+
+const StyledSelect = styled.select<{
+  width: string;
+  height: string;
+  hasError: boolean;
+}>`
+  height: ${props => props.height || "35px"};
+  padding: 5px 10px;
+  width: ${props => props.width || "200px"};
+  color: #ffffff;
+  font-size: 13px;
+  outline: none;
+  cursor: pointer;
+  border-radius: 5px;
+  background: none;
+  appearance: none;
+  overflow: hidden;
+  z-index: 1;
+  border: 1px solid ${props => props.hasError ? "#ff3b62" : "#494b4f"};
+  :hover {
+    border: 1px solid ${props => props.hasError ? "#ff3b62" : "#7a7b80"};
+  }
+`;

+ 54 - 0
dashboard/src/components/porter/Toggle.tsx

@@ -0,0 +1,54 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  items: any[];
+  active: string;
+  setActive: (active: string) => void;
+  highlightColor?: string;
+};
+
+const Toggle: React.FC<Props> = ({
+  items,
+  active,
+  setActive,
+  highlightColor,
+}) => {
+  return (
+    <StyledToggle>
+      {items.map((item, index) => (
+        <Item
+          active={item.value === active}
+          onClick={() => {
+            setActive(item.value);
+          }}
+          highlightColor={highlightColor}
+        >
+          {item.label}
+        </Item>
+      ))}
+    </StyledToggle>
+  );
+};
+
+export default Toggle;
+
+const StyledToggle = styled.div`
+  display: flex;
+  height: 30px;
+  background: ${(props) => props.theme.fg};
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+  align-items: center;
+`;
+
+const Item = styled.div<{ active: boolean; highlightColor?: string }>`
+  display: flex;
+  align-items: center;
+  height: 100%;
+  cursor: pointer;
+  justify-content: center;
+  padding: 10px;
+  background: ${(props) =>
+    props.active ? props.highlightColor ?? "#ffffff11" : "transparent"};
+`;

+ 95 - 0
dashboard/src/components/porter/VerticalSteps.tsx

@@ -0,0 +1,95 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  steps: React.ReactNode[];
+  currentStep: number;
+};
+
+const VerticalSteps: React.FC<Props> = ({
+  steps,
+  currentStep,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledVerticalSteps>
+      {steps.map((step, i) => {
+        return (
+          <StepWrapper isLast={i === steps.length - 1}>
+            {
+              (i !== steps.length - 1) && (
+                <Line isActive={i + 1 <= currentStep} />
+              )
+            }
+            <Dot
+              isActive={i <= currentStep}
+            />
+            <OpacityWrapper isActive={i <= currentStep}>
+              {step}
+              {
+                i > currentStep && (
+                  <ReadOnlyOverlay />
+                )
+              }
+            </OpacityWrapper>
+          </StepWrapper>
+        );
+      })}
+    </StyledVerticalSteps>
+  );
+};
+
+export default VerticalSteps;
+
+const ReadOnlyOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 999;
+`;
+
+const Line = styled.div<{
+  isActive: boolean;
+}>`
+  width: 1px;
+  height: calc(100% + 35px);
+  background: ${props => props.isActive ? "#fff" : "#414141"};
+  position: absolute;
+  left: 4px;
+  top: 8px;
+  opacity: 1;
+`;
+
+const Dot = styled.div<{
+  isActive: boolean;
+}>`
+  width: 9px;
+  height: 9px;
+  background: ${props => props.isActive ? "#fff" : "#414141"};
+  border-radius: 50%;
+  position: absolute;
+  left: 0;
+  top: 7px;
+  opacity: 1;
+`;
+
+const OpacityWrapper = styled.div<{
+  isActive: boolean;
+}>`
+  opacity: ${props => props.isActive ? 1 : 0.5};
+`;
+
+const StepWrapper = styled.div<{
+  isLast: boolean;
+}>`
+  padding-left: 30px;
+  position: relative;
+  margin-bottom: ${props => props.isLast ? "" : "35px"};
+`;
+
+const StyledVerticalSteps = styled.div<{
+}>`
+`;

+ 120 - 0
dashboard/src/components/repo-selector/ActionConfBranchSelector.tsx

@@ -0,0 +1,120 @@
+import React from "react";
+import styled from "styled-components";
+
+import { ActionConfigType } from "shared/types";
+
+import RepoList from "./RepoList";
+import BranchList from "./BranchList";
+import InputRow from "../form-components/InputRow";
+
+type Props = {
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
+};
+
+const ActionConfEditorStack: React.FC<Props> = ({
+  actionConfig,
+  setBranch,
+  setActionConfig,
+  branch,
+  setFolderPath,
+  setDockerfilePath,
+}) => {
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else if (!branch) {
+    setFolderPath("./");
+    return (
+      <>
+        <ExpandedWrapperAlt>
+          <BranchList
+            actionConfig={actionConfig}
+            setBranch={(branch: string) => setBranch(branch)}
+          />
+        </ExpandedWrapperAlt>
+        <Br />
+      </>
+    );
+  }
+  return (
+    <>
+      <InputRow
+        disabled={true}
+        label="Branch"
+        type="text"
+        width="100%"
+        value={branch}
+      />
+      <BackButton
+        width="145px"
+        onClick={() => {
+          setFolderPath("");
+          setBranch("");
+          setDockerfilePath("");
+        }}
+      >
+        <i className="material-icons">keyboard_backspace</i>
+        Select branch
+      </BackButton>
+    </>
+  );
+};
+
+export default ActionConfEditorStack;
+
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 110 - 0
dashboard/src/components/repo-selector/ActionConfEditorStack.tsx

@@ -0,0 +1,110 @@
+import React from "react";
+import styled from "styled-components";
+
+import { ActionConfigType } from "shared/types";
+
+import RepoList from "./RepoList";
+import InputRow from "../form-components/InputRow";
+
+type Props = {
+  actionConfig: ActionConfigType | null;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
+};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
+
+const ActionConfEditorStack: React.FC<Props> = ({
+  actionConfig,
+  setBranch,
+  setActionConfig,
+  setFolderPath,
+  setDockerfilePath,
+}) => {
+
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else {
+    return (
+      <>
+        <InputRow
+          disabled={true}
+          label="Git repository"
+          type="text"
+          width="100%"
+          value={actionConfig?.git_repo}
+        />
+        <BackButton
+          width="135px"
+          onClick={() => {
+            setActionConfig({ ...defaultActionConfig });
+            setBranch("");
+            setFolderPath("");
+            setDockerfilePath("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select repo
+        </BackButton>
+      </>
+    );
+  }
+};
+
+export default ActionConfEditorStack;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 577 - 0
dashboard/src/components/repo-selector/BuildpackStack.tsx

@@ -0,0 +1,577 @@
+import { DeviconsNameList } from "assets/devicons-name-list";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ActionConfigType } from "shared/types";
+import styled, { keyframes } from "styled-components";
+// Add the following imports
+import { Button as MuiButton, Modal as MuiModal } from "@material-ui/core";
+import { makeStyles, withStyles } from "@material-ui/core/styles";
+
+const DEFAULT_BUILDER_NAME = "heroku";
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+
+type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+export const BuildpackStack: React.FC<{
+  actionConfig: ActionConfigType;
+  folderPath: string;
+  branch: string;
+  hide: boolean;
+  onChange: (config: BuildConfig) => void;
+}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+  const renderModalContent = () => {
+    return (
+      <div
+        className="modal-content"
+        style={{
+          backgroundColor: "black",
+          color: "white",
+          outline: "none",
+          padding: "32px",
+          borderRadius: "8px",
+          width: "80%",
+          maxWidth: "600px",
+          position: "relative",
+          display: "flex",
+          flexDirection: "column",
+        }}
+      >
+        <h2 id="buildpack-configuration-title">Buildpack Configuration</h2>
+        <p id="buildpack-configuration-description">
+          Configure your buildpacks here.
+        </p>
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+
+        <Helper>Available buildpacks:</Helper>
+        {!!availableBuildpacks?.length && (
+          <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        )}
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+
+        <div style={{ marginTop: "auto" }}>
+          {/* Add Save button */}
+          <SaveButton variant="contained" onClick={() => setIsModalOpen(false)}>
+            Save
+          </SaveButton>
+        </div>
+      </div>
+    );
+  };
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+    if (typeof onChange === "function") {
+      onChange(buildConfig);
+    }
+  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
+
+  const detectBuildpack = () => {
+    if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        { dir: folderPath || "." },
+        {
+          project_id: currentProject.id,
+          integration_id: actionConfig.gitlab_integration_id,
+
+          repo_owner: actionConfig.git_repo.split("/")[0],
+          repo_name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      );
+    }
+
+    return api.detectBuildpack<DetectBuildpackResponse>(
+      "<token>",
+      {
+        dir: folderPath || ".",
+      },
+      {
+        project_id: currentProject.id,
+        git_repo_id: actionConfig.git_repo_id,
+        kind: "github",
+        owner: actionConfig.git_repo.split("/")[0],
+        name: actionConfig.git_repo.split("/")[1],
+        branch: branch,
+      }
+    );
+  };
+
+  const classes = useStyles();
+
+  useEffect(() => {
+    detectBuildpack()
+      // getMockData()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find(
+          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
+        );
+
+        const detectedBuildpacks = defaultBuilder.detected;
+        const availableBuildpacks = defaultBuilder.others;
+        const defaultStack = builders
+          .flatMap((builder) => builder.builders)
+          .find((stack) => {
+            return (
+              stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
+            );
+          });
+
+        setBuilders(builders);
+        setSelectedStack(defaultStack);
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(detectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(detectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.flatMap((builder) => {
+      return builder.builders.map((stack) => ({
+        label: `${builder.name} - ${stack}`,
+        value: stack.toLowerCase(),
+      }));
+    });
+  }, [builders]);
+
+  // const handleSelectBuilder = (builderName: string) => {
+  //   const builder = builders.find(
+  //     (b) => b.name.toLowerCase() === builderName.toLowerCase()
+  //   );
+  //   const detectedBuildpacks = builder.detected;
+  //   const availableBuildpacks = builder.others;
+  //   const defaultStack = builder.builders.find((stack) => {
+  //     return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
+  //   });
+  //   setSelectedBuilder(builderName);
+  //   setBuilders(builders);
+  //   setSelectedBuilder(builderName.toLowerCase());
+
+  //   setStacks(builder.builders);
+  //   setSelectedStack(defaultStack);
+
+  //   if (!Array.isArray(detectedBuildpacks)) {
+  //     setSelectedBuildpacks([]);
+  //   } else {
+  //     setSelectedBuildpacks(detectedBuildpacks);
+  //   }
+  //   if (!Array.isArray(availableBuildpacks)) {
+  //     setAvailableBuildpacks([]);
+  //   } else {
+  //     setAvailableBuildpacks(availableBuildpacks);
+  //   }
+  // };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add",
+    isLast: boolean = false
+  ) => {
+    return buildpacks?.map((buildpack, index) => {
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
+
+      let disableIcon = false;
+      if (!devicon) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={buildpack.name}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <ActionButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </ActionButton>
+            )}
+            {action === "remove" && (
+              <ActionButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </ActionButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  if (hide) {
+    return null;
+  }
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your builder and stack"
+        />
+        {!!selectedBuildpacks?.length && (
+          <Helper>
+            The following buildpacks were automatically detected. You can also
+            manually add/remove buildpacks.
+          </Helper>
+        )}
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+        {/* Add the "Add Build Pack" button */}
+        <AddBuildPackButton
+          variant="contained"
+          onClick={() => setIsModalOpen(true)}
+        >
+          Add Build Pack
+        </AddBuildPackButton>
+
+        {/* Add the styled Material-UI modal */}
+        <StyledModal
+          open={isModalOpen}
+          onClose={() => setIsModalOpen(false)}
+          aria-labelledby="buildpack-configuration-title"
+          aria-describedby="buildpack-configuration-description"
+          className={classes.modal} // Apply the custom styles
+        >
+          {renderModalContent()}
+        </StyledModal>
+      </>
+    </BuildpackConfigurationContainer>
+  );
+};
+
+export const AddCustomBuildpackForm: React.FC<{
+  onAdd: (buildpack: Buildpack) => void;
+}> = ({ onAdd }) => {
+  const [buildpackUrl, setBuildpackUrl] = useState("");
+  const [error, setError] = useState(false);
+
+  const handleAddCustomBuildpack = () => {
+    const buildpack: Buildpack = {
+      buildpack: buildpackUrl,
+      name: buildpackUrl,
+      config: null,
+    };
+    setBuildpackUrl("");
+    onAdd(buildpack);
+  };
+
+  return (
+    <StyledCard isLargeMargin>
+      <ContentContainer>
+        <EventInformation>
+          <BuildpackInputContainer>
+            GitHub or ZIP URL
+            <BuildpackUrlInput
+              placeholder="https://github.com/custom/buildpack"
+              type="input"
+              value={buildpackUrl}
+              isRequired
+              setValue={(newUrl) => {
+                setError(false);
+                setBuildpackUrl(newUrl as string);
+              }}
+            />
+            <ErrorText hasError={error}>Please enter a valid url</ErrorText>
+          </BuildpackInputContainer>
+        </EventInformation>
+      </ContentContainer>
+      <ActionContainer>
+        <ActionButton onClick={() => handleAddCustomBuildpack()}>
+          <span className="material-icons-outlined">add</span>
+        </ActionButton>
+      </ActionContainer>
+    </StyledCard>
+  );
+};
+
+const ErrorText = styled.span`
+  color: red;
+  margin-left: 10px;
+  display: ${(props: { hasError: boolean }) =>
+    props.hasError ? "inline-block" : "none"};
+`;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const BuildpackUrlInput = styled(InputRow)`
+  width: auto;
+  min-width: 300px;
+  max-width: 600px;
+  margin: unset;
+  margin-left: 10px;
+  display: inline-block;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #000010;
+  margin-bottom: 5px;
+  margin-bottom: ${({ isLargeMargin }) => (isLargeMargin ? "30px" : "5px")};
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const BuildpackInputContainer = styled(EventName)`
+  padding-left: 15px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const AddBuildPackButton = withStyles({
+  root: {
+    backgroundColor: "#8590ff",
+    color: "white",
+    marginBottom: "15px",
+    marginTop: "10px",
+  },
+})(MuiButton);
+
+const SaveButton = withStyles({
+  root: {
+    backgroundColor: "#8590ff",
+    color: "white",
+    marginTop: "24px",
+    position: "absolute",
+    bottom: "16px",
+    right: "16px",
+  },
+})(MuiButton);
+
+const StyledModal = withStyles({
+  root: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+  },
+})(MuiModal);
+const useStyles = makeStyles((theme) => ({
+  modal: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+  },
+}));

+ 544 - 0
dashboard/src/components/repo-selector/DetectContentsList.tsx

@@ -0,0 +1,544 @@
+import React, { useState, useEffect, useContext, useCallback } from "react";
+import styled from "styled-components";
+import file from "assets/file.svg";
+import folder from "assets/folder.svg";
+import info from "assets/info.svg";
+import close from "assets/close.png";
+import Button from "components/porter/Button";
+import api from "../../shared/api";
+import { Context } from "../../shared/Context";
+import { ActionConfigType, FileType } from "../../shared/types";
+
+import Loading from "../Loading";
+import Spacer from "components/porter/Spacer";
+import AdvancedBuildSettings from "main/home/app-dashboard/new-app-flow/AdvancedBuildSettings";
+import { render } from "react-dom";
+import BuildpackConfigSection from "main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection";
+
+interface AutoBuildpack {
+  name?: string;
+  valid: boolean;
+}
+
+type PropsType = {
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  dockerfilePath?: string;
+  folderPath: string;
+  procfilePath?: string;
+  porterYaml?: string;
+  setActionConfig: (x: ActionConfigType) => void;
+  setProcfileProcess?: (x: string) => void;
+  setDockerfilePath: (x: string) => void;
+  setProcfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
+  setBuildConfig: (x: any) => void;
+  setPorterYaml: (x: any) => void;
+};
+
+const DetectContentsList: React.FC<PropsType> = (props) => {
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const [contents, setContents] = useState<FileType[]>([]);
+  const [currentDir, setCurrentDir] = useState("");
+  const [autoBuildpack, setAutoBuildpack] = useState<AutoBuildpack>({
+    valid: false,
+    name: "",
+  });
+  const [showingBuildContextPrompt, setShowingBuildContextPrompt] = useState(
+    "buildpacks"
+  );
+  const context = useContext(Context);
+  const fetchAndSetPorterYaml = useCallback(async (fileName: string) => {
+    try {
+      const response = await fetchPorterYamlContent(fileName);
+      props.setPorterYaml(atob(response.data));
+    } catch (error) {
+      console.error("Error fetching porter.yaml content:", error);
+    }
+  }, []);
+
+  useEffect(() => {
+    const porterYamlItem = contents.find((item: FileType) =>
+      item.path.includes("porter.yaml")
+    );
+
+    if (porterYamlItem) {
+      fetchAndSetPorterYaml("porter.yaml");
+    }
+
+  }, [contents, fetchAndSetPorterYaml]);
+
+  useEffect(() => {
+    updateContents();
+  }, []);
+  useEffect(() => {
+    const dockerFileItem = contents.find((item: FileType) =>
+      item.path.includes("Dockerfile")
+    );
+
+    if (dockerFileItem) {
+      props.setDockerfilePath(dockerFileItem.path);
+      setShowingBuildContextPrompt("docker");
+    }
+  }, [contents]);
+
+  useEffect(() => {
+    detectBuildpacks().then(({ data }) => {
+      setAutoBuildpack(data);
+    });
+  }, [contents]);
+
+  const renderContentList = () => {
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error || !contents) {
+      return <LoadingWrapper>Error loading repo contents.</LoadingWrapper>;
+    }
+
+    return contents.map((item: FileType, i: number) => {
+      let splits = item.path.split("/");
+      let fileName = splits[splits.length - 1];
+      if (fileName.includes("Dockerfile")) {
+        return (
+          <AdvancedBuildSettings
+            setBuildConfig={props.setBuildConfig}
+            autoBuildPack={autoBuildpack}
+            showSettings={false}
+            buildView={"docker"}
+            actionConfig={props.actionConfig}
+            branch={props.branch}
+            folderPath={props.folderPath}
+          />
+        );
+      }
+    });
+  };
+
+  const fetchContents = () => {
+    let { currentProject } = context;
+    const { actionConfig, branch } = props;
+
+    if (actionConfig.kind === "gitlab") {
+      return api
+        .getGitlabFolderContent(
+          "<token>",
+          { dir: currentDir || "./" },
+          {
+            project_id: currentProject.id,
+            integration_id: actionConfig.gitlab_integration_id,
+            repo_owner: actionConfig.git_repo.split("/")[0],
+            repo_name: actionConfig.git_repo.split("/")[1],
+            branch: branch,
+          }
+        )
+        .then((res) => {
+          const { data } = res;
+
+          return {
+            data: data.map((x: FileType) => ({
+              ...x,
+              type: x.type === "tree" ? "dir" : "file",
+            })),
+          };
+        });
+    }
+    return api.getBranchContents(
+      "<token>",
+      { dir: currentDir || "./" },
+      {
+        project_id: currentProject.id,
+        git_repo_id: actionConfig.git_repo_id,
+        kind: "github",
+        owner: actionConfig.git_repo.split("/")[0],
+        name: actionConfig.git_repo.split("/")[1],
+        branch: branch,
+      }
+    );
+  };
+
+  const fetchPorterYamlContent = async (porterYaml: string) => {
+    let { currentProject } = context;
+    let { actionConfig, branch } = props;
+
+    try {
+      const res = await api.getPorterYamlContents(
+        "<token>",
+        {
+          path: porterYaml,
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      );
+      return res;
+    } catch (err) {
+      console.log(err);
+    }
+  };
+  const detectBuildpacks = () => {
+    let { currentProject } = context;
+    let { actionConfig, branch } = props;
+
+    if (actionConfig.kind === "github") {
+      return api.detectBuildpack(
+        "<token>",
+        {
+          dir: currentDir || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      );
+    }
+
+    return api.detectGitlabBuildpack(
+      "<token>",
+      { dir: currentDir || "." },
+      {
+        project_id: currentProject.id,
+        integration_id: actionConfig.gitlab_integration_id,
+
+        repo_owner: actionConfig.git_repo.split("/")[0],
+        repo_name: actionConfig.git_repo.split("/")[1],
+        branch: branch,
+      }
+    );
+  };
+
+  const updateContents = async () => {
+    try {
+      const res = await fetchContents();
+      let files = [] as FileType[];
+      let folders = [] as FileType[];
+      res.data.map((x: FileType, i: number) => {
+        x.type === "dir" ? folders.push(x) : files.push(x);
+      });
+
+      folders.sort((a: FileType, b: FileType) => {
+        return a.path < b.path ? 1 : 0;
+      });
+      files.sort((a: FileType, b: FileType) => {
+        return a.path < b.path ? 1 : 0;
+      });
+      let contents = folders.concat(files);
+
+      setContents(contents);
+      setLoading(false);
+      setError(false);
+    } catch (err) {
+      console.log(err);
+      setLoading(false);
+      setError(true);
+    }
+
+    try {
+      const { data } = await detectBuildpacks();
+      setAutoBuildpack(data);
+    } catch (err) {
+      console.log(err);
+      setAutoBuildpack({
+        valid: false,
+      });
+    }
+  };
+  return (
+    <>
+      {renderContentList()}
+      {props.dockerfilePath == null || props.dockerfilePath == "" ? (
+        <AdvancedBuildSettings
+          setBuildConfig={props.setBuildConfig}
+          autoBuildPack={autoBuildpack}
+          showSettings={false}
+          buildView={"buildpacks"}
+          actionConfig={props.actionConfig}
+          branch={props.branch}
+          folderPath={props.folderPath}
+        />
+      ) : (
+        <></>
+      )}
+    </>
+  );
+};
+
+export default DetectContentsList;
+
+const FlexWrapper = styled.div`
+  position: absolute;
+  bottom: 28px;
+  left: 195px;
+  display: flex;
+  align-items: center;
+`;
+
+const StatusWrapper = styled.a<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #949eff;
+  margin-right: 25px;
+  margin-left: 20px;
+  cursor: pointer;
+  text-decoration: none;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const BgOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.8);
+  z-index: -1;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  margin-right: 13px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Label = styled.div`
+  max-width: 500px;
+  line-height: 1.5em;
+  text-align: center;
+  font-size: 14px;
+`;
+
+const MultiSelectRow = styled.div`
+  display: flex;
+  min-width: 150px;
+  justify-content: space-between;
+`;
+
+const DockerfileList = styled.div`
+  border-radius: 3px;
+  margin-top: 20px;
+  border: 1px solid #aaaabb;
+  background: #ffffff22;
+  width: 100%;
+  max-width: 500px;
+  max-height: 140px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLast: boolean }>`
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  align-items: center;
+  border-bottom: ${(props) => !props.isLast && "1px solid #aaaabb"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 7px 12px;
+  outline: none;
+  border: 1px solid white;
+  margin-top: 25px;
+  border-radius: 10px;
+  text-align: center;
+  cursor: pointer;
+  opacity: 0;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from {
+      transform: translateY(20px);
+      opacity: 0;
+    }
+    to {
+      transform: translateY(0px);
+      opacity: 1;
+    }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const Overlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 0 90px;
+`;
+
+const UseButton = styled.div`
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #616feecc;
+  font-weight: 500;
+  padding: 10px 15px;
+  border-radius: 100px;
+  cursor: pointer;
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+const BackLabel = styled.div`
+  font-size: 16px;
+  padding-left: 16px;
+  margin-top: -4px;
+  padding-bottom: 4px;
+`;
+
+const Item = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props: { lastItem: boolean; isSelected?: boolean }) =>
+    props.lastItem ? "#00000000" : "#606166"};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected?: boolean; lastItem: boolean }) =>
+    props.isSelected ? "#ffffff22" : "#ffffff11"};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const FileItem = styled(Item)`
+  cursor: ${(props: { isADocker?: boolean }) =>
+    props.isADocker ? "pointer" : "default"};
+  color: ${(props: { isADocker?: boolean }) =>
+    props.isADocker ? "#fff" : "#ffffff55"};
+  :hover {
+    background: ${(props: { isADocker?: boolean }) =>
+    props.isADocker ? "#ffffff22" : "#ffffff11"};
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 5px 0 10px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 8px;
+  padding-left: 15px;
+  align-items: center;
+  background: #ffffff11;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;
+const DetectedBuildMessage = styled.div`
+  color: #0f872b;
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  margin-right: 10px;
+
+  > i {
+    margin-right: 6px;
+    font-size: 20px;
+    border-radius: 20px;
+    transform: none;
+  }
+`;

+ 172 - 143
dashboard/src/main/home/Home.tsx

@@ -1,9 +1,11 @@
 import React, { useEffect, useState, useContext, useRef } from "react";
 import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
-import styled from "styled-components";
+import styled, { ThemeProvider } from "styled-components";
 import { createPortal } from "react-dom";
 
 import api from "shared/api";
+import midnight from "shared/themes/midnight";
+import standard from "shared/themes/standard";
 import { Context } from "shared/Context";
 import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
 import { ClusterType, ProjectType } from "shared/types";
@@ -18,7 +20,8 @@ import LaunchWrapper from "./launch/LaunchWrapper";
 import Navbar from "./navbar/Navbar";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
-import PageNotFound from "components/PageNotFound";
+import AppDashboard from "./app-dashboard/AppDashboard";
+import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
 
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -29,10 +32,13 @@ import { NewProjectFC } from "./new-project/NewProject";
 import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
 import { overrideInfraTabEnabled } from "utils/infrastructure";
 import NoClusterPlaceHolder from "components/NoClusterPlaceHolder";
+import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
 import Modal from "components/porter/Modal";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import Button from "components/porter/Button";
+import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
+import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -82,12 +88,10 @@ const Home: React.FC<Props> = (props) => {
   } = useContext(Context);
 
   const [showWelcome, setShowWelcome] = useState(false);
-  const [prevProjectId, setPrevProjectId] = useState<number | null>(null);
   const [forceRefreshClusters, setForceRefreshClusters] = useState(false);
-  const [sidebarReady, setSidebarReady] = useState(false);
-  const [handleDO, setHandleDO] = useState(false);
   const [ghRedirect, setGhRedirect] = useState(false);
   const [forceSidebar, setForceSidebar] = useState(true);
+  const [theme, setTheme] = useState(standard);
   const [showWrongEmailModal, setShowWrongEmailModal] = useState(false);
 
   const redirectToNewProject = () => {
@@ -187,7 +191,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) {}
+    } catch (error) { }
   };
 
   useEffect(() => {
@@ -361,157 +365,182 @@ const Home: React.FC<Props> = (props) => {
 
   const { cluster, baseRoute } = props.match.params as any;
   return (
-    <StyledHome>
-      <ModalHandler setRefreshClusters={setForceRefreshClusters} />
-      {currentOverlay &&
-        createPortal(
-          <ConfirmOverlay
-            show={true}
-            message={currentOverlay.message}
-            onYes={currentOverlay.onYes}
-            onNo={currentOverlay.onNo}
-          />,
-          document.body
+    <ThemeProvider theme={currentProject?.simplified_view_enabled ? midnight : standard}>
+      <StyledHome>
+        <ModalHandler setRefreshClusters={setForceRefreshClusters} />
+        {currentOverlay &&
+          createPortal(
+            <ConfirmOverlay
+              show={true}
+              message={currentOverlay.message}
+              onYes={currentOverlay.onYes}
+              onNo={currentOverlay.onNo}
+            />,
+            document.body
+          )}
+        {/* Render sidebar when there's at least one project */}
+        {projects?.length > 0 && baseRoute !== "new-project" ? (
+          <Sidebar
+            key="sidebar"
+            forceSidebar={forceSidebar}
+            setWelcome={setShowWelcome}
+            currentView={props.currentRoute}
+            forceRefreshClusters={forceRefreshClusters}
+            setRefreshClusters={setForceRefreshClusters}
+          />
+        ) : (
+          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+            <Icon src={discordLogo} />
+            Join Our Discord
+          </DiscordButton>
         )}
-      {/* Render sidebar when there's at least one project */}
-      {projects?.length > 0 && baseRoute !== "new-project" ? (
-        <Sidebar
-          key="sidebar"
-          forceSidebar={forceSidebar}
-          setWelcome={setShowWelcome}
-          currentView={props.currentRoute}
-          forceRefreshClusters={forceRefreshClusters}
-          setRefreshClusters={setForceRefreshClusters}
-        />
-      ) : (
-        <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-          <Icon src={discordLogo} />
-          Join Our Discord
-        </DiscordButton>
-      )}
-      <ViewWrapper id="HomeViewWrapper">
-        <Navbar
-          logOut={props.logOut}
-          currentView={props.currentRoute} // For form feedback
-        />
-
-        <Switch>
-          <Route
-            path="/new-project"
-            render={() => {
-              return <NewProjectFC />;
-            }}
-          ></Route>
-          <Route
-            path="/onboarding"
-            render={() => {
-              return <Onboarding />;
-            }}
+        <ViewWrapper id="HomeViewWrapper">
+          <Navbar
+            logOut={props.logOut}
+            currentView={props.currentRoute} // For form feedback
           />
-          {(user?.isPorterUser ||
-            overrideInfraTabEnabled({
-              projectID: currentProject?.id,
-            })) && (
+
+          <Switch>
+            <Route
+              path="/apps/new"
+            >
+              <NewAppFlow />
+            </Route>
+            <Route path="/apps/:appName">
+              <ExpandedApp />
+            </Route>
+            <Route
+              path="/apps"
+            >
+              <AppDashboard />
+            </Route>
+            <Route
+              path="/addons/new"
+            >
+              <NewAddOnFlow />
+            </Route>
+            <Route
+              path="/addons"
+            >
+              <AddOnDashboard />
+            </Route>
             <Route
-              path="/infrastructure"
+              path="/new-project"
+              render={() => {
+                return <NewProjectFC />;
+              }}
+            ></Route>
+            <Route
+              path="/onboarding"
+              render={() => {
+                return <Onboarding />;
+              }}
+            />
+            {(user?.isPorterUser ||
+              overrideInfraTabEnabled({
+                projectID: currentProject?.id,
+              })) && (
+                <Route
+                  path="/infrastructure"
+                  render={() => {
+                    return (
+                      <DashboardWrapper>
+                        <InfrastructureRouter />
+                      </DashboardWrapper>
+                    );
+                  }}
+                />
+              )}
+            <Route
+              path="/dashboard"
               render={() => {
                 return (
                   <DashboardWrapper>
-                    <InfrastructureRouter />
+                    <Dashboard
+                      projectId={currentProject?.id}
+                      setRefreshClusters={setForceRefreshClusters}
+                    />
                   </DashboardWrapper>
                 );
               }}
             />
-          )}
-          <Route
-            path="/dashboard"
-            render={() => {
-              return (
-                <DashboardWrapper>
-                  <Dashboard
-                    projectId={currentProject?.id}
-                    setRefreshClusters={setForceRefreshClusters}
-                  />
-                </DashboardWrapper>
-              );
-            }}
-          />
-          <Route
-            path={[
-              "/cluster-dashboard",
-              "/applications",
-              "/jobs",
-              "/env-groups",
-              "/databases",
-              "/preview-environments",
-              "/stacks",
-            ]}
-            render={() => {
-              if (currentCluster?.id === -1) {
-                return <Loading />;
-              } else if (!currentCluster || !currentCluster.name) {
+            <Route
+              path={[
+                "/cluster-dashboard",
+                "/applications",
+                "/jobs",
+                "/env-groups",
+                "/databases",
+                "/preview-environments",
+                "/stacks",
+              ]}
+              render={() => {
+                if (currentCluster?.id === -1) {
+                  return <Loading />;
+                } else if (!currentCluster || !currentCluster.name) {
+                  return (
+                    <DashboardWrapper>
+                      <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                    </DashboardWrapper>
+                  );
+                }
                 return (
                   <DashboardWrapper>
-                    <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                    <DashboardRouter
+                      currentCluster={currentCluster}
+                      setSidebar={setForceSidebar}
+                      currentView={props.currentRoute}
+                    />
                   </DashboardWrapper>
                 );
-              }
-              return (
-                <DashboardWrapper>
-                  <DashboardRouter
-                    currentCluster={currentCluster}
-                    setSidebar={setForceSidebar}
-                    currentView={props.currentRoute}
-                  />
-                </DashboardWrapper>
-              );
-            }}
-          />
-          <Route
-            path={"/integrations"}
-            render={() => <GuardedIntegrations />}
-          />
-          <Route
-            path={"/project-settings"}
-            render={() => <GuardedProjectSettings />}
-          />
-          <Route path={"*"} render={() => <LaunchWrapper />} />
-        </Switch>
-      </ViewWrapper>
-      {createPortal(
-        <ConfirmOverlay
-          show={currentModal === "UpdateProjectModal"}
-          message={
-            currentProject
-              ? `Are you sure you want to delete ${currentProject.name}?`
-              : ""
-          }
-          onYes={handleDelete}
-          onNo={() => setCurrentModal(null, null)}
-        />,
-        document.body
-      )}
-      {showWrongEmailModal && 
-        <Modal>
-          <Text size={16}>
-            Oops! This invite link wasn't for {user?.email}
-          </Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            Your account email does not match the email associated with this project invite. 
-            Please log out and sign up again with the correct email using the invite link.
-          </Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            You should reach out to the person who sent you the invite link to get the correct email.
-          </Text>
-          <Spacer y={1} />
-          <Button onClick={props.logOut}>
-            Log out
-          </Button>
-        </Modal>
-      }
-    </StyledHome>
+              }}
+            />
+            <Route
+              path={"/integrations"}
+              render={() => <GuardedIntegrations />}
+            />
+            <Route
+              path={"/project-settings"}
+              render={() => <GuardedProjectSettings />}
+            />
+            <Route path={"*"} render={() => <LaunchWrapper />} />
+          </Switch>
+        </ViewWrapper>
+        {createPortal(
+          <ConfirmOverlay
+            show={currentModal === "UpdateProjectModal"}
+            message={
+              currentProject
+                ? `Are you sure you want to delete ${currentProject.name}?`
+                : ""
+            }
+            onYes={handleDelete}
+            onNo={() => setCurrentModal(null, null)}
+          />,
+          document.body
+        )}
+        {showWrongEmailModal &&
+          <Modal>
+            <Text size={16}>
+              Oops! This invite link wasn't for {user?.email}
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              Your account email does not match the email associated with this project invite.
+              Please log out and sign up again with the correct email using the invite link.
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              You should reach out to the person who sent you the invite link to get the correct email.
+            </Text>
+            <Spacer y={1} />
+            <Button onClick={props.logOut}>
+              Log out
+            </Button>
+          </Modal>
+        }
+      </StyledHome>
+    </ThemeProvider>
   );
 };
 

+ 335 - 0
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -0,0 +1,335 @@
+import React, { 
+  useEffect, 
+  useState, 
+  useContext, 
+  useMemo, 
+  useCallback 
+} from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import addOn from "assets/add-ons.png";
+import github from "assets/github.png";
+import time from "assets/time.png";
+import healthy from "assets/status-healthy.png";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import notFound from "assets/not-found.png";
+
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+import api from "shared/api";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import { readableDate } from "shared/string_utils";
+import Loading from "components/Loading";
+import { Link } from "react-router-dom";
+import Fieldset from "components/porter/Fieldset";
+import Select from "components/porter/Select";
+
+type Props = {
+};
+
+const namespaceBlacklist = [
+  "cert-manager",
+  "ingress-nginx",
+  "kube-node-lease",
+  "kube-public",
+  "kube-system",
+  "monitoring",
+];
+
+const templateBlacklist = [
+  "web",
+  "worker",
+  "job",
+  "umbrella",
+];
+
+const AppDashboard: React.FC<Props> = ({
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [addOns, setAddOns] = useState([]);
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState("grid");
+  const [isLoading, setIsLoading] = useState(true);
+
+  const filteredAddOns = useMemo(() => {
+    const filtered = addOns.filter((app: any) => {
+      return (
+        !namespaceBlacklist.includes(app.namespace) && 
+        !templateBlacklist.includes(app.chart.metadata.name)
+      );
+    });
+
+    const filteredBySearch = search(
+      filtered ?? [],
+      searchValue,
+      {
+        keys: ["name", "chart.metadata.name"],
+        isCaseSensitive: false,
+      }
+    );
+
+    return _.sortBy(filteredBySearch);
+  }, [addOns, searchValue]);
+
+  const getAddOns = async () => {
+    try {
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "failed",
+          ],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "all",
+        }
+      );
+      setIsLoading(false);
+      const charts = res.data || [];
+      setAddOns(charts);
+    } catch (err) {
+      setIsLoading(false);
+    };
+  };
+
+  useEffect(() => {
+    getAddOns();
+  }, [currentCluster, currentProject]);
+
+  const getExpandedChartLinkURL = useCallback((x: any) => {
+    const params = new Proxy(new URLSearchParams(window.location.search), {
+      get: (searchParams, prop: string) => searchParams.get(prop),
+    });
+    const cluster = currentCluster?.name;
+    const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
+    const newParams = {
+      // @ts-ignore
+      project_id: params.project_id,
+      closeChartRedirectUrl: '/addons',
+    };
+    const newURLSearchParams = new URLSearchParams(
+      _.omitBy(newParams, _.isNil)
+    );
+    return `${route}?${newURLSearchParams.toString()}`;
+  }, [currentCluster]);
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={addOn}
+        title="Add-ons"
+        capitalize={false}
+        description="Add-ons and supporting workloads for this project."
+        disableLineBreak
+      />
+      <Container row spaced>
+        <SearchBar 
+          value={searchValue}
+          setValue={setSearchValue}
+          placeholder="Search add-ons . . ."
+          width="100%"
+        />
+        <Spacer inline x={2} />
+        <Toggle
+          items={[
+            { label: <ToggleIcon src={grid} />, value: "grid" },
+            { label: <ToggleIcon src={list} />, value: "list" },
+          ]}
+          active={view}
+          setActive={setView}
+        />
+        <Spacer inline x={2} />
+        <Link to="/addons/new">
+          <Button onClick={() => {}} height="30px" width="130px">
+            <I className="material-icons">add</I> New add-on
+          </Button>
+        </Link>
+      </Container>
+      <Spacer y={1} />
+      {(!isLoading && filteredAddOns.length === 0) && (
+        <Fieldset>
+          <Container row>
+            <PlaceholderIcon src={notFound} />
+            <Text color="helper">No add-ons were found.</Text>
+          </Container>
+        </Fieldset>
+      )}
+      {isLoading ? <Loading offset="-150px" /> : view === "grid" ? (
+        <GridList>
+          {(filteredAddOns ?? []).map((app: any, i: number) => {
+            return (
+              <Block to={getExpandedChartLinkURL(app)}>
+                <Text size={14}>
+                  <Icon 
+                    src={
+                      hardcodedIcons[app.chart.metadata.name] ||
+                      app.chart.metadata.icon
+                    }
+                  />
+                  {app.name}
+                </Text>
+                <StatusIcon src={healthy} />
+                <Text size={13} color="#ffffff44">
+                  <SmallIcon opacity="0.4" src={time} />
+                  {readableDate(app.info.last_deployed)}
+                </Text>
+              </Block>
+            );
+          })}
+       </GridList>
+      ) : (
+        <List>
+          {(filteredAddOns ?? []).map((app: any, i: number) => {
+            return (
+              <Row to={getExpandedChartLinkURL(app)}>
+                <Text size={14}>
+                  <MidIcon
+                    src={
+                      hardcodedIcons[app.chart.metadata.name] ||
+                      app.chart.metadata.icon
+                    }
+                  />
+                  {app.name}
+                  <Spacer inline x={1} />
+                  <MidIcon src={healthy} height="16px" />
+                </Text>
+                <Spacer height="15px" />
+                <Text size={13} color="#ffffff44">
+                  <SmallIcon opacity="0.4" src={time} />
+                  {readableDate(app.info.last_deployed)}
+                </Text>
+              </Row>
+            );
+          })}
+        </List>
+      )}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default AppDashboard;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const Row = styled(Link)<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  display: block;
+  padding: 15px;
+  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${props => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+  margin-right: 13px;
+`;
+
+const MidIcon = styled.img<{ height?: string }>`
+  height: ${props => props.height || "18px"};
+  margin-right: 11px;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string }>`
+  margin-left: 2px;
+  height: 14px;
+  opacity: ${props => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const Block = styled(Link)`
+  height: 110px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${props => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 271 - 0
dashboard/src/main/home/add-on-dashboard/ConfigureTemplate.tsx

@@ -0,0 +1,271 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import { hardcodedNames, hardcodedIcons } from "shared/hardcodedNameDict";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { pushFiltered } from "shared/routing";
+
+import Back from "components/porter/Back";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import Link from "components/porter/Link";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import VerticalSteps from "components/porter/VerticalSteps";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+import Placeholder from "components/Placeholder";
+import Button from "components/porter/Button";
+import { generateSlug } from "random-word-slugs";
+import { RouteComponentProps, withRouter } from "react-router";
+import Error from "components/porter/Error";
+
+type Props = RouteComponentProps & {
+  currentTemplate: any;
+  currentForm?: any;
+  goBack: () => void;
+};
+
+const ConfigureTemplate: React.FC<Props> = ({
+  currentTemplate,
+  currentForm,
+  goBack,
+  ...props
+}) => {
+  const { currentCluster, currentProject, capabilities } = useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [name, setName] = useState<string>("");
+  const [buttonStatus, setButtonStatus] = useState<string>("");
+
+  const waitForHelmRelease = () => {
+    setTimeout(() => {
+      api.getChart(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          namespace: "default",
+          cluster_id: currentCluster.id,
+          name,
+          revision: 0,
+        }
+      )
+        .then((res) => {
+          if (res?.data?.version) {
+            setButtonStatus("success");
+            pushFiltered(props, "/addons", ["project_id"], {
+              cluster: currentCluster.name,
+            });
+          } else {
+            waitForHelmRelease();
+          }
+        })
+        .catch((err) => {
+          waitForHelmRelease();
+        });
+    }, 500);
+  };
+
+  const deployAddOn = async (wildcard?: any) => {
+    setButtonStatus("loading");
+    
+    let values: any = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+    console.log("values", values)
+    console.log("wildcard", wildcard)
+    api
+      .deployAddon(
+        "<token>",
+        {
+          template_name: currentTemplate.name,
+          template_version: "latest",
+          values: values,
+          name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "default",
+          repo_url: currentTemplate?.repo_url || capabilities.default_addon_helm_repo_url,
+        }
+      )
+      .then((_) => {
+        window.analytics?.track("Deployed Add-on", {
+          name: currentTemplate.name,
+          namespace: "default",
+          values: values,
+        });
+        waitForHelmRelease();
+      })
+      .catch((err) => {
+        let parsedErr = err?.response?.data?.error;
+        err = parsedErr || err.message || JSON.stringify(err);
+        setButtonStatus(err);
+        window.analytics?.track("Failed to Deploy Add-on", {
+          name: currentTemplate.name,
+          namespace: "default",
+          values: values,
+          error: err,
+        });
+        return;
+      });
+  };
+
+  const getStatus = () => {
+    if (!buttonStatus) {
+      return;
+    }
+    if (buttonStatus === "loading" || buttonStatus === "success") {
+      return buttonStatus;
+    } else {
+      return (
+        <Error message={buttonStatus} />
+      );
+    }
+  };
+  
+  const renderAddOnSettings = () => {
+    if (currentForm) {
+      return (
+        <PorterFormWrapper
+          formData={currentForm}
+          valuesToOverride={{
+            namespace: "default",
+            clusterId: currentCluster.id,
+          }}
+          buttonStatus={getStatus()}
+          isLaunch={true}
+          onSubmit={deployAddOn}
+        />
+      );
+    } else {
+      return (
+        <>
+          <Placeholder>
+            <div>
+              To configure this chart through Porter
+              <Spacer inline width="5px" />
+              <Link
+                target="_blank"
+                to="https://github.com/porter-dev/porter-charts/blob/master/docs/form-yaml-reference.md"
+              >
+                refer to our docs
+              </Link>
+              .
+            </div>
+          </Placeholder>
+          <Spacer y={1.2} />
+          <Button
+            width="150px"
+            onClick={deployAddOn}
+            status={getStatus()}
+          >
+            Deploy application
+          </Button>
+        </>
+      );
+    };
+  };
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back onClick={goBack} />
+          <DashboardHeader
+            prefix={
+              <Icon 
+                src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
+              />
+            }
+            title={`Configure new ${hardcodedNames[currentTemplate.name] || currentTemplate.name} instance`}
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <VerticalSteps
+            currentStep={currentStep}
+            steps={[
+              <>
+                <Text size={16}>Add-on name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Randomly generated if left blank (lowercase letters, numbers, and "-" only).
+                </Text>
+                <Spacer height="20px" />
+                <Input
+                  placeholder="ex: academic-sophon"
+                  value={name}
+                  width="300px"
+                  setValue={(e) => {
+                    if (e) {
+                      setCurrentStep(1);
+                    } else {
+                      setCurrentStep(0);
+                    }
+                    setName(e);
+                  }}
+                />
+              </>,
+              <>
+                <Text size={16}>Add-on settings</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                Configure settings for this add-on.
+                </Text>
+                <Spacer height="20px" />
+                {renderAddOnSettings()}
+              </>
+            ]}
+          />
+          <Spacer height="80px" />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(ConfigureTemplate);
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;

+ 160 - 0
dashboard/src/main/home/add-on-dashboard/ExpandedTemplate.tsx

@@ -0,0 +1,160 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Spacer from "components/porter/Spacer";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Text from "components/porter/Text";
+import Markdown from "markdown-to-jsx";
+
+import { hardcodedNames, hardcodedIcons } from "shared/hardcodedNameDict";
+
+type Props = {
+  currentTemplate: any;
+  proceed: (form?: any) => void;
+  goBack: () => void;
+};
+
+const ExpandedTemplate: React.FC<Props> = ({
+  currentTemplate,
+  proceed,
+  goBack,
+}) => {
+  const { capabilities, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [form, setForm] = useState<any>(null);
+  const [values, setValues] = useState("");
+  const [markdown, setMarkdown] = useState<any>(null);
+  const [keywords, setKeywords] = useState<any[]>([]);
+
+  const getTemplateInfo = async () => {
+    setIsLoading(true);
+    let params = {
+      repo_url: capabilities?.default_addon_helm_repo_url,
+    };
+
+    api.getTemplateInfo("<token>", params, {
+      project_id: currentProject.id,
+      name: currentTemplate.name.toLowerCase().trim(),
+      version: currentTemplate.currentVersion,
+    })
+      .then((res) => {
+        let { form, values, markdown, metadata } = res.data;
+        let keywords = metadata.keywords;
+        setForm(form);
+        setValues(values);
+        setMarkdown(markdown);
+        setKeywords(keywords);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        setIsLoading(false);
+      });
+  }
+
+  useEffect(() => {
+    getTemplateInfo();
+  }, [currentTemplate]);
+
+  return (
+    <StyledExpandedTemplate>
+      <Container row spaced>
+        <Container row>
+          <Button 
+            onClick={goBack}
+            alt
+          >
+            <I className="material-icons">first_page</I>
+            <Spacer inline x={1} />
+            Select template
+          </Button>
+          <Spacer x={1} inline />
+          <Icon src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon} />
+          <Text size={16}>
+            <Capitalize>
+              {hardcodedNames[currentTemplate.name] || currentTemplate.name}
+            </Capitalize>
+          </Text>
+        </Container>
+        <Button onClick={() => proceed(form)}>
+          <AddI className="material-icons">add</AddI>
+          Deploy add-on
+        </Button>
+      </Container>
+      <Spacer height="15px" />
+      {
+        isLoading ? <Loading offset="-150px" /> : (
+          markdown ? (
+            <MarkdownWrapper>
+              <Markdown>{markdown}</Markdown>
+            </MarkdownWrapper>
+          ) : (
+            <>
+              <Spacer y={0.5} />
+              <Text>{currentTemplate.description}</Text>
+            </>
+          )
+        )
+      }
+    </StyledExpandedTemplate>
+  );
+};
+
+export default ExpandedTemplate;
+
+const MarkdownWrapper = styled.div`
+  font-size: 13px;
+  line-height: 1.5;
+  color: #aaaabb;
+  > div {
+    > h1 {
+      color: ${({ theme }) => theme.text.primary};
+      font-size: 16px;
+      font-weight: 400;
+    }
+    > h2 {
+      color: ${({ theme }) => theme.text.primary};
+      font-size: 16px;
+      font-weight: 400;
+    }
+    > h3 {
+      color: ${({ theme }) => theme.text.primary};
+      font-size: 16px;
+      font-weight: 400;
+    }
+  }
+  padding-bottom: 80px;
+`;
+
+const Icon = styled.img`
+  height: 22px;
+  margin-right: 15px;
+`;
+
+const Capitalize = styled.span`
+  text-transform: capitalize;
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 16px;
+`;
+
+const AddI = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 10px;
+  justify-content: center;
+`;
+
+const StyledExpandedTemplate = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 182 - 0
dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx

@@ -0,0 +1,182 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import semver from "semver";
+import _ from "lodash";
+
+import addOn from "assets/add-ons.png";
+import notFound from "assets/not-found.png";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { search } from "shared/search";
+
+import TemplateList from "../launch/TemplateList";
+import SearchBar from "components/porter/SearchBar";
+import Spacer from "components/porter/Spacer";
+import Loading from "components/Loading";
+import ExpandedTemplate from "./ExpandedTemplate";
+import ConfigureTemplate from "./ConfigureTemplate";
+import Back from "components/porter/Back";
+import Fieldset from "components/porter/Fieldset";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+
+type Props = {
+};
+
+const HIDDEN_CHARTS = ["porter-agent", "loki"];
+
+const NewAddOnFlow: React.FC<Props> = ({
+}) => {
+  const { capabilities, currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [searchValue, setSearchValue] = useState("");
+  const [addOnTemplates, setAddOnTemplates] = useState<any[]>([]);
+  const [currentTemplate, setCurrentTemplate] = useState<any>(null);
+  const [currentForm, setCurrentForm] = useState<any>(null);
+
+  const filteredTemplates = useMemo(() => {
+    const filteredBySearch = search(
+      addOnTemplates ?? [],
+      searchValue,
+      {
+        keys: ["name"],
+        isCaseSensitive: false,
+      }
+    );
+
+    return _.sortBy(filteredBySearch);
+  }, [addOnTemplates, searchValue]);
+  
+  const getTemplates = async () => {
+    setIsLoading(true);
+    const default_addon_helm_repo_url = capabilities?.default_addon_helm_repo_url;
+    try {
+      const res = await api.getTemplates(
+        "<token>",
+        {
+          repo_url: default_addon_helm_repo_url,
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+      setIsLoading(false);
+      var sortedVersionData = res.data.map((template: any) => {
+        let versions = template.versions.reverse();
+        versions = template.versions.sort(semver.rcompare);
+        return {
+          ...template,
+          versions,
+          currentVersion: versions[0],
+        };
+      });
+      sortedVersionData.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      sortedVersionData = sortedVersionData.filter(
+        (template: any) => !HIDDEN_CHARTS.includes(template?.name)
+      );
+      setAddOnTemplates(sortedVersionData);
+    } catch (error) {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    getTemplates();
+  }, [currentProject, currentCluster]);
+
+  return (
+    <StyledTemplateComponent>
+      {
+        (currentForm && currentTemplate) ? (
+          <ConfigureTemplate
+            currentTemplate={currentTemplate}
+            currentForm={currentForm}
+            goBack={() => setCurrentForm(null)}
+          />
+        ) : (
+          <>
+            <Back to="/addons" />
+            <DashboardHeader
+              image={addOn}
+              title="Deploy a new add-on"
+              capitalize={false}
+              description="Select an add-on to deploy to this project."
+              disableLineBreak
+            />
+            {
+              currentTemplate ? (
+                <ExpandedTemplate 
+                  currentTemplate={currentTemplate}
+                  proceed={(form?: any) => setCurrentForm(form)}
+                  goBack={() => setCurrentTemplate(null)}
+                />
+              ) : (
+                <>
+                  <SearchBar 
+                    value={searchValue}
+                    setValue={setSearchValue}
+                    placeholder="Search available add-ons . . ."
+                    width="100%"
+                  />
+                  <Spacer y={1} />
+
+                  {filteredTemplates.length === 0 && (
+                    <Fieldset>
+                      <Container row>
+                        <PlaceholderIcon src={notFound} />
+                        <Text color="helper">No matching add-ons were found.</Text>
+                      </Container>
+                    </Fieldset>
+                  )}
+                  {isLoading ? <Loading offset="-150px" /> : (
+                    <>
+                      <DarkMatter />
+                      <TemplateList
+                        templates={filteredTemplates}
+                        setCurrentTemplate={(x) => setCurrentTemplate(x)}
+                      />
+                    </>
+                  )}
+                </>
+              )
+            }
+          </>
+        )
+      }
+    </StyledTemplateComponent>
+  );
+};
+
+export default NewAddOnFlow;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -35px;
+`;
+
+const I = styled.i`
+  font-size: 16px;
+  padding: 4px;
+  cursor: pointer;
+  border-radius: 50%;
+  margin-right: 15px;
+  background: ${props => props.theme.fg};
+  color: ${props => props.theme.text.primary};
+  border: 1px solid ${props => props.theme.border};
+  :hover {
+    filter: brightness(150%);
+  }
+`;
+
+const StyledTemplateComponent = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 269 - 0
dashboard/src/main/home/app-dashboard/AppDashboard.tsx

@@ -0,0 +1,269 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import web from "assets/web.png";
+import github from "assets/github.png";
+import time from "assets/time.png";
+import healthy from "assets/status-healthy.png";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+import api from "shared/api";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import Link from "components/porter/Link";
+
+type Props = {
+};
+
+const icons = [
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/go/go-original-wordmark.svg",
+  web,
+];
+
+const namespaceBlacklist = [
+  "cert-manager",
+  "default",
+  "ingress-nginx",
+  "kube-node-lease",
+  "kube-public",
+  "kube-system",
+  "monitoring",
+];
+
+const AppDashboard: React.FC<Props> = ({
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [apps, setApps] = useState([]);
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState("grid");
+  const [isLoading, setIsLoading] = useState(true);
+
+  const filteredApps = useMemo(() => {
+    const filteredBySearch = search(
+      apps ?? [],
+      searchValue,
+      {
+        keys: ["name"],
+        isCaseSensitive: false,
+      }
+    );
+
+    return _.sortBy(filteredBySearch);
+  }, [apps, searchValue]);
+
+  const getApps = async () => {
+    
+    // TODO: Currently using namespaces as placeholder (replace with apps)
+    try {
+      const res = await api.getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      setApps(res.data);
+    }
+    catch (err) {}
+  };
+
+  useEffect(() => {
+    getApps();
+  }, []);
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={web}
+        title="Applications"
+        description="Web services, workers, and jobs for this project."
+        disableLineBreak
+      />
+      <Container row spaced>
+        <SearchBar 
+          value={searchValue}
+          setValue={setSearchValue}
+          placeholder="Search applications . . ."
+          width="100%"
+        />
+        <Spacer inline x={2} />
+        <Toggle
+          items={[
+            { label: <ToggleIcon src={grid} />, value: "grid" },
+            { label: <ToggleIcon src={list} />, value: "list" },
+          ]}
+          active={view}
+          setActive={setView}
+        />
+        <Spacer inline x={2} />
+        <Link to="/apps/new">
+          <Button onClick={() => {}} height="30px" width="160px">
+            <I className="material-icons">add</I> New application
+          </Button>
+        </Link>
+      </Container>
+      <Spacer y={1} />
+      {view === "grid" ? (
+        <GridList>
+         {(filteredApps ?? []).map((app: any, i: number) => {
+           if (!namespaceBlacklist.includes(app.name)) {
+             return (
+               <Block>
+                 <Text size={14}>
+                   <Icon src={icons[i % icons.length]} />
+                   {app.name}
+                 </Text>
+                 <StatusIcon src={healthy} />
+                 <Text size={13} color="#ffffff44">
+                   <SmallIcon opacity="0.6" src={github} />
+                   porter-dev/porter
+                 </Text>
+                 <Text size={13} color="#ffffff44">
+                   <SmallIcon opacity="0.4" src={time} />
+                   Updated 6:35 PM on 4/23/2023
+                 </Text>
+               </Block>
+             );
+           }
+         })}
+       </GridList>
+      ) : (
+        <List>
+          {(filteredApps ?? []).map((app: any, i: number) => {
+            if (!namespaceBlacklist.includes(app.name)) {
+              return (
+                <Row>
+                  <Text size={14}>
+                    <MidIcon src={icons[i % icons.length]} />
+                    {app.name}
+                    <Spacer inline x={1} />
+                    <MidIcon src={healthy} />
+                  </Text>
+                  <Spacer height="15px" />
+                  <Text size={13} color="#ffffff44">
+                    <SmallIcon opacity="0.6" src={github} />
+                    porter-dev/porter
+                    <Spacer inline x={1} />
+                    <SmallIcon opacity="0.4" src={time} />
+                    Updated 6:35 PM on 4/23/2023
+                  </Text>
+                </Row>
+              );
+            }
+          })}
+        </List>
+      )}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default AppDashboard;
+
+const Row = styled.div<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  padding: 15px;
+  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${props => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const MidIcon = styled.img`
+  height: 16px;
+  margin-right: 13px;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string }>`
+  margin-left: 2px;
+  height: 14px;
+  opacity: ${props => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const Block = styled.div`
+  height: 150px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${props => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 164 - 0
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -0,0 +1,164 @@
+import React, { useEffect, useState, useContext } from "react";
+import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import notFound from "assets/not-found.png";
+
+import Fieldset from "components/porter/Fieldset";
+import Loading from "components/Loading";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import Back from "components/porter/Back";
+import TabSelector from "components/TabSelector";
+
+type Props = RouteComponentProps & {
+};
+
+const ExpandedApp: React.FC<Props> = ({
+  ...props
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [appData, setAppData] = useState(null);
+  const [tab, setTab] = useState("events");
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const getPorterApp = async () => {
+    setIsLoading(true);
+    const { appName } = props.match.params as any;
+    try {
+      const res = await api.getPorterApp(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          name: appName,
+        }
+      );
+      setAppData(res.data);
+      setIsLoading(false);
+    } catch (err) {
+      setIsLoading(false);
+    }
+  }
+
+  useEffect(() => {
+    if (currentCluster) {
+      getPorterApp();
+    }
+  }, [currentCluster]);
+
+  const renderTabContents = () => {
+    switch (tab) {
+      case "overview":
+        return (
+          <div>TODO: service list</div>
+        );
+      case "build-settings":
+        return (
+          <div>TODO: build settings</div>
+        );
+      case "settings":
+        return (
+          <div>TODO: stack deletion</div>
+        )
+      default:
+        return (
+          <div>dream on</div>
+        )
+    }
+  };
+
+  return (
+    <StyledExpandedApp>
+      {isLoading && (
+        <Loading />
+      )}
+      {!appData && !isLoading && (
+        <Placeholder>
+          <Container row>
+            <PlaceholderIcon src={notFound} />
+            <Text color="helper">
+              No application matching "{(props.match.params as any).appName}" was found.
+            </Text>
+          </Container>
+          <Spacer y={1} />
+          <Link to="/apps">Return to dashboard</Link> 
+        </Placeholder>
+      )}
+      {appData && (
+        <>
+          <Back to="/apps" />
+          <Container row>
+            <Icon src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg" />
+            <Text size={21}>
+              {appData.name}
+            </Text>
+            <Spacer inline x={1} />
+            <Text size={13}>
+              repo: porter-dev/porter
+            </Text>
+            <Spacer inline x={1} />
+            <Text size={13}>
+              branch: main
+            </Text>
+          </Container>
+          <Spacer y={1} />
+          <Text color="helper">
+            Last updated 2 days ago
+          </Text>
+          <Spacer y={1} />
+          <TabSelector
+            options={[
+              { label: "Events", value: "events" },
+              { label: "Logs", value: "logs" },
+              { label: "Metrics", value: "metrics" },
+              { label: "Overview", value: "overview" },
+              { label: "Build settings", value: "build-settings" },
+              { label: "Settings", value: "settings" },
+            ]}
+            currentTab={tab}
+            setCurrentTab={setTab}
+          />
+          <Spacer y={1} />
+          {renderTabContents()}
+        </>
+      )}
+    </StyledExpandedApp>
+  );
+};
+
+export default withRouter(ExpandedApp);
+
+const Icon = styled.img`
+  height: 24px;
+  margin-right: 15px;
+`;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
+
+const StyledExpandedApp = styled.div`
+  width: 100%;
+  height: 100%;
+`;

+ 282 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/AdvancedBuildSettings.tsx

@@ -0,0 +1,282 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import Toggle from "components/porter/Toggle";
+import AnimateHeight from "react-animate-height";
+import { DeviconsNameList } from "assets/devicons-name-list";
+import { BuildpackStack } from "components/repo-selector/BuildpackStack";
+import { ActionConfigType } from "shared/types";
+
+interface AutoBuildpack {
+  name?: string;
+  valid: boolean;
+}
+
+interface AdvancedBuildSettingsProps {
+  autoBuildPack?: AutoBuildpack;
+  buildView: string;
+  showSettings: boolean;
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  folderPath: string;
+  setBuildConfig: (x: any) => void;
+}
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config?: {
+    [key: string]: string;
+  };
+};
+
+const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = (props) => {
+  const [showSettings, setShowSettings] = useState<boolean>(props.showSettings);
+  const [buildView, setBuildView] = useState<string>(props.buildView);
+
+  const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+    setBuildView(e.target.value);
+  };
+  const createDockerView = () => {
+    return (
+      <>
+        <Text size={16}>Build with a Dockerfile</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">Specify your Dockerfile path.</Text>
+        <Spacer y={0.5} />
+        <Input
+          placeholder="ex: ./Dockerfile"
+          value=""
+          width="300px"
+          setValue={(e) => {}}
+        />
+        <Spacer y={0.5} />
+        <Text color="helper">Specify your Docker build context.</Text>
+        <Spacer y={0.5} />
+        <Input
+          placeholder="ex: academic-sophon"
+          value="./"
+          width="300px"
+          setValue={(e) => {}}
+        />
+        <Spacer y={0.5} />
+      </>
+    );
+  };
+
+  const createBuildpackView = () => {
+    return (
+      <>
+        <BuildpackStack
+          actionConfig={props.actionConfig}
+          branch={props.branch}
+          folderPath={props.folderPath}
+          onChange={(config) => {
+            props.setBuildConfig(config);
+          }}
+          hide={false}
+        />
+      </>
+    );
+  };
+
+  return (
+    <>
+      <StyledAdvancedBuildSettings
+        showSettings={showSettings}
+        isCurrent={true}
+        onClick={() => {
+          setShowSettings(!showSettings);
+        }}
+      >
+        {buildView == "docker" ? (
+          <AdvancedBuildTitle>
+            <i className="material-icons dropdown">arrow_drop_down</i>
+            Dockerfile Detected (configure Dockerfile Settings)
+          </AdvancedBuildTitle>
+        ) : (
+          <AdvancedBuildTitle>
+            <i className="material-icons dropdown">arrow_drop_down</i>
+            Configure Build Pack Settings
+          </AdvancedBuildTitle>
+        )}
+      </StyledAdvancedBuildSettings>
+
+      <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
+        <StyledSourceBox>
+          <SelectWrapper>
+            <SelectLabel>Select Build Context</SelectLabel>
+            <StyledSelect value={buildView} onChange={handleSelectChange}>
+              <option value="docker">Docker</option>
+              <option value="buildpacks">Buildpacks</option>
+            </StyledSelect>
+          </SelectWrapper>
+          <Spacer y={0.5} />
+          {buildView === "docker" ? createDockerView() : createBuildpackView()}
+        </StyledSourceBox>
+      </AnimateHeight>
+    </>
+  );
+};
+
+export default AdvancedBuildSettings;
+
+const StyledAdvancedBuildSettings = styled.div`
+  color: ${({ showSettings }) => (showSettings ? "white" : "#aaaabb")};
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  border-bottom-left-radius: ${({ showSettings }) => showSettings && "0px"};
+  border-bottom-right-radius: ${({ showSettings }) => showSettings && "0px"};
+
+  .dropdown {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showSettings: boolean; isCurrent: boolean }) =>
+      props.showSettings ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const AdvancedBuildTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  font-size: 13px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0px;
+  border-top-left-radius: 0px;
+  border-top-right-radius: 0px;
+`;
+
+const ToggleWrapper = styled.div`
+  display: flex;
+  justify-content: center;
+  width: 100%;
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+const SelectWrapper = styled.div`
+  display: flex;
+  justify-content: center;
+  width: 100%;
+  align-items: center;
+`;
+
+const SelectLabel = styled.label`
+  color: #ffffff;
+  font-size: 13px;
+  margin-right: 8px;
+`;
+
+const StyledSelect = styled.select`
+  background-color: #26292e;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  color: #aaaabb;
+  cursor: pointer;
+  font-size: 13px;
+  height: 30px;
+  outline: none;
+  padding: 0 8px;
+  width: 150px;
+
+  &:hover {
+    border: 1px solid #7a7b80;
+    color: #ffffff;
+  }
+`;

+ 146 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -0,0 +1,146 @@
+import Modal from "components/porter/Modal";
+import React, { useContext } from "react";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import ExpandableSection from "components/porter/ExpandableSection";
+import Fieldset from "components/porter/Fieldset";
+import styled from "styled-components";
+import Button from "components/porter/Button";
+import Input from "components/porter/Input";
+import Select from "components/porter/Select";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+interface GithubActionModalProps {
+  closeModal: () => void;
+  githubAppInstallationID?: number;
+  githubRepoOwner?: string;
+  githubRepoName?: string;
+  branch?: string;
+  stackName?: string;
+  projectId?: number;
+  clusterId?: number;
+  deployPorterApp: () => void;
+}
+
+type Choice = "open_pr" | "copy";
+
+const GithubActionModal: React.FC<GithubActionModalProps> = ({
+  closeModal,
+  githubAppInstallationID,
+  githubRepoOwner,
+  githubRepoName,
+  branch,
+  stackName,
+  projectId,
+  clusterId,
+  deployPorterApp,
+}) => {
+  const [choice, setChoice] = React.useState<Choice>("open_pr");
+  const [loading, setLoading] = React.useState<boolean>(false);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const submit = async () => {
+    if (githubAppInstallationID && githubRepoOwner && githubRepoName && branch && stackName) {
+      try {
+        setLoading(true)
+        const res = await api.createSecretAndOpenGitHubPullRequest(
+          "<token>",
+          {
+            github_app_installation_id: githubAppInstallationID,
+            github_repo_owner: githubRepoOwner,
+            github_repo_name: githubRepoName,
+            branch,
+            open_pr: choice === "open_pr",
+          },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            stack_name: stackName,
+          }
+        );
+        if (res?.data?.url) {
+            window.open(res.data.url, "_blank", "noreferrer")
+        }
+      } catch (error) {
+        console.log(error)
+      } finally {
+        setLoading(false)
+      }
+    } else {
+      console.log("missing information");
+    }
+  }
+  return (
+    <Modal closeModal={closeModal}>
+      <Text size={16}>
+        Continuous Integration (CI) with GitHub Actions
+      </Text>
+      <Spacer height="15px" />
+      <Text color="helper">
+        In order to automatically update your services every time new code is pushed to your GitHub branch, the following file must exist in your Github repository:
+      </Text>
+      <Spacer y={1} />
+      <ExpandableSection
+        noWrapper
+        expandText="[+] Show code"
+        collapseText="[-] Hide code"
+        Header={
+          <ModalHeader>./github/workflows/porter_deploy.yml</ModalHeader>
+        }
+        isInitiallyExpanded={true}
+        ExpandedSection={
+          <>
+            <Spacer height="15px" />
+            <Fieldset background="#1b1d2688">
+              • Amazon Elastic Kubernetes Service (EKS) = $73/mo
+              <Spacer height="15px" />
+              • Amazon EC2:
+              <Spacer height="15px" />
+              <Tab />+ System workloads: t3.medium instance (2) = $60.74/mo
+              <Spacer height="15px" />
+              <Tab />+ Monitoring workloads: t3.large instance (1) = $60.74/mo
+              <Spacer height="15px" />
+              <Tab />+ Application workloads: t3.xlarge instance (1) = $121.47/mo
+            </Fieldset>
+          </>
+        }
+      />
+      <Spacer y={1} />
+      <Text color="helper">
+        Porter can open a PR for you to approve and merge this file into your repository, or you can add it yourself. If you allow Porter to open a PR, you will be redirected to the PR in a new tab after hitting Complete below.
+      </Text>
+      <Spacer y={1} />
+      <Select
+        options={[
+          { label: "I authorize Porter to open a PR on my behalf", value: "open_pr" },
+          { label: "I will copy the file into my repository myself", value: "copy" },
+        ]}
+        setValue={(x: Choice) => setChoice(x)}
+        width="100%"
+      />
+      <Button
+        onClick={submit}
+        width={"100%"}
+        status={loading ? "loading" : undefined}
+        loadingText="Opening PR..."
+      >
+        Complete
+      </Button>
+    </Modal>
+  )
+}
+
+export default GithubActionModal;
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;
+
+const ModalHeader = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+  font-family: monospace; ;
+
+`;

+ 99 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx

@@ -0,0 +1,99 @@
+import Input from "components/porter/Input";
+import React from "react"
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import Checkbox from "components/porter/Checkbox";
+import { JobService } from "./serviceTypes";
+
+interface Props {
+  service: JobService
+  editService: (service: JobService) => void
+}
+
+const JobTabs: React.FC<Props> = ({
+  service,
+  editService
+}) => {
+  const [currentTab, setCurrentTab] = React.useState<string>('main');
+
+  const renderMain = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="Start command"
+          placeholder="ex: sh start.sh"
+          disabled={service.startCommand.readOnly}
+          value={service.startCommand.value}
+          width="300px"
+          setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Cron schedule (leave blank to run manually)"
+          placeholder="ex: */5 * * * *"
+          value={service.cronSchedule}
+          width="300px"
+          setValue={(e) => { editService({ ...service, cronSchedule: e }) }}
+        />
+      </>
+    )
+  };
+
+  const renderResources = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="CPUs"
+          placeholder="ex: 0.5"
+          value={service.cpu}
+          width="300px"
+          setValue={(e) => { editService({ ...service, cpu: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="RAM (GB)"
+          placeholder="ex: 1"
+          value={service.ram}
+          width="300px"
+          setValue={(e) => { editService({ ...service, ram: e }) }}
+        />
+      </>
+    )
+  };
+
+  const renderAdvanced = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Checkbox
+          checked={service.jobsExecuteConcurrently}
+          toggleChecked={() => { editService({ ...service, jobsExecuteConcurrently: !service.jobsExecuteConcurrently }) }}
+        >
+          <Text color="helper">Allow jobs to execute concurrently</Text>
+        </Checkbox>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: 'Main', value: 'main' },
+          { label: 'Resources', value: 'resources' },
+          { label: 'Advanced', value: 'advanced' },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {currentTab === 'main' && renderMain()}
+      {currentTab === 'resources' && renderResources()}
+      {currentTab === 'advanced' && renderAdvanced()}
+    </>
+  )
+}
+
+export default JobTabs;

+ 421 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -0,0 +1,421 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+import yaml from "js-yaml";
+
+import { hardcodedNames, hardcodedIcons } from "shared/hardcodedNameDict";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { pushFiltered } from "shared/routing";
+import web from "assets/web.png";
+
+import Back from "components/porter/Back";
+import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import Link from "components/porter/Link";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import VerticalSteps from "components/porter/VerticalSteps";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+import Placeholder from "components/Placeholder";
+import Button from "components/porter/Button";
+import { generateSlug } from "random-word-slugs";
+import { RouteComponentProps, withRouter } from "react-router";
+import Error from "components/porter/Error";
+import SourceSelector, { SourceType } from "./SourceSelector";
+import SourceSettings from "./SourceSettings";
+import Services from "./Services";
+import EnvGroupArray, {
+  KeyValueType,
+} from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import Select from "components/porter/Select";
+import GithubActionModal from "./GithubActionModal";
+import {
+  ActionConfigType,
+  FullActionConfigType,
+  FullGithubActionConfigType,
+  GithubActionConfigType,
+} from "shared/types";
+import { z } from "zod";
+import { PorterYamlSchema } from "./schema";
+import { createDefaultService } from "./serviceTypes";
+
+type Props = RouteComponentProps & {};
+
+const defaultActionConfig: GithubActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
+
+interface FormState {
+  applicationName: string;
+  selectedSourceType: SourceType | undefined;
+  serviceList: any[];
+  envVariables: KeyValueType[];
+  releaseCommand: string;
+}
+
+const INITIAL_STATE: FormState = {
+  applicationName: "",
+  selectedSourceType: undefined,
+  serviceList: [],
+  envVariables: [],
+  releaseCommand: "",
+};
+
+const Validators: {
+  [key in keyof FormState]: (value: FormState[key]) => boolean;
+} = {
+  applicationName: (value: string) => value.trim().length > 0,
+  selectedSourceType: (value: SourceType | undefined) => value !== undefined,
+  serviceList: (value: any[]) => value.length > 0,
+  envVariables: (value: KeyValueType[]) => true,
+  releaseCommand: (value: string) => true,
+};
+
+const NewAppFlow: React.FC<Props> = ({ ...props }) => {
+  const [templateName, setTemplateName] = useState("");
+
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("latest");
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [existingStep, setExistingStep] = useState<number>(0);
+  const [formState, setFormState] = useState<FormState>(INITIAL_STATE);
+  const [actionConfig, setActionConfig] = useState<GithubActionConfigType>({
+    ...defaultActionConfig,
+  });
+  const [procfileProcess, setProcfileProcess] = useState("");
+  const [branch, setBranch] = useState("");
+  const [repoType, setRepoType] = useState("");
+  const [dockerfilePath, setDockerfilePath] = useState(null);
+  const [procfilePath, setProcfilePath] = useState(null);
+  const [folderPath, setFolderPath] = useState(null);
+  const [selectedRegistry, setSelectedRegistry] = useState(null);
+  const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+  const [buildConfig, setBuildConfig] = useState();
+  const [porterYaml, setPorterYaml] = useState("");
+  const getFullActionConfig = (): FullGithubActionConfigType => {
+    let imageRepoURI = `${selectedRegistry?.url}/${templateName}`;
+    return {
+      kind: "github",
+      git_repo: actionConfig.git_repo,
+      git_branch: branch,
+      registry_id: selectedRegistry?.id,
+      dockerfile_path: dockerfilePath,
+      folder_path: folderPath,
+      image_repo_uri: imageRepoURI,
+      git_repo_id: actionConfig.git_repo_id,
+      should_create_workflow: shouldCreateWorkflow,
+    };
+  };
+  const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
+  const [porterJson, setPorterJson] = useState<z.infer<typeof PorterYamlSchema>>(null);
+
+  const validatePorterYaml = (yamlString: string) => {
+    let parsedYaml;
+    try {
+      parsedYaml = yaml.load(yamlString);
+      const parsedData = PorterYamlSchema.parse(parsedYaml);
+      const porterYaml = parsedData as z.infer<typeof PorterYamlSchema>;
+      setPorterJson(porterYaml)
+      // go through key value pairs and create services from them
+      const newServices = [];
+      for (const [name, app] of Object.entries(porterYaml.apps)) {
+        if (app.type) {
+          newServices.push(createDefaultService(name, app.type, { readOnly: true, value: app.run }))
+        } else if (name.includes('web')) {
+          newServices.push(createDefaultService(name, 'web', { readOnly: true, value: app.run }))
+        } else {
+          newServices.push(createDefaultService(name, 'worker', { readOnly: true, value: app.run }))
+        }
+      }
+      setFormState({ ...formState, serviceList: [...formState.serviceList, ...newServices] });
+      if (Validators.serviceList(formState.serviceList)) {
+        setCurrentStep(Math.max(currentStep, 4));
+      }
+    } catch (error) {
+      console.log("Error converting porter yaml file to input: " + error)
+    }
+  }
+
+  // Deploys a Helm chart and writes build settings to the DB
+  const isAppNameValid = (name: string) => {
+    const regex = /^[a-z0-9-]+$/;
+    return regex.test(name);
+  };
+
+  const handleAppNameChange = (name: string) => {
+    setCurrentStep(currentStep);
+    setFormState({ ...formState, applicationName: name });
+    if (isAppNameValid(name) && Validators.applicationName(name)) {
+      setCurrentStep(Math.max(Math.max(currentStep, 1), existingStep));
+    } else {
+      setExistingStep(Math.max(currentStep, existingStep));
+      setCurrentStep(0);
+    }
+  };
+
+  const shouldHighlightAppNameInput = () => {
+    return (
+      formState.applicationName !== "" &&
+      !isAppNameValid(formState.applicationName)
+    );
+  };
+  const deployPorterApp = async () => {
+    try {
+      // Write build settings to the DB
+      const res = await api.createPorterApp(
+        "<token>",
+        {
+          name: formState.applicationName,
+          repo_name: actionConfig.git_repo,
+          git_branch: branch,
+          build_context: folderPath,
+          builder: "heroku",
+          buildpacks: "nodejs,ruby",
+          dockerfile: dockerfilePath,
+
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      );
+    } catch (err) {
+      console.log(err);
+    }
+
+    // TODO: update Porter stack
+  };
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back to="/apps" />
+          <DashboardHeader
+            prefix={<Icon src={web} />}
+            title="Deploy a new application"
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <VerticalSteps
+            currentStep={currentStep}
+            steps={[
+              <>
+                <Text size={16}>Application name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and "-" only.
+                </Text>
+                <Spacer y={0.5}></Spacer>
+                <Input
+                  placeholder="ex: academic-sophon"
+                  value={formState.applicationName}
+                  width="300px"
+                  error={
+                    shouldHighlightAppNameInput() &&
+                    'Lowercase letters, numbers, and "-" only.'
+                  }
+                  setValue={(e) => {
+                    handleAppNameChange(e);
+                  }}
+                />
+                {shouldHighlightAppNameInput()}
+              </>,
+              <>
+                <Text size={16}>Deployment method</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Deploy from a Git repository or a Docker registry.
+                  <a
+                    href="https://docs.porter.run/deploying-applications/overview"
+                    target="_blank"
+                  >
+                    &nbsp;Learn more.
+                  </a>
+                </Text>
+                <Spacer y={0.5} />
+                <SourceSelector
+                  selectedSourceType={formState.selectedSourceType}
+                  setSourceType={(type) => {
+                    setFormState({ ...formState, selectedSourceType: type });
+                  }}
+                />
+                <SourceSettings
+                  source={formState.selectedSourceType}
+                  imageUrl={imageUrl}
+                  setImageUrl={(x) => {
+                    setImageUrl(x);
+                    setCurrentStep(Math.max(currentStep, 2));
+                  }}
+                  imageTag={imageTag}
+                  setImageTag={setImageTag}
+                  actionConfig={actionConfig}
+                  setActionConfig={setActionConfig}
+                  branch={branch}
+                  setBranch={setBranch}
+                  procfileProcess={procfileProcess}
+                  setProcfileProcess={setProcfileProcess}
+                  dockerfilePath={dockerfilePath}
+                  setDockerfilePath={setDockerfilePath}
+                  folderPath={folderPath}
+                  setFolderPath={setFolderPath}
+                  procfilePath={procfilePath}
+                  setProcfilePath={setProcfilePath}
+                  setBuildConfig={setBuildConfig}
+                  porterYaml={porterYaml}
+                  setPorterYaml={(newYaml: string) => {
+                    validatePorterYaml(newYaml)
+                  }}
+                />
+              </>,
+              <>
+                <Text size={16}>Application services</Text>
+                <Spacer y={0.5} />
+                {porterJson && porterJson.apps && Object.keys(porterJson.apps).length > 0 &&
+                  <AppearingDiv>
+                    <Text size={16} color={"green"}>Autodetected {Object.keys(porterJson.apps).length} services from porter.yml</Text>
+                    <Spacer y={1} />
+                  </AppearingDiv>
+                }
+                <Services
+                  setServices={(services: any[]) => {
+                    setFormState({ ...formState, serviceList: services });
+                    if (Validators.serviceList(services)) {
+                      setCurrentStep(Math.max(currentStep, 4));
+                    }
+                  }}
+                  services={formState.serviceList}
+                />
+              </>,
+              <>
+                <Text size={16}>Environment variables (optional)</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Specify environment variables shared among all services.
+                </Text>
+                <EnvGroupArray
+                  values={formState.envVariables}
+                  setValues={(x: any) => {
+                    setFormState({ ...formState, envVariables: x });
+                  }}
+                  fileUpload={true}
+                />
+              </>,
+              /*
+              <>
+                <Text size={16}>Release command (optional)</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  If specified, this command will be run before every
+                  deployment.
+                </Text>
+                <Spacer y={0.5} />
+                <Input
+                  placeholder="yarn ./scripts/run-migrations.js"
+                  value={formState.releaseCommand}
+                  width="300px"
+                  setValue={(e) => {
+                    setFormState({ ...formState, releaseCommand: e });
+                    if (Validators.releaseCommand(e)) {
+                      setCurrentStep(Math.max(currentStep, 6));
+                    }
+                  }}
+                />
+              </>,
+              */
+              <Button
+                onClick={() => {
+                  if (imageUrl) {
+                    deployPorterApp();
+                  } else {
+                    setShowGHAModal(true);
+                  }
+                }}
+              >
+                Deploy app
+              </Button>,
+            ]}
+          />
+          <Spacer y={3} />
+        </StyledConfigureTemplate>
+      </Div>
+      {showGHAModal && (
+        <GithubActionModal
+          closeModal={() => setShowGHAModal(false)}
+          githubAppInstallationID={actionConfig.git_repo_id}
+          githubRepoOwner={actionConfig.git_repo.split("/")[0]}
+          githubRepoName={actionConfig.git_repo.split("/")[1]}
+          branch={branch}
+          stackName={formState.applicationName}
+          projectId={currentProject.id}
+          clusterId={currentCluster.id}
+          deployPorterApp={deployPorterApp}
+        />
+      )}
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(NewAppFlow);
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 28px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const AppearingDiv = styled.div`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;

+ 176 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -0,0 +1,176 @@
+import React from "react"
+import AnimateHeight from "react-animate-height";
+import styled from "styled-components";
+
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+import job from "assets/job.png";
+
+import Spacer from "components/porter/Spacer";
+import WebTabs from "./WebTabs";
+import WorkerTabs from "./WorkerTabs";
+import JobTabs from "./JobTabs";
+import { Service } from "./serviceTypes";
+
+interface ServiceProps {
+  service: Service;
+  editService: (service: Service) => void;
+  deleteService: () => void;
+}
+
+const ServiceContainer: React.FC<ServiceProps> = ({
+  service,
+  deleteService,
+  editService,
+}) => {
+  const [showExpanded, setShowExpanded] = React.useState<boolean>(true)
+
+  const renderTabs = (service: Service) => {
+    switch (service.type) {
+      case 'web':
+        return <WebTabs service={service} editService={editService} />
+      case 'worker':
+        return <WorkerTabs service={service} editService={editService} />
+      case 'job':
+        return <JobTabs service={service} editService={editService} />
+    }
+  }
+
+  const renderIcon = (service: Service) => {
+    switch (service.type) {
+      case 'web':
+        return <Icon src={web} />
+      case 'worker':
+        return <Icon src={worker} />
+      case 'job':
+        return <Icon src={job} />
+    }
+  }
+
+  return (
+    <>
+      <ServiceHeader
+        showExpanded={showExpanded}
+        onClick={() => setShowExpanded(!showExpanded)}
+      >
+        <ServiceTitle>
+          <ActionButton >
+            <span className="material-icons dropdown">arrow_drop_down</span>
+          </ActionButton>
+          {renderIcon(service)}
+          {service.name.trim().length > 0 ? service.name : "New Service"}
+        </ServiceTitle>
+        <ActionButton onClick={(e) => {
+          deleteService();
+        }}>
+          <span className="material-icons">delete</span>
+        </ActionButton>
+      </ServiceHeader>
+      <AnimateHeight
+        height={showExpanded ? "auto" : 0}
+      >
+        <StyledSourceBox showExpanded={showExpanded}>
+          {renderTabs(service)}
+        </StyledSourceBox>
+      </AnimateHeight>
+      <Spacer y={0.5} />
+    </>
+  )
+}
+
+export default ServiceContainer;
+
+const ServiceTitle = styled.div`
+    display: flex;
+    align-items: center;
+`;
+
+const StyledSourceBox = styled.div<{ showExpanded: boolean }>`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 25px 30px;
+  position: relative;
+  font-size: 13px;
+  border-radius: 5px;
+  background: ${props => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0px;
+  border-top-left-radius: 0px;
+  border-top-right-radius: 0px;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    color: white;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+  margin-right: 5px;
+`;
+
+const ServiceHeader = styled.div`
+  flex-direction: row;
+  display: flex;
+  height: 60px;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${props => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  border-bottom-left-radius: ${({ showExpanded }) => showExpanded && "0px"};
+  border-bottom-right-radius: ${({ showExpanded }) => showExpanded && "0px"};
+
+  .dropdown {
+    font-size: 30px;
+    cursor: pointer;
+    border-radius: 20px;
+    margin-left: -10px;
+    transform: ${(props: { showExpanded: boolean }) => props.showExpanded ? "" : "rotate(-90deg)"};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 15px;
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 177 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -0,0 +1,177 @@
+import React, { useState } from "react";
+import ServiceContainer from "./ServiceContainer";
+import styled from "styled-components";
+import Spacer from "components/porter/Spacer";
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Select from "components/porter/Select";
+import Input from "components/porter/Input";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+
+import web from "assets/web.png";
+import worker from "assets/worker.png";
+import job from "assets/job.png";
+import { Service, ServiceType, createDefaultService } from "./serviceTypes";
+
+interface ServicesProps {
+  services: Service[];
+  setServices: (services: Service[]) => void;
+}
+
+const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
+  const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
+    false
+  );
+  const [serviceName, setServiceName] = useState<string>("");
+  const [serviceType, setServiceType] = useState<ServiceType>("web");
+  const isServiceNameValid = (name: string) => {
+    const regex = /^[a-z0-9-]+$/;
+    return regex.test(name);
+  };
+
+  return (
+    <>
+      {services.length > 0 && (
+        <>
+          <ServicesContainer>
+            {services.map((service, index) => {
+              return (
+                <ServiceContainer
+                  service={service}
+                  editService={(newService: Service) =>
+                    setServices(
+                      services.map((s, i) => (i === index ? newService : s))
+                    )
+                  }
+                  deleteService={() =>
+                    setServices(services.filter((_, i) => i !== index))
+                  }
+                />
+              );
+            })}
+          </ServicesContainer>
+          <Spacer y={0.5} />
+        </>
+      )}
+      <AddServiceButton onClick={() => setShowAddServiceModal(true)}>
+        <i className="material-icons add-icon">add_icon</i>
+        Add a new service
+      </AddServiceButton>
+      {showAddServiceModal && (
+        <Modal closeModal={() => setShowAddServiceModal(false)}>
+          <Text size={16}>Add a new service</Text>
+          <Spacer y={1} />
+          <Text color="helper">Select a service type:</Text>
+          <Spacer y={0.5} />
+          <Container row>
+            <ServiceIcon>
+              {serviceType === "web" && <img src={web} />}
+              {serviceType === "worker" && <img src={worker} />}
+              {serviceType === "job" && <img src={job} />}
+            </ServiceIcon>
+            <Select
+              value={serviceType}
+              // this is ugly
+              setValue={(value: string) => setServiceType(value as ServiceType)}
+              options={[
+                { label: "Web", value: "web" },
+                { label: "Worker", value: "worker" },
+                { label: "Job", value: "job" },
+              ]}
+            />
+          </Container>
+          <Spacer y={1} />
+          <Text color="helper">Name this service:</Text>
+          <Spacer y={0.5} />
+          <Input
+            placeholder="ex: my-service"
+            width="300px"
+            value={serviceName}
+            error={
+              !isServiceNameValid(serviceName) &&
+              'Lowercase letters, numbers, and "-" only.'
+            }
+            setValue={setServiceName}
+          />
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              setServices([
+                ...services,
+                createDefaultService(serviceName, serviceType, { readOnly: false, value: '' }),
+              ]);
+              setShowAddServiceModal(false);
+              setServiceName("");
+              setServiceType("web");
+            }}
+            disabled={!isServiceNameValid(serviceName)}
+          >
+            <I className="material-icons">add</I> Add service
+          </Button>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default Services;
+
+const ServiceIcon = styled.div`
+  border: 1px solid #494b4f;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 35px;
+  width: 35px;
+  margin-right: 10px;
+  overflow: hidden;
+  border-radius: 5px;
+  > img {
+    height: 18px;
+    animation: floatIn 0.5s 0s;
+    @keyframes floatIn {
+      from {
+        opacity: 0;
+        transform: translateY(7px);
+      }
+      to {
+        opacity: 1;
+        transform: translateY(0px);
+      }
+    }
+  }
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;
+
+const ServicesContainer = styled.div``;
+
+const AddServiceButton = styled.div`
+  color: #aaaabb;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  .add-icon {
+    width: 30px;
+    font-size: 20px;
+  }
+`;

+ 118 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSelector.tsx

@@ -0,0 +1,118 @@
+import React from "react";
+import styled from "styled-components";
+
+export type SourceType = "github" | "docker-registry";
+
+interface SourceSelectorProps {
+  selectedSourceType: SourceType | undefined;
+  setSourceType: (sourceType: SourceType) => void;
+}
+
+const SourceSelector: React.FC<SourceSelectorProps> = ({
+  selectedSourceType,
+  setSourceType
+}) => {
+  return (
+    <BlockList>
+      <Block
+        selected={selectedSourceType === 'github'}
+        onClick={() => setSourceType('github')}
+      >
+        <BlockIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
+        <BlockTitle>Git repository</BlockTitle>
+        <BlockDescription>
+          Deploy using source from a Git repo.
+        </BlockDescription>
+      </Block>
+      <Block
+        selected={selectedSourceType === 'docker-registry'}
+        onClick={() => setSourceType('docker-registry')}
+      >
+        <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+        <BlockTitle>Docker registry</BlockTitle>
+        <BlockDescription>
+          Deploy a container from an image registry.
+        </BlockDescription>
+      </Block>
+
+    </BlockList>
+  );
+}
+
+export default SourceSelector;
+
+const Block = styled.div<{ selected?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: ${props => props.selected ? "2px solid #8590ff" : "1px solid #494b4f"};
+  :hover {
+    border: ${({ selected }) => (!selected && "1px solid #7a7b80")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;

+ 193 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx

@@ -0,0 +1,193 @@
+import AnimateHeight from "react-animate-height";
+import React, { Component } from "react";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import AdvancedBuildSettings from "./AdvancedBuildSettings";
+import styled from "styled-components";
+import { SourceType } from "./SourceSelector";
+import ActionConfEditorStack from "components/repo-selector/ActionConfEditorStack";
+import { ActionConfigType } from "shared/types";
+import { RouteComponentProps } from "react-router";
+import { Context } from "shared/Context";
+import ActionConfBranchSelector from "components/repo-selector/ActionConfBranchSelector";
+import DetectContentsList from "components/repo-selector/DetectContentsList";
+
+type Props = {
+  source: SourceType | undefined;
+  imageUrl: string;
+  setImageUrl: (x: string) => void;
+  imageTag: string;
+  setImageTag: (x: string) => void;
+  actionConfig: ActionConfigType;
+  setActionConfig: (
+    x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
+  ) => void;
+  procfileProcess: string;
+  setProcfileProcess: (x: string) => void;
+  branch: string;
+  setBranch: (x: string) => void;
+  dockerfilePath: string | null;
+  setDockerfilePath: (x: string) => void;
+  procfilePath: string | null;
+  setProcfilePath: (x: string) => void;
+  folderPath: string | null;
+  setFolderPath: (x: string) => void;
+  setBuildConfig: (x: any) => void;
+  porterYaml: string;
+  setPorterYaml: (x: any) => void;
+};
+
+const SourceSettings: React.FC<Props> = ({
+  source,
+  imageUrl,
+  setImageUrl,
+  imageTag,
+  setImageTag,
+  actionConfig,
+  setActionConfig,
+  setProcfileProcess,
+  branch,
+  setBranch,
+  dockerfilePath,
+  setDockerfilePath,
+  procfilePath,
+  setProcfilePath,
+  folderPath,
+  setFolderPath,
+  setBuildConfig,
+  porterYaml,
+  setPorterYaml,
+}) => {
+  const renderGithubSettings = () => {
+    return (
+      <>
+        <Text size={16}>Build settings</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">Select your Github repository.</Text>
+        <Spacer y={0.5} />
+        <Subtitle>
+          Provide a repo folder to use as source.
+          <Required>*</Required>
+          <ActionConfEditorStack
+            actionConfig={actionConfig}
+            setActionConfig={(actionConfig: ActionConfigType) => {
+              setActionConfig((currentActionConfig: ActionConfigType) => ({
+                ...currentActionConfig,
+                ...actionConfig,
+              }));
+              setImageUrl(actionConfig.image_repo_uri);
+            }}
+            setBranch={setBranch}
+            setDockerfilePath={setDockerfilePath}
+            setFolderPath={setFolderPath}
+          />
+        </Subtitle>
+        <DarkMatter antiHeight="-4px" />
+        <br />
+        <Spacer y={0.5} />
+        {actionConfig.git_repo && (
+          <>
+            <Text color="helper">Select your branch.</Text>
+            <ActionConfBranchSelector
+              actionConfig={actionConfig}
+              branch={branch}
+              setActionConfig={(actionConfig: ActionConfigType) => {
+                setActionConfig((currentActionConfig: ActionConfigType) => ({
+                  ...currentActionConfig,
+                  ...actionConfig,
+                }));
+                setImageUrl(actionConfig.image_repo_uri);
+              }}
+              setBranch={setBranch}
+              setDockerfilePath={setDockerfilePath}
+              setFolderPath={setFolderPath}
+            />
+          </>
+        )}
+        <Spacer y={1} />
+        <Text color="helper">Specify your application root path.</Text>
+        <Spacer y={0.5} />
+        <Input
+          disabled={!branch ? true : false}
+          placeholder="ex: ./"
+          value={folderPath}
+          width="100%"
+          setValue={setFolderPath}
+        />
+        {actionConfig.git_repo && branch && (
+          <DetectContentsList
+            actionConfig={actionConfig}
+            branch={branch}
+            dockerfilePath={dockerfilePath}
+            procfilePath={procfilePath}
+            folderPath={folderPath}
+            setActionConfig={setActionConfig}
+            setDockerfilePath={setDockerfilePath}
+            setProcfilePath={setProcfilePath}
+            setProcfileProcess={setProcfileProcess}
+            setFolderPath={setFolderPath}
+            setBuildConfig={setBuildConfig}
+            porterYaml={porterYaml}
+            setPorterYaml={setPorterYaml}
+          />
+        )}
+      </>
+    );
+  };
+
+  const renderDockerSettings = () => {
+    return (
+      <>
+        <Text size={16}>Registry settings</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Specify the complete registry URL for your Docker image:
+        </Text>
+        <Spacer height="20px" />
+        <Input
+          placeholder="ex: nginx"
+          value={imageUrl}
+          width="300px"
+          setValue={setImageUrl}
+        />
+      </>
+    );
+  };
+
+  return (
+    <SourceSettingsContainer>
+      {source && <Spacer y={1} />}
+      <AnimateHeight height={source ? "auto" : 0}>
+        <div>
+          {source === "github"
+            ? renderGithubSettings()
+            : renderDockerSettings()}
+        </div>
+      </AnimateHeight>
+    </SourceSettingsContainer>
+  );
+};
+
+export default SourceSettings;
+
+const SourceSettingsContainer = styled.div``;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;

+ 154 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx

@@ -0,0 +1,154 @@
+import Input from "components/porter/Input";
+import React from "react"
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import Checkbox from "components/porter/Checkbox";
+import { WebService } from "./serviceTypes";
+
+interface Props {
+  service: WebService
+  editService: (service: WebService) => void
+}
+
+const WebTabs: React.FC<Props> = ({
+  service,
+  editService
+}) => {
+  const [currentTab, setCurrentTab] = React.useState<string>('main');
+
+  const renderMain = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="Start command"
+          placeholder="ex: sh start.sh"
+          value={service.startCommand.value}
+          width="300px"
+          disabled={service.startCommand.readOnly}
+          setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Container port"
+          placeholder="ex: 80"
+          value={service.port}
+          width="300px"
+          setValue={(e) => { editService({ ...service, port: e }) }}
+        />
+        <Spacer y={1} />
+        <Checkbox
+          checked={service.generateUrlForExternalTraffic}
+          toggleChecked={() => { editService({ ...service, generateUrlForExternalTraffic: !service.generateUrlForExternalTraffic }) }}
+        >
+          <Text color="helper">Generate a Porter URL for external traffic</Text>
+        </Checkbox>
+      </>
+    )
+  };
+
+  const renderResources = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="CPUs"
+          placeholder="ex: 0.5"
+          value={service.cpu}
+          width="300px"
+          setValue={(e) => { editService({ ...service, cpu: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="RAM (GB)"
+          placeholder="ex: 1"
+          value={service.ram}
+          width="300px"
+          setValue={(e) => { editService({ ...service, ram: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Replicas"
+          placeholder="ex: 1"
+          value={service.replicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, replicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Checkbox
+          checked={service.autoscalingOn}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+        >
+          <Text color="helper">Enable autoscaling (overrides replicas)</Text>
+        </Checkbox>
+        <Spacer y={1} />
+        <Input
+          label="Min replicas"
+          placeholder="ex: 1"
+          value={service.minReplicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Max replicas"
+          placeholder="ex: 10"
+          value={service.maxReplicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Target CPU utilization (%)"
+          placeholder="ex: 50"
+          value={service.targetCPUUtilizationPercentage}
+          width="300px"
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Target RAM utilization (%)"
+          placeholder="ex: 50"
+          value={service.targetRAMUtilizationPercentage}
+          width="300px"
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+        />
+      </>
+    )
+  };
+
+  const renderAdvanced = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="Custom domain"
+          placeholder="ex: my-app.my-domain.com"
+          value={service.customDomain ?? ''}
+          width="300px"
+          setValue={(e) => { editService({ ...service, customDomain: e }) }}
+        />
+      </>
+    );
+  };
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: 'Main', value: 'main' },
+          { label: 'Resources', value: 'resources' },
+          { label: 'Advanced', value: 'advanced' },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {currentTab === 'main' && renderMain()}
+      {currentTab === 'resources' && renderResources()}
+      {currentTab === 'advanced' && renderAdvanced()}
+    </>
+  )
+}
+
+export default WebTabs;

+ 131 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx

@@ -0,0 +1,131 @@
+import Input from "components/porter/Input";
+import React from "react"
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import Checkbox from "components/porter/Checkbox";
+import { WorkerService } from "./serviceTypes";
+
+interface Props {
+  service: WorkerService
+  editService: (service: WorkerService) => void
+}
+
+const WorkerTabs: React.FC<Props> = ({
+  service,
+  editService
+}) => {
+  const [currentTab, setCurrentTab] = React.useState<string>('main');
+
+  const renderMain = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="Start command"
+          placeholder="ex: sh start.sh"
+          disabled={service.startCommand.readOnly}
+          value={service.startCommand.value}
+          width="300px"
+          setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+        />
+      </>
+    )
+  };
+
+  const renderResources = () => {
+    return (
+      <>
+        <Spacer y={1} />
+        <Input
+          label="CPUs"
+          placeholder="ex: 0.5"
+          value={service.cpu}
+          width="300px"
+          setValue={(e) => { editService({ ...service, cpu: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="RAM (GB)"
+          placeholder="ex: 1"
+          value={service.ram}
+          width="300px"
+          setValue={(e) => { editService({ ...service, ram: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Replicas"
+          placeholder="ex: 1"
+          value={service.replicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, replicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Checkbox
+          checked={service.autoscalingOn}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+        >
+          <Text color="helper">Enable autoscaling (overrides replicas)</Text>
+        </Checkbox>
+        <Spacer y={1} />
+        <Input
+          label="Min replicas"
+          placeholder="ex: 1"
+          value={service.minReplicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Max replicas"
+          placeholder="ex: 10"
+          value={service.maxReplicas}
+          width="300px"
+          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Target CPU utilization (%)"
+          placeholder="ex: 50"
+          value={service.targetCPUUtilizationPercentage}
+          width="300px"
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+        />
+        <Spacer y={1} />
+        <Input
+          label="Target RAM utilization (%)"
+          placeholder="ex: 50"
+          value={service.targetRAMUtilizationPercentage}
+          width="300px"
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+        />
+      </>
+    )
+  };
+
+  const renderAdvanced = () => {
+    return (
+      <>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <TabSelector
+        options={[
+          { label: 'Main', value: 'main' },
+          { label: 'Resources', value: 'resources' },
+          // { label: 'Advanced', value: 'advanced' },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+      />
+      {currentTab === 'main' && renderMain()}
+      {currentTab === 'resources' && renderResources()}
+      {/* currentTab === 'advanced' && renderAdvanced() */}
+    </>
+  )
+}
+
+export default WorkerTabs;

+ 41 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -0,0 +1,41 @@
+import * as z from "zod";
+
+const appConfigSchema = z.object({
+    run: z.string().min(1),
+    config: z.any().optional(),
+    type: z.enum(['web', 'worker', 'job']).optional(),
+});
+
+const appsSchema = z.record(appConfigSchema);
+
+const envSchema = z.record(z.string());
+
+const buildSchema = z.object({
+    method: z.string().refine(value => ["pack", "docker", "registry"].includes(value)),
+    context: z.string().optional(),
+    builder: z.string().optional(),
+    buildpacks: z.array(z.string()).optional(),
+    dockerfile: z.string().optional(),
+    image: z.string().optional()
+}).refine(value => {
+    if (value.method === "pack") {
+        return value.builder != null;
+    }
+    if (value.method === "docker") {
+        return value.dockerfile != null;
+    }
+    if (value.method === "registry") {
+        return value.image != null;
+    }
+    return false;
+},
+    { message: "Invalid build configuration" });
+
+
+export const PorterYamlSchema = z.object({
+    version: z.string().optional(),
+    build: buildSchema.optional(),
+    env: envSchema.optional(),
+    apps: appsSchema,
+    release: z.string().optional(),
+});

+ 92 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -0,0 +1,92 @@
+export type Service = WorkerService | WebService | JobService;
+export type ServiceType = 'web' | 'worker' | 'job';
+
+type ServiceReadOnlyField = {
+    readOnly: boolean;
+    value: string;
+}
+
+type SharedServiceParams = {
+    name: string;
+    cpu: string;
+    ram: string;
+    startCommand: ServiceReadOnlyField;
+    type: ServiceType;
+}
+
+export type WorkerService = SharedServiceParams & {
+    type: 'worker';
+    replicas: string;
+    autoscalingOn: boolean;
+    minReplicas: string;
+    maxReplicas: string;
+    targetCPUUtilizationPercentage: string;
+    targetRAMUtilizationPercentage: string;
+}
+const WorkerService = {
+    default: (name: string, startCommand: ServiceReadOnlyField): WorkerService => ({
+        name,
+        cpu: '',
+        ram: '',
+        startCommand: startCommand,
+        type: 'worker',
+        replicas: '1',
+        autoscalingOn: false,
+        minReplicas: '1',
+        maxReplicas: '10',
+        targetCPUUtilizationPercentage: '50',
+        targetRAMUtilizationPercentage: '50',
+    }),
+}
+
+export type WebService = SharedServiceParams & Omit<WorkerService, 'type'> & {
+    type: 'web';
+    port: string;
+    generateUrlForExternalTraffic: boolean;
+    customDomain?: string;
+}
+const WebService = {
+    default: (name: string, startCommand: ServiceReadOnlyField): WebService => ({
+        name,
+        cpu: '',
+        ram: '',
+        startCommand: startCommand,
+        type: 'web',
+        replicas: '1',
+        autoscalingOn: false,
+        minReplicas: '1',
+        maxReplicas: '10',
+        targetCPUUtilizationPercentage: '50',
+        targetRAMUtilizationPercentage: '50',
+        port: '80',
+        generateUrlForExternalTraffic: true,
+    }),
+}
+
+export type JobService = SharedServiceParams & {
+    type: 'job';
+    jobsExecuteConcurrently: boolean;
+    cronSchedule: string;
+}
+const JobService = {
+    default: (name: string, startCommand: ServiceReadOnlyField): JobService => ({
+        name,
+        cpu: '',
+        ram: '',
+        startCommand: startCommand,
+        type: 'job',
+        jobsExecuteConcurrently: false,
+        cronSchedule: '',
+    }),
+}
+
+export const createDefaultService = (name: string, type: ServiceType, startCommand: ServiceReadOnlyField) => {
+    switch (type) {
+        case 'web':
+            return WebService.default(name, startCommand);
+        case 'worker':
+            return WorkerService.default(name, startCommand);
+        case 'job':
+            return JobService.default(name, startCommand);
+    }
+}

+ 14 - 9
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -6,6 +6,7 @@ import { Context } from "shared/Context";
 import TitleSection from "components/TitleSection";
 import Spacer from "components/porter/Spacer";
 import Tooltip from "components/porter/Tooltip";
+import Container from "components/porter/Container";
 
 type PropsType = {
   image?: any;
@@ -14,6 +15,7 @@ type PropsType = {
   materialIconClass?: string;
   disableLineBreak?: boolean;
   capitalize?: boolean;
+  prefix?: any;
 };
 
 type StateType = {};
@@ -22,15 +24,18 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        <TitleSection
-          capitalize={
-            this.props.capitalize === undefined || this.props.capitalize
-          }
-          icon={this.props.image}
-          materialIconClass={this.props.materialIconClass}
-        >
-          {this.props.title}
-        </TitleSection>
+        <Container row>
+          {this.props.prefix}
+          <TitleSection
+            capitalize={
+              this.props.capitalize === undefined || this.props.capitalize
+            }
+            icon={this.props.image}
+            materialIconClass={this.props.materialIconClass}
+          >
+            {this.props.title}
+          </TitleSection>
+        </Container>
 
         {this.props.description && (
           <>

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/apps/AppDashboard.tsx

@@ -61,7 +61,7 @@ const AppDashboard: React.FC<Props> = ({
                 currentView={currentView}
               />
               <Spacer inline width="10px" />
-              {!currentProject.capi_provisioner_enabled && (
+              {!currentProject?.capi_provisioner_enabled && (
                 <NamespaceSelector
                   setNamespace={(x) => {
                     setNamespace(x);

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -86,8 +86,8 @@ export const Dashboard: React.FunctionComponent = () => {
     if (
       context.currentCluster.status !== "UPDATING_UNAVAILABLE" &&
       !tabOptions.find((tab) => tab.value === "nodes")
-    ) {
-      if (!context.currentProject.capi_provisioner_enabled) {
+    ) {  
+      if (!context.currentProject?.capi_provisioner_enabled) {
         tabOptions.unshift({ label: "Namespaces", value: "namespaces" });
         tabOptions.unshift({ label: "Metrics", value: "metrics" });
         tabOptions.unshift({ label: "Nodes", value: "nodes" });
@@ -97,7 +97,7 @@ export const Dashboard: React.FunctionComponent = () => {
     }
 
     if (
-      context.currentProject.capi_provisioner_enabled &&
+      context.currentProject?.capi_provisioner_enabled &&
       !tabOptions.find((tab) => tab.value === "configuration")
     ) {
       tabOptions.unshift({ value: "configuration", label: "Configuration" });
@@ -125,7 +125,7 @@ export const Dashboard: React.FunctionComponent = () => {
   // Need to reset tab to reset views that don't auto-update on cluster switch (esp namespaces + settings)
   useEffect(() => {
     setShowProvisionerStatus(false);
-    if (context.currentProject.capi_provisioner_enabled) {
+    if (context.currentProject?.capi_provisioner_enabled) {
       setCurrentTab("configuration");
     } else {
       setCurrentTab("nodes");
@@ -155,7 +155,7 @@ export const Dashboard: React.FunctionComponent = () => {
   }, []);
 
   const renderContents = () => {
-    if (context.currentProject.capi_provisioner_enabled) {
+    if (context.currentProject?.capi_provisioner_enabled) {
       return (
         <>
           <ClusterRevisionSelector

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -107,7 +107,7 @@ const EnvGroupDashboard = (props: PropsType) => {
                 sortType={state.sortType}
               />
               <Spacer inline width="10px" />
-              {!currentProject.capi_provisioner_enabled && (
+              {!currentProject?.capi_provisioner_enabled && (
                 <NamespaceSelector
                   setNamespace={setNamespace}
                   namespace={state.namespace}
@@ -125,7 +125,7 @@ const EnvGroupDashboard = (props: PropsType) => {
 
           <EnvGroupList
             currentCluster={props.currentCluster}
-            namespace={currentProject.capi_provisioner_enabled ? "default" : state.namespace}
+            namespace={currentProject?.capi_provisioner_enabled ? "default" : state.namespace}
             sortType={state.sortType}
             setExpandedEnvGroup={setExpandedEnvGroup}
           />

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

@@ -422,7 +422,7 @@ const RollbackButton = styled.div`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#405eddbb"};
+    props.disabled ? "" : "#405eddbb"};
   }
 `;
 
@@ -434,7 +434,7 @@ const Tr = styled.tr`
     props.selected ? "#ffffff11" : ""};
   :hover {
     background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-      props.disableHover ? "" : "#ffffff22"};
+    props.disableHover ? "" : "#ffffff22"};
   }
 `;
 
@@ -476,6 +476,7 @@ const RevisionHeader = styled.div`
   width: 100%;
   padding-left: 10px;
   cursor: pointer;
+  background: #26292e;
   :hover {
     background: ${(props) => props.showRevisions && "#ffffff18"};
   }
@@ -486,7 +487,7 @@ const RevisionHeader = styled.div`
     cursor: pointer;
     border-radius: 20px;
     transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "" : "rotate(-90deg)"};
+    props.showRevisions ? "" : "rotate(-90deg)"};
   }
 `;
 

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

@@ -190,7 +190,7 @@ const SettingsSection: React.FC<PropsType> = ({
 
   const renderWebhookSection = () => {
     if (!currentChart?.form?.hasSource) {
-      return;
+      return <DarkMatter />;
     }
 
     const protocol = window.location.protocol == "https:" ? "https" : "http";
@@ -371,7 +371,7 @@ export default SettingsSection;
 const DarkMatter = styled.div`
   width: 100%;
   height: 0;
-  margin-top: -10px;
+  margin-top: -40px;
 `;
 
 const Br = styled.div`

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/jobs/JobDashboard.tsx

@@ -59,7 +59,7 @@ const JobDashboard: React.FC<Props> = ({
                 lastRunStatus={lastRunStatus}
                 setLastRunStatus={setLastRunStatus}
               />
-              {!currentProject.capi_provisioner_enabled && (
+              {!currentProject?.capi_provisioner_enabled && (
                 <NamespaceSelector
                   setNamespace={(x) => {
                     setNamespace(x);
@@ -117,7 +117,7 @@ const JobDashboard: React.FC<Props> = ({
               currentView={currentView}
               currentCluster={currentCluster}
               lastRunStatus={lastRunStatus}
-              namespace={currentProject.capi_provisioner_enabled ? "default" : namespace}
+              namespace={currentProject?.capi_provisioner_enabled ? "default" : namespace}
               sortType={sortType}
               selectedTag={selectedTag}
             />

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

@@ -194,7 +194,7 @@ class Dashboard extends Component<PropsType, StateType> {
                   </Description>
                 </InfoSection>
                 {
-                  currentProject.capi_provisioner_enabled ? (
+                  currentProject?.capi_provisioner_enabled ? (
                     <ClusterSection />
                   ) : (
                     <TabRegion

+ 1 - 1
dashboard/src/main/home/integrations/Integrations.tsx

@@ -4,7 +4,7 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 import { integrationList } from "shared/common";
 import styled from "styled-components";
 import { pushFiltered } from "shared/routing";
-import integrations from "assets/integrations.svg";
+import integrations from "assets/integrations.png";
 
 import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";

+ 0 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -14,7 +14,6 @@ import TitleSection from "components/TitleSection";
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 
-import rocket from "assets/rocket.png";
 import semver from "semver";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, getQueryParams, pushFiltered } from "shared/routing";

+ 4 - 1
dashboard/src/main/home/launch/TemplateList.tsx

@@ -4,7 +4,7 @@ import api from "shared/api";
 import styled from "styled-components";
 
 import Loading from "components/Loading";
-import { hardcodedNames } from "shared/hardcodedNameDict";
+import { hardcodedIcons, hardcodedNames } from "shared/hardcodedNameDict";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
 
@@ -113,6 +113,9 @@ const TemplateList: React.FC<Props> = ({
     if (name === "job") {
       return <NewIcon src={job} />;
     }
+    if (hardcodedIcons[name]) {
+      return <Icon src={hardcodedIcons[name]} />;
+    }
     if (icon) {
       return <Icon src={icon} />;
     }

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -248,7 +248,7 @@ class SettingsPage extends Component<PropsType, StateType> {
         <StyledSettingsPage>
           {this.renderHeaderSection()}
           {this.props.isCloning && this.getNameInput()}
-          {!currentProject.capi_provisioner_enabled && (
+          {!currentProject?.capi_provisioner_enabled && (
             <>
               <Heading>Destination</Heading>
               <Helper>

+ 1 - 1
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
-import settings from "assets/settings-centered.svg";
+import settings from "assets/settings.png";
 
 import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";

+ 10 - 5
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -20,6 +20,7 @@ type PropsType = RouteComponentProps & {
   currentView: string;
   isSelected: boolean;
   forceRefreshClusters: boolean;
+  display?: string;
   setRefreshClusters: (x: boolean) => void;
 };
 
@@ -47,7 +48,7 @@ class Clusters extends Component<PropsType, StateType> {
 
     // TODO: query with selected filter once implemented
     api
-      .getClusters("<token>", {}, { id: currentProject.id })
+      .getClusters("<token>", {}, { id: currentProject?.id })
       .then((res) => {
         window.analytics?.identify(user.userId, {
           currentProject,
@@ -82,7 +83,7 @@ class Clusters extends Component<PropsType, StateType> {
 
             this.setState({ clusters });
             let saved = JSON.parse(
-              localStorage.getItem(currentProject.id + "-cluster")
+              localStorage.getItem(currentProject?.id + "-cluster")
             );
             if (!defaultCluster && saved && saved !== "null") {
               // Ensures currentCluster isn't prematurely set (causes issues downstream)
@@ -146,7 +147,7 @@ class Clusters extends Component<PropsType, StateType> {
     if (
       clusters.length > 0 &&
       currentCluster &&
-      !currentProject.capi_provisioner_enabled
+      !currentProject?.capi_provisioner_enabled
     ) {
       clusters.sort((a, b) => a.id - b.id);
 
@@ -168,7 +169,7 @@ class Clusters extends Component<PropsType, StateType> {
           />
         );
       });
-    } else if (currentProject.capi_provisioner_enabled) {
+    } else if (currentProject?.capi_provisioner_enabled) {
       const cluster = clusters[0];
       return (
         <>
@@ -252,7 +253,7 @@ class Clusters extends Component<PropsType, StateType> {
   };
 
   render() {
-    return <>{this.renderContents()}</>;
+    return <Wrapper display={this.props.display}>{this.renderContents()}</Wrapper>;
   }
 }
 
@@ -260,6 +261,10 @@ Clusters.contextType = Context;
 
 export default withRouter(Clusters);
 
+const Wrapper = styled.div<{ display: string }>`
+  display: ${props => props.display || ""};
+`;
+
 const InlineSVGWrapper = styled.svg`
   width: 32px;
   height: 32px;

+ 63 - 6
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -1,9 +1,11 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import category from "assets/category.svg";
-import integrations from "assets/integrations.svg";
+import integrations from "assets/integrations-bold.png";
 import rocket from "assets/rocket.png";
-import settings from "assets/settings.svg";
+import settings from "assets/settings-bold.png";
+import web from "assets/web-bold.png";
+import addOns from "assets/add-ons-bold.png";
 
 import { Context } from "shared/Context";
 
@@ -103,7 +105,7 @@ class Sidebar extends Component<PropsType, StateType> {
   renderProjectContents = () => {
     let { currentView } = this.props;
     let { currentProject, user, currentCluster, hasFinishedOnboarding } = this.context;
-    if (currentProject) {
+    if (!currentProject?.simplified_view_enabled) {
       return (
         <ScrollWrapper>
           <SidebarLabel>Home</SidebarLabel>
@@ -141,7 +143,7 @@ class Sidebar extends Component<PropsType, StateType> {
             "delete",
           ]) && (
             <NavButton path={"/project-settings"}>
-              <Img enlarge={true} src={settings} />
+              <Img src={settings} />
               Project settings
             </NavButton>
           )}
@@ -149,7 +151,7 @@ class Sidebar extends Component<PropsType, StateType> {
           <br />
 
           <SidebarLabel>
-            {currentProject.capi_provisioner_enabled ? (
+            {currentProject?.capi_provisioner_enabled ? (
               "Your team"
             ) : (
               "Clusters"
@@ -164,6 +166,56 @@ class Sidebar extends Component<PropsType, StateType> {
           />
         </ScrollWrapper>
       );
+    } else if (currentProject.simplified_view_enabled) {
+      return (
+        <ScrollWrapper>
+          <NavButton
+            path="/apps"
+            active={window.location.pathname.startsWith("/apps")}
+          >
+            <Img src={web} />
+            Applications
+          </NavButton>
+          <NavButton
+            path="/addons"
+            active={window.location.pathname.startsWith("/addons")}
+          >
+            <Img src={addOns} />
+            Add-ons
+          </NavButton>
+          {this.props.isAuthorized("integrations", "", [
+            "get",
+            "create",
+            "update",
+            "delete",
+          ]) && (
+            <NavButton path={"/integrations"}>
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
+            <NavButton path={"/project-settings"}>
+              <Img src={settings} />
+              Project settings
+            </NavButton>
+          )}
+
+          {/* Hacky workaround for setting currentCluster with legacy method */}
+          <Clusters
+            display="none"
+            setWelcome={this.props.setWelcome}
+            currentView={currentView}
+            isSelected={false}
+            forceRefreshClusters={this.props.forceRefreshClusters}
+            setRefreshClusters={this.props.setRefreshClusters}
+          />
+        </ScrollWrapper>
+      );
     }
 
     // Render placeholder if no project exists
@@ -245,6 +297,12 @@ const NavButton = styled(SidebarLink)`
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
+  background: ${(props: any) => (props.active ? "#ffffff11" : "")};
+
+  :hover {
+    background: ${(props: any) => (props.active ? "#ffffff11" : "#ffffff08")};
+  }
+
   &.active {
     background: #ffffff11;
 
@@ -268,7 +326,6 @@ const NavButton = styled(SidebarLink)`
 const Img = styled.img<{ enlarge?: boolean }>`
   padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
   height: 22px;
-  width: 22px;
   padding-top: 4px;
   border-radius: 3px;
   margin-right: 8px;

+ 164 - 61
dashboard/src/shared/api.tsx

@@ -164,6 +164,57 @@ const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
 
+const getPorterApp = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    name: string;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id, name } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${name}`;
+});
+
+const createPorterApp = baseApi<
+  {
+    name: string;
+    repo_name: string;
+    git_branch: string;
+    build_context: string;
+    builder: string;
+    buildpacks: string;
+    dockerfile: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  let { project_id, cluster_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/update_config`;
+});
+
+const updatePorterStack = baseApi<
+  {
+    stack_name: string;
+    dependencies: {
+      name: string;
+      alias: string;
+      version: string;
+      repository: string;
+    }[];
+    values: any;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  let { project_id, cluster_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks`;
+});
+
 const createEnvironment = baseApi<
   {
     name: string;
@@ -329,12 +380,12 @@ const createInvite = baseApi<
   return `/api/projects/${pathParams.id}/invites`;
 });
 
-const inviteAdmin = baseApi<
-  {},
-  { project_id: number }
->("POST", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/invite_admin`;
-});
+const inviteAdmin = baseApi<{}, { project_id: number }>(
+  "POST",
+  (pathParams) => {
+    return `/api/projects/${pathParams.project_id}/invite_admin`;
+  }
+);
 
 const createPasswordReset = baseApi<
   {
@@ -586,9 +637,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -619,9 +672,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -637,9 +692,31 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+});
+
+const getPorterYamlContents = baseApi<
+  {
+    path: string;
+  },
+  {
+    project_id: number;
+    git_repo_id: number;
+    kind: string;
+    owner: string;
+    name: string;
+    branch: string;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const getGitlabProcfileContents = baseApi<
@@ -869,30 +946,30 @@ const getInfraTemplate = baseApi<
 
 const provisionCluster = baseApi<
   {
-    project_id: number,
-    cluster_id?: number,
-    cloud_provider: string,
-    cloud_provider_credentials_id: string,
+    project_id: number;
+    cluster_id?: number;
+    cloud_provider: string;
+    cloud_provider_credentials_id: string;
     cluster_settings: {
-      cluster_name: string,
-      cluster_version: string,
-      cidr_range: string,
-      region: string,
+      cluster_name: string;
+      cluster_version: string;
+      cidr_range: string;
+      region: string;
       node_groups: [
         {
-          instance_type: string,
-          min_instances: number,
-          max_instances: number,
-          node_group_type: number
+          instance_type: string;
+          min_instances: number;
+          max_instances: number;
+          node_group_type: number;
         },
         {
-          instance_type: string,
-          min_instances: number,
-          max_instances: number,
-          node_group_type: number
+          instance_type: string;
+          min_instances: number;
+          max_instances: number;
+          node_group_type: number;
         }
-      ]
-    }
+      ];
+    };
   },
   {
     project_id: number;
@@ -901,33 +978,33 @@ const provisionCluster = baseApi<
   return `/api/projects/${project_id}/provision/cluster`;
 });
 
-const createContract = baseApi<
-  Contract,
-  { project_id: number }
->("POST", ({ project_id }) => {
-  return `/api/projects/${project_id}/contract`;
-});
+const createContract = baseApi<Contract, { project_id: number }>(
+  "POST",
+  ({ project_id }) => {
+    return `/api/projects/${project_id}/contract`;
+  }
+);
 
-const getContracts = baseApi<
-  { cluster_id?: number },
-  { project_id: number }
->("GET", ({ project_id }) => {
-  return `/api/projects/${project_id}/contracts`;
-});
+const getContracts = baseApi<{ cluster_id?: number }, { project_id: number }>(
+  "GET",
+  ({ project_id }) => {
+    return `/api/projects/${project_id}/contracts`;
+  }
+);
 
-const deleteContract = baseApi<
-  {},
-  { project_id: number, revision_id: string }
->("DELETE", ({ project_id, revision_id }) => {
-  return `/api/projects/${project_id}/contracts/${revision_id}`;
-});
+const deleteContract = baseApi<{}, { project_id: number; revision_id: string }>(
+  "DELETE",
+  ({ project_id, revision_id }) => {
+    return `/api/projects/${project_id}/contracts/${revision_id}`;
+  }
+);
 
-const getClusterState = baseApi<
-  {},
-  { project_id: number, cluster_id: number }
->("GET", ({ project_id, cluster_id }) => {
-  return `/api/projects/${project_id}/clusters/${cluster_id}/state`;
-});
+const getClusterState = baseApi<{}, { project_id: number; cluster_id: number }>(
+  "GET",
+  ({ project_id, cluster_id }) => {
+    return `/api/projects/${project_id}/clusters/${cluster_id}/state`;
+  }
+);
 
 const provisionInfra = baseApi<
   {
@@ -1493,9 +1570,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -2408,7 +2487,26 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+
+const createSecretAndOpenGitHubPullRequest = baseApi<
+  {
+    github_app_installation_id: number;
+    github_repo_owner: string;
+    github_repo_name: string;
+    open_pr: boolean;
+    branch: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    stack_name: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, stack_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${stack_name}/pr`
+);
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
@@ -2443,6 +2541,9 @@ export default {
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createProject,
+  getPorterApp,
+  createPorterApp,
+  updatePorterStack,
   createConfigMap,
   deleteCluster,
   deleteConfigMap,
@@ -2516,6 +2617,7 @@ export default {
   getOAuthIds,
   getPodEvents,
   getProcfileContents,
+  getPorterYamlContents,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,
@@ -2613,6 +2715,7 @@ export default {
   createContract,
   getContracts,
   deleteContract,
+  createSecretAndOpenGitHubPullRequest,
   // TRACKING
   updateOnboardingStep,
   // STACKS

+ 13 - 4
dashboard/src/shared/hardcodedNameDict.tsx

@@ -9,6 +9,7 @@ const hardcodedNames: { [key: string]: string } = {
   mysql: "MySQL",
   postgresql: "PostgreSQL",
   redis: "Redis",
+  "node-local": "Node Local DNS",
   ubuntu: "Ubuntu",
   web: "Web Service",
   worker: "Worker",
@@ -19,6 +20,11 @@ const hardcodedNames: { [key: string]: string } = {
   rabbitmq: "RabbitMQ",
   logdna: "LogDNA",
   "tailscale-relay": "Tailscale",
+  questdb: "QuestDB",
+  "postgres-toolbox": "PostgreSQL Toolbox",
+  keda: "KEDA",
+  "grafana-agent": "Grafana Agent",
+  "ecr-secrets-updater": "ECR Secrets Updater",
 };
 
 const hardcodedIcons: { [key: string]: string } = {
@@ -27,14 +33,14 @@ const hardcodedIcons: { [key: string]: string } = {
   metabase:
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
   mongodb:
-    "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
+    "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/mongodb/mongodb-original.svg",
   datadog: "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
   wallarm:
     "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
   agones: "https://avatars.githubusercontent.com/u/36940055?v=4",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
-    "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",
+    "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg",
   redis:
     "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png",
   ubuntu: "Ubuntu",
@@ -51,10 +57,13 @@ const hardcodedIcons: { [key: string]: string } = {
   prometheus:
     "https://raw.githubusercontent.com/prometheus/prometheus.github.io/master/assets/prometheus_logo-cb55bb5c346.png",
   rabbitmq:
-    "https://bitnami.com/assets/stacks/rabbitmq/img/rabbitmq-stack-220x234.png",
+    "https://static-00.iconduck.com/assets.00/rabbitmq-icon-484x512-s9lfaapn.png",
   logdna:
     "https://user-images.githubusercontent.com/65516095/118185526-a2447480-b40a-11eb-9bdb-82aa0a306f26.png",
-  "tailscale-relay": "Tailscale",
+  "node-local": "https://hostingdata.co.uk/wp-content/uploads/2020/06/dns-png-6.png",
+  "tailscale-relay": "https://play-lh.googleusercontent.com/wczDL05-AOb39FcL58L32h6j_TrzzGTXDLlOrOmJ-aNsnoGsT1Gkk2vU4qyTb7tGxRw=w240-h480-rw",
+  "postgres-toolbox": "https://cdn-icons-png.flaticon.com/512/5133/5133626.png",
+  "ecr-secrets-updater": "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/amazonwebservices/amazonwebservices-original.svg",
 };
 
 export { hardcodedNames, hardcodedIcons };

+ 4 - 0
dashboard/src/shared/routing.tsx

@@ -14,6 +14,8 @@ export type PorterUrl =
   | "onboarding"
   | "databases"
   | "preview-environments"
+  | "apps"
+  | "addons"
   | "stacks";
 
 export const PorterUrls = [
@@ -30,6 +32,8 @@ export const PorterUrls = [
   "onboarding",
   "databases",
   "preview-environments",
+  "apps",
+  "addons",
   "stacks",
 ];
 

+ 3 - 0
dashboard/src/shared/themes/midnight.ts

@@ -1,9 +1,12 @@
 const theme = {
   bg: "#121212",
   fg: "#171B21",
+  border: "#494b4f",
+  button: "#3A48CA",
   clickable: {
     bg: "linear-gradient(180deg, #171B21, #121212)",
   },
+  modalBg: "#171B2111",
   text: {
     primary: "#DFDFE1",
   },

+ 2 - 1
dashboard/src/shared/themes/standard.ts

@@ -1,8 +1,9 @@
 const theme = {
   bg: "#202227",
   fg: "#27292e",
+  button: "#5561C0",
   clickable: {
-    bg: "#27292e",
+    bg: "linear-gradient(180deg, #26292e, #24272c)",
   },
   text: {
     primary: "#ffffff",

+ 14 - 6
dashboard/src/shared/types.tsx

@@ -243,15 +243,15 @@ export interface FormElement {
 export type RepoType = {
   FullName: string;
 } & (
-  | {
+    | {
       Kind: "github";
       GHRepoID: number;
     }
-  | {
+    | {
       Kind: "gitlab";
       GitIntegrationId: number;
     }
-);
+  );
 
 export interface FileType {
   path: string;
@@ -267,6 +267,7 @@ export interface ProjectType {
   capi_provisioner_enabled: boolean;
   api_tokens_enabled: boolean;
   stacks_enabled: boolean;
+  simplified_view_enabled: boolean;
   roles: {
     id: number;
     kind: string;
@@ -308,15 +309,15 @@ export type ActionConfigType = {
   image_repo_uri: string;
   dockerfile_path?: string;
 } & (
-  | {
+    | {
       kind: "gitlab";
       gitlab_integration_id: number;
     }
-  | {
+    | {
       kind: "github";
       git_repo_id: number;
     }
-);
+  );
 
 export type GithubActionConfigType = ActionConfigType & {
   kind: "github";
@@ -329,6 +330,13 @@ export type FullActionConfigType = ActionConfigType & {
   should_create_workflow: boolean;
 };
 
+export type FullGithubActionConfigType = GithubActionConfigType & {
+  dockerfile_path: string;
+  folder_path: string;
+  registry_id: number;
+  should_create_workflow: boolean;
+};
+
 export interface CapabilityType {
   github: boolean;
   provisioner: boolean;

+ 1 - 0
ee/billing/client.go

@@ -278,5 +278,6 @@ func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.Project
 			StacksEnabled:              usageData.StacksEnabled,
 			ManagedDatabasesEnabled:    usageData.ManagedDatabasesEnabled,
 			CapiProvisionerEnabled:     usageData.CapiProvisionerEnabled,
+			SimplifiedViewEnabled:      usageData.SimplifiedViewEnabled,
 		}, nil
 }

+ 1 - 0
ee/billing/types.go

@@ -28,6 +28,7 @@ type APIWebhookRequest struct {
 	StacksEnabled              string `json:"stacks_enabled,omitempty"`
 	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
 	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
+	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
 }
 
 type CreateBillingCookieRequest struct {

+ 3 - 3
internal/integrations/ci/actions/actions.go

@@ -80,7 +80,7 @@ func (g *GithubActions) Setup() ([]byte, error) {
 
 	if !g.DryRun {
 		// create porter token secret
-		if err := createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
+		if err := CreateGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
 			return nil, err
 		}
 	}
@@ -310,7 +310,7 @@ func (g *GithubActions) getClient() (*github.Client, error) {
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
 
-func createGithubSecret(
+func CreateGithubSecret(
 	client *github.Client,
 	secretName,
 	secretValue,
@@ -386,7 +386,7 @@ func (g *GithubActions) createEnvSecret(client *github.Client) error {
 
 	secretName := g.getBuildEnvSecretName()
 
-	return createGithubSecret(client, secretName, strings.Join(lines, "\n"), g.GitRepoOwner, g.GitRepoName)
+	return CreateGithubSecret(client, secretName, strings.Join(lines, "\n"), g.GitRepoOwner, g.GitRepoName)
 }
 
 func (g *GithubActions) getWebhookSecretName() string {

+ 1 - 1
internal/integrations/ci/actions/preview.go

@@ -43,7 +43,7 @@ func SetupEnv(opts *EnvOpts) error {
 	}
 
 	// create porter token secret
-	err = createGithubSecret(
+	err = CreateGithubSecret(
 		opts.Client,
 		getPreviewEnvSecretName(opts.ProjectID, opts.ClusterID, opts.InstanceName),
 		opts.PorterToken,

+ 121 - 0
internal/integrations/ci/actions/stack.go

@@ -0,0 +1,121 @@
+package actions
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+	"gopkg.in/yaml.v2"
+)
+
+type GithubPROpts struct {
+	Client                    *github.Client
+	GitRepoOwner, GitRepoName string
+	ApplyWorkflowYAML         string
+	StackName                 string
+	ProjectID, ClusterID      uint
+	ServerURL                 string
+	DefaultBranch             string
+	SecretName                string
+}
+
+type GetStackApplyActionYAMLOpts struct {
+	ServerURL            string
+	StackName            string
+	ProjectID, ClusterID uint
+	DefaultBranch        string
+	SecretName           string
+}
+
+func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
+	var pr *github.PullRequest
+	applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
+		ServerURL:     opts.ServerURL,
+		ClusterID:     opts.ClusterID,
+		ProjectID:     opts.ProjectID,
+		StackName:     opts.StackName,
+		DefaultBranch: opts.DefaultBranch,
+		SecretName:    opts.SecretName,
+	})
+	if err != nil {
+		return pr, err
+	}
+
+	prBranchName := "porter-stack"
+
+	err = createNewBranch(opts.Client,
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		opts.DefaultBranch,
+		prBranchName)
+	if err != nil {
+		return pr, fmt.Errorf(
+			"error creating branch: %w",
+			err,
+		)
+	}
+
+	_, err = commitWorkflowFile(
+		opts.Client,
+		fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
+		applyWorkflowYAML, opts.GitRepoOwner,
+		opts.GitRepoName, prBranchName, false,
+	)
+
+	if err != nil {
+		return pr, fmt.Errorf(
+			"error committing file: %w",
+			err,
+		)
+	}
+
+	pr, _, err = opts.Client.PullRequests.Create(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
+			Title: github.String("Enable Porter Application"),
+			Base:  github.String(opts.DefaultBranch),
+			Head:  github.String(prBranchName),
+		},
+	)
+	if err != nil {
+		return pr, fmt.Errorf(
+			"error creating PR: %w",
+			err,
+		)
+	}
+	return pr, nil
+}
+
+func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getCheckoutCodeStep(),
+		getSetTagStep(),
+		getDeployStackStep(
+			opts.ServerURL,
+			opts.SecretName,
+			opts.StackName,
+			"v0.1.0",
+			opts.ProjectID,
+			opts.ClusterID,
+		),
+	}
+
+	actionYAML := GithubActionYAML{
+		On: GithubActionYAMLOnPush{
+			Push: GithubActionYAMLOnPushBranches{
+				Branches: []string{
+					opts.DefaultBranch,
+				},
+			},
+		},
+		Name: "Deploy to Porter",
+		Jobs: map[string]GithubActionYAMLJob{
+			"porter-deploy": {
+				RunsOn: "ubuntu-latest",
+				Steps:  gaSteps,
+			},
+		},
+	}
+
+	return yaml.Marshal(actionYAML)
+}

+ 23 - 0
internal/integrations/ci/actions/steps.go

@@ -8,6 +8,7 @@ import (
 const (
 	updateAppActionName     = "porter-dev/porter-update-action"
 	createPreviewActionName = "porter-dev/porter-preview-action"
+	cliActionName           = "porter-dev/porter-cli-action"
 )
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
@@ -69,3 +70,25 @@ func getCreatePreviewEnvStep(
 		Timeout: 30,
 	}
 }
+
+func getDeployStackStep(
+	serverURL, porterTokenSecretName, stackName, actionVersion string,
+	projectID, clusterID uint,
+) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Deploy stack",
+		Uses: fmt.Sprintf("%s@%s", cliActionName, actionVersion),
+		With: map[string]string{
+			"command": "apply -f porter.yaml",
+		},
+		Env: map[string]string{
+			"PORTER_CLUSTER":    fmt.Sprintf("%d", clusterID),
+			"PORTER_HOST":       serverURL,
+			"PORTER_PROJECT":    fmt.Sprintf("%d", projectID),
+			"PORTER_TOKEN":      fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"PORTER_TAG":        "${{ steps.vars.outputs.sha_short }}",
+			"PORTER_STACK_NAME": stackName,
+		},
+		Timeout: 30,
+	}
+}

+ 47 - 0
internal/models/porter_app.go

@@ -0,0 +1,47 @@
+package models
+
+import (
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// PorterApp (stack) type that extends gorm.Model
+type PorterApp struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+
+	Name string
+
+	ImageRepoURI string
+
+	// Git repo information (optional)
+	GitRepoID uint
+	RepoName  string
+	GitBranch string
+
+	BuildContext string
+	Builder      string
+	Buildpacks   string
+	Dockerfile   string
+}
+
+// ToPorterAppType generates an external types.PorterApp to be shared over REST
+func (a *PorterApp) ToPorterAppType() *types.PorterApp {
+	return &types.PorterApp{
+		ID:           a.ID,
+		ProjectID:    a.ProjectID,
+		ClusterID:    a.ClusterID,
+		Name:         a.Name,
+		ImageRepoURI: a.ImageRepoURI,
+		GitRepoID:    a.GitRepoID,
+		RepoName:     a.RepoName,
+		GitBranch:    a.GitBranch,
+		BuildContext: a.BuildContext,
+		Builder:      a.Builder,
+		Buildpacks:   a.Buildpacks,
+		Dockerfile:   a.Dockerfile,
+	}
+}

+ 2 - 0
internal/models/project.go

@@ -64,6 +64,7 @@ type Project struct {
 	StacksEnabled          bool
 	APITokensEnabled       bool
 	CapiProvisionerEnabled bool
+	SimplifiedViewEnabled  bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -84,5 +85,6 @@ func (p *Project) ToProjectType() *types.Project {
 		StacksEnabled:          p.StacksEnabled,
 		APITokensEnabled:       p.APITokensEnabled,
 		CapiProvisionerEnabled: p.CapiProvisionerEnabled,
+		SimplifiedViewEnabled:  p.SimplifiedViewEnabled,
 	}
 }

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -60,6 +60,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.MonitorTestResult{},
 		&models.APIContractRevision{},
 		&models.AWSAssumeRoleChain{},
+		&models.PorterApp{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 55 - 0
internal/repository/gorm/porter_app.go

@@ -0,0 +1,55 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PorterAppRepository uses gorm.DB for querying the database
+type PorterAppRepository struct {
+	db *gorm.DB
+}
+
+// NewPorterAppRepository returns a PorterAppRepository which uses
+// gorm.DB for querying the database
+func NewPorterAppRepository(db *gorm.DB) repository.PorterAppRepository {
+	return &PorterAppRepository{db}
+}
+
+func (repo *PorterAppRepository) CreatePorterApp(a *models.PorterApp) (*models.PorterApp, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+func (repo *PorterAppRepository) ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error) {
+	apps := []*models.PorterApp{}
+
+	/*
+		if err := repo.db.Where("project_id = ? AND NOT revoked", projectID).Find(&tokens).Error; err != nil {
+			return nil, err
+		}
+	*/
+
+	return apps, nil
+}
+
+func (repo *PorterAppRepository) ReadPorterApp(clusterID uint, name string) (*models.PorterApp, error) {
+	app := &models.PorterApp{}
+
+	if err := repo.db.Where("cluster_id = ? AND name = ?", clusterID, name).First(&app).Error; err != nil {
+		return nil, err
+	}
+
+	return app, nil
+}
+
+func (repo *PorterAppRepository) UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error) {
+	if err := repo.db.Save(app).Error; err != nil {
+		return nil, err
+	}
+
+	return app, nil
+}

+ 6 - 0
internal/repository/gorm/repository.go

@@ -51,6 +51,7 @@ type GormRepository struct {
 	monitor                   repository.MonitorTestResultRepository
 	apiContractRevisions      repository.APIContractRevisioner
 	awsAssumeRoleChainer      repository.AWSAssumeRoleChainer
+	porterApp                 repository.PorterAppRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -217,6 +218,10 @@ func (t *GormRepository) Stack() repository.StackRepository {
 	return t.stack
 }
 
+func (t *GormRepository) PorterApp() repository.PorterAppRepository {
+	return t.porterApp
+}
+
 func (t *GormRepository) MonitorTestResult() repository.MonitorTestResultRepository {
 	return t.monitor
 }
@@ -277,5 +282,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		monitor:                   NewMonitorTestResultRepository(db),
 		apiContractRevisions:      NewAPIContractRevisioner(db),
 		awsAssumeRoleChainer:      NewAWSAssumeRoleChainer(db),
+		porterApp:                 NewPorterAppRepository(db),
 	}
 }

+ 13 - 0
internal/repository/porter_app.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PorterAppRepository represents the set of queries on the PorterApp model
+type PorterAppRepository interface {
+	CreatePorterApp(app *models.PorterApp) (*models.PorterApp, error)
+	// ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error)
+	ReadPorterApp(clusterID uint, name string) (*models.PorterApp, error)
+	UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error)
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác